chore(compose): add docker-compose.yml

This commit is contained in:
2025-12-26 18:01:54 +03:00
parent eaa0e0a3eb
commit a7e5670d7c
7 changed files with 260 additions and 2 deletions

0
.example.env Normal file
View File

56
db/init.sql Normal file
View File

@@ -0,0 +1,56 @@
-- TimescaleDB hypertable for time-series audio metrics
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Create audio data table
CREATE TABLE IF NOT EXISTS audio_data (
time TIMESTAMPTZ NOT NULL,
rms_db REAL CHECK (rms_db >= -40.0 AND rms_db <= 80.0),
frequency_hz INTEGER CHECK (frequency_hz >= 100 AND frequency_hz <= 8000),
is_silence BOOLEAN NOT NULL DEFAULT FALSE
);
-- Convert to hypertable (time-series optimization)
SELECT create_hypertable('audio_data', 'time', if_not_exists => TRUE);
-- Create index for frequency queries (exclude silence for performance)
CREATE INDEX IF NOT EXISTS idx_frequency
ON audio_data(frequency_hz)
WHERE NOT is_silence;
-- Create index for time-based queries
CREATE INDEX IF NOT EXISTS idx_time_desc
ON audio_data(time DESC);
-- Optional: Create continuous aggregate for 1-minute averages
CREATE MATERIALIZED VIEW IF NOT EXISTS audio_data_1min
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 minute', time) AS bucket,
AVG(rms_db) AS avg_rms_db,
MAX(rms_db) AS max_rms_db,
MIN(rms_db) AS min_rms_db,
mode() WITHIN GROUP (ORDER BY frequency_hz) AS dominant_freq_hz,
SUM(CASE WHEN is_silence THEN 1 ELSE 0 END)::REAL / COUNT(*) AS silence_ratio
FROM audio_data
GROUP BY bucket
WITH NO DATA;
-- Refresh policy: update aggregate every 5 minutes
SELECT add_continuous_aggregate_policy('audio_data_1min',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '5 minutes',
if_not_exists => TRUE
);
-- Data retention: keep raw data for 7 days
SELECT add_retention_policy('audio_data',
INTERVAL '7 days',
if_not_exists => TRUE
);
-- Grant permissions
GRANT ALL ON audio_data TO postgres;
GRANT SELECT ON audio_data_1min TO postgres;

80
docker-compose.yml Normal file
View File

@@ -0,0 +1,80 @@
services:
db:
image: timescale/timescaledb:2.13.1-pg16
container_name: audio_timescaledb
environment:
POSTGRES_DB: ${DB_NAME:-audio_analyzer}
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
volumes:
- timescale_data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "${DB_PORT:-5432}:5432"
networks:
- audio_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
collector:
build:
context: ./services/collector
dockerfile: Dockerfile
container_name: audio_collector
depends_on:
db:
condition: service_healthy
environment:
SERIAL_PORT: ${SERIAL_PORT:-/dev/ttyACM0}
BAUDRATE: ${BAUDRATE:-115200}
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${DB_NAME:-audio_analyzer}
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
devices:
- "${SERIAL_PORT:-/dev/ttyACM0}:${SERIAL_PORT:-/dev/ttyACM0}"
networks:
- audio_network
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
api:
build:
context: ./services/api
dockerfile: Dockerfile
container_name: audio_api
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer
ports:
- "8000:8000"
volumes:
- ./services/api:/app
networks:
- audio_network
healthcheck:
test: ["CMD-SHELL", "curl --fail http://localhost:8000/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
timescale_data:
driver: local
networks:
audio_network:
driver: bridge

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
import argparse
import time
import serial
from protocol_parser import ProtocolParser
def main():
ap = argparse.ArgumentParser(description="FR-1.4 binary stream monitor")
ap.add_argument("--port", default="/dev/ttyACM0")
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--timeout", type=float, default=0.2)
args = ap.parse_args()
parser = ProtocolParser()
with serial.Serial(args.port, args.baud, timeout=args.timeout) as ser:
while True:
data = ser.read(ser.in_waiting or 1)
if not data:
continue
packets = parser.feed(data)
st = parser.get_stats()
for pkt in packets:
# Одна строка на пакет + счётчик CRC ошибок
print(
f"{pkt.timestamp_ms:010d} "
f"rms_db={pkt.rms_db:+6.1f} "
f"freq_hz={pkt.freq_hz:4d} "
f"crc_err={st.crc_errors}"
)
time.sleep(0.001)
if __name__ == "__main__":
main()

View File

@@ -34,9 +34,9 @@ def main():
else:
print(f"[WARN] Invalid packet: {pkt}")
# Show stats every 100 packets
# Show stats every 30 packets
stats = parser.get_stats()
if stats.packets_received % 100 == 0 and stats.packets_received > 0:
if stats.packets_received % 30 == 0 and stats.packets_received > 0:
print(
f"Stats: RX={stats.packets_received} "
f"CRC_err={stats.crc_errors} "

View File

@@ -0,0 +1,81 @@
import struct
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from protocol_parser import ProtocolParser
def build_packet(timestamp_ms: int, rms_db_x10: int, freq_hz: int) -> bytes:
sof = bytes([ProtocolParser.SOF])
header = bytes([ProtocolParser.TYPE_AUDIO_V1, ProtocolParser.PAYLOAD_LEN])
payload = struct.pack("<IhH", timestamp_ms, rms_db_x10, freq_hz)
crc_data = header + payload # bytes 1..10 in the wire format
crc = ProtocolParser._crc8_atm(crc_data)
return sof + header + payload + bytes([crc])
def test_valid_packet():
p = ProtocolParser()
raw = build_packet(timestamp_ms=1234, rms_db_x10=-123, freq_hz=440)
packets = p.feed(raw)
assert len(packets) == 1
pkt = packets[0]
assert pkt.valid is True
assert pkt.timestamp_ms == 1234
assert pkt.rms_db == -12.3
assert pkt.freq_hz == 440
st = p.get_stats()
assert st.packets_received == 1
assert st.crc_errors == 0
def test_bad_crc_packet():
p = ProtocolParser()
raw = bytearray(build_packet(timestamp_ms=1, rms_db_x10=-10, freq_hz=1000))
raw[-1] ^= 0xFF # ломаем CRC
packets = p.feed(bytes(raw))
assert packets == []
st = p.get_stats()
assert st.packets_received == 0
assert st.crc_errors == 1
def test_garbage_then_valid_packet_resync():
p = ProtocolParser()
garbage = b"\x00\xff\xaa\x01\x02\x03\x04" # содержит ложный SOF и мусор
raw = garbage + build_packet(timestamp_ms=777, rms_db_x10=-321, freq_hz=1234)
packets = p.feed(raw)
assert len(packets) == 1
assert packets[0].timestamp_ms == 777
assert packets[0].rms_db == -32.1
assert packets[0].freq_hz == 1234
assert p.get_stats().length_errors >= 1 # парсер должен "проглотить" мусор
def test_two_packets_in_one_chunk():
p = ProtocolParser()
raw = build_packet(timestamp_ms=10, rms_db_x10=-100, freq_hz=500) + build_packet(
timestamp_ms=20, rms_db_x10=-200, freq_hz=600
)
packets = p.feed(raw)
assert len(packets) == 2
assert packets[0].timestamp_ms == 10
assert packets[1].timestamp_ms == 20