Step 2 of docs/micromate_client_spec.md: micromate/protocol.py plus 35 offline tests. Reads only; nothing here writes, erases or changes monitoring state. The tests replay Thor's captured responses through a scripted transport and assert the bytes we emit are the bytes Thor emits -- including a full replay of the six-event download session, all 56 0x5A frames byte-for-byte. A passing test therefore means a real unit has already answered exactly that frame. Measuring the spec's command table against the captures found three more errors in it, on top of the three the framing work found: 1. SUB 0x0A is the MONITOR-LOG WALK, not a keyed "event header, 30 B list record" read. The same request repeated returns successive 297-byte records -- serial, mode, thresholds -- until an 11-byte ack ends the list. The device holds the cursor; nothing in the request selects a record, and all nine captured frames carry identical params. Structural divergence worth noting: Series III reaches the same data via a record-type discriminator on its event chain, so partials and events share one walk. Here the monitor log has its own cursor and the event chain never sees it. 2. 0x1E/0x1F carry token 0xFE at params[7]. The protocol reference documents all-zero params -- that was our own browse probing, which also worked. Thor sends 0xFE on browse and download alike. 3. SUB 0x01 (device info) is never read by Thor in any captured session. Its 0xFFFF offset comes from our own probes, so it is the one read in the table with no Thor precedent. Flagged in the docstring. Two useful negatives, both from absence rather than presence: - No SESSION_RESET (41 03). Series III needs that 2-byte signal or a monitoring unit will not answer POLL over TCP. Zero occurrences across all 8 sessions, including 40 frames exchanged with a unit that WAS monitoring. - No universal preamble. The only invariant is that a session opens with POLL; POLL -> SERIAL -> 0x49 -> POLL is Thor's connection check and appears in 3 of 8 sessions. Setup pushes and scheduler reads open differently. Two deliberate divergences from the Series III sibling: - strict_checksums defaults True and RAISES. minimateplus logs and continues because its parser cannot always tell an inner-frame delimiter from a checksum byte; that does not apply here, where the rule is exact on 251/251 frames. The lenient default is instructive -- it hid a wrong checksum rule for two days. - read_event_file() raises ShortRead rather than returning a truncated event. The expected length is known up front, so the check is free, and a silently short event is the failure mode this codebase keeps hitting. Every exchange resets the parser before sending, so a leftover frame is discarded rather than answered with -- expected_sub catches a mismatched SUB, but a same-SUB leftover would sail through with data for the wrong key. File transfer (0x94/0x48) is deliberately out of scope: it needs a data-carrying request frame, which is the frame type writes use, and that boundary is worth keeping crisp in a read-only pass. Full suite unchanged at 16 pre-existing failures (missing gitignored fixtures); 419 passed, up 35. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
403 lines
15 KiB
Python
403 lines
15 KiB
Python
"""Protocol-layer tests for the Micromate (series-4) live client.
|
|
|
|
The load-bearing assertion in here is not "our parser understands the device" —
|
|
it is **"the bytes we put on the wire are the bytes THOR puts on the wire."**
|
|
Every request constant below is lifted from
|
|
``bridges/captures/9-24-26 - micromate2/`` (UM12947, firmware 11.0CB), so a
|
|
passing test means a real unit has already answered exactly that frame.
|
|
|
|
Responses are replayed through a scripted transport. No hardware, no network.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from micromate import protocol as P
|
|
from micromate.framing import ETX, STX, checksum, stuff
|
|
from micromate.protocol import (
|
|
ACK_DATA_LEN,
|
|
ChecksumError,
|
|
MicromateProtocol,
|
|
ShortRead,
|
|
UnexpectedResponse,
|
|
chunk_params,
|
|
key_lo_params,
|
|
key_params,
|
|
token_params,
|
|
)
|
|
|
|
FLAGS_CB = 0xC5
|
|
|
|
|
|
# ── Test doubles ──────────────────────────────────────────────────────────────
|
|
|
|
class ScriptedTransport:
|
|
"""Hands back queued responses; records every byte written."""
|
|
|
|
def __init__(self, responses: list[bytes] | None = None) -> None:
|
|
self.queue = list(responses or [])
|
|
self.written: list[bytes] = []
|
|
self._connected = True
|
|
|
|
# BaseTransport surface actually used by MicromateProtocol
|
|
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, page: int = 0) -> bytes:
|
|
"""Build a response frame the way a unit would."""
|
|
payload = bytes([0x00, flags, rsp_sub, (page >> 8) & 0xFF, page & 0xFF]) + data
|
|
return bytes([STX]) + stuff(payload + bytes([checksum(payload)])) + bytes([ETX])
|
|
|
|
|
|
def ack(rsp_sub: int) -> bytes:
|
|
return frame(rsp_sub, bytes(ACK_DATA_LEN))
|
|
|
|
|
|
def proto(responses: list[bytes], **kw) -> tuple[MicromateProtocol, ScriptedTransport]:
|
|
t = ScriptedTransport(responses)
|
|
return MicromateProtocol(t, recv_timeout=0.5, **kw), t
|
|
|
|
|
|
# ── Captured THOR request frames ──────────────────────────────────────────────
|
|
|
|
REQ = {
|
|
"poll": bytes.fromhex("41021010005b000030000000000000000000009b03"),
|
|
"serial": bytes.fromhex("41021010001500000a000000000000000000002f03"),
|
|
"state": bytes.fromhex("41021010004900ffff000000000000000000005703"),
|
|
"compliance": bytes.fromhex("41021010001a00ffff000000000000000000002803"),
|
|
"arm": bytes.fromhex("41021010009300ffff00000000000000000000a103"),
|
|
"setup_first": bytes.fromhex("41021010003f00ffff000000000000000000004d03"),
|
|
"setup_next": bytes.fromhex("41021010004000ffff000000000000000000004e03"),
|
|
}
|
|
|
|
# The complete 0x5A sequence for event 055d4a81 (4,076 bytes → 4 chunks), as
|
|
# THOR sent it. Chunk 1's params hold a literal 0x04 and chunk 3's offset is
|
|
# the exact remainder.
|
|
REQ_CHUNKS_4A81 = [
|
|
bytes.fromhex("41021010005a00100400055d4a810000000000009b03"),
|
|
bytes.fromhex("41021010005a0010040000001004000000000000007203"),
|
|
bytes.fromhex("41021010005a00100400000008000000000000007603"),
|
|
bytes.fromhex("41021010005a001003ec00000c000000000000006503"),
|
|
]
|
|
SIZE_4A81 = 4076
|
|
|
|
|
|
# ── Params builders ───────────────────────────────────────────────────────────
|
|
|
|
def test_event_token_sits_at_params_7():
|
|
"""⚠ THOR sends 0xFE here; the protocol reference documents all-zero params.
|
|
|
|
That reference entry describes our own browse probing, not THOR's.
|
|
"""
|
|
assert token_params() == bytes.fromhex("00000000000000fe0000")
|
|
|
|
|
|
def test_event_record_takes_the_full_key_at_params_4():
|
|
assert key_params(bytes.fromhex("055d4a81")) == bytes.fromhex("00000000055d4a810000")
|
|
|
|
|
|
def test_monitor_log_takes_only_the_low_half_of_the_key():
|
|
"""⚠ Inferred from one key value -- see key_lo_params' docstring."""
|
|
assert key_lo_params(bytes.fromhex("055d4a81")) == bytes.fromhex("0000000000004a810000")
|
|
|
|
|
|
def test_chunk_params_switch_from_key_to_byte_offset():
|
|
key = bytes.fromhex("055d4a81")
|
|
assert chunk_params(key, 0) == bytes.fromhex("055d4a81000000000000")
|
|
assert chunk_params(key, 1024) == bytes.fromhex("00000400000000000000")
|
|
assert chunk_params(key, 13312) == bytes.fromhex("00003400000000000000")
|
|
|
|
|
|
@pytest.mark.parametrize("bad", [b"", b"\x01\x02\x03", b"\x01\x02\x03\x04\x05"])
|
|
def test_params_builders_reject_a_wrong_length_key(bad):
|
|
for fn in (key_params, key_lo_params):
|
|
with pytest.raises(ValueError):
|
|
fn(bad)
|
|
|
|
|
|
# ── Each read emits the frame THOR emits ──────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize(
|
|
"name, method, rsp_sub, data_len",
|
|
[
|
|
("poll", "poll", 0xA4, 59),
|
|
("serial", "read_serial", 0xEA, 21),
|
|
("state", "read_state", 0xB6, 16),
|
|
("compliance", "read_compliance_config", 0xE5, 2103),
|
|
("setup_first", "read_first_setup", 0xC0, 266),
|
|
("setup_next", "read_next_setup", 0xBF, 266),
|
|
],
|
|
)
|
|
def test_reads_match_thors_wire_bytes(name, method, rsp_sub, data_len):
|
|
p, t = proto([frame(rsp_sub, bytes(data_len))])
|
|
getattr(p, method)()
|
|
assert t.written == [REQ[name]]
|
|
|
|
|
|
def test_arm_event_matches_thors_wire_bytes():
|
|
p, t = proto([ack(0x6C)])
|
|
p.arm_event()
|
|
assert t.written == [REQ["arm"]]
|
|
|
|
|
|
def test_poll_is_the_only_read_with_a_non_ffff_offset_besides_serial():
|
|
"""Reads are single-step at 0xFFFF; POLL and SERIAL are the exceptions."""
|
|
assert set(P._OFFSETS) == {P.SUB_POLL, P.SUB_SERIAL}
|
|
assert P._OFFSETS[P.SUB_POLL] == 0x0030
|
|
assert P._OFFSETS[P.SUB_SERIAL] == 0x000A
|
|
|
|
|
|
# ── The chunk walk ────────────────────────────────────────────────────────────
|
|
|
|
def test_download_reproduces_thors_chunk_sequence_byte_for_byte():
|
|
"""The whole point of step 2. Four chunks, 4,076 bytes, THOR's exact frames."""
|
|
payload = bytes(range(256)) * 16 # 4096 B, we use the first 4076
|
|
payload = payload[:SIZE_4A81]
|
|
responses = []
|
|
for i in range(4):
|
|
want = min(P.CHUNK_SIZE, SIZE_4A81 - i * P.CHUNK_SIZE)
|
|
body = payload[i * P.CHUNK_SIZE: i * P.CHUNK_SIZE + want]
|
|
responses.append(frame(0xA5, bytes(11) + body, page=want // 256))
|
|
|
|
p, t = proto(responses)
|
|
got = p.read_event_file(bytes.fromhex("055d4a81"), SIZE_4A81)
|
|
|
|
assert t.written == REQ_CHUNKS_4A81
|
|
assert got == payload
|
|
assert len(got) == SIZE_4A81
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"size, n_chunks, last_offset",
|
|
[
|
|
(4076, 4, 0x03EC), (11032, 11, 0x0318), (11502, 12, 0x00EE),
|
|
(13424, 14, 0x0070), (8746, 9, 0x022A), (6092, 6, 0x03CC),
|
|
(1024, 1, 0x0400), (1, 1, 0x0001), (1025, 2, 0x0001),
|
|
],
|
|
)
|
|
def test_chunk_count_and_final_offset(size, n_chunks, last_offset):
|
|
"""The first six rows are the six bench events, with THOR's real offsets."""
|
|
responses = []
|
|
for i in range(n_chunks):
|
|
want = min(P.CHUNK_SIZE, size - i * P.CHUNK_SIZE)
|
|
responses.append(frame(0xA5, bytes(11) + bytes(want)))
|
|
|
|
p, t = proto(responses)
|
|
p.read_event_file(bytes.fromhex("055d4a81"), size)
|
|
|
|
assert len(t.written) == n_chunks
|
|
# offset is payload[4:5] of the request; recover it from the built frame
|
|
final = t.written[-1]
|
|
assert final[7:9] in (
|
|
bytes([last_offset >> 8, last_offset & 0xFF]),
|
|
# a 0x02/0x03/0x04/0x10 high byte arrives escaped, shifting the pair
|
|
bytes([0x10, last_offset >> 8]),
|
|
)
|
|
|
|
|
|
def test_a_short_chunk_raises_rather_than_truncating():
|
|
"""A silently short event is the failure mode this codebase keeps hitting."""
|
|
p, _ = proto([frame(0xA5, bytes(11) + bytes(900))]) # asked for 1024
|
|
with pytest.raises(ShortRead, match="asked for 1024 B, got 900"):
|
|
p.read_event_file(bytes.fromhex("055d4a81"), 1024)
|
|
|
|
|
|
def test_a_chunk_too_short_to_hold_its_header_raises():
|
|
p, _ = proto([frame(0xA5, bytes(4))])
|
|
with pytest.raises(ShortRead, match="too short to hold a chunk header"):
|
|
p.read_event_file(bytes.fromhex("055d4a81"), 1024)
|
|
|
|
|
|
def test_download_rejects_a_nonsense_size():
|
|
p, _ = proto([])
|
|
with pytest.raises(ValueError):
|
|
p.read_event_file(bytes.fromhex("055d4a81"), 0)
|
|
|
|
|
|
# ── The monitor-log walk ──────────────────────────────────────────────────────
|
|
|
|
def test_monitor_log_walk_ends_on_a_short_response():
|
|
"""⚠ Not a keyed read -- the same request repeated, device-side cursor.
|
|
|
|
Eight records then an 11-byte ack, which is what the capture shows.
|
|
"""
|
|
key = bytes.fromhex("055d4a81")
|
|
responses = [frame(0xF5, bytes(297)) for _ in range(8)] + [ack(0xF5)]
|
|
p, t = proto(responses)
|
|
|
|
records = []
|
|
while (rec := p.read_monitor_log_next(key)) is not None:
|
|
records.append(rec)
|
|
|
|
assert len(records) == 8
|
|
assert len(t.written) == 9
|
|
assert len(set(t.written)) == 1, "every request in the walk is identical"
|
|
|
|
|
|
# ── Error handling ────────────────────────────────────────────────────────────
|
|
|
|
def test_a_bad_checksum_raises_by_default():
|
|
"""⚠ Deliberately stricter than the Series III sibling.
|
|
|
|
That one logs and continues because its parser cannot always tell an
|
|
inner-frame delimiter from a checksum byte. The Micromate rule is exact on
|
|
251/251 captured frames, so a mismatch here means something real.
|
|
"""
|
|
bad = bytearray(frame(0xA4, bytes(59)))
|
|
bad[-2] ^= 0xFF
|
|
p, _ = proto([bytes(bad)])
|
|
with pytest.raises(ChecksumError, match="checksum mismatch"):
|
|
p.poll()
|
|
|
|
|
|
def test_a_bad_checksum_can_be_downgraded_for_field_diagnosis():
|
|
bad = bytearray(frame(0xA4, bytes(59)))
|
|
bad[-2] ^= 0xFF
|
|
p, _ = proto([bytes(bad)], strict_checksums=False)
|
|
assert p.poll().sub == 0xA4
|
|
|
|
|
|
def test_the_wrong_response_sub_raises():
|
|
p, _ = proto([frame(0xE0, bytes(19))]) # 0xE0 answers 0x1F, not 0x1E
|
|
with pytest.raises(UnexpectedResponse, match="expected SUB 0xE1"):
|
|
p.read_event_first()
|
|
|
|
|
|
def test_a_timeout_reports_how_many_bytes_arrived():
|
|
"""Separates "nothing came back" from "bytes arrived but never framed".
|
|
|
|
Those have completely different causes -- and on a Micromate the second one
|
|
is the signature of a modem forwarding a session it should not be.
|
|
"""
|
|
p, _ = proto([])
|
|
with pytest.raises(P.TimeoutError, match="0 bytes were received"):
|
|
p.poll()
|
|
|
|
unframed = b"\x02\x00\xc5\xa4garbage-no-terminator"
|
|
p2, _ = proto([unframed])
|
|
with pytest.raises(P.TimeoutError, match=f"{len(unframed)} bytes were received"):
|
|
p2.poll()
|
|
|
|
|
|
def test_a_leftover_frame_is_discarded_rather_than_answered_with():
|
|
"""If a read returns two frames, the extra must not answer the NEXT request.
|
|
|
|
Every exchange resets the parser before sending, so anything already
|
|
buffered is treated as stale. Delivering it would be the worse failure:
|
|
`expected_sub` happens to catch a mismatched SUB, but a same-SUB leftover
|
|
would sail through and return data for the wrong key.
|
|
"""
|
|
# Both frames arrive while answering arm_event(); the 0xE1 is left over.
|
|
p, _ = proto([ack(0x6C) + frame(0xE1, b"\xaa" * 19)])
|
|
p.arm_event()
|
|
|
|
# The next request gets no bytes of its own, so it must time out rather
|
|
# than hand back the stale 0xE1.
|
|
with pytest.raises(P.TimeoutError):
|
|
p.read_event_first()
|
|
|
|
|
|
# ── Against the real capture, when it happens to be present ───────────────────
|
|
|
|
_CAPTURES = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "bridges" / "captures" / "9-24-26 - micromate2"
|
|
)
|
|
_DOWNLOAD = "raw_bw_20260925_011403_Download_events_then_delete_1_event.bin"
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not (_CAPTURES / _DOWNLOAD).is_file(),
|
|
reason="capture is gitignored; present only on a dev box",
|
|
)
|
|
def test_every_captured_download_frame_is_one_we_would_have_sent():
|
|
"""Replay the real session: for each event, assert our chunk walk emits
|
|
exactly the frames THOR emitted -- all 50-odd of them, six events."""
|
|
from micromate.framing import ACK, DLE
|
|
|
|
def destuffed_frames(blob: bytes, is_req: bool):
|
|
i, n = 0, len(blob)
|
|
while i < n:
|
|
if is_req:
|
|
if not (blob[i] == ACK and i + 1 < n and blob[i + 1] == STX):
|
|
i += 1
|
|
continue
|
|
j = i + 2
|
|
else:
|
|
if blob[i] != STX:
|
|
i += 1
|
|
continue
|
|
j = i + 1
|
|
out = bytearray()
|
|
while j < n:
|
|
if blob[j] == DLE and j + 1 < n:
|
|
out.append(blob[j + 1])
|
|
j += 2
|
|
continue
|
|
if blob[j] == ETX:
|
|
break
|
|
out.append(blob[j])
|
|
j += 1
|
|
if len(out) >= 6:
|
|
yield blob[i:j + 1], bytes(out[:-1])
|
|
i = j + 1
|
|
|
|
bw = list(destuffed_frames((_CAPTURES / _DOWNLOAD).read_bytes(), True))
|
|
s3 = list(
|
|
destuffed_frames(
|
|
(_CAPTURES / _DOWNLOAD.replace("raw_bw", "raw_s3")).read_bytes(), False
|
|
)
|
|
)
|
|
|
|
# Group THOR's 0x5A frames per event, taking each event's key+size from the
|
|
# 1E/1F that preceded them.
|
|
events, cur = [], None
|
|
for (wire, req), (_, rsp) in zip(bw, s3):
|
|
sub, data = req[2], rsp[5:]
|
|
if sub in (0x1E, 0x1F) and len(data) >= 19:
|
|
cur = {"key": data[11:15], "size": int.from_bytes(data[15:19], "big"),
|
|
"reqs": [], "rsps": []}
|
|
if cur["size"]:
|
|
events.append(cur)
|
|
elif sub == 0x5A and cur is not None:
|
|
cur["reqs"].append(wire)
|
|
cur["rsps"].append(rsp)
|
|
|
|
# The capture walks the chain twice (it deletes an event on the second
|
|
# pass), so some 1E/1F hits carry a size but no download behind them.
|
|
events = [e for e in events if e["reqs"]]
|
|
assert len(events) == 6, f"expected 6 downloaded events, found {len(events)}"
|
|
|
|
total = 0
|
|
for e in events:
|
|
p, t = proto([bytes([STX]) + stuff(r + bytes([checksum(r)])) + bytes([ETX])
|
|
for r in e["rsps"]])
|
|
got = p.read_event_file(e["key"], e["size"])
|
|
assert t.written == e["reqs"], (
|
|
f"event {e['key'].hex()}: our {len(t.written)} frames differ from "
|
|
f"THOR's {len(e['reqs'])}"
|
|
)
|
|
assert len(got) == e["size"]
|
|
total += len(e["reqs"])
|
|
|
|
assert total == 56, f"expected 56 download frames across the 6 events, saw {total}"
|