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