diff --git a/docs/micromate_protocol_reference.md b/docs/micromate_protocol_reference.md index a6371ab..326e6bc 100644 --- a/docs/micromate_protocol_reference.md +++ b/docs/micromate_protocol_reference.md @@ -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. diff --git a/tests/test_micromate_client.py b/tests/test_micromate_client.py index 6f1f914..2baac1d 100644 --- a/tests/test_micromate_client.py +++ b/tests/test_micromate_client.py @@ -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" diff --git a/tests/test_micromate_framing.py b/tests/test_micromate_framing.py index 15966c1..3392154 100644 --- a/tests/test_micromate_framing.py +++ b/tests/test_micromate_framing.py @@ -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():