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
397 lines
15 KiB
Python
397 lines
15 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.
|
|
"""
|
|
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()
|