Files
seismo-relay/micromate/client.py
T
serversdownandClaude Opus 5 b55e4e946a verify(micromate): 11.0BD confirmed on hardware -- every inference held
UM20147 read over USB by mm_client_check.py.  The Thor firmware line was
entirely inference until now, and two of its three differences were covered
only by SYNTHESISED test frames.  All three held:

- flags = 0x03 identifies the line -> reported firmware_line="thor".  That is
  only reachable if `10 03` in the flags position destuffs before indexing,
  since 0x03 is ETX -- so the escaped-flags case is confirmed too.
- the model string is shorter -> reported "MM/ISEE/S" exactly.  Anchoring the
  search on b"MM/" rather than a fixed span is what made this work.
- the 0x1C block is 4 bytes longer with the extras TRAILING, so from-the-start
  offsets survive -> battery 3.55 V and a clock correct to the second.

That last one is the one that mattered.  Series III's from-the-end offsets
would have given this unit 577.92 V, which is why mm_client_check watches for
an impossible voltage: it is a free self-check on exactly the inference most
likely to be wrong.

Also clean: serial UM20147, 5 setups (factory.MMB -> test2.mmb), active setup
test2.mmb, 15,000,000 B total and free, 21 reads / 2,165 B in.

"One protocol stack drives the whole fleet regardless of firmware line" -- the
headline finding of 2026-09-23 -- is now demonstrated by a working client
rather than by matching response SUBs.

Test and docstring claims downgraded from inference to confirmed where the
hardware settled them, and left as synthesised-frame notes where it did not:
the BEHAVIOUR is confirmed but raw BD bytes are still not in the repo.

Added --capture DIR to mm_client_check: writes a raw_bw_*/raw_s3_* pair in the
layout scratch/mm_frame_parse.py already reads, so a run on an unfamiliar unit
becomes a test fixture without setting up a relay.  Verified by round-tripping
its own output through that parser: 28 frames, 0 bad checksums.

Still not covered: a download from a BD unit (UM20147 had no events stored, so
the chunk walk remains CB-only), raw BD fixture bytes, a monitoring unit, a
nearly-full buffer, and the inbound call-home session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2026-09-30 13:17:53 -04:00

296 lines
13 KiB
Python
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.
"""
client.py — high-level API for a live Micromate (Series IV).
Owns the transport, turns raw payloads into models. Read-only, like the layer
below it: nothing here writes, erases, or changes monitoring state.
with MicromateClient(TcpTransport("63.45.161.30", 9034)) as mm:
info = mm.connect()
print(info) # UM12947 MM/ISEE/S/IO blastware fw idle
print(mm.get_state()) # idle 2026-09-25 01:14:05 3.80 V memory 0.4% used
for name in mm.list_setups():
print(name)
The response layout, measured rather than assumed
-------------------------------------------------
**Every response carries an 11-byte prefix, and the content starts at
``data[11]``.** That one rule covers every command.
⚠ **``data[0]`` looks like the content length and is only its low byte.** A
2,092-byte setup block (`SUB 0x1A`) reports 44, and a 1,024-byte download chunk
reports 0. It happens to be right for every response shorter than 256 bytes,
which is most of them — so it reads as a working length field right up until it
silently loses 2,048 bytes. There is no high byte anywhere in the prefix; it is
``length & 0xFF`` and nothing more.
This is the same trap as ``MicromateFrame.probe_length``, in a different place,
and it is now the third time a length in this protocol has been read too narrow.
**Take the content as ``data[11:]`` and let the frame's own length bound it.**
"""
from __future__ import annotations
import datetime
import logging
from typing import Optional
from minimateplus.transport import BaseTransport
from .models import MicromateDeviceInfo, MicromateState
from .protocol import MicromateProtocol, ProtocolError
log = logging.getLogger(__name__)
# The content of every response begins here; the first 11 bytes are a prefix
# whose only decoded field is an unreliable low-byte length (see module docstring).
CONTENT = 11
# Field offsets, relative to the start of content. Sources are named because
# two of them disagree with docs/micromate_protocol_reference.md.
_STATE_FLAG = 0 # 0x49: 0x00 idle, 0x02 monitoring
_MS_FLAG = 1 # 0x1C: monitoring flag — test NON-ZERO
_MS_DAY, _MS_MONTH, _MS_YEAR = 2, 3, slice(4, 6)
_MS_UNKNOWN_6 = 6 # ⚠ NOT the hour — see read note below
_MS_HOUR, _MS_MIN, _MS_SEC = 7, 8, 9
_MS_BATTERY = slice(34, 36) # uint16 BE, volts × 100
_MS_MEM_TOTAL = slice(36, 40) # uint32 BE
_MS_MEM_FREE = slice(40, 44) # uint32 BE
# A setup-list walk that does not terminate is a bug, not a big fleet. The
# bench unit holds 22 setups; this is a generous ceiling, not a limit.
_MAX_SETUPS = 512
def _content(data: bytes) -> bytes:
"""Strip the 11-byte response prefix."""
return data[CONTENT:] if len(data) > CONTENT else b""
def _cstring(buf: bytes, offset: int = 0) -> str:
"""A null-terminated ASCII run, stripped."""
return buf[offset:].split(b"\x00")[0].decode("ascii", "replace").strip()
class MicromateClient:
"""High-level read-only client for one Micromate.
Owns the transport, unlike ``MicromateProtocol``, which borrows it.
"""
def __init__(
self,
transport: BaseTransport,
recv_timeout: float = 10.0,
strict_checksums: bool = True,
) -> None:
self._transport = transport
self._proto = MicromateProtocol(
transport, recv_timeout=recv_timeout, strict_checksums=strict_checksums
)
self._firmware_line: Optional[str] = None
# ── Lifecycle ─────────────────────────────────────────────────────────────
def open(self) -> None:
self._transport.connect()
def close(self) -> None:
self._transport.disconnect()
def is_open(self) -> bool:
return self._transport.is_connected()
def __enter__(self) -> "MicromateClient":
self.open()
return self
def __exit__(self, *_) -> None:
self.close()
@property
def protocol(self) -> MicromateProtocol:
"""The wire layer, for anything this class does not wrap yet."""
return self._proto
# ── Identity ──────────────────────────────────────────────────────────────
def connect(self, *, with_active_setup: bool = True) -> MicromateDeviceInfo:
"""`POLL → SERIAL → state`, plus the active setup name.
⚠ **This is deliberately not Thor's full preamble.** Thor sends
`POLL → SERIAL → 0x49 → POLL` and the client spec said to copy it
verbatim on the grounds that it is known-good. Measuring all 8 captured
sessions showed the only invariant is that a session **opens with
POLL** — the four-command form appears in 3 of 8 and is Thor's
*connection check*, run where it wants to refresh what it displays. The
trailing POLL is a repeat of the first.
So this sends the three reads that actually gather something. Dropping
the fourth is a judgement call on measured evidence, not a proof that
nothing depends on it; if a unit ever refuses the next command after a
cold connect, put it back and say so in the protocol reference.
`SUB 0x01` (device info) is **not** read. Thor never reads it in any
captured session, its field layout is unmapped beyond eight `1.0f`
floats, and `firmware_line` — the one thing we would want from it — comes
free from the flags byte of any response.
"""
poll = self._proto.poll()
self._firmware_line = poll.firmware_line
manufacturer, model = self._parse_poll(poll.data)
serial = _cstring(_content(self._proto.read_serial()))
monitoring = self._parse_state(self._proto.read_state())
info = MicromateDeviceInfo(
serial=serial,
manufacturer=manufacturer,
model=model,
firmware_line=poll.firmware_line,
monitoring=monitoring,
)
if with_active_setup:
try:
info.active_setup = self.get_active_setup()
except ProtocolError as e:
# Not worth failing a connect over: a unit with no setup loaded
# is a real state, and the caller can still read everything else.
log.warning("active setup unreadable: %s", e)
log.info("connected: %s", info)
return info
@staticmethod
def _parse_poll(data: bytes) -> tuple[Optional[str], Optional[str]]:
"""Manufacturer and model out of the POLL block.
`Instantel` sits at content[4] and the model at content[26], with 13
binary bytes between them.
⚠ A generic "find the printable runs" scan does **not** work here, which
cost a test failure before it cost anything worse. content[3] is `0x50`
— printable as `P` — sitting immediately before `Instantel`, so a run
scan returns `PInstantel`. Nothing distinguishes a length or tag byte
from text by inspection.
So: the manufacturer comes from a fixed offset, and the model is found by
searching for `MM/`. That anchor is structural rather than positional,
which matters because the model string **differs by firmware line** —
`MM/ISEE/S/IO` on the Blastware build, `MM/ISEE/S` on the Thor build —
and only its tail changes.
"""
c = _content(data)
manufacturer = _cstring(c, 4) or None
idx = c.find(b"MM/")
model = _cstring(c, idx) if idx >= 0 else None
return manufacturer, model
@staticmethod
def _parse_state(data: bytes) -> Optional[bool]:
"""`SUB 0x49` content[0]: 0x00 idle, 0x02 monitoring.
⚠ Tested for non-zero, never against `0x02`. The sibling flag in
`SUB 0x1C` has read both `0x0E` and `0x0C` while monitoring, so this
family of flags is not a stable enum.
"""
c = _content(data)
return bool(c[_STATE_FLAG]) if c else None
# ── State ─────────────────────────────────────────────────────────────────
def get_state(self) -> MicromateState:
"""`SUB 0x1C` — monitoring, device clock, battery, memory.
⚠ Every offset here is **forward from the start of content**, never
backward from the end. Series III reads battery and memory from the end
of this block, and this block is **4 bytes longer on the Thor firmware
line** — applying from-the-end offsets to a `11.0BD` unit yields a
battery voltage of 577.92 V. The four extra bytes are trailing, so
from-the-start offsets hold for both lines.
✅ **Confirmed on `11.0BD` 2026-09-30.** UM20147 read back 3.55 V and
a clock correct to the second over USB, so the from-the-start offsets do
survive the four extra trailing bytes. Had they not, the battery would
have read 577.92 V — which is what makes this cheap to check.
"""
data = self._proto.read_monitor_status()
c = _content(data)
if len(c) < 44:
raise ProtocolError(
f"monitor status content is {len(c)} B, need at least 44"
)
battery = int.from_bytes(c[_MS_BATTERY], "big") / 100.0
return MicromateState(
monitoring=bool(c[_MS_FLAG]),
device_time=self._parse_clock(c),
battery_volts=battery,
memory_total_bytes=int.from_bytes(c[_MS_MEM_TOTAL], "big"),
memory_free_bytes=int.from_bytes(c[_MS_MEM_FREE], "big"),
raw=data,
)
@staticmethod
def _parse_clock(c: bytes) -> Optional[datetime.datetime]:
"""The unit's own clock, in its own local time.
⚠ **content[6] is not part of the time.** The layout is day, month,
year, *one unidentified byte*, then h/m/s — so the hour is at content[7].
The protocol reference's `SUB 0x1C` section has this right and names
`data[17]` as unidentified; its one-line summary in the divergences list
("day/month/year/h/m/s at `data[13:21]`") reads as six contiguous fields
and is the version worth not trusting.
Re-measured here across three captures: content[6] read 32, 100 and 116,
none a valid hour, while content[7:10] gave 19:12:25, 19:13:34 and
01:14:05 against capture filenames stamped 19:12:14, 19:12:14 and
01:14:03 — each seconds to a minute after its session opened, which is
what a device clock should do.
content[6] is undecoded and deliberately not exposed.
"""
try:
return datetime.datetime(
year=int.from_bytes(c[_MS_YEAR], "big"),
month=c[_MS_MONTH],
day=c[_MS_DAY],
hour=c[_MS_HOUR],
minute=c[_MS_MIN],
second=c[_MS_SEC],
)
except ValueError as e:
# A unit with a dead clock battery reports an impossible date. That
# is information, not a reason to fail the whole state read.
log.warning("device clock unreadable (%s): %s", e, c[2:10].hex(" "))
return None
# ── Setups ────────────────────────────────────────────────────────────────
def get_active_setup(self) -> str:
"""`SUB 0x41` — the loaded `.MMB` file name, e.g. `TEST1.mmb`."""
return _cstring(_content(self._proto.read_active_setup_name()))
def list_setups(self) -> list[str]:
"""`0x3F` then `0x40`… — every setup file stored on the unit.
A cursor walk: the device holds the position, so the same `0x40` request
returns the next name. **An empty name terminates the list** — it is
not an error and not a real setup.
Measured on the bench unit: 23 responses, 22 names then the empty one,
`factory.MMB` first through `TEST1.mmb` last.
"""
names: list[str] = []
raw = self._proto.read_first_setup()
for _ in range(_MAX_SETUPS):
name = _cstring(_content(raw))
if not name:
return names
names.append(name)
raw = self._proto.read_next_setup()
raise ProtocolError(
f"setup list did not terminate after {_MAX_SETUPS} entries — the "
f"device cursor is not advancing"
)