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
19 KiB
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
Eventobjects 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 |
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:
0x0Ais 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/0x1Fcarry token0xFEatparams[7]. The reference documents all-zero params (our own probing, which also worked). Thor's form is the one with mileage.0x01has no Thor frame behind it — it is never read in any captured session. Its0xFFFFcomes 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
- ✅
framing.py+ its tests — done 2026-09-27, 31 tests, offline - ✅
protocol.py+ its tests — done 2026-09-27, 35 tests, offline - ✅
client.py+ its tests — done 2026-09-27, 26 tests, offline - the event chain and
download_event() - 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
0x5Aform real? Our 2026-09-23 probes setoffset_hi = 0x10and 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.
Eventmodel fit — Series IV carries fields Series III lacks (setup file name,LMic/SMicchannels). Extendmicromate/models.py; do not fork the sharedEvent.- Which
0x0Cfields to trust. The peak float there runs 2–5% abovemax(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.