chore(compose): add docker-compose.yml
This commit is contained in:
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()
|
||||
136
services/collector/protocol_parser.py
Normal file
136
services/collector/protocol_parser.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FR-1.4 Data Transmission Protocol Parser (v1)
|
||||
12-byte binary packet: [0xAA][TYPE=0x02][LEN=8][TIMESTAMP(4)][RMS_DB(2)][FREQ_HZ(2)][CRC8(1)]
|
||||
"""
|
||||
|
||||
import struct
|
||||
from typing import Optional, NamedTuple
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class AudioMetrics(NamedTuple):
|
||||
"""Parsed audio metrics packet"""
|
||||
timestamp_ms: int
|
||||
rms_db: float
|
||||
freq_hz: int
|
||||
valid: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProtocolStats:
|
||||
"""Protocol statistics"""
|
||||
packets_received: int = 0
|
||||
crc_errors: int = 0
|
||||
length_errors: int = 0
|
||||
range_errors: int = 0
|
||||
|
||||
|
||||
class ProtocolParser:
|
||||
"""
|
||||
Stream parser for FR-1.4 protocol with automatic resynchronization.
|
||||
"""
|
||||
|
||||
SOF = 0xAA
|
||||
TYPE_AUDIO_V1 = 0x02
|
||||
PAYLOAD_LEN = 0x08
|
||||
PACKET_SIZE = 12
|
||||
|
||||
def __init__(self):
|
||||
self.buffer = bytearray()
|
||||
self.stats = ProtocolStats()
|
||||
|
||||
@staticmethod
|
||||
def _crc8_atm(data: bytes) -> int:
|
||||
"""CRC-8/ATM: poly=0x07, init=0x00, refin=false, refout=false, xorout=0x00"""
|
||||
crc = 0x00
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x80:
|
||||
crc = ((crc << 1) ^ 0x07) & 0xFF
|
||||
else:
|
||||
crc = (crc << 1) & 0xFF
|
||||
return crc
|
||||
|
||||
def feed(self, data: bytes) -> list[AudioMetrics]:
|
||||
"""
|
||||
Feed incoming bytes, return list of parsed packets.
|
||||
|
||||
Args:
|
||||
data: Raw bytes from serial port
|
||||
|
||||
Returns:
|
||||
List of successfully parsed AudioMetrics
|
||||
"""
|
||||
self.buffer.extend(data)
|
||||
packets = []
|
||||
|
||||
while len(self.buffer) >= self.PACKET_SIZE:
|
||||
# Find SOF
|
||||
sof_idx = self.buffer.find(self.SOF)
|
||||
if sof_idx == -1:
|
||||
# No SOF found, discard all but last byte
|
||||
self.buffer = self.buffer[-1:]
|
||||
break
|
||||
|
||||
# Discard bytes before SOF
|
||||
if sof_idx > 0:
|
||||
self.buffer = self.buffer[sof_idx:]
|
||||
|
||||
# Need at least 3 bytes for SOF + TYPE + LEN
|
||||
if len(self.buffer) < 3:
|
||||
break
|
||||
|
||||
packet_type = self.buffer[1]
|
||||
payload_len = self.buffer[2]
|
||||
|
||||
# Validate TYPE and LEN
|
||||
if packet_type != self.TYPE_AUDIO_V1 or payload_len != self.PAYLOAD_LEN:
|
||||
self.stats.length_errors += 1
|
||||
self.buffer.pop(0) # Remove false SOF, retry
|
||||
continue
|
||||
|
||||
# Full packet size = SOF(1) + TYPE(1) + LEN(1) + PAYLOAD(8) + CRC(1) = 12
|
||||
total_len = 3 + payload_len + 1
|
||||
if len(self.buffer) < total_len:
|
||||
break # Wait for more data
|
||||
|
||||
packet = bytes(self.buffer[:total_len])
|
||||
|
||||
# Verify CRC (over bytes 1..10: TYPE, LEN, payload)
|
||||
crc_data = packet[1:11]
|
||||
expected_crc = packet[11]
|
||||
calculated_crc = self._crc8_atm(crc_data)
|
||||
|
||||
if calculated_crc != expected_crc:
|
||||
self.stats.crc_errors += 1
|
||||
self.buffer.pop(0) # Remove bad packet, retry
|
||||
continue
|
||||
|
||||
# Parse payload (little-endian)
|
||||
timestamp_ms, rms_db_x10, freq_hz = struct.unpack_from('<IhH', packet, 3)
|
||||
|
||||
# Convert and validate ranges
|
||||
rms_db = rms_db_x10 / 10.0
|
||||
valid = True
|
||||
if not (-40.0 <= rms_db <= 80.0) or not (100 <= freq_hz <= 8000):
|
||||
self.stats.range_errors += 1
|
||||
valid = False
|
||||
|
||||
self.stats.packets_received += 1
|
||||
packets.append(AudioMetrics(
|
||||
timestamp_ms=timestamp_ms,
|
||||
rms_db=rms_db,
|
||||
freq_hz=freq_hz,
|
||||
valid=valid
|
||||
))
|
||||
|
||||
# Remove processed packet
|
||||
self.buffer = self.buffer[total_len:]
|
||||
|
||||
return packets
|
||||
|
||||
def get_stats(self) -> ProtocolStats:
|
||||
"""Get current statistics"""
|
||||
return self.stats
|
||||
56
services/collector/receiver.py
Normal file
56
services/collector/receiver.py
Normal file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example client for FR-1.4 protocol
|
||||
"""
|
||||
import serial
|
||||
import time
|
||||
from protocol_parser import ProtocolParser
|
||||
|
||||
|
||||
def main():
|
||||
SERIAL_PORT = "/dev/ttyACM0"
|
||||
BAUDRATE = 115200
|
||||
|
||||
parser = ProtocolParser()
|
||||
|
||||
try:
|
||||
with serial.Serial(SERIAL_PORT, BAUDRATE, timeout=1) as ser:
|
||||
print(f"Connected to {SERIAL_PORT}")
|
||||
|
||||
while True:
|
||||
# Read available data
|
||||
if ser.in_waiting > 0:
|
||||
data = ser.read(ser.in_waiting)
|
||||
|
||||
# Parse packets
|
||||
packets = parser.feed(data)
|
||||
for pkt in packets:
|
||||
if pkt.valid:
|
||||
print(
|
||||
f"[{pkt.timestamp_ms:010d}] "
|
||||
f"RMS: {pkt.rms_db:+6.1f} dB "
|
||||
f"Freq: {pkt.freq_hz:4d} Hz"
|
||||
)
|
||||
else:
|
||||
print(f"[WARN] Invalid packet: {pkt}")
|
||||
|
||||
# Show stats every 30 packets
|
||||
stats = parser.get_stats()
|
||||
if stats.packets_received % 30 == 0 and stats.packets_received > 0:
|
||||
print(
|
||||
f"Stats: RX={stats.packets_received} "
|
||||
f"CRC_err={stats.crc_errors} "
|
||||
f"LEN_err={stats.length_errors} "
|
||||
f"Range_err={stats.range_errors}"
|
||||
)
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nExiting...")
|
||||
except serial.SerialException as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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