diff --git a/bridges/mm_client_check.py b/bridges/mm_client_check.py index 712ad81..bce8921 100644 --- a/bridges/mm_client_check.py +++ b/bridges/mm_client_check.py @@ -125,13 +125,33 @@ class StdlibSerial: class _Timed: - """Count bytes and time each read, so the two transports can be compared.""" + """Count bytes and time each read, so the two transports can be compared. - def __init__(self, inner) -> None: + 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() @@ -144,6 +164,8 @@ class _Timed: 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: @@ -151,6 +173,8 @@ class _Timed: if chunk: self.reads += 1 self.bytes_in += len(chunk) + if self._s3: + self._s3.write(chunk); self._s3.flush() return chunk @@ -236,6 +260,10 @@ def main() -> int: 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("--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() @@ -248,7 +276,7 @@ def main() -> int: inner = StdlibSerial(a.target, baud=a.baud) path = f"serial {a.target} @ {a.baud}" - transport = _Timed(inner) + transport = _Timed(inner, capture=a.capture) mm = MicromateClient(transport, recv_timeout=a.timeout, strict_checksums=not a.lenient) @@ -311,6 +339,11 @@ def main() -> int: 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, " diff --git a/docs/micromate_protocol_reference.md b/docs/micromate_protocol_reference.md index d11e450..819aa09 100644 --- a/docs/micromate_protocol_reference.md +++ b/docs/micromate_protocol_reference.md @@ -854,11 +854,41 @@ captured `0x5A` responses and fed to `micromate.idf_file.read_idf_file()`: `thor-watcher` forwards today, so `/db/import/idf_file` ingests a directly downloaded event unchanged. +### ✅ `11.0BD` confirmed on real hardware (2026-09-30) + +UM20147 read over USB. **Every inference about the Thor firmware line held**, +and two of the three were covered only by *synthesised* test frames until now: + +| inference | predicted | UM20147 reported | +|---|---|---| +| `flags = 0x03` identifies the line | `thor` | ✅ `thor` | +| `0x03` is ETX, so it arrives as `10 03` | destuffs before indexing | ✅ implied — the line could not be identified otherwise | +| model string is shorter | `MM/ISEE/S` | ✅ `MM/ISEE/S` | +| `0x1C` is 4 bytes longer, extras **trailing** | forward offsets survive | ✅ battery **3.55 V**, clock correct to the second | + +That last row is the one that mattered. Series III's from-the-end offsets would +have produced **577.92 V** on this unit — a number so obviously wrong it is a +cheap self-check, which is why `mm_client_check.py` watches for it. Reading a +plausible 3.55 V and a correct clock confirms the four extra bytes really are +trailing. + +Also read cleanly: serial `UM20147`, 5 setups (`factory.MMB` → `test2.mmb`), +active setup `test2.mmb`, 15,000,000 bytes total and free. 21 reads, 2,165 B in. + +**One protocol stack drives the whole fleet regardless of firmware line** — the +headline finding of 2026-09-23 — is now demonstrated by a working client, not +just by matching response SUBs. + ### Still not covered -- **`11.0BD`** — UM12947 is a `11.0CB` unit. The Thor firmware line is still - entirely inference: `flags = 0x03`, a shorter model string, and a `0x1C` - block 4 bytes longer. `mm_client_check.py` says so loudly when it meets one. +- **A download from a BD unit.** UM20147 had no events stored, so the chunk + walk is verified on `11.0CB` only. Nothing suggests it differs — the `0x5A` + prefix and the offset arithmetic are not firmware-line dependent in anything + observed — but it is untested. +- **Raw BD bytes are still not in the repo.** The behaviour is confirmed; the + test fixtures for the Thor line remain synthesised. `mm_client_check.py + --capture DIR` now writes a `raw_bw_*`/`raw_s3_*` pair in the layout + `scratch/mm_frame_parse.py` reads, so one more run on UM20147 would close it. - **A unit that is monitoring**, and a unit with a nearly-full event buffer. - **The inbound call-home session** — still the one protocol unknown. diff --git a/micromate/client.py b/micromate/client.py index 997faca..8149863 100644 --- a/micromate/client.py +++ b/micromate/client.py @@ -208,9 +208,10 @@ class MicromateClient: battery voltage of 577.92 V. The four extra bytes are trailing, so from-the-start offsets hold for both lines. - ⚠ Verified on `11.0CB` only. That the same offsets hold on `11.0BD` - follows from the extra bytes being trailing, which is documented but not - something this code has seen. + ✅ **Confirmed on `11.0BD` 2026-09-30.** UM20147 read back 3.55 V and + a clock correct to the second over USB, so the from-the-start offsets do + survive the four extra trailing bytes. Had they not, the battery would + have read 577.92 V — which is what makes this cheap to check. """ data = self._proto.read_monitor_status() c = _content(data) diff --git a/tests/test_micromate_client.py b/tests/test_micromate_client.py index ce0964c..6f1f914 100644 --- a/tests/test_micromate_client.py +++ b/tests/test_micromate_client.py @@ -245,6 +245,10 @@ def test_the_model_string_is_anchored_on_MM_not_on_an_offset(): Anchoring on b"MM/" survives that; a fixed end offset would not. A generic printable-run scan fails for a different reason -- see _parse_poll. + + ✅ CONFIRMED on real hardware 2026-09-30: UM20147 (11.0BD) read back + model="MM/ISEE/S" over USB. The frame below is still synthesised because + no BD capture is in the repo, but the string it asserts is the real one. """ bd = bytearray(POLL) assert bd[CONTENT + 26:CONTENT + 38] == b"MM/ISEE/S/IO" diff --git a/tests/test_micromate_framing.py b/tests/test_micromate_framing.py index d2c15b2..15966c1 100644 --- a/tests/test_micromate_framing.py +++ b/tests/test_micromate_framing.py @@ -236,10 +236,13 @@ def test_thor_firmware_line_survives_destuffing(): A parser that does not destuff ends the frame at byte 2 on half the fleet. - SYNTHESISED: no 11.0BD capture is on disk -- UM20147's sweep was recorded - on 2026-09-23 and those bins never landed in the repo. Built by re-stuffing - the captured POLL probe reply's payload with flags flipped to 0x03, so the - only difference from a real frame is the one byte under test. + SYNTHESISED frame, but the BEHAVIOUR IS CONFIRMED on real hardware: + UM20147 (11.0BD) was read over USB on 2026-09-30 and reported + firmware_line="thor", which is only reachable if `10 03` in the flags + position destuffed correctly. Raw BD bytes are still not in the repo, so + this frame stays synthesised -- built by re-stuffing the captured POLL + probe reply with flags flipped to 0x03, so the only difference from a real + frame is the one byte under test. """ real = unstuff(RSP_POLL_PROBE[1:-1])[:-1] payload = bytes([real[0], FLAGS_THOR]) + real[2:]