feat(collector): add collector service
This commit is contained in:
122
services/collector/serial_reader.py
Normal file
122
services/collector/serial_reader.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
FR-2.1: Asynchronous Serial Reader
|
||||
Reads binary packets from STM32 via USB CDC
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
import serial
|
||||
from serial import SerialException
|
||||
|
||||
from protocol_parser import ProtocolParser, AudioMetrics
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SerialReader:
|
||||
"""
|
||||
Asynchronous serial port reader with protocol parsing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: str,
|
||||
baudrate: int = 115200,
|
||||
on_packet: Callable[[AudioMetrics], Awaitable[None]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize serial reader.
|
||||
|
||||
Args:
|
||||
port: Serial port path (e.g., /dev/ttyACM0)
|
||||
baudrate: Serial baudrate (default 115200)
|
||||
on_packet: Async callback for each received packet
|
||||
"""
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.on_packet = on_packet
|
||||
|
||||
self.serial: serial.Serial = None
|
||||
self.parser = ProtocolParser()
|
||||
self._running = False
|
||||
self._read_task: asyncio.Task = None
|
||||
|
||||
async def connect(self):
|
||||
"""Open serial port connection"""
|
||||
try:
|
||||
self.serial = serial.Serial(
|
||||
self.port, self.baudrate, timeout=0.1, write_timeout=1.0
|
||||
)
|
||||
logger.info(f"Connected to {self.port} @ {self.baudrate} baud")
|
||||
except SerialException as e:
|
||||
logger.error(f"Failed to open serial port {self.port}: {e}")
|
||||
raise
|
||||
|
||||
async def disconnect(self):
|
||||
"""Close serial port connection"""
|
||||
self._running = False
|
||||
|
||||
if self._read_task and not self._read_task.done():
|
||||
self._read_task.cancel()
|
||||
try:
|
||||
await self._read_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.serial and self.serial.is_open:
|
||||
self.serial.close()
|
||||
logger.info("Serial port closed")
|
||||
|
||||
async def start_reading(self):
|
||||
"""Start background task for reading serial data"""
|
||||
self._running = True
|
||||
self._read_task = asyncio.create_task(self._read_loop())
|
||||
|
||||
async def _read_loop(self):
|
||||
"""Background task: continuously read and parse serial data"""
|
||||
while self._running:
|
||||
try:
|
||||
# Read available data (non-blocking due to timeout)
|
||||
if self.serial.in_waiting > 0:
|
||||
data = self.serial.read(self.serial.in_waiting or 1)
|
||||
|
||||
if data:
|
||||
# Parse packets
|
||||
packets = self.parser.feed(data)
|
||||
|
||||
# Process each packet
|
||||
for packet in packets:
|
||||
if self.on_packet:
|
||||
try:
|
||||
await self.on_packet(packet)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in packet handler: {e}")
|
||||
|
||||
# Log statistics periodically
|
||||
stats = self.parser.get_stats()
|
||||
if stats.packets_received % 100 == 0:
|
||||
logger.info(
|
||||
f"Stats: RX={stats.packets_received} "
|
||||
f"CRC_err={stats.crc_errors} "
|
||||
f"LEN_err={stats.length_errors} "
|
||||
f"Range_err={stats.range_errors}"
|
||||
)
|
||||
|
||||
# Small delay to prevent CPU spinning
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
except SerialException as e:
|
||||
logger.error(f"Serial read error: {e}")
|
||||
self._running = False
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in read loop: {e}")
|
||||
|
||||
def get_stats(self):
|
||||
"""Get protocol parser statistics"""
|
||||
return self.parser.get_stats()
|
||||
Reference in New Issue
Block a user