Files
seismo-relay/tests/test_micromate_client.py
T
serversdownandClaude Opus 5 080fb92ba2 test(micromate): real 11.0BD fixtures replace the synthesised ones
UM20147 captured over USB with mm_client_check.py --capture, read out with
scratch/mm_frame_parse.py: 11 frames each direction, 0 bad checksums.  The two
BD-specific response data sections are now verbatim fixtures; only the frame
wrapper is rebuilt, which is sound because the framing is verified 251/251
elsewhere in the same file, and the reconstruction is pinned against the
payload length the parser reported.

Every field lands where the forward-offset model says it should:

  data[0]        = 0x30 = 48 = 59 - 11   (CB declares 0x2C = 44; the +4 holds)
  content[1]     = 0x0e   monitoring
  content[2:10]  = 30 Sep 2026 14:00:49  matches the wall clock
  content[34:36] = 3.50 V
  content[36:44] = 15,000,000 total / 14,848,448 free

AND THE WARNING IS NOW DEMONSTRATED ON THE UNIT IT PREDICTED.  Series III reads
battery from data[-10:-8], which on this block is e1 c0 -> 577.92 V.  That is
the exact figure the A/B section named, and there is now a test asserting it,
so the reason the offsets are forward cannot be quietly refactored away.

Two corrections to the reference:

1. The four extra BD bytes are `0f a0 00 04`, not `0f a0 00 00`.  Only the
   first two look fixed.  Nothing reads them, but the last is not padding.

2. POLL content[3] VARIES BETWEEN UNITS -- 0x50 on UM12947, 0x56 on UM20147.
   It is printable in both cases ("P" and "V"), which is a second, independent
   reason the POLL block cannot be parsed by scanning for printable runs: a
   scan returns "PInstantel" on one unit and "VInstantel" on the other.  The
   fixed offset content[4] was the right call for a reason I had not seen.

The model string sits at content[26] on both firmware lines; only its tail
differs, so anchoring on "MM/" remains correct.

98 micromate tests pass.  Full suite unchanged at 16 pre-existing failures.

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

494 lines
20 KiB
Python

"""Client-layer tests for the Micromate (series-4) live client.
Every response constant below is a **real data section**, captured from UM12947
(firmware 11.0CB) in ``bridges/captures/9-24-26 - micromate2/``. They are
embedded as hex because the captures are gitignored.
Where a decoded value can be checked against something outside the bytes, it is:
the device clock against the capture's own filename timestamp, the battery
against Thor's event reports (3.8 V), the setup list against what the unit
displays.
"""
from __future__ import annotations
import datetime
import os
import sys
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from micromate.client import CONTENT, MicromateClient, _content, _cstring
from micromate.framing import ETX, STX, checksum, stuff
from micromate.protocol import ProtocolError
FLAGS_CB, FLAGS_THOR = 0xC5, 0x03
# ── Captured response data sections ───────────────────────────────────────────
# Generated from the captures by hand-free extraction -- the hex below is
# verbatim response data, not reconstructed. The trailing comment on each
# names the capture it came from, which is what lets the clock assertions be
# checked against a wall-clock timestamp.
POLL = bytes.fromhex( # 59 B, from_20260924_185113_
"300000000000000000000000000050496e7374616e74656c"
"000600c3f04a00e4194f0074024d4d2f495345452f532f49"
"4f00001f603e7657603e76"
)
SERIAL = bytes.fromhex( # 21 B, from_20260924_191214_
"0a00000000000000000000554d3132393437003100"
)
STATE_IDLE = bytes.fromhex( # 16 B, from_20260924_191214_
"050000000000000000000000e8000b00"
)
STATE_MONITORING = bytes.fromhex( # 16 B, from_20260924_191214_
"050000000000000000000002e8000b00"
)
MS_MONITORING = bytes.fromhex( # 55 B, from_20260924_191214_
"2c00000000000000000000000e180907ea20130c19000000"
"000001000000000000000000000000000000000000017c00"
"e4e1c000e3f1c0"
)
MS_IDLE = bytes.fromhex( # 55 B, from_20260924_191214_
"2c000000000000000000000000180907ea64130d22000000"
"000001000000000000000000000000000000000000017c00"
"e4e1c000e3e1c0"
)
MS_LATE = bytes.fromhex( # 55 B, from_20260925_011403_
"2c000000000000000000000000190907ea74010e05000000"
"000001000000000000000000000000000000000000017c00"
"e4e1c000e3e1c0"
)
# ── Real 11.0BD bytes (UM20147, captured over USB 2026-09-30) ─────────────────
#
# The Thor firmware line, which was pure inference until this capture. These
# are the two responses where BD differs from CB. Captured with
# `mm_client_check.py --capture` and read out of the resulting pair with
# scratch/mm_frame_parse.py, so the data sections are verbatim; only the frame
# wrapper is rebuilt, and the framing is independently verified 251/251.
POLL_BD = bytes.fromhex( # 59 B -- flags 0x03, and the SHORTER model string
"30000000000000000000000000005649"
"6e7374616e74656c000600c3f04a00e4"
"194f0052024d4d2f495345452f530058"
"f406001f60c0755760c075"
)
MS_BD_MONITORING = bytes.fromhex( # 59 B -- FOUR BYTES LONGER than CB's 55
"3000000000000000000000000e1e0907"
"ea600e00310000000000000000000000"
"00000000000000000000000000015e00"
"e4e1c000e291c00fa00004"
)
_SETUP_PAD = 266 - CONTENT
def setup_response(name: str) -> bytes:
"""A 0x41/0x3F/0x40 response: 11-byte prefix then a null-padded name."""
body = name.encode("ascii").ljust(_SETUP_PAD, b"\x00")
return bytes([0xFF]) + bytes(10) + body
# The real 22 names, in the order the unit walked them.
SETUP_NAMES = [
"factory.MMB", "TEST.MMB", "BUS TEST.MMB", "Walsh JV 241.mmb",
"Walsh JV 008.mmb", "Hawbaker 322.mmb", "Hawbaker 322 blasting.mmb",
"min.mmb", "Playhouse Loc 1.mmb", "Valley Rock Solution.MMB",
"RecordingSetup.mmb", "UPMC.mmb", "UPMC Loc 3.mmb", "Residence Inn.mmb",
"Micromate ext trigger.mmb", "Micromate remort alarm.mmb",
"Tree of Life - Loc 1 - 5861 Solway.mmb", "Mele-PWSA-Carroll -Loc 4.mmb",
"Micromate min trigger mmb.mmb", "Fay - Layton Bridge Project.mmb",
"Default Micromate ISEE.mmb", "TEST1.mmb",
]
# ── Test doubles ──────────────────────────────────────────────────────────────
class ScriptedTransport:
def __init__(self, responses: list[bytes]) -> None:
self.queue = list(responses)
self.written: list[bytes] = []
self._connected = False
def connect(self) -> None:
self._connected = True
def disconnect(self) -> None:
self._connected = False
def is_connected(self) -> bool:
return self._connected
def write(self, data: bytes) -> None:
self.written.append(data)
def read(self, n: int) -> bytes:
return self.queue.pop(0) if self.queue else b""
def frame(rsp_sub: int, data: bytes, *, flags: int = FLAGS_CB) -> bytes:
payload = bytes([0x00, flags, rsp_sub, 0x00, 0x00]) + data
return bytes([STX]) + stuff(payload + bytes([checksum(payload)])) + bytes([ETX])
def client(responses: list[bytes], **kw) -> tuple[MicromateClient, ScriptedTransport]:
t = ScriptedTransport(responses)
return MicromateClient(t, recv_timeout=0.5, **kw), t
# ── The captured constants are what we think they are ─────────────────────────
def test_captured_constants_have_the_expected_lengths():
assert len(POLL) == 59
assert len(SERIAL) == 21
assert len(STATE_IDLE) == len(STATE_MONITORING) == 16
assert len(MS_MONITORING) == len(MS_IDLE) == len(MS_LATE) == 55
def test_the_prefix_length_byte_is_only_the_low_byte():
"""⚠ data[0] is `content_length & 0xFF`, with no high byte anywhere.
True for every response under 256 bytes, which is why it reads as a working
length field -- and then loses 2,048 bytes on a setup block. The client
takes content as data[11:] for exactly this reason.
"""
for data in (POLL, SERIAL, STATE_IDLE, MS_MONITORING):
assert data[0] == (len(data) - CONTENT) & 0xFF
assert data[1] == 0x00, "no high byte is stored"
# The two that prove it is not a real length: a 2,092-byte setup block
# reports 44, and a 1,024-byte download chunk reports 0.
assert (2092 & 0xFF) == 44
assert (1024 & 0xFF) == 0
# ── Helpers ───────────────────────────────────────────────────────────────────
def test_a_printable_byte_precedes_the_manufacturer_string():
"""content[2] is 0x50 -- "P". This is why the POLL parse cannot be a scan."""
c = _content(POLL)
assert c[3] == 0x50 and chr(c[3]) == "P"
assert c[4:13] == b"Instantel"
def test_content_strips_exactly_eleven_bytes():
assert _content(SERIAL) == bytes.fromhex("554d3132393437003100")
assert _content(b"short") == b""
def test_cstring_stops_at_the_null():
assert _cstring(bytes.fromhex("554d3132393437003100")) == "UM12947"
assert _cstring(b"\x00rest") == ""
# ── connect() ─────────────────────────────────────────────────────────────────
def test_connect_decodes_identity():
mm, t = client([
frame(0xA4, POLL), frame(0xEA, SERIAL), frame(0xB6, STATE_IDLE),
frame(0xBE, setup_response("TEST1.mmb")),
])
info = mm.connect()
assert info.serial == "UM12947"
assert info.manufacturer == "Instantel"
assert info.model == "MM/ISEE/S/IO"
assert info.firmware_line == "blastware"
assert info.monitoring is False
assert info.active_setup == "TEST1.mmb"
assert "UM12947" in str(info) and "idle" in str(info)
def test_connect_sends_three_reads_not_thors_four():
"""⚠ Deliberately narrower than Thor's POLL -> SERIAL -> 0x49 -> POLL.
The trailing POLL repeats the first; measuring all 8 captured sessions
showed the four-command form is Thor's connection check (3 of 8 sessions),
not a handshake. Only "opens with POLL" is invariant.
"""
mm, t = client([
frame(0xA4, POLL), frame(0xEA, SERIAL), frame(0xB6, STATE_IDLE),
frame(0xBE, setup_response("TEST1.mmb")),
])
mm.connect()
subs = [w[5] for w in t.written] # payload[2] lands at wire[5]
assert subs == [0x5B, 0x15, 0x49, 0x41]
assert 0x01 not in subs, "device info has no Thor precedent; do not read it"
def test_connect_can_skip_the_active_setup():
mm, t = client([frame(0xA4, POLL), frame(0xEA, SERIAL), frame(0xB6, STATE_IDLE)])
info = mm.connect(with_active_setup=False)
assert info.active_setup is None
assert len(t.written) == 3
def test_connect_survives_an_unreadable_active_setup():
"""A unit with no setup loaded is a real state, not a failed connect."""
mm, _ = client([
frame(0xA4, POLL), frame(0xEA, SERIAL), frame(0xB6, STATE_IDLE),
frame(0x00, bytes(20)), # wrong SUB -> UnexpectedResponse
])
info = mm.connect()
assert info.serial == "UM12947"
assert info.active_setup is None
def test_connect_reports_a_monitoring_unit():
mm, _ = client([
frame(0xA4, POLL), frame(0xEA, SERIAL), frame(0xB6, STATE_MONITORING),
frame(0xBE, setup_response("TEST1.mmb")),
])
assert mm.connect().monitoring is True
def test_the_state_flag_is_tested_for_non_zero():
"""⚠ Never compared against 0x02 -- this flag family is not a stable enum.
Its sibling in SUB 0x1C has read both 0x0E and 0x0C while monitoring.
"""
for value in (0x01, 0x02, 0x0C, 0x0E, 0xFF):
data = bytearray(STATE_IDLE)
data[CONTENT] = value
mm, _ = client([
frame(0xA4, POLL), frame(0xEA, SERIAL), frame(0xB6, bytes(data)),
frame(0xBE, setup_response("x.mmb")),
])
assert mm.connect().monitoring is True, f"0x{value:02x} should read as monitoring"
def test_the_model_string_is_anchored_on_MM_not_on_an_offset():
"""The Thor firmware line reports a SHORTER model string, "MM/ISEE/S".
Anchoring on b"MM/" survives that; a fixed end offset would not. A generic
printable-run scan fails for a different reason -- see _parse_poll.
✅ CONFIRMED on real hardware 2026-09-30: UM20147 (11.0BD) read back
model="MM/ISEE/S" over USB. The frame below is still synthesised because
no BD capture is in the repo, but the string it asserts is the real one.
"""
bd = bytearray(POLL)
assert bd[CONTENT + 26:CONTENT + 38] == b"MM/ISEE/S/IO"
bd[CONTENT + 26:CONTENT + 38] = b"MM/ISEE/S\x00\x00\x00"
mm, _ = client([
frame(0xA4, bytes(bd), flags=FLAGS_THOR), frame(0xEA, SERIAL),
frame(0xB6, STATE_IDLE), frame(0xBE, setup_response("x.mmb")),
])
info = mm.connect()
assert info.model == "MM/ISEE/S"
assert info.manufacturer == "Instantel"
assert info.firmware_line == "thor"
# ── get_state() ───────────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"data, monitoring, when, free",
[
(MS_MONITORING, True, datetime.datetime(2026, 9, 24, 19, 12, 25), 0x00E3F1C0),
(MS_IDLE, False, datetime.datetime(2026, 9, 24, 19, 13, 34), 0x00E3E1C0),
(MS_LATE, False, datetime.datetime(2026, 9, 25, 1, 14, 5), 0x00E3E1C0),
],
ids=["monitoring", "idle", "after-midnight"],
)
def test_get_state_decodes_the_real_reads(data, monitoring, when, free):
"""⚠ There is an unidentified byte at content[6]; the hour is at content[7].
The protocol reference's 0x1C section has this right. Its one-line summary
in the divergences list reads as six contiguous fields and does not.
Each expected time is checked against the capture filename that produced the
bytes: 19:12:14, 19:12:14 and 01:14:03. All three decode to seconds-to-a-
minute after their session opened, which is what a device clock should do.
Reading content[6] as the hour gives 32, 100 and 116.
"""
mm, _ = client([frame(0xE3, data)])
st = mm.get_state()
assert st.monitoring is monitoring
assert st.device_time == when
assert st.battery_volts == 3.80 # Thor's reports print 3.8 V
assert st.memory_total_bytes == 15_000_000
assert st.memory_free_bytes == free
assert st.raw == data
def test_content_6_is_not_the_hour():
"""The byte the reference implies is the hour reads 32, 100 and 116."""
for data in (MS_MONITORING, MS_IDLE, MS_LATE):
assert _content(data)[6] not in range(24)
def test_memory_derivations():
mm, _ = client([frame(0xE3, MS_MONITORING)])
st = mm.get_state()
assert st.memory_used_bytes == 15_000_000 - 0x00E3F1C0
assert 0 < st.memory_used_fraction < 0.02
assert "3.80 V" in str(st)
def test_battery_and_memory_are_read_forward_from_content_start():
"""⚠ NOT backward from the end.
This block is 4 bytes longer on the Thor firmware line, and Series III's
from-the-end offsets give a 11.0BD unit a battery reading of 577.92 V. The
extra bytes are trailing, so appending four does not move anything.
"""
bd = MS_MONITORING + bytes.fromhex("0fa00000")
bd = bytes([0x30]) + bd[1:] # low-byte length becomes 48
mm, _ = client([frame(0xE3, bd)])
st = mm.get_state()
assert st.battery_volts == 3.80, "forward offsets must survive the 4 extra bytes"
assert st.memory_total_bytes == 15_000_000
# What the Series III from-the-end offsets would have produced:
assert int.from_bytes(bd[-10:-8], "big") / 100 == pytest.approx(577.92, abs=0.01)
def test_a_dead_clock_battery_does_not_fail_the_whole_read():
"""An impossible date is information; the rest of the block is still good."""
broken = bytearray(MS_MONITORING)
broken[CONTENT + 3] = 0xFF # month 255
mm, _ = client([frame(0xE3, bytes(broken))])
st = mm.get_state()
assert st.device_time is None
assert st.battery_volts == 3.80
def test_a_truncated_state_block_raises():
mm, _ = client([frame(0xE3, bytes(20))])
with pytest.raises(ProtocolError, match="need at least 44"):
mm.get_state()
# ── Setups ────────────────────────────────────────────────────────────────────
def test_list_setups_walks_to_the_empty_terminator():
"""22 real names then an empty one, exactly as the unit walked them."""
responses = [frame(0xC0, setup_response(SETUP_NAMES[0]))]
responses += [frame(0xBF, setup_response(n)) for n in SETUP_NAMES[1:]]
responses += [frame(0xBF, setup_response(""))]
mm, t = client(responses)
assert mm.list_setups() == SETUP_NAMES
assert len(t.written) == 23, "22 names plus the terminator"
assert t.written[0][5] == 0x3F
assert {w[5] for w in t.written[1:]} == {0x40}
def test_list_setups_handles_an_empty_unit():
mm, _ = client([frame(0xC0, setup_response(""))])
assert mm.list_setups() == []
def test_list_setups_refuses_to_loop_forever():
"""A cursor that never advances is a bug, and must not hang the caller."""
from micromate import client as C
mm, _ = client([frame(0xC0, setup_response("a.mmb"))]
+ [frame(0xBF, setup_response("a.mmb"))] * (C._MAX_SETUPS + 5))
with pytest.raises(ProtocolError, match="not advancing"):
mm.list_setups()
def test_get_active_setup_handles_a_long_name():
long_name = "Tree of Life - Loc 1 - 5861 Solway.mmb"
mm, _ = client([frame(0xBE, setup_response(long_name))])
assert mm.get_active_setup() == long_name
# ── Lifecycle ─────────────────────────────────────────────────────────────────
def test_the_client_owns_the_transport():
mm, t = client([])
assert not mm.is_open()
mm.open()
assert mm.is_open() and t.is_connected()
mm.close()
assert not mm.is_open()
def test_context_manager_opens_and_closes():
t = ScriptedTransport([frame(0xA4, POLL), frame(0xEA, SERIAL),
frame(0xB6, STATE_IDLE), frame(0xBE, setup_response("x.mmb"))])
with MicromateClient(t, recv_timeout=0.5) as mm:
assert t.is_connected()
assert mm.connect().serial == "UM12947"
assert not t.is_connected()
# ── The Thor firmware line, on real bytes ─────────────────────────────────────
def test_bd_constants_have_the_lengths_the_parser_reported():
assert len(POLL_BD) == 59
assert len(MS_BD_MONITORING) == 59, "CB is 55; BD is four bytes longer"
assert MS_BD_MONITORING[0] == 0x30, "low-byte length 48 = 59 - 11"
assert MS_IDLE[0] == 0x2C, "the CB equivalent declares 44"
def test_connect_on_a_thor_line_unit():
"""UM20147, real POLL bytes. flags 0x03 is ETX, so it arrives as `10 03`."""
mm, _ = client([
frame(0xA4, POLL_BD, flags=FLAGS_THOR), frame(0xEA, SERIAL),
frame(0xB6, STATE_MONITORING), frame(0xBE, setup_response("test2.MMB")),
])
info = mm.connect()
assert info.firmware_line == "thor"
assert info.model == "MM/ISEE/S", "the BD model string is shorter than CB's"
assert info.manufacturer == "Instantel"
assert info.monitoring is True
def test_poll_content_3_is_not_a_constant():
"""⚠ 0x50 on UM12947, 0x56 on UM20147 -- it VARIES between units.
This is why the manufacturer is read at the fixed offset content[4] and not
by scanning: content[3] is printable in both cases ("P" and "V"), so a
printable-run scan would return "PInstantel" on one unit and "VInstantel" on
the other. Whatever the byte is, it is not a stable marker to anchor on.
"""
assert _content(POLL)[3] == 0x50
assert _content(POLL_BD)[3] == 0x56
assert chr(_content(POLL_BD)[3]) == "V"
def test_get_state_on_a_thor_line_unit():
"""⚠ THE test for the forward-offset decision. Real UM20147 bytes.
The 0x1C block is four bytes longer here, and the extras are TRAILING, so
offsets measured from the start of content are unmoved. Series III reads
battery and memory from the END of this block; test_series_iii_offsets_...
below shows what that produces.
"""
mm, _ = client([frame(0xE3, MS_BD_MONITORING, flags=FLAGS_THOR)])
st = mm.get_state()
assert st.monitoring is True
assert st.device_time == datetime.datetime(2026, 9, 30, 14, 0, 49)
assert st.battery_volts == 3.50
assert st.memory_total_bytes == 15_000_000
assert st.memory_free_bytes == 14_848_448
def test_series_iii_from_the_end_offsets_give_577_volts_on_a_bd_unit():
"""The exact number the protocol reference warned about, now demonstrated.
577.92 V is not a plausible battery reading for anything, which is what
makes it a free self-check -- bridges/mm_client_check.py watches for it.
"""
assert int.from_bytes(MS_BD_MONITORING[-10:-8], "big") / 100 == 577.92
def test_the_four_extra_bd_bytes_are_not_all_zero():
"""⚠ The reference records them as `0f a0 00 00`; UM20147 sent `0f a0 00 04`.
Only the first two bytes look fixed. Nothing reads them, but a future
decoder must not treat the last one as padding.
"""
assert _content(MS_BD_MONITORING)[44:] == bytes.fromhex("0fa00004")
assert _content(MS_IDLE)[44:] == b"", "the CB block has no such tail"