Files
seismo-relay/tests/test_micromate_framing.py
T
serversdownandClaude Opus 5 b55e4e946a 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
2026-09-30 13:17:53 -04:00

403 lines
16 KiB
Python

"""Framing tests for the Micromate (series-4) live protocol.
Every constant below is a **real frame**, lifted from
``bridges/captures/9-24-26 - micromate2/`` (UM12947, firmware 11.0CB) or from
``scratch/fake_unit.py``, which preserves a POLL probe reply. Frames are
embedded as hex rather than read from disk because both ``bridges/captures/``
and ``tests/fixtures/`` are gitignored -- these tests must pass on a fresh
clone.
The few synthesised frames are marked ``SYNTH_`` and each says what it stands
in for and why a captured frame was not available.
Two of these tests exist because the first draft of
``docs/micromate_client_spec.md`` got the rule wrong, and both wrong rules
fail quietly -- a frame the unit ignores, or a checksum that reads as bad:
* ``test_builder_matches_thor_byte_for_byte`` -- the escape set. Escaping
only 0x10 (the series-3 rule) reproduces 161 of Thor's 218 read frames.
* ``test_checksum_is_plain_sum8_over_destuffed_payload`` -- the checksum.
The DLE-aware variant disagrees with the wire on 55 of 251 responses.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from micromate.framing import (
ACK,
DLE,
ETX,
FLAGS_BLASTWARE,
FLAGS_THOR,
STX,
MicromateFrame,
MicromateFrameParser,
build_request,
checksum,
stuff,
unstuff,
)
# ── Captured response frames ──────────────────────────────────────────────────
# POLL probe reply, 19 B on the wire -- the shortest valid frame there is.
# Preserved in scratch/fake_unit.py, captured from UM12947 on 2026-09-24.
# Its payload[8:10] is 0x0030, the data length POLL then asks for.
RSP_POLL_PROBE = bytes.fromhex("0200c5a400000000000030000000000000009903")
# SUB 0x49 -> 0xB6, the cheap state read. Carries a literal 0x02, escaped.
RSP_STATE = bytes.fromhex("0200c5b6000005000000000000000000001002e8000b007503")
# SUB 0x48 -> 0xB7, a path-addressed file read. offset_hi 0x04 arrives escaped,
# so page_hi is only correct if the parser destuffs before indexing.
RSP_FILE_READ = bytes.fromhex("0200c5b70000100400000000000000010000000000018203")
# An 0x5A download chunk, 138 B on the wire. THE checksum case: its payload
# holds literal 0x10 bytes, so plain SUM8 (0xC1, correct) and the DLE-aware
# variant (0x91) disagree. Also holds a literal 0x41, which is NOT escaped.
RSP_CHUNK_WITH_DLE = bytes.fromhex(
"0200c5a500007000003400000000000000e3fd1f10020f0f0e2e1e1fd4f2d4c3d2f00f000"
"e003fe03fe200d6e2d12d2e1c4e101011d101f3d0f3e0f2efe23d2b2e3c0d3e0e2c4101e0"
"d4d3e21f1d311e202fe2e2f1e3f100210d201002ee2e22d2f2f0f30fd3f0101000f0111f1"
"ff01e011010011f0f1002b4d200e0f0302c4010020001fffedb2dc103"
)
# ── Captured request frames (Thor -> unit) ────────────────────────────────────
REQ_POLL = bytes.fromhex("41021010005b000030000000000000000000009b03")
REQ_SERIAL = bytes.fromhex("41021010001500000a000000000000000000002f03")
REQ_STATE = bytes.fromhex("41021010004900ffff000000000000000000005703")
REQ_COMPLIANCE = bytes.fromhex("41021010001a00ffff000000000000000000002803")
REQ_ARM_EVENT = bytes.fromhex("41021010009300ffff00000000000000000000a103")
# ⚠ The two frames that break a 0x10-only escaper.
# Scheduler enable: params[7] = 0x03, on the wire as `10 03`.
REQ_SCHED_ON = bytes.fromhex("41021010004700ffff00000000000000100300005803")
# Bulk download, first chunk of event 055d4a81: offset 0x0400 puts a literal
# 0x04 in offset_hi, on the wire as `10 04`. EVERY download frame needs this.
REQ_DOWNLOAD_CHUNK0 = bytes.fromhex(
"41021010005a00100400055d4a810000000000009b03"
)
# A later chunk of the same event: params[2:4] = 0x1000, doubled to `10 10`.
REQ_DOWNLOAD_CHUNK4 = bytes.fromhex(
"41021010005a0010040000001010000000000000007e03"
)
# ── Stuffing ──────────────────────────────────────────────────────────────────
def test_escape_set_is_exactly_four_bytes():
"""0x02, 0x03, 0x04 and 0x10 -- and nothing else.
ACK (0x41) in particular is NOT escaped; assuming it was reproduced only
196 of 251 captured responses.
"""
assert stuff(bytes([0x02, 0x03, 0x04, 0x10])) == bytes(
[DLE, 0x02, DLE, 0x03, DLE, 0x04, DLE, 0x10]
)
for b in (0x00, 0x01, 0x05, 0x41, 0xC5, 0xFF):
assert stuff(bytes([b])) == bytes([b]), f"0x{b:02x} must not be escaped"
def test_unstuff_is_uniform_with_no_inner_frame_carve_out():
"""`10 XX` -> `XX` for any XX -- the series-3 DLE+ETX exception is absent."""
assert unstuff(bytes.fromhex("1003")) == b"\x03"
assert unstuff(bytes.fromhex("1010")) == b"\x10"
assert unstuff(bytes.fromhex("001002ff")) == bytes.fromhex("0002ff")
def test_stuff_unstuff_round_trips_over_every_byte_value():
data = bytes(range(256))
assert unstuff(stuff(data)) == data
def test_a_trailing_dle_is_held_not_dropped():
"""A DLE as the last byte of a chunk must not consume nothing and vanish."""
assert unstuff(b"\xff\x10") == b"\xff\x10"
# ── Checksum ──────────────────────────────────────────────────────────────────
def test_checksum_is_plain_sum8_over_destuffed_payload():
"""⚠ Plain SUM8 -- do NOT exclude 0x10 bytes.
RSP_CHUNK_WITH_DLE is a real frame whose payload holds literal 0x10 bytes.
The wire says 0xC1; plain SUM8 gives 0xC1 and the DLE-aware variant used by
series-3 `5A`/write frames gives 0x91. 55 of 251 captured responses
disagree the same way.
"""
payload = unstuff(RSP_CHUNK_WITH_DLE[1:-1])[:-1]
chk_on_wire = unstuff(RSP_CHUNK_WITH_DLE[1:-1])[-1]
assert DLE in payload, "this frame is only interesting if it holds a 0x10"
assert checksum(payload) == chk_on_wire == 0xC1
assert (sum(b for b in payload if b != DLE) & 0xFF) == 0x91 # the wrong rule
def test_every_captured_frame_validates():
parser = MicromateFrameParser()
frames = parser.feed(
RSP_POLL_PROBE + RSP_STATE + RSP_FILE_READ + RSP_CHUNK_WITH_DLE
)
assert len(frames) == 4
assert all(f.checksum_valid for f in frames)
# ── Request builder ───────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"wire, sub, offset, params",
[
(REQ_POLL, 0x5B, 0x0030, bytes(10)),
(REQ_SERIAL, 0x15, 0x000A, bytes(10)),
(REQ_STATE, 0x49, 0xFFFF, bytes(10)),
(REQ_COMPLIANCE, 0x1A, 0xFFFF, bytes(10)),
(REQ_ARM_EVENT, 0x93, 0xFFFF, bytes(10)),
(REQ_SCHED_ON, 0x47, 0xFFFF, bytes.fromhex("00000000000000030000")),
(REQ_DOWNLOAD_CHUNK0, 0x5A, 0x0400, bytes.fromhex("055d4a81000000000000")),
(REQ_DOWNLOAD_CHUNK4, 0x5A, 0x0400, bytes.fromhex("00001000000000000000")),
],
ids="poll serial state compliance arm sched_on dl_chunk0 dl_chunk4".split(),
)
def test_builder_matches_thor_byte_for_byte(wire, sub, offset, params):
assert build_request(sub, offset, params) == wire
def test_a_0x10_in_params_needs_no_special_handling():
"""The spec's one open question. Thor sends it; the wire doubles it."""
frame = build_request(0x5A, 0x0400, bytes.fromhex("00001000000000000000"))
assert bytes([DLE, DLE]) in frame
assert frame == REQ_DOWNLOAD_CHUNK4
def test_builder_rejects_malformed_arguments():
with pytest.raises(ValueError):
build_request(0x5B, 0, bytes(9))
with pytest.raises(ValueError):
build_request(0x5B, 0x10000)
with pytest.raises(ValueError):
build_request(0x100)
# ── Parsing ───────────────────────────────────────────────────────────────────
def test_poll_probe_reply_fields():
(f,) = MicromateFrameParser().feed(RSP_POLL_PROBE)
assert f.sub == 0xA4
assert f.request_sub == 0x5B
assert f.flags == FLAGS_BLASTWARE
assert f.firmware_line == "blastware"
assert f.checksum_valid
assert f.probe_length == 0x0030
def test_probe_length_is_a_uint16_not_a_byte():
"""⚠ Read as data[3] alone, SUB 0x1A's 0x082C (2092) reads as 44 -- 47x low.
SYNTHESISED: no probe response survives in the captures on disk (the
9-24-26 session uses single-step reads at offset 0xFFFF throughout, so it
contains no probes at all). The field position is taken from the captured
POLL probe reply above, which does exercise it for real.
"""
payload = bytes([0x00, FLAGS_BLASTWARE, 0xE5, 0x00, 0x00]) + bytes(
[0x00, 0x00, 0x00, 0x08, 0x2C]
)
synth = bytes([STX]) + stuff(payload + bytes([checksum(payload)])) + bytes([ETX])
(f,) = MicromateFrameParser().feed(synth)
assert f.probe_length == 0x082C == 2092
assert f.data[3] == 0x08, "the high byte is where a byte-wide read loses 2048"
def test_escaped_bytes_land_in_the_right_field():
"""RSP_FILE_READ's first data byte is 0x04, which arrives as `10 04`.
Without destuffing it reads as 0x10 and every field after it is one byte
late -- the failure mode that put `SUB 0x02` in the log as `SUB_10` for an
afternoon.
"""
(f,) = MicromateFrameParser().feed(RSP_FILE_READ)
assert f.sub == 0xB7
assert f.request_sub == 0x48
assert (f.page_hi, f.page_lo) == (0x00, 0x00)
assert f.data[0] == 0x04
assert len(f.data) == 15, "one byte shorter than the wire suggests"
assert f.checksum_valid
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.
"""
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])
assert bytes([DLE, ETX]) in synth, "flags must be escaped on the wire"
(f,) = MicromateFrameParser().feed(synth)
assert f.flags == FLAGS_THOR
assert f.firmware_line == "thor"
assert f.sub == 0xA4
assert f.checksum_valid
def test_an_escaped_checksum_byte_is_read_correctly():
"""SYNTHESISED, but the behaviour is real: three captured responses have a
checksum of 0x02/0x03/0x04 and all three escape it on the wire. The
shortest is 1,070 B (an 0x5A chunk in
raw_s3_20260925_011403_Download_events_then_delete_1_event.bin, chk = 0x03),
too long to embed for one byte's worth of assertion.
"""
payload = bytes([0x00, FLAGS_BLASTWARE, 0xA4, 0x00, 0x00, 0x03])
assert checksum(payload) == 0x03 + FLAGS_BLASTWARE + 0xA4 & 0xFF
body = payload + bytes([checksum(payload)])
synth = bytes([STX]) + stuff(body) + bytes([ETX])
(f,) = MicromateFrameParser().feed(synth)
assert f.chk_byte == checksum(payload)
assert f.checksum_valid
def test_a_corrupted_checksum_still_yields_a_frame():
"""Flag it, do not swallow it -- a dropped frame looks like a dead unit."""
broken = bytearray(RSP_POLL_PROBE)
broken[-2] ^= 0xFF
(f,) = MicromateFrameParser().feed(bytes(broken))
assert f.sub == 0xA4
assert not f.checksum_valid
def test_a_truncated_frame_yields_nothing():
parser = MicromateFrameParser()
assert parser.feed(RSP_POLL_PROBE[:-1]) == []
assert parser.frames == []
assert parser.bytes_fed == len(RSP_POLL_PROBE) - 1
def test_a_frame_too_short_to_hold_a_header_is_rejected():
assert MicromateFrameParser().feed(bytes([STX, 0x00, 0xC5, 0xA4, ETX])) == []
def test_request_frames_are_not_mistaken_for_responses():
"""Feeding a bidirectional capture must yield only the unit's side."""
parser = MicromateFrameParser()
frames = parser.feed(REQ_POLL + RSP_POLL_PROBE + REQ_SERIAL)
assert len(frames) == 1
assert frames[0].request_sub == 0x5B
def test_leading_noise_is_discarded():
"""Cold-boot banners and the RV55's RING/CONNECT chatter precede frames."""
noise = b"\r\nRING\r\n\r\nCONNECT\r\n" + bytes([ACK])
(f,) = MicromateFrameParser().feed(noise + RSP_POLL_PROBE)
assert f.sub == 0xA4
assert f.checksum_valid
def test_frames_split_across_feeds_reassemble():
"""The transport hands over whatever the socket returned, DLE pairs and all."""
whole = RSP_STATE + RSP_CHUNK_WITH_DLE
for cut in (1, 2, 5, 17, 24, 25, 40, len(RSP_STATE), len(whole) - 1):
parser = MicromateFrameParser()
got = parser.feed(whole[:cut]) + parser.feed(whole[cut:])
assert len(got) == 2, f"split at {cut} lost a frame"
assert all(f.checksum_valid for f in got), f"split at {cut} broke a checksum"
def test_reset_clears_partial_state():
parser = MicromateFrameParser()
parser.feed(RSP_POLL_PROBE[:6])
parser.reset()
assert parser.bytes_fed == 0
(f,) = parser.feed(RSP_POLL_PROBE)
assert f.checksum_valid
def test_firmware_line_of_an_unknown_flags_byte():
f = MicromateFrame(sub=0xA4, flags=0x99, page_hi=0, page_lo=0,
data=b"", checksum_valid=True)
assert f.firmware_line == "unknown"
assert f.probe_length is None
# ── Whole-session round trip ──────────────────────────────────────────────────
_CAPTURES = (
Path(__file__).resolve().parents[1]
/ "bridges" / "captures" / "9-24-26 - micromate2"
)
@pytest.mark.skipif(
not _CAPTURES.is_dir(),
reason="capture directory is gitignored; present only on a dev box",
)
def test_whole_captured_sessions_parse_with_no_bad_checksums():
"""Belt-and-braces against the real bytes when they happen to be here.
scratch/mm_frame_parse.py reported zero bad checksums on these sessions
only because it accepts a frame matching *either* checksum rule. This
asserts the single rule holds across all of them.
"""
total = 0
for path in sorted(_CAPTURES.rglob("raw_s3_*.bin")):
parser = MicromateFrameParser()
frames = parser.feed(path.read_bytes())
assert frames, f"{path.name}: no frames parsed"
bad = [f for f in frames if not f.checksum_valid]
assert not bad, f"{path.name}: {len(bad)} bad checksums"
total += len(frames)
assert total == 251, f"expected 251 response frames across the corpus, got {total}"
@pytest.mark.skipif(
not _CAPTURES.is_dir(),
reason="capture directory is gitignored; present only on a dev box",
)
def test_builder_reproduces_every_captured_read_frame():
"""218/218. This is the test that would have caught the escape-set error."""
checked = 0
for path in sorted(_CAPTURES.rglob("raw_bw_*.bin")):
blob = path.read_bytes()
i = 0
while i < len(blob):
if not (blob[i] == ACK and i + 1 < len(blob) and blob[i + 1] == STX):
i += 1
continue
j = i + 2
body = bytearray()
while j < len(blob):
if blob[j] == DLE and j + 1 < len(blob):
body.append(blob[j + 1])
j += 2
continue
if blob[j] == ETX:
break
body.append(blob[j])
j += 1
payload = bytes(body[:-1])
if len(payload) == 16: # a read frame; writes carry a data section
sub = payload[2]
offset = (payload[4] << 8) | payload[5]
assert build_request(sub, offset, payload[6:16]) == blob[i:j + 1], (
f"{path.name} @0x{i:04x} SUB=0x{sub:02x} offset=0x{offset:04x}"
)
checked += 1
i = j + 1
assert checked == 218, f"expected 218 read frames, checked {checked}"