Files
sound-analyze/services/collector/serial_reader.py

123 lines
4.0 KiB
Python

#!/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()