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
224 lines
8.2 KiB
Python
224 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
mm_frame_parse.py — parse Micromate (Series IV) frames out of a seismo_lab
|
|
raw capture pair.
|
|
|
|
Why this exists
|
|
---------------
|
|
`minimateplus.framing.S3FrameParser` cannot see Micromate traffic. It locates
|
|
frames by scanning for `DLE STX`, and a Micromate response has **no leading
|
|
DLE** — it starts at a bare `STX`. It also expects `payload[1] == 0x10`, where
|
|
the Micromate sends `0xC5` (Blastware firmware) or `0x03` (Thor firmware).
|
|
|
|
The practical consequence, seen on the 9-24-26 setup-push capture: the
|
|
Blastware-side requests parse fine (Thor emits Series III request frames), but
|
|
**every device response is silently dropped or mis-framed** — so a capture that
|
|
actually contains 12 acked writes looks like 12 unanswered requests.
|
|
|
|
Destuffing
|
|
----------
|
|
One rule covers both directions: after the leading doubled `BW_CMD`, every
|
|
`10 XX` pair on the wire destuffs to `XX`. That includes `10 03` — Thor
|
|
escapes literal `0x03` bytes in write data so they are not mistaken for ETX,
|
|
exactly as Blastware does.
|
|
|
|
A Micromate escapes exactly four byte values — `0x02 0x03 0x04 0x10` — and
|
|
nothing else, which is what makes the uniform rule exact rather than merely
|
|
convenient. Established by re-stuffing all 502 captured frames and comparing
|
|
to the wire: 251/251 each direction, where `{0x10}` alone gets 130 and 177.
|
|
|
|
⚠ Checksum, corrected 2026-09-27
|
|
--------------------------------
|
|
With uniform destuffing the checksum is **plain SUM8 of the destuffed
|
|
payload**. This script used to try SUM8 *and* a "DLE-aware" variant that
|
|
excludes `0x10` bytes, and report whichever matched — which is why it never
|
|
flagged a bad frame and why the protocol reference carried the wrong rule for
|
|
two days. The DLE-aware form belongs with *Series III* destuffing, where an
|
|
escaped byte survives as two bytes; applying it after uniform destuffing
|
|
subtracts the correction twice and disagrees with the wire on 55 of 251
|
|
responses.
|
|
|
|
The lesson generalises: a tool that accepts any of N candidate rules cannot
|
|
falsify any of them. It now validates against SUM8 alone, and reports
|
|
`DLE-aware` only to name what a mismatch *would* have been — never as a pass.
|
|
See `docs/micromate_protocol_reference.md` → *Checksum*, and
|
|
`tests/test_micromate_framing.py`, which pins it.
|
|
|
|
`micromate/framing.py` is the production implementation; this stays as the
|
|
one-shot capture-inspection tool.
|
|
|
|
Usage
|
|
-----
|
|
python scratch/mm_frame_parse.py <capture-dir>
|
|
python scratch/mm_frame_parse.py <raw_bw.bin> <raw_s3.bin>
|
|
python scratch/mm_frame_parse.py <capture-dir> --dump 0x71
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
DLE, STX, ETX, ACK = 0x10, 0x02, 0x03, 0x41
|
|
|
|
# Request SUB -> short name. Series III names where they carry over; the
|
|
# Series IV additions are marked.
|
|
SUBNAME = {
|
|
0x01: "DEVICE_INFO",
|
|
0x06: "STORAGE_RANGE",
|
|
0x08: "EVENT_INDEX",
|
|
0x0A: "WAVEFORM_HDR",
|
|
0x0C: "WAVEFORM_REC",
|
|
0x15: "SERIAL",
|
|
0x1A: "COMPLIANCE_CFG",
|
|
0x1C: "MONITOR_STATUS",
|
|
0x1E: "EVENT_HDR",
|
|
0x2C: "CALLHOME_CFG",
|
|
0x2E: "TRIGGER_CFG_READ", # Series IV
|
|
0x3E: "OPERATOR",
|
|
0x41: "SETUP_NAME_READ", # Series IV
|
|
0x5A: "BULK_DOWNLOAD",
|
|
0x5B: "POLL",
|
|
0x68: "EVENT_INDEX_WRITE",
|
|
0x69: "WAVEFORM_WRITE",
|
|
0x71: "COMPLIANCE_WRITE",
|
|
0x72: "CONFIRM_A",
|
|
0x73: "CONFIRM_B",
|
|
0x74: "CONFIRM_C",
|
|
0x82: "TRIGGER_WRITE",
|
|
0x83: "TRIGGER_CONFIRM",
|
|
0xDA: "SETUP_FILE_DECL", # Series IV — names the target .MMB
|
|
0xFE: "FULL_CFG",
|
|
}
|
|
|
|
|
|
def destuff(blob: bytes, start: int, *, is_request: bool) -> tuple[bytes, int, int]:
|
|
"""Destuff one frame starting at `start`.
|
|
|
|
Returns (payload, checksum, index_of_terminating_ETX). `payload` excludes
|
|
the trailing checksum byte. A request frame opens `ACK STX 10 10`; a
|
|
response opens with a bare `STX`.
|
|
"""
|
|
i = start + (2 if is_request else 1)
|
|
out = bytearray()
|
|
if is_request:
|
|
# The doubled BW_CMD is the one guaranteed stuffed byte.
|
|
if blob[i : i + 2] != bytes([DLE, DLE]):
|
|
raise ValueError(f"@0x{start:04x}: request does not open with 10 10")
|
|
out.append(DLE)
|
|
i += 2
|
|
while i < len(blob):
|
|
b = blob[i]
|
|
if b == DLE and i + 1 < len(blob):
|
|
out.append(blob[i + 1])
|
|
i += 2
|
|
continue
|
|
if b == ETX:
|
|
break
|
|
out.append(b)
|
|
i += 1
|
|
if len(out) < 2:
|
|
raise ValueError(f"@0x{start:04x}: frame too short")
|
|
return bytes(out[:-1]), out[-1], i
|
|
|
|
|
|
def frames(blob: bytes, *, is_request: bool):
|
|
"""Yield (offset, payload, chk, checksum_kind)."""
|
|
i, n = 0, len(blob)
|
|
while i < n:
|
|
if is_request:
|
|
if not (blob[i] == ACK and i + 1 < n and blob[i + 1] == STX):
|
|
i += 1
|
|
continue
|
|
elif blob[i] != STX:
|
|
i += 1
|
|
continue
|
|
try:
|
|
payload, chk, end = destuff(blob, i, is_request=is_request)
|
|
except ValueError:
|
|
i += 1
|
|
continue
|
|
# SUM8 of the destuffed payload is THE rule -- 502/502 captured frames.
|
|
# The DLE-aware variant is reported only to name a near-miss; it is
|
|
# never a pass. See the module docstring.
|
|
if (sum(payload) & 0xFF) == chk:
|
|
kind = "ok"
|
|
elif (sum(b for b in payload if b != DLE) & 0xFF) == chk:
|
|
kind = "BAD(dle-aware)"
|
|
else:
|
|
kind = "BAD"
|
|
yield i, payload, chk, kind
|
|
i = end + 1
|
|
|
|
|
|
def describe(payload: bytes, is_request: bool) -> str:
|
|
if len(payload) < 3:
|
|
return "??"
|
|
sub = payload[2]
|
|
if is_request:
|
|
return SUBNAME.get(sub, f"SUB_{sub:02X}")
|
|
req = 0xFF - sub
|
|
return "rsp<-" + SUBNAME.get(req, f"SUB_{req:02X}")
|
|
|
|
|
|
def report(path: Path, *, is_request: bool, dump_sub: int | None) -> None:
|
|
blob = path.read_bytes()
|
|
side = "Thor" if is_request else "unit"
|
|
print(f"== {side:4} {path.name} ({len(blob)} bytes)")
|
|
n_bad = 0
|
|
for idx, (off, p, chk, kind) in enumerate(frames(blob, is_request=is_request)):
|
|
if kind == "BAD":
|
|
n_bad += 1
|
|
sub = p[2] if len(p) > 2 else -1
|
|
flags = p[1] if len(p) > 1 else -1
|
|
# Requests carry offset at payload[4:6]; responses page at [3:5].
|
|
word = int.from_bytes(p[4:6] if is_request else p[3:5], "big")
|
|
data = len(p) - 16 if is_request else max(len(p) - 5, 0)
|
|
print(
|
|
f" [{idx:2}] @0x{off:04x} payload={len(p):5} data={data:5} "
|
|
f"flags=0x{flags:02x} SUB=0x{sub:02x} {describe(p, is_request):18} "
|
|
f"{'offset' if is_request else 'page'}=0x{word:04x} chk={kind}"
|
|
)
|
|
if dump_sub is not None and sub == dump_sub:
|
|
body = p[16:] if is_request else p[5:]
|
|
print(f" ---- data ({len(body)} bytes) ----")
|
|
for o in range(0, len(body), 16):
|
|
chunk = body[o : o + 16]
|
|
txt = "".join(chr(c) if 32 <= c < 127 else "." for c in chunk)
|
|
print(f" {o:06x} {chunk.hex(' '):<47} |{txt}|")
|
|
print(f" -- {idx + 1} frames, {n_bad} bad checksum\n")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("paths", nargs="+",
|
|
help="a capture directory, or raw_bw.bin and raw_s3.bin")
|
|
ap.add_argument("--dump", default=None,
|
|
help="hex-dump the data section of this SUB (e.g. 0x71)")
|
|
args = ap.parse_args()
|
|
|
|
dump_sub = int(args.dump, 0) if args.dump else None
|
|
|
|
if len(args.paths) == 1 and Path(args.paths[0]).is_dir():
|
|
d = Path(args.paths[0])
|
|
bw = sorted(d.glob("raw_bw_*.bin"))
|
|
s3 = sorted(d.glob("raw_s3_*.bin"))
|
|
if not bw or not s3:
|
|
print(f"{d}: need one raw_bw_*.bin and one raw_s3_*.bin", file=sys.stderr)
|
|
return 2
|
|
pairs = [(bw[0], True), (s3[0], False)]
|
|
elif len(args.paths) == 2:
|
|
pairs = [(Path(args.paths[0]), True), (Path(args.paths[1]), False)]
|
|
else:
|
|
ap.error("pass a capture directory, or exactly two .bin files")
|
|
|
|
for path, is_request in pairs:
|
|
report(path, is_request=is_request, dump_sub=dump_sub)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|