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
This commit is contained in:
@@ -277,7 +277,23 @@ front.
|
||||
|
||||
---
|
||||
|
||||
## `micromate/client.py`
|
||||
## `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.
|
||||
|
||||
```python
|
||||
class MicromateClient:
|
||||
@@ -358,7 +374,7 @@ then `download_event()` and assert the bytes decode and match a
|
||||
|
||||
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` — `connect()`, `get_state()`, `list_setups()`
|
||||
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
|
||||
|
||||
|
||||
@@ -1300,6 +1300,53 @@ through a record-type discriminator (`0x2C` partial vs `0x46` full) *on its
|
||||
event walk*, so partial records and events share one chain. Here the monitor
|
||||
log has its own cursor and the event chain never sees it.
|
||||
|
||||
#### 🔑 Every response has an 11-byte prefix — and its length byte lies
|
||||
|
||||
**Content starts at `data[11]`.** One rule, every command.
|
||||
|
||||
⚠ **`data[0]` is the content length `& 0xFF`, and there is no high byte
|
||||
anywhere in the prefix.** `data[1]` is zero on every frame examined. So it
|
||||
reads as a perfectly good length field for any response under 256 bytes — which
|
||||
is most of them — and then:
|
||||
|
||||
| command | true content | `data[0]` says |
|
||||
|---|---|---|
|
||||
| `0x1A` compliance | 2,092 | **44** |
|
||||
| `0x0A` monitor log | 286 | **30** |
|
||||
| `0x5A` download chunk | 1,024 | **0** |
|
||||
| `0x41` setup name | 255 | 255 ✓ (only just) |
|
||||
|
||||
182 of 251 captured responses agree with a naive uint16 LE read; the 69 that do
|
||||
not are exactly the ones ≥ 256 bytes.
|
||||
|
||||
**This is the third time a length in this protocol has been read too narrow** —
|
||||
after `payload[9]` vs `payload[8:10]` in the probe response, and after
|
||||
`data[0]` here. The pattern is worth naming rather than fixing case by case:
|
||||
*take the content as `data[11:]` and let the frame's own length bound it.*
|
||||
Nothing needs the declared length; the frame already knows how long it is.
|
||||
|
||||
#### `SUB 0x5B` POLL content — where the strings actually sit
|
||||
|
||||
```
|
||||
content[3] 0x50 ⚠ printable as "P", immediately before the vendor string
|
||||
content[4] "Instantel\0"
|
||||
content[17:26] binary — 06 00 c3 f0 4a 00 e4 19 4f 00 74 02
|
||||
content[26] "MM/ISEE/S/IO\0" ← "MM/ISEE/S" on the Thor line
|
||||
```
|
||||
|
||||
⚠ **Do not parse this by scanning for printable runs.** That `0x50` at
|
||||
content[3] is printable, so a run scan returns `PInstantel`. Nothing
|
||||
distinguishes a length or tag byte from text by inspection. Read the vendor
|
||||
from the fixed offset and find the model by searching for `MM/` — structural
|
||||
rather than positional, which is what survives the model string's
|
||||
firmware-line difference.
|
||||
|
||||
#### `SUB 0x3F`/`0x40` setup walk — the terminator is an empty name
|
||||
|
||||
23 responses on the bench unit: 22 names, then a record whose name field is all
|
||||
zeros. The empty name **is** the end of list — not an error, not a setup.
|
||||
`factory.MMB` first, `TEST1.mmb` last.
|
||||
|
||||
#### `SUB 0x01` has no Thor frame behind it
|
||||
|
||||
Thor never reads device info in any captured session. `0xFFFF` for `0x01` comes
|
||||
|
||||
Reference in New Issue
Block a user