Files
seismo-relay/docs/micromate_client_spec.md
T
serversdownandClaude Opus 5 f09b7dcaaf feat(micromate): client layer -- connect, state, setups; read-only
Step 3 of docs/micromate_client_spec.md: micromate/client.py, two models in
micromate/models.py, 26 offline tests.  Every response constant in the tests
is a real captured data section from UM12947.

Field offsets were measured rather than taken from the spec, which turned up
one general rule and one trap:

THE RESPONSE SHAPE.  Every response carries an 11-byte prefix and content
starts at data[11].  One rule, every command.

THE TRAP: data[0] is the content length & 0xFF, with no high byte anywhere in
the prefix.  It is therefore correct for every response under 256 bytes --
most of them -- and then reports 44 for a 2,092-byte setup block, 30 for a
286-byte monitor-log record, and 0 for a 1,024-byte download chunk.  182 of
251 captured responses agree with a naive read; the 69 that disagree are
exactly the ones >= 256 bytes.

That is the third length in this protocol read too narrow, after
payload[9]-vs-payload[8:10] in the probe response.  The client takes content
as data[11:] and lets the frame's own length bound it -- nothing needs the
declared length, since the frame already knows how long it is.

Also measured:

- POLL content[3] is 0x50, printable as "P", immediately before "Instantel".
  A generic printable-run scan therefore returns "PInstantel" -- it caught a
  test, not a unit.  Vendor comes from a fixed offset; the model is found by
  searching for "MM/", which is structural rather than positional and so
  survives the Thor line's shorter "MM/ISEE/S".
- The setup walk terminates on an EMPTY NAME, not an error: 23 responses, 22
  names, factory.MMB first through TEST1.mmb last.
- The 0x1C clock has an unidentified byte at content[6]; the hour is at
  content[7].  The protocol reference's 0x1C section already had this right
  and names the byte -- its one-line summary in the divergences list reads as
  six contiguous fields and is the version not to trust.  Re-verified against
  three captures: 19:12:25, 19:13:34 and 01:14:05 against filenames stamped
  19:12:14, 19:12:14 and 01:14:03.
- Battery and memory are read FORWARD from content start, never backward from
  the end.  This block is 4 bytes longer on the Thor line; the Series III
  from-the-end offsets give a 11.0BD unit 577.92 V.  A test appends the four
  trailing bytes and asserts the forward offsets survive.

connect() is narrower than the spec asked.  The spec said to mirror Thor's
POLL -> SERIAL -> 0x49 -> POLL "because it is known-good"; measurement showed
that is Thor's connection check (3 of 8 sessions) and its fourth frame repeats
its first.  So connect() sends the three reads that gather something, and
0x01 is not read at all -- Thor never reads it, its layout is unmapped, and
firmware_line comes free from any response's flags byte.  If a unit ever
refuses the next command after a cold connect, put the fourth POLL back and
record it.

A dead clock battery yields device_time=None rather than failing the whole
state read; an unreadable active setup yields active_setup=None rather than
failing connect.  Both are real device states.

Still verified only against 11.0CB and only over USB.  The BD offsets follow
from the extra bytes being trailing, which is documented but not something
this code has seen.

Full suite unchanged at 16 pre-existing failures; 445 passed, up 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2026-09-28 20:25:40 -04:00

19 KiB
Raw Blame History

Spec — a live client for Series IV (Micromate)

Drafted 2026-09-26, ahead of implementation. The protocol work is finished; this is the plan for turning docs/micromate_protocol_reference.md into code SFM can run.

Read that document first. Everything here assumes it, and every constant below is sourced from it rather than restated with justification.


Goal and scope

micromate/ is codec-only today — idf_file.py, models.py, the report writers. There is no way to talk to a unit. This adds the live half, mirroring minimateplus/.

In scope, first pass:

  • connect over TCP (a field modem) or serial/USB (a bench unit)
  • identify a unit, read its state, clock, memory and setups
  • walk the event chain and download events
  • return Event objects the existing codec already understands

Explicitly out of scope, first pass:

  • ⚠ Any write. Setups, schedules, call-home config, monitoring start/stop, and per-event delete are all mapped, and none of them will be implemented here. No command has ever been originated against a unit by this project — every write observed was performed by THOR while we recorded. Keeping that true through the read client is deliberate: it means the first thing we ever send to a customer's instrument is a decision someone made on purpose, not a side effect of a client that happened to grow a method.
  • the inbound call-home session — still the one protocol unknown

Layout

micromate/
  framing.py     NEW   frame building, response parsing, checksum
  protocol.py    NEW   one method per wire command, returns raw payloads
  client.py      NEW   high-level API, returns models
  idf_file.py    (existing — decodes what 0x5A returns, unchanged)
  models.py      (existing — extend, do not fork)

Transport is reused, not rewritten. minimateplus/transport.py is byte-level and protocol-agnostic — BaseTransport, SerialTransport, TcpTransport, plus read_until_idle() which already handles the RV50/RV55 habit of emitting \r\nRING\r\n\r\nCONNECT\r\n to a caller. Import it.

⚠ Do not import minimateplus.framing. The two framings differ in ways that look small and are not, and a shared module would accumulate if series == branches until neither case is readable.


micromate/framing.py — ✅ BUILT 2026-09-27

Implemented, with tests/test_micromate_framing.py (31 tests, all passing). Two things in this section as originally drafted were wrong, and both were caught by measuring against the captures before writing code rather than after. They are left in place below, struck through, because both are the kind of mistake that would be made again.

Requests

Series IV accepts Series III request frames unmodified. The simplest correct implementation re-exports the builder rather than duplicating it:

from minimateplus.framing import build_bw_frame   # ✗ WRONG — 161/218

⚠ build_bw_frame reproduces only 161 of Thor's 218 captured read frames. The payload layout is identical; the stuffing is not. A Micromate escapes four byte values — 0x02, 0x03, 0x04, 0x10 — where Series III escapes one. The frames this breaks are every SUB 0x5A download (offset = 0x0400 → a literal 0x04 in offset_hi) and the scheduler enable. An unescaped 0x03/0x04 terminates the frame early, so the unit just does not answer.

build_request() with the correct escape set reproduces 218/218.

✅ Settled: 0x10 inside params needs no special handling. The planned NotImplementedError guard is unnecessary — Thor sends params = 00 00 10 00 … on SUB 0x5A in five captured frames and the wire carries an ordinary doubled 10 10. One rule covers the whole payload.

Responses — where Series III's parser cannot follow

Series III Micromate
frame start DLE STX bare STX
payload[1] 0x10 0xC5 (Blastware fw) / 0x03 (Thor fw)
destuffing DLE+ETX kept as literal inner-frame data 10 XX → XX, uniformly

The first row is why S3FrameParser returns nothing at all on Series IV traffic: it scans for DLE+STX, which never appears.

The third is a genuine simplification — no inner-frame carve-out. Validated by checksum across every capture in bridges/captures/9-24-26 - micromate2/: four candidate destuffing rules were tried, and only this one makes all frames validate.

Checksum

def checksum(payload: bytes) -> int:
    return sum(payload) & 0xFF          # payload already de-stuffed

The DLE-aware variant, same as Series III's 5A and write frames.

⚠ Plain SUM8, not the DLE-aware variant — 251/251 both directions. The DLE-aware form is the right answer paired with Series III de-stuffing, which leaves an escaped byte in the payload as two bytes. De-stuffing 10 XX → XX already removes the 0x10, so excluding it again subtracts the correction twice, and the result disagrees with the wire on 55 of 251 captured responses — every frame holding a literal 0x10.

scratch/mm_frame_parse.py shipped with exactly that pairing. It looked clean only because it accepts a frame matching either rule, so it labelled those 55 SUM8 and never flagged one bad. "Zero bad checksums" was true and carried no information. Fixed there too.

⚠ The SUB byte can be escaped

When a SUB's value is 0x02, 0x03, 0x04 or 0x10 it arrives as 10 XX. Reading it positionally without destuffing reports 0x10. This bit once already — SUB 0x02 was logged as SUB_10 for an afternoon. Destuff first, then index.

Response shape

@dataclass
class MicromateFrame:
    sub: int              # response SUB;  request = 0xFF - sub
    flags: int            # 0xC5 Blastware line, 0x03 Thor line
    page_hi: int
    page_lo: int
    data: bytes           # payload[5:], checksum stripped
    checksum_valid: bool

    @property
    def request_sub(self) -> int:        # 0xFF - sub
    @property
    def page_key(self) -> int:           # uint16 BE at payload[3:5]
    @property
    def firmware_line(self) -> str:      # "blastware" | "thor" | "unknown"
    @property
    def probe_length(self) -> int | None: # uint16 BE at data[3:5] (= payload[8:10])

⚠ probe_length is a uint16 BE. Read as a single byte it under-reads SUB 0x1A by 47x — 44 against a true 2092. This is the single most expensive mistake available in this protocol and it has already been made once.

Renamed from declared_length, because it is only meaningful in the reply to an offset = 0 probe — and Thor never probes. Across all 251 captured responses the field reads 0 or a page count, never a length, precisely because that session is single-step reads throughout. page_key is the field that carries meaning there. The only genuine probe reply we hold is the POLL one preserved in scratch/fake_unit.py.

MicromateFrameParser mirrors S3FrameParser: feed(bytes) -> list[frame], accumulates in .frames, reset(), and keeps the bytes_fed counter (it is what distinguishes "no bytes at all" from "bytes but no complete frame" on a timeout, and that distinction earned its keep during the Series III work).


micromate/protocol.py — ✅ BUILT 2026-09-27

Implemented, with tests/test_micromate_protocol.py (35 tests). Reads only; nothing here writes, erases or changes monitoring state.

The tests replay Thor's captured responses through a scripted transport and assert the bytes we emit are the bytes Thor emits — including a full replay of the six-event download session, all 56 0x5A frames byte-for-byte. That is a stronger guarantee than "our parser understands the device": a passing test means a real unit has already answered exactly that frame.

One method per command, returning raw payload bytes. No interpretation — that belongs in client.py.

Reads use offset = 0xFFFF and return the whole block in one response; Series III's two-step probe/data dance is unnecessary. POLL is the exception, taking its data length. Per-command offsets, all observed:

command SUB rsp offset returns
poll 0x5B 0xA4 0x0030 device string, model
serial 0x15 0xEA 0x000A UM12947
device info 0x01 0xFE 0xFFFF firmware, calibration
state 0x49 0xB6 0xFFFF data[11]: non-zero = monitoring
monitor status 0x1C 0xE3 0xFFFF flag, device clock, battery, memory
storage range 0x06 0xF9 0xFFFF event storage extent
active setup name 0x41 0xBE 0xFFFF TEST1.mmb
first setup 0x3F 0xC0 0xFFFF setup-list walk head
next setup 0x40 0xBF 0xFFFF …until an empty name
compliance config 0x1A 0xE5 0xFFFF ~2103 B setup block
call-home config 0x2C 0xD3 0xFFFF 137 B
arm event 0x93 0x6C — before every event
first event 0x1E 0xE1 0xFFFF key + size
next event 0x1F 0xE0 0xFFFF key + size
event record 0x0C 0xF3 0xFFFF 221 B — project, location, peaks
event header monitor log 0x0A 0xF5 0xFFFF ⚠ 297 B, a walk — see below
bulk download 0x5A 0xA5 computed the .IDFW verbatim, 1024 B at a time

⚠ Corrected 2026-09-27, from Thor's frames. Three rows of the table above were wrong or incomplete, and the last one is a different command than labelled:

  • 0x0A is the monitor-log walk, not a keyed "30 B list record" read. The same request repeated returns successive 297-byte records — serial, mode, thresholds — until an 11-byte ack ends the list. The device holds the cursor; nothing in the request selects a record. Series III reaches this data through a record-type discriminator on its event chain; here it has its own cursor and the event chain never sees it.
  • 0x1E/0x1F carry token 0xFE at params[7]. The reference documents all-zero params (our own probing, which also worked). Thor's form is the one with mileage.
  • 0x01 has no Thor frame behind it — it is never read in any captured session. Its 0xFFFF comes from our probes.

And one useful negative: no SESSION_RESET (41 03). Series III needs that 2-byte signal or a monitoring unit will not answer POLL over TCP. Thor never sends it — zero occurrences across 8 sessions, including 40 frames exchanged with a unit that was monitoring.

⚠ SUB 0x1C is 4 bytes longer on the Thor firmware line (0x30 vs 0x2C). Parse forward from declared_length, never backward from the end — Series III reads battery and memory from the end of that block, and doing so on a BD unit yields a battery voltage of 577.92 V.

⚠ Test the monitoring flag for non-zero, never against a constant. It has read both 0x0E and 0x0C while monitoring.

0x5A — a bounded chunk loop, and much simpler than Series III

⚠ Corrected 2026-09-27. This section said "no chunk loop — one request returns the whole event", with offset_word = 0x1000 + 2 * ceil(size / 512). That describes our own 2026-09-23 probes, which set offset_hi = 0x10. Thor chunks, and Thor's form is the one verified from bytes on disk:

n = ceil(size / 1024)                       # size from the chain walk
for i in range(n):
    offset = min(1024, size - 1024 * i)     # a BYTE COUNT
    params = key4 + bytes(6) if i == 0 else bytes(2) + pack(">H", 1024*i) + bytes(6)
    file_bytes += response.data[11:]        # response data is exactly offset + 11

Verified on all six bench events (4,076 → 13,424 B): sum(offsets) == size exactly, with the predicted chunk count and final offset every time.

Still no arming ritual for 0x5A itself, no STRT end-offset parsing and no TERM frame — the simplification the original claim celebrated is real, it just is not single-shot. (SUB 0x93 arms the chain walk, before 1E/1F, not the download.)

The concatenated payload is the .IDFW file, byte for byte — so it feeds micromate.idf_file.read_idf_file() and /db/import/idf_file unchanged.

⚠ Do not port the Series III 5A walk. Its address arithmetic caused a 5x over-read and a > 64 KB page-boundary bug that is still open on the Series III side. None of that applies here: the chunk index is a byte offset into the file, bounded by a size the device told us, and it cannot run past the event.

⚠ assert sum(len(chunk) - 11 for chunk in chunks) == size. A silently short event is the failure mode this project has been bitten by repeatedly on the Series III side, and here the check is free because the size is known up front.


micromate/client.py — ✅ BUILT (read half) 2026-09-27

connect(), get_state(), get_active_setup(), list_setups() plus MicromateDeviceInfo / MicromateState in models.py. 26 tests, every response constant a real captured data section.

⚠ connect() is deliberately narrower than this spec asked for. The spec said to mirror Thor's POLL → SERIAL → 0x49 → POLL "because it is known-good". Measurement showed the four-command form is Thor's connection check, present in 3 of 8 sessions, and its fourth frame repeats its first — so connect() sends the three reads that gather something. 0x01 is not read at all: Thor never reads it, its layout is unmapped, and firmware_line comes free from any response's flags byte.

Event-chain methods (list_events, download_event, get_event) are step 4 and not yet written; MicromateProtocol.read_event_file() already does the download.

class MicromateClient:
    def __init__(self, transport: BaseTransport): ...
    def open(self) / close(self) / is_open(self)

    # identity and state
    def connect(self) -> DeviceInfo          # poll → serial → device info → state
    def get_state(self) -> UnitState         # monitoring?, clock, battery, memory
    def get_active_setup(self) -> str
    def list_setups(self) -> list[str]       # 0x3F → 0x40… until empty

    # events
    def list_events(self) -> list[EventRef]  # 0x93 → 0x1E → 0x1F… (key + size)
    def download_event(self, ref) -> bytes   # raw .IDFW/.IDFH
    def get_event(self, ref) -> Event        # download + decode via idf_file

connect() should mirror THOR's preamble (POLL → SERIAL → 0x49 → POLL) — ⚠ but note the reference records that whether the unit requires it is untested. Do it because it is known-good, not because it is known-necessary, and say so in the docstring.

list_events() returns the key and the size, because download_event() needs the size to compute its offset word.


Tests

Offline, from captured bytes — no hardware. This is the part worth doing first, because it can be fully verified tonight's-captures-style before any unit is involved.

tests/test_micromate_framing.py

⚠ bridges/captures/ and tests/fixtures/ are both gitignored, so tests must not depend on files being present. Embed the frames as hex constants — they are 19–138 bytes each. ✅ Done; what actually landed:

case source why
POLL probe reply, 19 B captured (via fake_unit.py) shortest valid frame; the only real probe reply we hold
0x5A chunk, 138 B captured holds literal 0x10 and literal 0x41 — the checksum case, and proves ACK is not escaped
0x49 state reply, 25 B captured a literal 0x02, escaped
0x48 file reply, 24 B captured escaped 0x04 in data[0] — one byte late without destuffing
8 Thor request frames captured byte-for-byte against build_request(), incl. both 0x5A forms and the scheduler enable
probe_length = 0x082C synthesised no probe reply for 0x1A exists on disk — the 9-24-26 session never probes
Thor-line reply, flags = 0x03 synthesised no 11.0BD capture is in the repo; built by flipping one byte of the real POLL reply
escaped checksum byte synthesised the shortest real one is 1,070 B, too long to embed for one assertion
truncated / corrupt / split-across-feeds derived parser must return nothing, flag rather than swallow, and survive any split point

⚠ Synthesised frames are marked SYNTH_-style in the test and each says what it stands in for and why no capture was available. Do not let that set grow quietly: the flags = 0x03 case in particular is the only coverage of half the fleet, and it deserves a real 11.0BD capture the next time UM20147 is on a bench.

Two corpus-backed tests run when the captures happen to be on the box and skip cleanly otherwise: 251 response frames parse with zero bad checksums, and build_request() reproduces 218/218 read frames. The second is the test that would have caught the escape-set error, so it is worth the skip marker.

⚠ Do not assert against scratch/mm_frame_parse.py's output as the original plan proposed. That script accepts either checksum rule and is wrong about which one is right — using it as an oracle would have pinned the bug.

Live, second: against the bench unit on mint-mac via mm_link.py. connect(), list_setups() (should return the 23 known names), list_events(), then download_event() and assert the bytes decode and match a /db/import/idf_file ingest of the same event.


Order of work

  1. ✅ framing.py + its tests — done 2026-09-27, 31 tests, offline
  2. ✅ protocol.py + its tests — done 2026-09-27, 35 tests, offline
  3. ✅ client.py + its tests — done 2026-09-27, 26 tests, offline
  4. the event chain and download_event()
  5. decode end-to-end and compare against a store event

Steps 1–2 need no hardware at all.

Worth carrying forward. Both steps began by measuring against the captures rather than trusting this document, and both found errors in it — three in the framing rules (the escape set, 26% of frames; the checksum, 22%; the 0x5A chunk model) and three more in the command table (0x0A's meaning, the 1E/1F token, 0x01's provenance). All six fail quietly. The captures are on disk and a measure-then-write loop costs about two minutes per rule, so keep doing it for client.py's field offsets — and treat this spec as a plan, not a source.


Open questions to settle while implementing

  • Request param stuffing — ✅ settled 2026-09-27; no special handling.
  • Is the single-request 0x5A form real? Our 2026-09-23 probes set offset_hi = 0x10 and appeared to get a whole 11 KB event back, where Thor chunks at 1024 B. Plausibly a distinct streaming mode that returns several frames. One bench test settles it; implement Thor's form regardless.
  • Is THOR's preamble required? Try one command cold and find out; it is a two-minute test with the bench unit and it removes a ritual if unnecessary.
  • Event model fit — Series IV carries fields Series III lacks (setup file name, LMic/SMic channels). Extend micromate/models.py; do not fork the shared Event.
  • Which 0x0C fields to trust. The peak float there runs 2–5% above max(T,V,L) and is not the vector sum; its offset was inferred, not established. The reference marks it do-not-rely-on — prefer decoded samples.