Thor pushed a setup named TEST1.mmb to UM12947 while seismo_lab's TCP bridge
recorded both directions. We still have not originated a write frame -- the
wire format is now known, our encoder is not written.
Topology worth reusing: socat shares /dev/ttyACM0 on TCP from mint-mac,
seismo_lab relays Thor to it. Thor is pointed at 127.0.0.1 as if the unit were
a field modem. No modem, no SIM, production Thor box untouched.
The sequence is Series III's, plus one command:
Thor: 5B | 41 | 08 | 2E | 1A | DA | 68->73 | 82->83 | 71->72
unit: A4 | BE | F7 | D1 | E5 | 25 | 97 8C | 7D 7C | 8E 8D
All 12 device responses checksum-validate and every write is acked. Every
write response SUB matches the Series III table exactly.
New:
* SUB 0xDA names the target .MMB file -- 256 bytes, filename null-padded,
nothing else. This is why no generic file-transfer command exists: Thor
names the file, then writes the ordinary config block into it.
* SUB 0x41 reads the active setup's filename; SUB 0x2E reads trigger config.
* Reads are single-step -- Thor asks offset=0xFFFF and skips the probe.
* 0x71 writes the whole 2090-byte block in ONE frame, not Series III's three
chunks. 0x69/0x74 are absent.
Write-frame destuffing is `10 XX` -> `XX` uniformly, including `10 03`. Chosen
by checksum, not assumption: of four candidate rules, only this one makes all
four data-carrying write frames validate. 0x71's data holds 4 literal 0x03
bytes escaped as `10 03`, so escaping is mandatory for any writer.
The write body IS the read body -- 0x71 and the 0xE5 response align at a fixed
11-byte shift with 1902/2090 bytes equal (91.0%). Setups are read-modify-write.
The 12 differing regions are fully mapped: setup name, four 64-byte
[label:22][value:42] note entries, sensor location, and the three geo trigger
levels (0.3 -> 0.5 in/s) on a 48-byte channel stride.
Independent confirmation of the geo LSB: each channel block carries float32BE
3.10308 at label+24. 3.10308/10000 = 0.000310308 = _GEO_LSB_IPS to 8 figures,
and 10.0/3.10308*10000 = 32226.046 = the 32226.05 full scale. That value was
derived statistically from 991,415 rounding constraints in v0.30.0; the unit
reports it directly. It is exactly half Series III's 6.206053, so the ADC runs
10,000 counts per volt. Do NOT retune _GEO_LSB_IPS -- this corroborates it.
The `offset` field is NOT a single length formula: two frames are len, two are
len+2, and Series III's data[1]+2 reproduces neither. Recorded as observed
constants the device accepted; pinning the rule needs a capture with
differently-sized payloads. This doc has been wrong once by inferring a length
field -- not inferring this one.
Also adds scratch/mm_frame_parse.py, because S3FrameParser cannot see Micromate
responses at all (it scans for DLE+STX; Micromate responses start at a bare
STX). That is why the first pass at this capture looked like 12 unanswered
requests. 24/24 frames parse with 0 bad checksums.
Stale claims corrected: the "write half is not yet attempted" note, the
"empty unit" limitation (5 events since 2026-09-23), and the unsafe-until-agreed
list, which now distinguishes observed from exercised.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
203 lines
7.0 KiB
Python
203 lines
7.0 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.
|
|
|
|
That rule was chosen by evidence, not assumption: of the four candidates tried
|
|
against the 9-24-26 capture's four data-carrying write frames, it is the only
|
|
one under which all four checksums validate. See
|
|
`docs/micromate_protocol_reference.md` → *The write path*.
|
|
|
|
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 = sum(payload) & 0xFF
|
|
dle_aware = (sum(b for b in payload if b != DLE) & 0xFF)
|
|
if sum8 == chk:
|
|
kind = "SUM8"
|
|
elif dle_aware == chk:
|
|
kind = "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())
|