chore(compose): add docker-compose.yml
This commit is contained in:
0
.example.env
Normal file
0
.example.env
Normal file
56
db/init.sql
Normal file
56
db/init.sql
Normal 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
80
docker-compose.yml
Normal 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
|
||||
41
services/collector/monitor.py
Normal file
41
services/collector/monitor.py
Normal 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()
|
||||
@@ -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} "
|
||||
81
services/collector/tests/test_protocol_parser.py
Normal file
81
services/collector/tests/test_protocol_parser.py
Normal 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
|
||||
Reference in New Issue
Block a user