Files
seismo-relay/docs/micromate_client_spec.md
T
serversdownandClaude Opus 5 8a1d1c3f17 docs(series4): spec the live client, so tomorrow is implementation not design
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
2026-09-26 01:59:35 -04:00

292 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
### Requests
Series IV accepts Series III request frames unmodified. The simplest correct
implementation re-exports the builder rather than duplicating it:
```python
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:
```python
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
```python
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
```python
@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:
```python
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`
```python
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
1. `framing.py` + its tests — offline, verifiable immediately
2. `protocol.py` — reads only, one method per row of the table above
3. `client.py` — `connect()`, `get_state()`, `list_setups()`
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.
---
## Open questions to settle while implementing
- **Request param stuffing** — raise `NotImplementedError` rather 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.
- **`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.