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
401 lines
16 KiB
Python
401 lines
16 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"
|
|
)
|
|
|
|
_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()
|