From c7ffdab5708b999a0084e2c1861d924348e1d73b Mon Sep 17 00:00:00 2001 From: serversdown Date: Mon, 28 Sep 2026 20:30:11 -0400 Subject: [PATCH] tooling(micromate): mm_client_check -- exercise the read client on real hardware Read-only: POLL, SERIAL, state, monitor status, the setup walk, and optionally one event download. Never writes, erases, or changes monitoring state. Exists because everything in micromate/{framing,protocol,client}.py is 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 buffers up to ~1 s before forwarding, so one logical response arrives as many small reads. The client reads to frame completion rather than using read_until_idle's idle-gap detection, which should be strictly more robust for this -- but that needed proving. - The 11.0BD firmware line, which reports flags=0x03, a shorter model string, and a 0x1C block 4 bytes longer. All inference from one 2026-09-23 sweep whose captures never landed in the repo. The tool says so loudly when it meets one, and flags an impossible battery voltage as the signature of the from-the-end offset bug. Run it over both paths and diff; anything differing beyond timings is a finding. It counts reads and bytes per transport, because a higher read count for the same bytes IS the buffering, made visible. Smoke-tested against a scripted TCP unit replaying captured response bytes in 37-byte dribbles: 289 reads to carry 10,958 B, every frame reassembled, a 4,076 B event downloaded across 4 chunks with the assembled length exact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL --- bridges/mm_client_check.py | 209 +++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 bridges/mm_client_check.py diff --git a/bridges/mm_client_check.py b/bridges/mm_client_check.py new file mode 100644 index 0000000..8bd86cb --- /dev/null +++ b/bridges/mm_client_check.py @@ -0,0 +1,209 @@ +#!/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 --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 sys +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 SerialTransport, TcpTransport # noqa: E402 + + +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; a Micromate's modem port runs at 115200") + 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 = SerialTransport(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())