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