The tool downloaded only the first event, which on UM20147 is a 4,796 B histogram -- so it could not reach the 72,560 B event that is the whole point of the uint32 chunk-offset change. --event takes a key in hex, 'first', 'largest' or 'all'. 'largest' is the one worth running on an unfamiliar unit, since it is what exercises offsets past 64 KB; the output flags any event over 64 KB as carrying into params[1], and prints the chunk count so a 71-chunk walk is visible as such. Selected events are downloaded from inside iter_events(), so the chain cursor sits where THOR's would -- non-selected events are walked past without a download, which the browse walk already attests is fine. Also: connect() returning nothing now prints a hint rather than just a bare timeout. /dev/ttyACM* numbering shifts whenever another CDC-ACM device is plugged in, and this cost a debugging session when the tool was aimed at a TMI-Seismo MCU instead of the Micromate. `ls -l /dev/serial/by-id/` names each device and is stable, so the hint says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
429 lines
18 KiB
Python
429 lines
18 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.
|
|
|
|
With `capture`, also writes the raw byte streams to a `raw_bw_*` /
|
|
`raw_s3_*` pair in the layout `scratch/mm_frame_parse.py` already reads --
|
|
so a run on an unfamiliar unit can be turned into test fixtures without
|
|
setting up a relay.
|
|
"""
|
|
|
|
def __init__(self, inner, capture: str | None = None) -> None:
|
|
self._inner = inner
|
|
self.reads = 0
|
|
self.bytes_in = 0
|
|
self.bytes_out = 0
|
|
self._bw = self._s3 = None
|
|
if capture:
|
|
stamp = time.strftime("%Y%m%d_%H%M%S")
|
|
d = Path(capture)
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
self.bw_path = d / f"raw_bw_{stamp}_mm_client_check.bin"
|
|
self.s3_path = d / f"raw_s3_{stamp}_mm_client_check.bin"
|
|
self._bw = open(self.bw_path, "wb")
|
|
self._s3 = open(self.s3_path, "wb")
|
|
|
|
def close_capture(self) -> None:
|
|
for f in (self._bw, self._s3):
|
|
if f:
|
|
f.close()
|
|
|
|
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)
|
|
if self._bw:
|
|
self._bw.write(data); self._bw.flush()
|
|
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)
|
|
if self._s3:
|
|
self._s3.write(chunk); self._s3.flush()
|
|
return chunk
|
|
|
|
|
|
# ⚠ HYPOTHESIS, 6 events. content[11] of the 0x0C record separated 4 waveforms
|
|
# from 2 histograms cleanly and was constant within each group. A 4/2 split is
|
|
# thin evidence for a byte that could be anything, so _decode() below does NOT
|
|
# trust it -- it tries the other suffix on failure and says when the guess was
|
|
# wrong. The protocol reference states no type field is known; this may be it.
|
|
_TYPE_BYTE = 11
|
|
_TYPES = {0x07: ".IDFW", 0x08: ".IDFH"}
|
|
|
|
|
|
def _event_type(record: bytes) -> str:
|
|
if len(record) <= _TYPE_BYTE:
|
|
return "?"
|
|
b = record[_TYPE_BYTE]
|
|
return {0x07: "waveform", 0x08: "histogram"}.get(b, f"unknown(0x{b:02x})")
|
|
|
|
|
|
def _decode(blob: bytes, key: bytes, record: bytes) -> None:
|
|
"""Decode the downloaded bytes, proving they are a real event file.
|
|
|
|
read_idf_file() picks waveform vs histogram from the FILENAME SUFFIX, and a
|
|
wire download has no filename -- so the suffix has to come from somewhere.
|
|
This tries the 0x0C type byte first and the other suffix second; getting a
|
|
decode either way proves the chunk assembly, and which one worked is itself
|
|
the finding.
|
|
"""
|
|
import tempfile
|
|
from micromate.idf_file import read_idf_file
|
|
|
|
guess = _TYPES.get(record[_TYPE_BYTE] if len(record) > _TYPE_BYTE else -1, ".IDFW")
|
|
order = [guess] + [e for e in (".IDFW", ".IDFH") if e != guess]
|
|
|
|
for n, ext in enumerate(order):
|
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
|
|
f.write(blob)
|
|
tmp = f.name
|
|
try:
|
|
res = read_idf_file(tmp)
|
|
samples = sum(len(v) for v in getattr(res, "samples", {}).values())
|
|
note = "" if n == 0 else f" *** the 0x0C type byte guessed {guess} — WRONG ***"
|
|
print(f" decoded OK as {ext}: {samples} samples{note}")
|
|
os.unlink(tmp)
|
|
return
|
|
except Exception as e:
|
|
last = f"{ext}: {type(e).__name__}: {e}"
|
|
finally:
|
|
if os.path.exists(tmp):
|
|
os.unlink(tmp)
|
|
|
|
print(f" decode failed BOTH ways — last: {last}")
|
|
out = Path(f"./{key.hex()}.bin")
|
|
out.write_bytes(blob)
|
|
print(f" saved to {out} for offline analysis")
|
|
|
|
|
|
def _select(refs, which: str):
|
|
"""Pick which events `--download` fetches."""
|
|
which = which.strip().lower()
|
|
if which == "first":
|
|
return refs[:1]
|
|
if which == "all":
|
|
return refs
|
|
if which == "largest":
|
|
return [max(refs, key=lambda r: r.size)]
|
|
return [r for r in refs if r.key_hex.lower() == which]
|
|
|
|
|
|
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 walk the event chain and download (read-only)")
|
|
ap.add_argument("--event", default="first", metavar="WHICH",
|
|
help="which event --download fetches: a key in hex "
|
|
"(e.g. 055d4a83), 'first', 'largest', or 'all'. "
|
|
"'largest' is the one worth running on an unfamiliar "
|
|
"unit -- it is what exercises offsets past 64 KB.")
|
|
ap.add_argument("--capture", metavar="DIR",
|
|
help="also write a raw_bw_*/raw_s3_*.bin pair to DIR, so "
|
|
"this run can become a test fixture. Worth doing on "
|
|
"any unit whose firmware line is new to us.")
|
|
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, capture=a.capture)
|
|
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 is None:
|
|
print("\n Nothing answered. If the port opened but no frame came back,")
|
|
print(" check it is actually a Micromate and not another CDC-ACM device —")
|
|
print(" /dev/ttyACM* numbering shifts when anything else is plugged in.")
|
|
print(" `ls -l /dev/serial/by-id/` names each device and is stable.")
|
|
return 3
|
|
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}")
|
|
|
|
# ── SUB 0x06: is content[0:4] really the event count? ─────────────
|
|
# Two samples on one unit said yes, and THOR reads it BEFORE the chain
|
|
# walk then stops without ever reading the sentinel. A third value
|
|
# either confirms it or kills it.
|
|
claimed = None
|
|
raw06 = step("0x06 storage range", mm.protocol.read_storage_range)
|
|
if raw06:
|
|
c = _content(raw06)
|
|
claimed = int.from_bytes(c[0:4], "big")
|
|
print(f" content[0:4] = {claimed} <- CANDIDATE: event count")
|
|
print(f" content[4:8] = {int.from_bytes(c[4:8],'big')} "
|
|
f"<- unexplained (read 9 alongside a 6 on UM12947)")
|
|
|
|
if a.download:
|
|
print("\n event chain (read-only), via MicromateClient:")
|
|
t1 = time.monotonic()
|
|
refs = mm.list_events() # walks to the sentinel
|
|
walk = time.monotonic() - t1
|
|
print(f" {len(refs)} events in {walk:.1f} s "
|
|
f"({walk/max(len(refs),1):.2f} s each, 3 round trips per event)")
|
|
|
|
if claimed is not None:
|
|
verdict = ("✓ AGREES" if claimed == len(refs)
|
|
else f"✗ DISAGREES (0x06 said {claimed})")
|
|
print(f" 0x06 count vs chain length: {verdict}")
|
|
|
|
for ref in refs:
|
|
print(f" {ref}")
|
|
print(f" would be filed as {ref.filename}")
|
|
|
|
if refs:
|
|
wanted = _select(refs, a.event)
|
|
if not wanted:
|
|
print(f"\n --event {a.event!r} matched nothing")
|
|
else:
|
|
print(f"\n download ({len(wanted)} of {len(refs)}, "
|
|
f"THOR's interleaved order):")
|
|
want_keys = {r.key_hex for r in wanted}
|
|
# iter_events() walks the chain; download only the selected
|
|
# events, at the cursor position THOR would be at.
|
|
for ref in mm.iter_events():
|
|
if ref.key_hex not in want_keys:
|
|
continue
|
|
n_chunks = -(-ref.size // 1024)
|
|
note = (" <- past 64 KB, carries into params[1]"
|
|
if ref.size > 0x10000 else "")
|
|
t1 = time.monotonic()
|
|
try:
|
|
result = mm.get_event(ref) # verify=True
|
|
except Exception as e:
|
|
print(f" {ref.key_hex} {ref.size:7} B "
|
|
f"FAILED {type(e).__name__}: {e}")
|
|
continue
|
|
dt = max(time.monotonic() - t1, 1e-6)
|
|
n = sum(len(v) for v in getattr(result, "samples", {}).values())
|
|
err = mm.decode_error(ref, result)
|
|
check = ("PVS %+.4f%%" % (100 * err) if err is not None
|
|
else "PVS n/a (histogram)")
|
|
print(f" {ref.key_hex} {ref.record_type:9} "
|
|
f"{ref.size:7} B {n_chunks:3} chunks {dt:5.1f} s "
|
|
f"{n:6} samples {check}{note}")
|
|
|
|
except ProtocolError as e:
|
|
print(f"\n ABORTED {type(e).__name__}: {e}")
|
|
return 3
|
|
finally:
|
|
mm.close()
|
|
transport.close_capture()
|
|
if a.capture:
|
|
print(f"\n capture written:\n {transport.bw_path}\n {transport.s3_path}")
|
|
print(" parse it with: python3 scratch/mm_frame_parse.py "
|
|
f"{transport.bw_path} {transport.s3_path}")
|
|
|
|
elapsed = time.monotonic() - t0
|
|
print(f"\n transport: {transport.reads} reads, "
|
|
f"{transport.bytes_in} B in, {transport.bytes_out} B out, "
|
|
f"{elapsed:.1f} s total")
|
|
print(" Measured 2026-09-29, UM12947, same unit both ways:")
|
|
print(" USB-B (CDC-ACM) 83 reads list_setups 0.46 s download 394 KiB/s")
|
|
print(" RX55 (TCP) 36 reads list_setups 16.05 s download 1.6 KiB/s")
|
|
print(" The modem needs FEWER reads, not more -- it buffers ~1 s and then")
|
|
print(" forwards one large segment, where CDC-ACM delivers many small ones.")
|
|
print(" Cost is ~0.65 s PER ROUND TRIP regardless of payload size, so what")
|
|
print(" matters over cellular is the number of commands, not the bytes.\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|