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:
2026-09-28 20:25:40 -04:00
co-authored by Claude Opus 5
parent a7e3a8f20a
commit f09b7dcaaf
5 changed files with 837 additions and 2 deletions
+82
View File
@@ -396,3 +396,85 @@ class IdfEvent:
)
ev._waveform_key = waveform_key
return ev
# ── Live-device models (2026-09-27) ───────────────────────────────────────────
#
# These describe what a unit reports over the wire, not what Thor wrote to a
# file. Everything above this line came out of Thor's exports; everything below
# came out of Thor's *traffic*. Field offsets are recorded in
# ``micromate/client.py`` next to the code that reads them.
@dataclass
class MicromateDeviceInfo:
"""Identity gathered by ``MicromateClient.connect()``.
Sourced from three reads:
``0x5B`` POLL → manufacturer, model
``0x15`` SERIAL → serial
``0x49`` STATE → monitoring
plus ``firmware_line``, which comes free from the flags byte of any
response and needs no read of its own.
"""
serial: str
manufacturer: Optional[str] = None # "Instantel"
model: Optional[str] = None # "MM/ISEE/S/IO" (CB) / "MM/ISEE/S" (BD)
firmware_line: Optional[str] = None # "blastware" | "thor" | "unknown"
monitoring: Optional[bool] = None
active_setup: Optional[str] = None # e.g. "TEST1.mmb"
def __str__(self) -> str:
bits = [self.serial]
if self.model:
bits.append(self.model)
if self.firmware_line:
bits.append(f"{self.firmware_line} fw")
if self.monitoring is not None:
bits.append("MONITORING" if self.monitoring else "idle")
if self.active_setup:
bits.append(f"setup={self.active_setup}")
return " ".join(bits)
@dataclass
class MicromateState:
"""A unit's live state, from ``SUB 0x1C``.
``device_time`` is the unit's own clock, in its own local timezone — it is
NOT converted. Nothing else this protocol exposes reports the unit's time,
which makes it the only way to detect a drifted clock before it lands in
event timestamps.
"""
monitoring: bool
device_time: Optional[datetime.datetime] = None
battery_volts: Optional[float] = None
memory_total_bytes: Optional[int] = None
memory_free_bytes: Optional[int] = None
raw: Optional[bytes] = field(default=None, repr=False)
@property
def memory_used_bytes(self) -> Optional[int]:
if self.memory_total_bytes is None or self.memory_free_bytes is None:
return None
return self.memory_total_bytes - self.memory_free_bytes
@property
def memory_used_fraction(self) -> Optional[float]:
used = self.memory_used_bytes
if used is None or not self.memory_total_bytes:
return None
return used / self.memory_total_bytes
def __str__(self) -> str:
bits = ["MONITORING" if self.monitoring else "idle"]
if self.device_time:
bits.append(self.device_time.strftime("%Y-%m-%d %H:%M:%S"))
if self.battery_volts is not None:
bits.append(f"{self.battery_volts:.2f} V")
frac = self.memory_used_fraction
if frac is not None:
bits.append(f"memory {frac * 100:.1f}% used")
return " ".join(bits)