""" transport.py — Serial (and future TCP) transport layer for the MiniMate Plus protocol. Provides a thin I/O abstraction so that protocol.py never imports pyserial directly. The only concrete implementation here is SerialTransport; a TcpTransport can be added later without touching any other layer. Typical usage: from minimateplus.transport import SerialTransport t = SerialTransport("COM5") t.connect() t.write(frame_bytes) data = t.read_until_idle(timeout=2.0) t.disconnect() # or as a context manager: with SerialTransport("COM5") as t: t.write(frame_bytes) data = t.read_until_idle() """ from __future__ import annotations import time from abc import ABC, abstractmethod from typing import Optional # pyserial is the only non-stdlib dependency in this project. # Import lazily so unit-tests that mock the transport can run without it. try: import serial # type: ignore except ImportError: # pragma: no cover serial = None # type: ignore # ── Abstract base ───────────────────────────────────────────────────────────── class BaseTransport(ABC): """Common interface for all transport implementations.""" @abstractmethod def connect(self) -> None: """Open the underlying connection.""" @abstractmethod def disconnect(self) -> None: """Close the underlying connection.""" @property @abstractmethod def is_connected(self) -> bool: """True while the connection is open.""" @abstractmethod def write(self, data: bytes) -> None: """Write *data* bytes to the wire.""" @abstractmethod def read(self, n: int) -> bytes: """ Read up to *n* bytes. Returns immediately with whatever is available (may return fewer than *n* bytes, or b"" if nothing is ready). """ # ── Context manager ─────────────────────────────────────────────────────── def __enter__(self) -> "BaseTransport": self.connect() return self def __exit__(self, *_) -> None: self.disconnect() # ── Higher-level read helpers ───────────────────────────────────────────── def read_until_idle( self, timeout: float = 2.0, idle_gap: float = 0.05, chunk: int = 256, ) -> bytes: """ Read bytes until the line goes quiet. Keeps reading in *chunk*-sized bursts. Returns when either: - *timeout* seconds have elapsed since the first byte arrived, or - *idle_gap* seconds pass with no new bytes (line went quiet). This mirrors how Blastware behaves: it waits for the seismograph to stop transmitting rather than counting bytes. Args: timeout: Hard deadline (seconds) from the moment read starts. idle_gap: How long to wait after the last byte before declaring done. chunk: How many bytes to request per low-level read() call. Returns: All bytes received as a single bytes object (may be b"" if nothing arrived within *timeout*). """ buf = bytearray() deadline = time.monotonic() + timeout last_rx = None while time.monotonic() < deadline: got = self.read(chunk) if got: buf.extend(got) last_rx = time.monotonic() else: # Nothing ready — check idle gap if last_rx is not None and (time.monotonic() - last_rx) >= idle_gap: break time.sleep(0.005) return bytes(buf) def read_exact(self, n: int, timeout: float = 2.0) -> bytes: """ Read exactly *n* bytes or raise TimeoutError. Useful when the caller already knows the expected response length (e.g. fixed-size ACK packets). """ buf = bytearray() deadline = time.monotonic() + timeout while len(buf) < n: if time.monotonic() >= deadline: raise TimeoutError( f"read_exact: wanted {n} bytes, got {len(buf)} " f"after {timeout:.1f}s" ) got = self.read(n - len(buf)) if got: buf.extend(got) else: time.sleep(0.005) return bytes(buf) # ── Serial transport ────────────────────────────────────────────────────────── # Default baud rate confirmed from Blastware / MiniMate Plus documentation. DEFAULT_BAUD = 38_400 # pyserial serial port config matching the MiniMate Plus RS-232 spec: # 8 data bits, no parity, 1 stop bit (8N1). _SERIAL_BYTESIZE = 8 # serial.EIGHTBITS _SERIAL_PARITY = "N" # serial.PARITY_NONE _SERIAL_STOPBITS = 1 # serial.STOPBITS_ONE class SerialTransport(BaseTransport): """ pyserial-backed transport for a direct RS-232 cable connection. The port is opened with a very short read timeout (10 ms) so that read() returns quickly and the caller can implement its own framing / timeout logic without blocking the whole process. Args: port: COM port name (e.g. "COM5" on Windows, "/dev/ttyUSB0" on Linux). baud: Baud rate (default 38400). rts_cts: Enable RTS/CTS hardware flow control (default False — MiniMate typically uses no flow control). """ # Internal read timeout (seconds). Short so read() is non-blocking in practice. _READ_TIMEOUT = 0.01 def __init__( self, port: str, baud: int = DEFAULT_BAUD, rts_cts: bool = False, ) -> None: if serial is None: raise ImportError( "pyserial is required for SerialTransport. " "Install it with: pip install pyserial" ) self.port = port self.baud = baud self.rts_cts = rts_cts self._ser: Optional[serial.Serial] = None # ── BaseTransport interface ─────────────────────────────────────────────── def connect(self) -> None: """Open the serial port. Raises serial.SerialException on failure.""" if self._ser and self._ser.is_open: return # Already open — idempotent self._ser = serial.Serial( port = self.port, baudrate = self.baud, bytesize = _SERIAL_BYTESIZE, parity = _SERIAL_PARITY, stopbits = _SERIAL_STOPBITS, timeout = self._READ_TIMEOUT, rtscts = self.rts_cts, xonxoff = False, dsrdtr = False, ) # Flush any stale bytes left in device / OS buffers from a previous session self._ser.reset_input_buffer() self._ser.reset_output_buffer() def disconnect(self) -> None: """Close the serial port. Safe to call even if already closed.""" if self._ser: try: self._ser.close() except Exception: pass self._ser = None @property def is_connected(self) -> bool: return bool(self._ser and self._ser.is_open) def write(self, data: bytes) -> None: """ Write *data* to the serial port. Raises: RuntimeError: if not connected. serial.SerialException: on I/O error. """ if not self.is_connected: raise RuntimeError("SerialTransport.write: not connected") self._ser.write(data) # type: ignore[union-attr] self._ser.flush() # type: ignore[union-attr] def read(self, n: int) -> bytes: """ Read up to *n* bytes from the serial port. Returns b"" immediately if no data is available (non-blocking in practice thanks to the 10 ms read timeout). Raises: RuntimeError: if not connected. """ if not self.is_connected: raise RuntimeError("SerialTransport.read: not connected") return self._ser.read(n) # type: ignore[union-attr] # ── Extras ──────────────────────────────────────────────────────────────── def flush_input(self) -> None: """Discard any unread bytes in the OS receive buffer.""" if self.is_connected: self._ser.reset_input_buffer() # type: ignore[union-attr] def __repr__(self) -> str: state = "open" if self.is_connected else "closed" return f"SerialTransport({self.port!r}, baud={self.baud}, {state})"