diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..e69de29 diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..8316a2d --- /dev/null +++ b/db/init.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b2152a --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/services/collector/monitor.py b/services/collector/monitor.py new file mode 100644 index 0000000..12984bc --- /dev/null +++ b/services/collector/monitor.py @@ -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() diff --git a/client/src/protocol_parser.py b/services/collector/protocol_parser.py similarity index 100% rename from client/src/protocol_parser.py rename to services/collector/protocol_parser.py diff --git a/client/src/receiver.py b/services/collector/receiver.py similarity index 91% rename from client/src/receiver.py rename to services/collector/receiver.py index 14b3381..1505f2d 100644 --- a/client/src/receiver.py +++ b/services/collector/receiver.py @@ -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} " diff --git a/services/collector/tests/test_protocol_parser.py b/services/collector/tests/test_protocol_parser.py new file mode 100644 index 0000000..c2e9a32 --- /dev/null +++ b/services/collector/tests/test_protocol_parser.py @@ -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("= 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