Files
seismo-relay/bridges/mm_client_check.py
T
serversdownandClaude Opus 5 f7d9a1d9cb 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
2026-09-29 14:06:55 -04:00

281 lines
10 KiB
Python

#!/usr/bin/env python3
"""
mm_client_check.py — exercise the Micromate read client against a real unit.
**Read-only.** It sends POLL, SERIAL, state, monitor status, the setup walk and
(optionally) one event download. It never writes, never erases, never starts or
stops monitoring.
Why it exists
-------------
`micromate/{framing,protocol,client}.py` are verified against captures taken
**over USB**, on **one firmware line** (`11.0CB`). Two things that cannot be
verified that way:
* **the modem path.** An RX55/RV55 bridges serial to TCP transparently, but
it buffers up to ~1 s before forwarding, so a single logical response can
arrive as many small reads. The client reads to frame completion rather
than using idle-gap detection, which should be strictly more robust — but
"should be" is the point of this script.
* **the other firmware line.** `11.0BD` reports `flags = 0x03`, a shorter
model string, and a `SUB 0x1C` block 4 bytes longer. Everything about that
is currently inference from one 2026-09-23 sweep whose captures never
landed in the repo.
Run it over both paths and diff the two reports. Anything that differs beyond
timings is a finding.
Usage
-----
# over the modem
python3 bridges/mm_client_check.py 63.45.161.30:9034
# over USB / direct serial
python3 bridges/mm_client_check.py /dev/ttyACM0 --baud 115200
# include one event download (still read-only)
python3 bridges/mm_client_check.py <target> --download
⚠ These modems bridge ONE TCP session to serial at a time. If THOR holds the
unit, this will connect and then see nothing — that is contention, not a fault.
`bridges/mm_probe.py` explains that case; disconnect THOR first.
"""
from __future__ import annotations
import argparse
import errno
import os
import select
import sys
import termios
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from micromate.client import MicromateClient, _content # noqa: E402
from micromate.protocol import ProtocolError # 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:
"""Count bytes and time each read, so the two transports can be compared."""
def __init__(self, inner) -> None:
self._inner = inner
self.reads = 0
self.bytes_in = 0
self.bytes_out = 0
def connect(self):
return self._inner.connect()
def disconnect(self):
return self._inner.disconnect()
def is_connected(self):
return self._inner.is_connected()
def write(self, data: bytes):
self.bytes_out += len(data)
return self._inner.write(data)
def read(self, n: int) -> bytes:
chunk = self._inner.read(n)
if chunk:
self.reads += 1
self.bytes_in += len(chunk)
return chunk
def step(label: str, fn):
"""Run one read, report how long it took and what it returned."""
t0 = time.monotonic()
try:
value = fn()
except Exception as e:
print(f" {label:.<26} FAILED {type(e).__name__}: {e}")
return None
ms = 1000 * (time.monotonic() - t0)
shown = value if isinstance(value, str) else repr(value)
if isinstance(value, list):
shown = f"{len(value)} entries"
print(f" {label:.<26} {ms:7.0f} ms {shown}")
return value
def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("target", help="host:port for TCP, or a serial device path")
ap.add_argument("--baud", type=int, default=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("--download", action="store_true",
help="also download the first stored event (read-only)")
ap.add_argument("--lenient", action="store_true",
help="do not raise on a bad checksum — for diagnosis only")
a = ap.parse_args()
if ":" in a.target and not Path(a.target).exists():
host, _, port = a.target.rpartition(":")
inner = TcpTransport(host, int(port), connect_timeout=a.timeout)
path = f"TCP {host}:{port}"
else:
inner = StdlibSerial(a.target, baud=a.baud)
path = f"serial {a.target} @ {a.baud}"
transport = _Timed(inner)
mm = MicromateClient(transport, recv_timeout=a.timeout,
strict_checksums=not a.lenient)
print(f"\n{path} (read-only: POLL, SERIAL, state, status, setups)\n")
t0 = time.monotonic()
try:
mm.open()
except OSError as e:
print(f" connect.................... FAILED {e}")
return 2
print(f" {'connect':.<26} {1000*(time.monotonic()-t0):7.0f} ms")
try:
info = step("connect() identity", mm.connect)
if info:
print(f" serial={info.serial} model={info.model} "
f"fw={info.firmware_line} monitoring={info.monitoring}")
print(f" active setup={info.active_setup!r}")
if info.firmware_line == "thor":
print(" *** 11.0BD unit — the FIRST one this code has met. ***")
print(" *** Check the battery and clock below carefully: ***")
print(" *** its 0x1C block is 4 bytes longer. ***")
state = step("get_state()", mm.get_state)
if state:
print(f" {state}")
if state.battery_volts and not 2.5 < state.battery_volts < 9.0:
print(f" *** battery {state.battery_volts} V is impossible — "
f"this is the from-the-end offset bug. ***")
if state.device_time is None:
print(" *** device clock did not decode — dump raw below. ***")
print(f" raw 0x1C content: {_content(state.raw).hex(' ')}")
setups = step("list_setups()", mm.list_setups)
if setups:
print(f" first={setups[0]!r} last={setups[-1]!r}")
if a.download:
print("\n event chain (read-only):")
proto = mm.protocol
proto.arm_event()
hdr = _content(proto.read_event_first())
key, size = hdr[0:4], int.from_bytes(hdr[4:8], "big")
if not size:
print(" no events stored")
else:
print(f" first event key={key.hex()} size={size} B")
t1 = time.monotonic()
blob = proto.read_event_file(key, size)
dt = time.monotonic() - t1
print(f" downloaded {len(blob)} B in {dt:.1f} s "
f"({len(blob)/dt/1024:.1f} KiB/s)")
assert len(blob) == size
# Decode it with the existing codec to prove the bytes are real.
try:
from micromate.idf_file import read_idf_file
import tempfile
with tempfile.NamedTemporaryFile(suffix=".IDFW", delete=False) as f:
f.write(blob)
tmp = f.name
ev = read_idf_file(tmp)
print(f" decoded OK: {ev}")
except Exception as e:
print(f" decode failed: {type(e).__name__}: {e}")
out = Path(f"./{key.hex()}.IDFW")
out.write_bytes(blob)
print(f" saved to {out} for offline analysis")
except ProtocolError as e:
print(f"\n ABORTED {type(e).__name__}: {e}")
return 3
finally:
mm.close()
print(f"\n transport: {transport.reads} reads, "
f"{transport.bytes_in} B in, {transport.bytes_out} B out")
print(" A modem path should show MORE reads for the same bytes than USB —")
print(" that is the buffering, and it is exactly what needed proving.\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())