Files
seismo-relay/tests/test_micromate_framing.py
T
serversdownandClaude Opus 5 5fe99568a2 feat(micromate): framing layer -- and three spec rules the bytes refuted
Step 1 of docs/micromate_client_spec.md: micromate/framing.py plus 31
offline tests.  Every rule was checked against the captures BEFORE being
written, which is the only reason this commit is not a bug.

Three things the spec asserted are wrong, all of which fail silently:

1. Requests are NOT plain Series III frames.  A Micromate escapes four
   byte values -- 0x02 0x03 0x04 0x10 -- where Series III escapes one.
   minimateplus.build_bw_frame reproduces 161 of Thor's 218 captured read
   frames; build_request() reproduces 218/218.  The 57 it missed include
   EVERY 0x5A download (offset 0x0400 puts a literal 0x04 in offset_hi)
   and the scheduler enable.  An unescaped 0x03/0x04 terminates the frame,
   so the unit never answers -- indistinguishable from a dead unit, and
   event download would have hit it on the first request ever sent.

2. The checksum is plain SUM8 of the destuffed payload, not the DLE-aware
   variant.  251/251 both directions.  The DLE-aware form is correct
   paired with Series III destuffing, which leaves an escaped byte as two
   bytes; after uniform destuffing it subtracts the correction twice and
   disagrees with the wire on 55 of 251 responses.

   scratch/mm_frame_parse.py shipped with exactly that pairing and looked
   clean only because it accepts either rule -- so it labelled those 55
   "SUM8" and never flagged one bad.  "Zero bad checksums" was true and
   carried no information.  A tool that tries N candidate rules cannot
   falsify any of them.  Fixed to validate against SUM8 alone.

3. SUB 0x5A is a 1024-byte chunk loop, not one request per event.  Thor's
   form, verified on all six bench events (4,076 -> 13,424 B): chunks =
   ceil(size/1024), offset = min(1024, size - 1024*i) as a byte count,
   params[2:4] = the byte offset, response data = offset + 11.
   sum(offsets) == size exactly, every time.

   This does not retract the earlier single-request observation -- that
   used offset_hi = 0x10, which in Series III is the bulk-stream marker,
   so it is plausibly a distinct streaming mode returning several frames.
   Those captures never landed in the repo, so it cannot be re-derived.
   Implement Thor's form; the other is worth one bench test.

Also: a 0x10 inside request params needs no special handling (settled --
Thor sends it, the wire doubles it), so the planned NotImplementedError
guard is gone.  declared_length -> probe_length, because it is only
meaningful in a probe reply and Thor never probes.

Synthesised test frames are marked and each says what it stands in for.
The flags=0x03 case is the only coverage of the Thor firmware line -- it
wants a real 11.0BD capture next time UM20147 is on a bench.

No writes.  Read-path framing only; nothing here can originate a command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2026-09-27 05:24:24 -04:00

400 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: no 11.0BD capture is on disk -- UM20147's sweep was recorded
on 2026-09-23 and those bins never landed in the repo. Built by re-stuffing
the captured POLL probe reply's payload 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}"