""" 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 import math import os import struct from typing import Optional from minimateplus.transport import BaseTransport from .models import MicromateDeviceInfo, MicromateEventRef, 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 # `SUB 0x0C` record, relative to content. Established against 7 events across # both firmware lines. _REC_DAY, _REC_MONTH, _REC_YEAR = 0, 1, slice(2, 4) _REC_UNKNOWN_4 = 4 # ⚠ same shape as 0x1C's content[6]; undecoded _REC_HOUR, _REC_MIN, _REC_SEC = 5, 6, 7 _REC_TYPE = 11 # 0x07 waveform, 0x08 histogram _REC_LOCATION = 12 _REC_SETUP = 34 _REC_SERIAL = 76 _RECORD_TYPES = {0x07: "waveform", 0x08: "histogram"} # The channel labels the record carries, in the order they appear. The peak # float sits `label + 6`; the peak vector sum sits 12 bytes BEFORE "Tran". _REC_CHANNELS = (b"Tran", b"Vert", b"Long", b"Mic") _REC_PVS_BACK = 12 # A chain walk terminates on an all-zero key. The ceilings below are guards # against a device cursor that never advances, not fleet limits. _NULL_KEY = bytes(4) _MAX_EVENTS = 4096 # 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 DecodeMismatch(ProtocolError): """Our decoded peak disagrees with the one the device computed itself.""" 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 self._serial: 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())) self._serial = 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" ) # ── Events ──────────────────────────────────────────────────────────────── def serial(self) -> str: """The unit's serial, cached from `connect()` or read on demand. Needed by anything that handles an event, because an event key is ambiguous without it — see `MicromateEventRef`. """ if self._serial is None: self._serial = _cstring(_content(self._proto.read_serial())) return self._serial def list_events(self, *, with_records: bool = True) -> list[MicromateEventRef]: """Walk the event chain. `0x93 → 1E`, then `0x93 → 1F` until the sentinel. THOR sends `0x93` before **every** chain read, and this mirrors that. The chain ends on an all-zero key. ⚠ `with_records=True` costs **one extra round trip per event** for the `0x0C` read, and over cellular a round trip is ~0.65 s regardless of size. On a unit with 40 events that is the difference between ~52 s and ~78 s. Pass False when you only need "what is here and how big" — but note the **record is where the type and timestamp live**, so without it `ref.filename` is None and `get_event()` cannot pick a suffix. ⚠ **To download, prefer `iter_events()`.** This walks the whole chain first; THOR interleaves, downloading each event before advancing. `0x5A` addresses an event by key, so downloading afterwards *should* work — but "should" is doing real work in that sentence, and the interleaved order is the one with captures behind it. See `iter_events()`. """ serial = self.serial() refs: list[MicromateEventRef] = [] for i in range(_MAX_EVENTS): self._proto.arm_event() raw = (self._proto.read_event_first() if i == 0 else self._proto.read_event_next()) c = _content(raw) if len(c) < 8: raise ProtocolError( f"chain entry {i}: {len(c)} B of content, need 8 (key + size)" ) key, size = c[0:4], int.from_bytes(c[4:8], "big") if key == _NULL_KEY: return refs # the sentinel, not an error ref = MicromateEventRef(index=i, key=key, size=size, serial=serial) if with_records: self._read_record_into(ref) refs.append(ref) raise ProtocolError( f"event chain did not terminate after {_MAX_EVENTS} entries — the " f"device cursor is not advancing" ) def iter_events(self, *, with_records: bool = True): """Walk the chain, yielding each event **at the cursor position THOR uses.** for ref in mm.iter_events(): if ref.is_histogram: continue data = mm.download_event(ref) # ← safe here Why this exists alongside `list_events()`: THOR's captured order is 0x93 → 1E → 0C → 5A×n → 0x93 → 1F → 0C → 5A×n → … — it downloads each event *before* advancing the chain. `list_events()` walks to the end first, which is fine for browsing (the browse walk is separately attested) but means a later download happens with the device cursor parked past the event. `0x5A` is key-addressed, so it very probably does not care; nothing observed says it does, and nothing observed says it does not. Downloading inside this loop reproduces THOR's sequence exactly, so it is the path to use when it matters. ⚠ Do not advance the generator before finishing with the event it yielded. """ serial = self.serial() for i in range(_MAX_EVENTS): self._proto.arm_event() raw = (self._proto.read_event_first() if i == 0 else self._proto.read_event_next()) c = _content(raw) if len(c) < 8: raise ProtocolError( f"chain entry {i}: {len(c)} B of content, need 8 (key + size)" ) key, size = c[0:4], int.from_bytes(c[4:8], "big") if key == _NULL_KEY: return ref = MicromateEventRef(index=i, key=key, size=size, serial=serial) if with_records: self._read_record_into(ref) yield ref raise ProtocolError( f"event chain did not terminate after {_MAX_EVENTS} entries — the " f"device cursor is not advancing" ) def _read_record_into(self, ref: MicromateEventRef) -> None: """`SUB 0x0C` — 210 B of content: timestamp, type, names, peaks.""" raw = self._proto.read_event_record(ref.key) c = _content(raw) if len(c) <= _REC_SERIAL: raise ProtocolError(f"event record is {len(c)} B, too short to decode") ref.raw_record = raw ref.record_type = _RECORD_TYPES.get(c[_REC_TYPE]) if ref.record_type is None: # Worth saying out loud rather than filing the event as a waveform: # the suffix decides which codec runs. log.warning("event %s: unknown record type 0x%02x at content[%d]", ref.key_hex, c[_REC_TYPE], _REC_TYPE) try: ref.timestamp = datetime.datetime( year=int.from_bytes(c[_REC_YEAR], "big"), month=c[_REC_MONTH], day=c[_REC_DAY], hour=c[_REC_HOUR], minute=c[_REC_MIN], second=c[_REC_SEC], ) except ValueError as e: log.warning("event %s: bad timestamp (%s): %s", ref.key_hex, e, c[:8].hex(" ")) ref.sensor_location = _cstring(c, _REC_LOCATION) or None ref.setup = _cstring(c, _REC_SETUP) or None # Prefer the record's own serial over the cached one — they have always # agreed, but the record is the event's own account of where it came from. if rec_serial := _cstring(c, _REC_SERIAL): ref.serial = rec_serial peaks: dict[str, float] = {} for label in _REC_CHANNELS: i = c.find(label) if i < 0 or i + len(label) + 10 > len(c): continue peaks[label.decode()] = struct.unpack( ">f", c[i + len(label) + 2: i + len(label) + 6])[0] ref.peaks_ips = peaks or None tran = c.find(b"Tran") if tran >= _REC_PVS_BACK: ref.peak_vector_sum_ips = struct.unpack( ">f", c[tran - _REC_PVS_BACK: tran - _REC_PVS_BACK + 4])[0] def download_event(self, ref: MicromateEventRef) -> bytes: """The raw `.IDFW`/`.IDFH` bytes, exactly as THOR would have stored them. Feeds `micromate.idf_file.read_idf_file()` and `/db/import/idf_file` unchanged — no new codec work is needed for a directly downloaded event. """ return self._proto.read_event_file(ref.key, ref.size) def get_event(self, ref: MicromateEventRef, *, verify: bool = True, tolerance: float = 0.01): """Download and decode one event. Returns the codec's `IdfReadResult`. Needs `ref.record_type`, since `read_idf_file()` dispatches on the filename suffix and there is no filename on the wire — so call `list_events(with_records=True)` first. ⚠ `verify=True` re-computes the **peak vector sum** from the decoded samples and compares it against the float the *device* put in the `0x0C` record. Those are two independent computations over the same samples — the device's from its own firmware, ours from our codec — so a disagreement means our decode is wrong. Measured agreement on the bench events is **0.000%**. This is cheap insurance in a codebase whose decode failures have historically been *silent*: unhandled block tags shorten a channel and nothing raises. ⚠ It is a decode-correctness check, **not** a truncation detector — a channel cut after its peak still yields the right PVS. Histograms are not verified: `samples` is empty for them. """ if not ref.record_type: raise ValueError( f"event {ref.key_hex}: record_type is unknown, so the codec " f"cannot be dispatched. Use list_events(with_records=True)." ) blob = self.download_event(ref) import tempfile from .idf_file import read_idf_file # read_idf_file dispatches on the suffix, so the bytes need a name. with tempfile.NamedTemporaryFile(suffix=ref.suffix, delete=False) as f: f.write(blob) tmp = f.name try: result = read_idf_file(tmp) finally: os.unlink(tmp) if verify and not ref.is_histogram: err = self.decode_error(ref, result) if err is not None and abs(err) > tolerance: raise DecodeMismatch( f"event {ref.uid}: decoded peak vector sum differs from the " f"device's own by {100 * err:+.3f}% (tolerance " f"{100 * tolerance:.1f}%) — the decode is suspect, not the " f"device. Stored {ref.peak_vector_sum_ips:.5f} in/s." ) if err is not None: log.debug("event %s: PVS agrees to %+.4f%%", ref.uid, 100 * err) return result @staticmethod def decode_error(ref: MicromateEventRef, result) -> Optional[float]: """Relative error between our decoded PVS and the device's stored one. None when either side is unavailable. Positive means the device's figure is higher than ours. """ from .idf_file import geo_count_to_ips stored = ref.peak_vector_sum_ips if not stored or not getattr(result, "samples", None): return None ch = {k.lower(): v for k, v in result.samples.items()} try: t, v, l = ch["tran"], ch["vert"], ch["long"] except KeyError: return None n = min(len(t), len(v), len(l)) if not n: return None pvs = max( math.sqrt(geo_count_to_ips(t[i]) ** 2 + geo_count_to_ips(v[i]) ** 2 + geo_count_to_ips(l[i]) ** 2) for i in range(n) ) return (stored - pvs) / pvs if pvs else None