tooling(micromate): drop the pyserial dependency from mm_client_check

Hit on the bench: mint-mac has no pyserial, and `pip install pyserial` is
refused outright by PEP 668 (externally-managed-environment) on Mint 22 /
Ubuntu 24.04 / Debian 12.  A field diagnostic that needs a pip install on a
locked-down host is one you cannot run at the moment you need it -- which is
exactly when this tool is for.

Replaced minimateplus.SerialTransport with a ~30-line stdlib `termios` port,
the same approach bridges/mm_link.py and scratch/fake_unit.py already take in
this repo.  Runs on a stock Python 3 anywhere.

Not a general SerialTransport replacement: no flow control, no parity options,
Linux/macOS only.  Enough for a Micromate, which is 8N1 with no handshaking.

Verified end to end against a fake unit on a pty, dribbling responses in
64-byte pieces: 194 reads for 10,958 B, identity/state/setup-walk all correct,
and a 4,076 B event downloaded across 4 chunks at the exact expected length.

Also clarified --baud in the help: it applies to the USB-A/FTDI path, and is
ignored by the USB-B "PC" port, which is CDC-ACM and negotiates its own rate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
This commit is contained in:
2026-09-29 14:06:55 -04:00
co-authored by Claude Opus 5
parent c7ffdab570
commit f7d9a1d9cb
+74 -3
View File
@@ -44,7 +44,11 @@ unit, this will connect and then see nothing — that is contention, not a fault
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import errno
import os
import select
import sys import sys
import termios
import time import time
from pathlib import Path from pathlib import Path
@@ -52,7 +56,72 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from micromate.client import MicromateClient, _content # noqa: E402 from micromate.client import MicromateClient, _content # noqa: E402
from micromate.protocol import ProtocolError # noqa: E402 from micromate.protocol import ProtocolError # noqa: E402
from minimateplus.transport import SerialTransport, TcpTransport # noqa: E402 from minimateplus.transport import TcpTransport # noqa: E402
class StdlibSerial:
"""Raw serial on stdlib `termios` — no pyserial.
`minimateplus.SerialTransport` needs pyserial, and a bench host is whatever
is to hand. On a PEP 668 distro (Mint 22, Ubuntu 24.04, Debian 12) a plain
`pip install pyserial` is refused outright, so a diagnostic that depends on
it is one you cannot run at the moment you need it. `bridges/mm_link.py`
and `scratch/fake_unit.py` already take this approach; this is the same
~30 lines, and it means the tool runs on a stock Python 3 anywhere.
Not a general replacement for SerialTransport — no flow control, no
parity options, Linux/macOS only. Enough for a Micromate, which is 8N1
with no handshaking.
"""
_BAUD = {9600: termios.B9600, 19200: termios.B19200, 38400: termios.B38400,
57600: termios.B57600, 115200: termios.B115200}
def __init__(self, path: str, baud: int = 115200) -> None:
if baud not in self._BAUD:
raise ValueError(f"unsupported baud {baud}; pick from {sorted(self._BAUD)}")
self.path, self.baud, self.fd = path, baud, None
def connect(self) -> None:
if self.fd is not None:
return
self.fd = os.open(self.path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
a = termios.tcgetattr(self.fd)
a[0] = a[1] = a[3] = 0 # raw in/out, non-canonical
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL # 8N1, ignore modem lines
a[4] = a[5] = self._BAUD[self.baud]
a[6] = list(a[6])
a[6][termios.VMIN] = 0
a[6][termios.VTIME] = 0
termios.tcsetattr(self.fd, termios.TCSANOW, a)
termios.tcflush(self.fd, termios.TCIOFLUSH)
def disconnect(self) -> None:
if self.fd is not None:
os.close(self.fd)
self.fd = None
def is_connected(self) -> bool:
return self.fd is not None
def read(self, n: int) -> bytes:
if self.fd is None:
return b""
r, _, _ = select.select([self.fd], [], [], 0.05)
if not r:
return b""
try:
return os.read(self.fd, n)
except OSError as e:
if e.errno in (errno.EAGAIN, errno.EWOULDBLOCK):
return b""
raise
def write(self, data: bytes) -> None:
if self.fd is None:
raise OSError("port is not open")
while data:
data = data[os.write(self.fd, data):]
class _Timed: class _Timed:
@@ -107,7 +176,9 @@ def main() -> int:
) )
ap.add_argument("target", help="host:port for TCP, or a serial device path") ap.add_argument("target", help="host:port for TCP, or a serial device path")
ap.add_argument("--baud", type=int, default=115200, ap.add_argument("--baud", type=int, default=115200,
help="serial only; a Micromate's modem port runs at 115200") help="serial only; the USB-A/FTDI path runs at 115200. "
"Ignored by the USB-B 'PC' port, which is CDC-ACM "
"and negotiates its own rate.")
ap.add_argument("--timeout", type=float, default=10.0) ap.add_argument("--timeout", type=float, default=10.0)
ap.add_argument("--download", action="store_true", ap.add_argument("--download", action="store_true",
help="also download the first stored event (read-only)") help="also download the first stored event (read-only)")
@@ -120,7 +191,7 @@ def main() -> int:
inner = TcpTransport(host, int(port), connect_timeout=a.timeout) inner = TcpTransport(host, int(port), connect_timeout=a.timeout)
path = f"TCP {host}:{port}" path = f"TCP {host}:{port}"
else: else:
inner = SerialTransport(a.target, baud=a.baud) inner = StdlibSerial(a.target, baud=a.baud)
path = f"serial {a.target} @ {a.baud}" path = f"serial {a.target} @ {a.baud}"
transport = _Timed(inner) transport = _Timed(inner)