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
400 lines
16 KiB
Python
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}"
|