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
494 lines
20 KiB
Python
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"
|