verify(micromate): 11.0BD confirmed on hardware -- every inference held

UM20147 read over USB by mm_client_check.py.  The Thor firmware line was
entirely inference until now, and two of its three differences were covered
only by SYNTHESISED test frames.  All three held:

- flags = 0x03 identifies the line -> reported firmware_line="thor".  That is
  only reachable if `10 03` in the flags position destuffs before indexing,
  since 0x03 is ETX -- so the escaped-flags case is confirmed too.
- the model string is shorter -> reported "MM/ISEE/S" exactly.  Anchoring the
  search on b"MM/" rather than a fixed span is what made this work.
- the 0x1C block is 4 bytes longer with the extras TRAILING, so from-the-start
  offsets survive -> battery 3.55 V and a clock correct to the second.

That last one is the one that mattered.  Series III's from-the-end offsets
would have given this unit 577.92 V, which is why mm_client_check watches for
an impossible voltage: it is a free self-check on exactly the inference most
likely to be wrong.

Also clean: serial UM20147, 5 setups (factory.MMB -> test2.mmb), active setup
test2.mmb, 15,000,000 B 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
rather than by matching response SUBs.

Test and docstring claims downgraded from inference to confirmed where the
hardware settled them, and left as synthesised-frame notes where it did not:
the BEHAVIOUR is confirmed but raw BD bytes are still not in the repo.

Added --capture DIR to mm_client_check: writes a raw_bw_*/raw_s3_* pair in the
layout scratch/mm_frame_parse.py already reads, so a run on an unfamiliar unit
becomes a test fixture without setting up a relay.  Verified by round-tripping
its own output through that parser: 28 frames, 0 bad checksums.

Still not covered: a download from a BD unit (UM20147 had no events stored, so
the chunk walk remains CB-only), raw BD fixture bytes, a monitoring unit, a
nearly-full buffer, and the inbound call-home session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
This commit is contained in:
2026-09-30 13:17:53 -04:00
co-authored by Claude Opus 5
parent 0d6eb1621f
commit b55e4e946a
5 changed files with 84 additions and 13 deletions
+36 -3
View File
@@ -125,13 +125,33 @@ class StdlibSerial:
class _Timed: 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._inner = inner
self.reads = 0 self.reads = 0
self.bytes_in = 0 self.bytes_in = 0
self.bytes_out = 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): def connect(self):
return self._inner.connect() return self._inner.connect()
@@ -144,6 +164,8 @@ class _Timed:
def write(self, data: bytes): def write(self, data: bytes):
self.bytes_out += len(data) self.bytes_out += len(data)
if self._bw:
self._bw.write(data); self._bw.flush()
return self._inner.write(data) return self._inner.write(data)
def read(self, n: int) -> bytes: def read(self, n: int) -> bytes:
@@ -151,6 +173,8 @@ class _Timed:
if chunk: if chunk:
self.reads += 1 self.reads += 1
self.bytes_in += len(chunk) self.bytes_in += len(chunk)
if self._s3:
self._s3.write(chunk); self._s3.flush()
return chunk return chunk
@@ -236,6 +260,10 @@ def main() -> int:
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)")
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", ap.add_argument("--lenient", action="store_true",
help="do not raise on a bad checksum — for diagnosis only") help="do not raise on a bad checksum — for diagnosis only")
a = ap.parse_args() a = ap.parse_args()
@@ -248,7 +276,7 @@ def main() -> int:
inner = StdlibSerial(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, capture=a.capture)
mm = MicromateClient(transport, recv_timeout=a.timeout, mm = MicromateClient(transport, recv_timeout=a.timeout,
strict_checksums=not a.lenient) strict_checksums=not a.lenient)
@@ -311,6 +339,11 @@ def main() -> int:
return 3 return 3
finally: finally:
mm.close() 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 elapsed = time.monotonic() - t0
print(f"\n transport: {transport.reads} reads, " print(f"\n transport: {transport.reads} reads, "
+33 -3
View File
@@ -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 `thor-watcher` forwards today, so `/db/import/idf_file` ingests a directly
downloaded event unchanged. 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 ### Still not covered
- **`11.0BD`** — UM12947 is a `11.0CB` unit. The Thor firmware line is still - **A download from a BD unit.** UM20147 had no events stored, so the chunk
entirely inference: `flags = 0x03`, a shorter model string, and a `0x1C` walk is verified on `11.0CB` only. Nothing suggests it differs — the `0x5A`
block 4 bytes longer. `mm_client_check.py` says so loudly when it meets one. 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. - **A unit that is monitoring**, and a unit with a nearly-full event buffer.
- **The inbound call-home session** — still the one protocol unknown. - **The inbound call-home session** — still the one protocol unknown.
+4 -3
View File
@@ -208,9 +208,10 @@ class MicromateClient:
battery voltage of 577.92 V. The four extra bytes are trailing, so battery voltage of 577.92 V. The four extra bytes are trailing, so
from-the-start offsets hold for both lines. from-the-start offsets hold for both lines.
⚠ Verified on `11.0CB` only. That the same offsets hold on `11.0BD` ✅ **Confirmed on `11.0BD` 2026-09-30.** UM20147 read back 3.55 V and
follows from the extra bytes being trailing, which is documented but not a clock correct to the second over USB, so the from-the-start offsets do
something this code has seen. 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() data = self._proto.read_monitor_status()
c = _content(data) c = _content(data)
+4
View File
@@ -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 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. 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) bd = bytearray(POLL)
assert bd[CONTENT + 26:CONTENT + 38] == b"MM/ISEE/S/IO" assert bd[CONTENT + 26:CONTENT + 38] == b"MM/ISEE/S/IO"
+7 -4
View File
@@ -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. 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 SYNTHESISED frame, but the BEHAVIOUR IS CONFIRMED on real hardware:
on 2026-09-23 and those bins never landed in the repo. Built by re-stuffing UM20147 (11.0BD) was read over USB on 2026-09-30 and reported
the captured POLL probe reply's payload with flags flipped to 0x03, so the firmware_line="thor", which is only reachable if `10 03` in the flags
only difference from a real frame is the one byte under test. 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] real = unstuff(RSP_POLL_PROBE[1:-1])[:-1]
payload = bytes([real[0], FLAGS_THOR]) + real[2:] payload = bytes([real[0], FLAGS_THOR]) + real[2:]