Turns docs/micromate_protocol_reference.md into a build plan for the missing half of micromate/ -- it is codec-only today with no way to talk to a unit. Layout mirrors minimateplus/: framing, protocol, client. Transport is REUSED -- minimateplus/transport.py is byte-level and protocol-agnostic, and already handles the RV50/RV55 habit of emitting RING/CONNECT to a caller. But minimateplus/framing is deliberately NOT shared: 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. Records the three things that make S3FrameParser useless on Series IV (bare STX, 0xC5/0x03 flags, uniform 10 XX -> XX destuffing with no inner-frame carve-out), and the traps that have already cost time: the declared length is a uint16 BE and reading it as a byte under-reads SUB 0x1A by 47x; SUB 0x1C is four bytes longer on the Thor line so parse forward not backward, or a BD unit reports 577.92 V; the monitoring flag must be tested non-zero; and the SUB byte itself can arrive DLE-escaped, so destuff before indexing. Scope is READ-ONLY, stated with the reasoning: no command has ever been originated against a unit by this project, and keeping that true through the read client means the first thing we ever send to a customer's instrument is a deliberate decision rather than a side effect of a client that grew a method. Tests are specified to embed frames as hex constants rather than read fixtures, since bridges/captures/ and tests/fixtures/ are both gitignored -- with a table of which cases matter and why, and a round-trip assertion against scratch/mm_frame_parse.py, which is already known good across three sessions at zero bad checksums. Steps 1-2 need no hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
12 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
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 # requests are identical
⚠ One open question, flagged in the protocol reference and not settled:
whether 0x10 bytes inside request params need stuffing. No probe we sent
carried one. Until it is settled, assert on it rather than guessing:
def build_request(sub: int, offset: int = 0, params: bytes = bytes(10)) -> bytes:
if 0x10 in params:
raise NotImplementedError(
"params containing 0x10 — stuffing rule unconfirmed; see "
"micromate_protocol_reference.md, 'Untested and unsafe-until-agreed'"
)
return build_bw_frame(sub, offset, params)
That turns an unknown into a loud failure instead of a corrupt frame.
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(b for b in payload if b != 0x10) & 0xFF
The DLE-aware variant, same as Series III's 5A and write frames — not the plain
SUM8 of ordinary Series III reads.
⚠ 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 firmware_line(self) -> str: # "blastware" | "thor" | "unknown"
@property
def declared_length(self) -> int: # uint16 BE at data[3:5] (= payload[8:10])
⚠ declared_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.
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
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 |
210 B — project, location, peaks |
| event header | 0x0A |
0xF5 |
0xFFFF |
30 B list record |
| bulk download | 0x5A |
0xA5 |
computed | the .IDFW verbatim |
⚠ 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 — simpler than Series III, deliberately
No arming ritual, no chunk loop, no STRT end-offset parsing, no TERM frame.
One request returns the whole event:
offset_word = 0x1000 + 2 * ceil(size / 512) # size from the chain walk
The 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.
micromate/client.py
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 16–68 bytes each, and a handful covers every case:
| case | why |
|---|---|
| POLL probe reply, 19 B | shortest valid frame |
| POLL data reply, 68 B | contains a literal 0x10 — only the DLE-aware checksum matches |
0x1A response, 2108 B |
exercises declared_length as a true uint16 (0x082C) |
a Thor-line reply, flags = 0x03 |
0x03 is ETX; proves destuffing before framing |
a frame whose SUB is 0x02 |
arrives as 10 02; proves destuff-then-index |
| a truncated frame | parser must return nothing, not a bad frame |
| a corrupted checksum | checksum_valid == False, frame still returned |
Then a round-trip assertion: feed a whole captured session through the parser and
assert the frame count and every SUB, against scratch/mm_frame_parse.py's
output — which is already known good, having parsed 24, 38 and 40-frame sessions
with zero bad checksums.
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 — offline, verifiable immediatelyprotocol.py— reads only, one method per row of the table aboveclient.py—connect(),get_state(),list_setups()- the event chain and
download_event() - decode end-to-end and compare against a store event
Steps 1–2 need no hardware at all.
Open questions to settle while implementing
- Request param stuffing — raise
NotImplementedErrorrather than guess. - 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.