259 lines
9.0 KiB
Python
259 lines
9.0 KiB
Python
"""
|
|
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})"
|