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:
@@ -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, "
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
|||||||
Reference in New Issue
Block a user