test(micromate): real 11.0BD fixtures replace the synthesised ones

UM20147 captured over USB with mm_client_check.py --capture, read out with
scratch/mm_frame_parse.py: 11 frames each direction, 0 bad checksums.  The two
BD-specific response data sections are now verbatim fixtures; only the frame
wrapper is rebuilt, which is sound because the framing is verified 251/251
elsewhere in the same file, and the reconstruction is pinned against the
payload length the parser reported.

Every field lands where the forward-offset model says it should:

  data[0]        = 0x30 = 48 = 59 - 11   (CB declares 0x2C = 44; the +4 holds)
  content[1]     = 0x0e   monitoring
  content[2:10]  = 30 Sep 2026 14:00:49  matches the wall clock
  content[34:36] = 3.50 V
  content[36:44] = 15,000,000 total / 14,848,448 free

AND THE WARNING IS NOW DEMONSTRATED ON THE UNIT IT PREDICTED.  Series III reads
battery from data[-10:-8], which on this block is e1 c0 -> 577.92 V.  That is
the exact figure the A/B section named, and there is now a test asserting it,
so the reason the offsets are forward cannot be quietly refactored away.

Two corrections to the reference:

1. The four extra BD bytes are `0f a0 00 04`, not `0f a0 00 00`.  Only the
   first two look fixed.  Nothing reads them, but the last is not padding.

2. POLL content[3] VARIES BETWEEN UNITS -- 0x50 on UM12947, 0x56 on UM20147.
   It is printable in both cases ("P" and "V"), which is a second, independent
   reason the POLL block cannot be parsed by scanning for printable runs: a
   scan returns "PInstantel" on one unit and "VInstantel" on the other.  The
   fixed offset content[4] was the right call for a reason I had not seen.

The model string sits at content[26] on both firmware lines; only its tail
differs, so anchoring on "MM/" remains correct.

98 micromate tests pass.  Full suite unchanged at 16 pre-existing failures.

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 14:08:27 -04:00
co-authored by Claude Opus 5
parent ea229ed19d
commit 080fb92ba2
3 changed files with 161 additions and 16 deletions
+45 -4
View File
@@ -919,16 +919,57 @@ know about?" answers *no* on this unit. Worth fixing before it is a field bug
Thor's own UI couples the scheduler to a setup name, so name matching is on the
path for anything that replaces it.
### ✅ Real BD bytes, and the exact numbers (2026-09-30)
Captured with `mm_client_check.py --capture` and read out with
`scratch/mm_frame_parse.py`: 11 frames each direction, 0 bad checksums. Both
BD-specific responses are now real fixtures in `tests/`, replacing synthesised
ones.
**`SUB 0x1C`, BD, 59 B data — the block the from-the-end offsets break on:**
```
content[ 1] 0x0e monitoring
content[ 2:6] 1e 09 07ea 30 Sep 2026
content[ 6] 0x60 = 96 ⚠ still unidentified (32/100/116 on CB)
content[ 7:10] 0e 00 31 14:00:49 ✓ matches the wall clock
content[34:36] 01 5e 3.50 V
content[36:40] 00 e4 e1 c0 15,000,000
content[40:44] 00 e2 91 c0 14,848,448
content[44:48] 0f a0 00 04 ⚠ the four extra bytes
```
`data[0] = 0x30` = 48 = 59 − 11, against CB's `0x2C` = 44. The low-byte length
rule and the +4 both hold.
⚠ **The extra four bytes are `0f a0 00 04`, not `0f a0 00 00`** as recorded
earlier. Only the first two look fixed. Nothing reads them, but do not treat
the last as padding.
**And the warning is now demonstrated on the unit it predicted:**
`data[-10:-8]` on this block is `e1 c0` → **577.92 V**. Exactly the figure in
the A/B section. Forward offsets give 3.50 V.
**`SUB 0x5B` POLL, BD:**
```
content[ 3] 0x56 ⚠ 0x50 on UM12947 — THIS BYTE VARIES BETWEEN UNITS
content[ 4] "Instantel\0"
content[26] "MM/ISEE/S\0" (CB: "MM/ISEE/S/IO\0", same offset)
```
⚠ **content[3] is not a constant.** It is printable on both units — `P` and `V`
— which is the second reason a printable-run scan is the wrong way to read this
block: it would return `PInstantel` on one unit and `VInstantel` on the other.
Read the vendor at the fixed offset content[4]; the model is at content[26] on
both lines, but anchor on `MM/` since only its tail varies.
### Still not covered
- **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.
+93
View File
@@ -63,6 +63,27 @@ MS_LATE = bytes.fromhex( # 55 B, from_20260925_011403_
"e4e1c000e3e1c0"
)
# ── Real 11.0BD bytes (UM20147, captured over USB 2026-09-30) ─────────────────
#
# The Thor firmware line, which was pure inference until this capture. These
# are the two responses where BD differs from CB. Captured with
# `mm_client_check.py --capture` and read out of the resulting pair with
# scratch/mm_frame_parse.py, so the data sections are verbatim; only the frame
# wrapper is rebuilt, and the framing is independently verified 251/251.
POLL_BD = bytes.fromhex( # 59 B -- flags 0x03, and the SHORTER model string
"30000000000000000000000000005649"
"6e7374616e74656c000600c3f04a00e4"
"194f0052024d4d2f495345452f530058"
"f406001f60c0755760c075"
)
MS_BD_MONITORING = bytes.fromhex( # 59 B -- FOUR BYTES LONGER than CB's 55
"3000000000000000000000000e1e0907"
"ea600e00310000000000000000000000"
"00000000000000000000000000015e00"
"e4e1c000e291c00fa00004"
)
_SETUP_PAD = 266 - CONTENT
@@ -398,3 +419,75 @@ def test_context_manager_opens_and_closes():
assert t.is_connected()
assert mm.connect().serial == "UM12947"
assert not t.is_connected()
# ── The Thor firmware line, on real bytes ─────────────────────────────────────
def test_bd_constants_have_the_lengths_the_parser_reported():
assert len(POLL_BD) == 59
assert len(MS_BD_MONITORING) == 59, "CB is 55; BD is four bytes longer"
assert MS_BD_MONITORING[0] == 0x30, "low-byte length 48 = 59 - 11"
assert MS_IDLE[0] == 0x2C, "the CB equivalent declares 44"
def test_connect_on_a_thor_line_unit():
"""UM20147, real POLL bytes. flags 0x03 is ETX, so it arrives as `10 03`."""
mm, _ = client([
frame(0xA4, POLL_BD, flags=FLAGS_THOR), frame(0xEA, SERIAL),
frame(0xB6, STATE_MONITORING), frame(0xBE, setup_response("test2.MMB")),
])
info = mm.connect()
assert info.firmware_line == "thor"
assert info.model == "MM/ISEE/S", "the BD model string is shorter than CB's"
assert info.manufacturer == "Instantel"
assert info.monitoring is True
def test_poll_content_3_is_not_a_constant():
"""⚠ 0x50 on UM12947, 0x56 on UM20147 -- it VARIES between units.
This is why the manufacturer is read at the fixed offset content[4] and not
by scanning: content[3] is printable in both cases ("P" and "V"), so a
printable-run scan would return "PInstantel" on one unit and "VInstantel" on
the other. Whatever the byte is, it is not a stable marker to anchor on.
"""
assert _content(POLL)[3] == 0x50
assert _content(POLL_BD)[3] == 0x56
assert chr(_content(POLL_BD)[3]) == "V"
def test_get_state_on_a_thor_line_unit():
"""⚠ THE test for the forward-offset decision. Real UM20147 bytes.
The 0x1C block is four bytes longer here, and the extras are TRAILING, so
offsets measured from the start of content are unmoved. Series III reads
battery and memory from the END of this block; test_series_iii_offsets_...
below shows what that produces.
"""
mm, _ = client([frame(0xE3, MS_BD_MONITORING, flags=FLAGS_THOR)])
st = mm.get_state()
assert st.monitoring is True
assert st.device_time == datetime.datetime(2026, 9, 30, 14, 0, 49)
assert st.battery_volts == 3.50
assert st.memory_total_bytes == 15_000_000
assert st.memory_free_bytes == 14_848_448
def test_series_iii_from_the_end_offsets_give_577_volts_on_a_bd_unit():
"""The exact number the protocol reference warned about, now demonstrated.
577.92 V is not a plausible battery reading for anything, which is what
makes it a free self-check -- bridges/mm_client_check.py watches for it.
"""
assert int.from_bytes(MS_BD_MONITORING[-10:-8], "big") / 100 == 577.92
def test_the_four_extra_bd_bytes_are_not_all_zero():
"""⚠ The reference records them as `0f a0 00 00`; UM20147 sent `0f a0 00 04`.
Only the first two bytes look fixed. Nothing reads them, but a future
decoder must not treat the last one as padding.
"""
assert _content(MS_BD_MONITORING)[44:] == bytes.fromhex("0fa00004")
assert _content(MS_IDLE)[44:] == b"", "the CB block has no such tail"
+23 -12
View File
@@ -231,29 +231,40 @@ def test_escaped_bytes_land_in_the_right_field():
assert f.checksum_valid
# The real 11.0BD POLL data section, UM20147 over USB 2026-09-30. This
# replaces a synthesised frame -- the Thor firmware line was inference-only
# until this capture.
_POLL_BD_DATA = bytes.fromhex(
"30000000000000000000000000005649"
"6e7374616e74656c000600c3f04a00e4"
"194f0052024d4d2f495345452f530058"
"f406001f60c0755760c075"
)
def test_thor_firmware_line_survives_destuffing():
"""⚠ flags = 0x03 is ETX, so it arrives as `10 03`.
A parser that does not destuff ends the frame at byte 2 on half the fleet.
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.
The data section is REAL (UM20147, 11.0BD); the frame wrapper is rebuilt,
which is sound because the framing is verified 251/251 elsewhere in this
file. scratch/mm_frame_parse.py reported payload=64 for this frame, and
the assertion below pins that, so the reconstruction is checked rather than
assumed.
"""
real = unstuff(RSP_POLL_PROBE[1:-1])[:-1]
payload = bytes([real[0], FLAGS_THOR]) + real[2:]
synth = bytes([STX]) + stuff(payload + bytes([checksum(payload)])) + bytes([ETX])
payload = bytes([0x00, FLAGS_THOR, 0xA4, 0x00, 0x00]) + _POLL_BD_DATA
assert len(payload) == 64, "the parser reported payload=64 for this frame"
wire = bytes([STX]) + stuff(payload + bytes([checksum(payload)])) + bytes([ETX])
assert bytes([DLE, ETX]) in synth, "flags must be escaped on the wire"
(f,) = MicromateFrameParser().feed(synth)
assert bytes([DLE, ETX]) in wire, "flags 0x03 must be escaped on the wire"
(f,) = MicromateFrameParser().feed(wire)
assert f.flags == FLAGS_THOR
assert f.firmware_line == "thor"
assert f.sub == 0xA4
assert f.request_sub == 0x5B
assert f.checksum_valid
assert b"MM/ISEE/S\x00" in f.data, "the shorter BD model string"
def test_an_escaped_checksum_byte_is_read_correctly():