Two-step probe+fetch for SUB 08 (EVENT_INDEX), returning the raw 88-byte (0x58) index block. SUB_EVENT_INDEX and DATA_LENGTHS[0x08]=0x58 were already registered — this just wires the method that calls them. Docstring notes the partially-decoded layout (event count at +3 as uint32 BE, timestamps at +7) pending live device confirmation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
666 lines
27 KiB
Python
666 lines
27 KiB
Python
"""
|
||
protocol.py — High-level MiniMate Plus request/response protocol.
|
||
|
||
Implements the request/response patterns documented in
|
||
docs/instantel_protocol_reference.md on top of:
|
||
- minimateplus.framing — DLE codec, frame builder, S3 streaming parser
|
||
- minimateplus.transport — byte I/O (SerialTransport / future TcpTransport)
|
||
|
||
This module knows nothing about pyserial or TCP — it only calls
|
||
transport.write() and transport.read_until_idle().
|
||
|
||
Key patterns implemented:
|
||
- POLL startup handshake (two-step, special payload[5] format)
|
||
- Generic two-step paged read (probe → get length → fetch data)
|
||
- Response timeout + checksum validation
|
||
- Boot-string drain (device sends "Operating System" ASCII before framing)
|
||
|
||
All public methods raise ProtocolError on timeout, bad checksum, or
|
||
unexpected response SUB.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import time
|
||
from typing import Optional
|
||
|
||
from .framing import (
|
||
S3Frame,
|
||
S3FrameParser,
|
||
build_bw_frame,
|
||
waveform_key_params,
|
||
token_params,
|
||
POLL_PROBE,
|
||
POLL_DATA,
|
||
)
|
||
from .transport import BaseTransport
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
# Response SUB = 0xFF - Request SUB (confirmed pattern, no known exceptions
|
||
# among read commands; one write-path exception documented for SUB 1C→6E).
|
||
def _expected_rsp_sub(req_sub: int) -> int:
|
||
return (0xFF - req_sub) & 0xFF
|
||
|
||
|
||
# SUB byte constants (request side) — see protocol reference §5.1
|
||
SUB_POLL = 0x5B
|
||
SUB_SERIAL_NUMBER = 0x15
|
||
SUB_FULL_CONFIG = 0x01
|
||
SUB_EVENT_INDEX = 0x08
|
||
SUB_CHANNEL_CONFIG = 0x06
|
||
SUB_TRIGGER_CONFIG = 0x1C
|
||
SUB_EVENT_HEADER = 0x1E
|
||
SUB_EVENT_ADVANCE = 0x1F
|
||
SUB_WAVEFORM_HEADER = 0x0A
|
||
SUB_WAVEFORM_RECORD = 0x0C
|
||
SUB_BULK_WAVEFORM = 0x5A
|
||
SUB_COMPLIANCE = 0x1A
|
||
SUB_UNKNOWN_2E = 0x2E
|
||
|
||
# Hardcoded data lengths for the two-step read protocol.
|
||
#
|
||
# The S3 probe response page_key is always 0x0000 — it does NOT carry the
|
||
# data length back to us. Instead, each SUB has a fixed known payload size
|
||
# confirmed from BW capture analysis (offset at payload[5] of the data-request
|
||
# frame).
|
||
#
|
||
# Key: request SUB byte. Value: offset/length byte sent in the data-request.
|
||
# Entries marked 🔶 are inferred from captured frames and may need adjustment.
|
||
DATA_LENGTHS: dict[int, int] = {
|
||
SUB_POLL: 0x30, # POLL startup data block ✅
|
||
SUB_SERIAL_NUMBER: 0x0A, # 10-byte serial number block ✅
|
||
SUB_FULL_CONFIG: 0x98, # 152-byte full config block ✅
|
||
SUB_EVENT_INDEX: 0x58, # 88-byte event index ✅
|
||
SUB_TRIGGER_CONFIG: 0x2C, # 44-byte trigger config 🔶
|
||
SUB_EVENT_HEADER: 0x08, # 8-byte event header (waveform key + event data) ✅
|
||
SUB_EVENT_ADVANCE: 0x08, # 8-byte next-key response ✅
|
||
# SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response
|
||
# data[4]. Do NOT add it here; use read_waveform_header() instead. ✅
|
||
SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅
|
||
SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶
|
||
0x09: 0xCA, # 202 bytes, purpose TBD 🔶
|
||
# SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total;
|
||
# NOT handled here — requires specialised read logic.
|
||
}
|
||
|
||
# Default timeout values (seconds).
|
||
# MiniMate Plus is a slow device — keep these generous.
|
||
DEFAULT_RECV_TIMEOUT = 10.0
|
||
POLL_RECV_TIMEOUT = 10.0
|
||
|
||
|
||
# ── Exception ─────────────────────────────────────────────────────────────────
|
||
|
||
class ProtocolError(Exception):
|
||
"""Raised when the device violates the expected protocol."""
|
||
|
||
|
||
class TimeoutError(ProtocolError):
|
||
"""Raised when no response is received within the allowed time."""
|
||
|
||
|
||
class ChecksumError(ProtocolError):
|
||
"""Raised when a received frame has a bad checksum."""
|
||
|
||
|
||
class UnexpectedResponse(ProtocolError):
|
||
"""Raised when the response SUB doesn't match what we requested."""
|
||
|
||
|
||
# ── MiniMateProtocol ──────────────────────────────────────────────────────────
|
||
|
||
class MiniMateProtocol:
|
||
"""
|
||
Protocol state machine for one open connection to a MiniMate Plus device.
|
||
|
||
Does not own the transport — transport lifetime is managed by MiniMateClient.
|
||
|
||
Typical usage (via MiniMateClient — not directly):
|
||
proto = MiniMateProtocol(transport)
|
||
proto.startup() # POLL handshake, drain boot string
|
||
data = proto.read(SUB_FULL_CONFIG)
|
||
sn_data = proto.read(SUB_SERIAL_NUMBER)
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
transport: BaseTransport,
|
||
recv_timeout: float = DEFAULT_RECV_TIMEOUT,
|
||
) -> None:
|
||
self._transport = transport
|
||
self._recv_timeout = recv_timeout
|
||
self._parser = S3FrameParser()
|
||
# Extra frames buffered by _recv_one that arrived alongside the target frame.
|
||
# Used when reset_parser=False so we don't discard already-parsed frames.
|
||
self._pending_frames: list[S3Frame] = []
|
||
|
||
# ── Public API ────────────────────────────────────────────────────────────
|
||
|
||
def startup(self) -> S3Frame:
|
||
"""
|
||
Perform the POLL startup handshake and return the POLL data frame.
|
||
|
||
Steps (matching §6 Session Startup Sequence):
|
||
1. Drain any boot-string bytes ("Operating System" ASCII)
|
||
2. Send POLL_PROBE (SUB 5B, offset=0x00)
|
||
3. Receive probe ack (page_key is 0x0000; data length 0x30 is hardcoded)
|
||
4. Send POLL_DATA (SUB 5B, offset=0x30)
|
||
5. Receive data frame with "Instantel" + "MiniMate Plus" strings
|
||
|
||
Returns:
|
||
The data-phase POLL response S3Frame.
|
||
|
||
Raises:
|
||
ProtocolError: if either POLL step fails.
|
||
"""
|
||
log.debug("startup: draining boot string")
|
||
self._drain_boot_string()
|
||
|
||
log.debug("startup: POLL probe")
|
||
self._send(POLL_PROBE)
|
||
probe_rsp = self._recv_one(
|
||
expected_sub=_expected_rsp_sub(SUB_POLL),
|
||
timeout=self._recv_timeout,
|
||
)
|
||
log.debug(
|
||
"startup: POLL probe response page_key=0x%04X", probe_rsp.page_key
|
||
)
|
||
|
||
log.debug("startup: POLL data request")
|
||
self._send(POLL_DATA)
|
||
data_rsp = self._recv_one(
|
||
expected_sub=_expected_rsp_sub(SUB_POLL),
|
||
timeout=self._recv_timeout,
|
||
)
|
||
log.debug("startup: POLL data received, %d bytes", len(data_rsp.data))
|
||
return data_rsp
|
||
|
||
def read(self, sub: int) -> bytes:
|
||
"""
|
||
Execute a two-step paged read and return the data payload bytes.
|
||
|
||
Step 1: send probe frame (offset=0x00) → device sends a short ack
|
||
Step 2: send data-request (offset=DATA_LEN) → device sends the data block
|
||
|
||
The S3 probe response does NOT carry the data length — page_key is always
|
||
0x0000 in observed frames. DATA_LENGTHS holds the known fixed lengths
|
||
derived from BW capture analysis.
|
||
|
||
Args:
|
||
sub: Request SUB byte (e.g. SUB_FULL_CONFIG = 0x01).
|
||
|
||
Returns:
|
||
De-stuffed data payload bytes (payload[5:] of the response frame,
|
||
with the checksum already stripped by the parser).
|
||
|
||
Raises:
|
||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||
KeyError: if sub is not in DATA_LENGTHS (caller should add it).
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(sub)
|
||
|
||
# Step 1 — probe (offset = 0)
|
||
log.debug("read SUB=0x%02X: probe", sub)
|
||
self._send(build_bw_frame(sub, 0))
|
||
_probe = self._recv_one(expected_sub=rsp_sub) # ack; page_key always 0
|
||
|
||
# Look up the hardcoded data length for this SUB
|
||
if sub not in DATA_LENGTHS:
|
||
raise ProtocolError(
|
||
f"No known data length for SUB=0x{sub:02X}. "
|
||
"Add it to DATA_LENGTHS in protocol.py."
|
||
)
|
||
length = DATA_LENGTHS[sub]
|
||
log.debug("read SUB=0x%02X: data request offset=0x%02X", sub, length)
|
||
|
||
if length == 0:
|
||
log.warning("read SUB=0x%02X: DATA_LENGTHS entry is zero", sub)
|
||
return b""
|
||
|
||
# Step 2 — data-request (offset = length)
|
||
self._send(build_bw_frame(sub, length))
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
log.debug("read SUB=0x%02X: received %d data bytes", sub, len(data_rsp.data))
|
||
return data_rsp.data
|
||
|
||
def send_keepalive(self) -> None:
|
||
"""
|
||
Send a single POLL_PROBE keepalive without waiting for a response.
|
||
|
||
Blastware sends these every ~80ms during idle. Useful if you need to
|
||
hold the session open between real requests.
|
||
"""
|
||
self._send(POLL_PROBE)
|
||
|
||
# ── Event download API ────────────────────────────────────────────────────
|
||
|
||
def read_event_index(self) -> bytes:
|
||
"""
|
||
Send the SUB 08 (EVENT_INDEX) two-step read and return the raw 88-byte
|
||
(0x58) index block.
|
||
|
||
The index block contains:
|
||
+0x00 (3 bytes): total index size or record count — purpose partially
|
||
decoded; byte [3] may be a high byte of event count.
|
||
+0x03 (4 bytes): stored event count as uint32 BE ❓ (inferred from
|
||
captures; see §7.4 in protocol reference)
|
||
+0x07 onwards: 6-byte event timestamps (see §8), one per event
|
||
|
||
Caller is responsible for parsing the returned bytes.
|
||
|
||
Returns:
|
||
Raw 88-byte data section (data[11:11+0x58]).
|
||
|
||
Raises:
|
||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(SUB_EVENT_INDEX)
|
||
length = DATA_LENGTHS[SUB_EVENT_INDEX] # 0x58
|
||
|
||
log.debug("read_event_index: 08 probe")
|
||
self._send(build_bw_frame(SUB_EVENT_INDEX, 0))
|
||
self._recv_one(expected_sub=rsp_sub)
|
||
|
||
log.debug("read_event_index: 08 data request offset=0x%02X", length)
|
||
self._send(build_bw_frame(SUB_EVENT_INDEX, length))
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
raw = data_rsp.data[11 : 11 + length]
|
||
log.debug("read_event_index: got %d bytes", len(raw))
|
||
return raw
|
||
|
||
def read_event_first(self) -> tuple[bytes, bytes]:
|
||
"""
|
||
Send the SUB 1E (EVENT_HEADER) two-step read and return the first
|
||
waveform key and accompanying 8-byte event data block.
|
||
|
||
This always uses all-zero params — the device returns the first stored
|
||
event's waveform key unconditionally.
|
||
|
||
Returns:
|
||
(key4, event_data8) where:
|
||
key4 — 4-byte opaque waveform record address (data[11:15])
|
||
event_data8 — full 8-byte data section (data[11:19])
|
||
|
||
Raises:
|
||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||
|
||
Confirmed from 3-31-26 capture: 1E request uses all-zero params;
|
||
response data section layout is:
|
||
[LENGTH_ECHO:1][00×4][KEY_ECHO:4][00×2][KEY4:4][EXTRA:4] …
|
||
Actual data starts at data[11]; first 4 bytes are the waveform key.
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(SUB_EVENT_HEADER)
|
||
length = DATA_LENGTHS[SUB_EVENT_HEADER] # 0x08
|
||
|
||
log.debug("read_event_first: 1E probe")
|
||
self._send(build_bw_frame(SUB_EVENT_HEADER, 0))
|
||
self._recv_one(expected_sub=rsp_sub)
|
||
|
||
log.debug("read_event_first: 1E data request offset=0x%02X", length)
|
||
self._send(build_bw_frame(SUB_EVENT_HEADER, length))
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
event_data8 = data_rsp.data[11:19]
|
||
key4 = data_rsp.data[11:15]
|
||
log.debug("read_event_first: key=%s", key4.hex())
|
||
return key4, event_data8
|
||
|
||
def read_waveform_header(self, key4: bytes) -> tuple[bytes, int]:
|
||
"""
|
||
Send the SUB 0A (WAVEFORM_HEADER) two-step read for *key4*.
|
||
|
||
The data length for 0A is VARIABLE and must be read from the probe
|
||
response at data[4]. Two known values:
|
||
0x30 — full histogram bin (has a waveform record to follow)
|
||
0x26 — partial histogram bin (no waveform record)
|
||
|
||
Args:
|
||
key4: 4-byte waveform record address from 1E or 1F.
|
||
|
||
Returns:
|
||
(header_bytes, record_length) where:
|
||
header_bytes — raw data section starting at data[11]
|
||
record_length — DATA_LENGTH read from probe (0x30 or 0x26)
|
||
|
||
Raises:
|
||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||
|
||
Confirmed from 3-31-26 capture: 0A probe response data[4] carries
|
||
the variable length; data-request uses that length as the offset byte.
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_HEADER)
|
||
params = waveform_key_params(key4)
|
||
|
||
log.debug("read_waveform_header: 0A probe key=%s", key4.hex())
|
||
self._send(build_bw_frame(SUB_WAVEFORM_HEADER, 0, params))
|
||
probe_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
# Variable length — read from probe response data[4]
|
||
length = probe_rsp.data[4] if len(probe_rsp.data) > 4 else 0x30
|
||
log.debug("read_waveform_header: 0A data request offset=0x%02X", length)
|
||
|
||
if length == 0:
|
||
return b"", 0
|
||
|
||
self._send(build_bw_frame(SUB_WAVEFORM_HEADER, length, params))
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
header_bytes = data_rsp.data[11:11 + length]
|
||
log.debug(
|
||
"read_waveform_header: key=%s length=0x%02X is_full=%s",
|
||
key4.hex(), length, length == 0x30,
|
||
)
|
||
return header_bytes, length
|
||
|
||
def read_waveform_record(self, key4: bytes) -> bytes:
|
||
"""
|
||
Send the SUB 0C (WAVEFORM_RECORD / FULL_WAVEFORM_RECORD) two-step read.
|
||
|
||
Returns the 210-byte waveform/histogram record containing:
|
||
- Record type string ("Histogram" or "Waveform") at a variable offset
|
||
- Per-channel labels ("Tran", "Vert", "Long", "MicL") with PPV floats
|
||
at label_offset + 6
|
||
|
||
Args:
|
||
key4: 4-byte waveform record address.
|
||
|
||
Returns:
|
||
210-byte record bytes (data[11:11+0xD2]).
|
||
|
||
Raises:
|
||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||
|
||
Confirmed from 3-31-26 capture: 0C always uses offset=0xD2 (210 bytes).
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_RECORD)
|
||
length = DATA_LENGTHS[SUB_WAVEFORM_RECORD] # 0xD2
|
||
params = waveform_key_params(key4)
|
||
|
||
log.debug("read_waveform_record: 0C probe key=%s", key4.hex())
|
||
self._send(build_bw_frame(SUB_WAVEFORM_RECORD, 0, params))
|
||
self._recv_one(expected_sub=rsp_sub)
|
||
|
||
log.debug("read_waveform_record: 0C data request offset=0x%02X", length)
|
||
self._send(build_bw_frame(SUB_WAVEFORM_RECORD, length, params))
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
record = data_rsp.data[11:11 + length]
|
||
log.debug("read_waveform_record: received %d record bytes", len(record))
|
||
return record
|
||
|
||
def advance_event(self) -> bytes:
|
||
"""
|
||
Send the SUB 1F (EVENT_ADVANCE) two-step read with download-mode token
|
||
(0xFE) and return the next waveform key.
|
||
|
||
In download mode (token=0xFE), the device skips partial histogram bins
|
||
and returns the key of the next FULL record directly. This is the
|
||
Blastware-observed behaviour for iterating through all stored events.
|
||
|
||
Returns:
|
||
key4 — 4-byte next waveform key from data[11:15].
|
||
Returns b'\\x00\\x00\\x00\\x00' when there are no more events.
|
||
|
||
Raises:
|
||
ProtocolError: on timeout, bad checksum, or wrong response SUB.
|
||
|
||
Confirmed from 3-31-26 capture: 1F uses token=0xFE at params[6];
|
||
loop termination is key4 == b'\\x00\\x00\\x00\\x00'.
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(SUB_EVENT_ADVANCE)
|
||
length = DATA_LENGTHS[SUB_EVENT_ADVANCE] # 0x08
|
||
params = token_params(0xFE)
|
||
|
||
log.debug("advance_event: 1F probe")
|
||
self._send(build_bw_frame(SUB_EVENT_ADVANCE, 0, params))
|
||
self._recv_one(expected_sub=rsp_sub)
|
||
|
||
log.debug("advance_event: 1F data request offset=0x%02X", length)
|
||
self._send(build_bw_frame(SUB_EVENT_ADVANCE, length, params))
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
|
||
key4 = data_rsp.data[11:15]
|
||
log.debug(
|
||
"advance_event: next key=%s done=%s",
|
||
key4.hex(), key4 == b"\x00\x00\x00\x00",
|
||
)
|
||
return key4
|
||
|
||
def read_compliance_config(self) -> bytes:
|
||
"""
|
||
Send the SUB 1A (COMPLIANCE_CONFIG) multi-step read and accumulate
|
||
all E5 response frames into a single config byte string.
|
||
|
||
BE18189 sends the full config in one large E5 frame (~4245 cfg bytes).
|
||
BE11529 appears to chunk the response — each E5 frame carries ~44 bytes
|
||
of cfg data. This method loops until the expected 0x082A (2090) bytes
|
||
are accumulated or the inter-frame gap exceeds _INTER_FRAME_TIMEOUT.
|
||
|
||
Frame structure (confirmed from raw BW captures 3-11-26):
|
||
Probe (Frame A): byte[5]=0x00, params[7]=0x64
|
||
Data req (Frame D): byte[5]=0x2A, params[2]=0x08, params[7]=0x64
|
||
|
||
0x082A split: byte[5]=0x2A (offset low), params[2]=0x08 (length high)
|
||
params[7]=0x64 required in both probe and data-request.
|
||
|
||
Returns:
|
||
Accumulated compliance config bytes. First frame: data[11:] (skips
|
||
11-byte echo header). Subsequent frames: structure logged and
|
||
accumulated from data[11:] as well — adjust offset if structure differs.
|
||
|
||
Raises:
|
||
ProtocolError: if the very first E5 frame is not received (hard timeout).
|
||
"""
|
||
rsp_sub = _expected_rsp_sub(SUB_COMPLIANCE)
|
||
|
||
# Probe — params[7]=0x64 required (confirmed from BW capture)
|
||
_PROBE_PARAMS = bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00])
|
||
log.debug("read_compliance_config: 1A probe")
|
||
self._send(build_bw_frame(SUB_COMPLIANCE, 0, _PROBE_PARAMS))
|
||
self._recv_one(expected_sub=rsp_sub)
|
||
|
||
# Data request — 0x082A encoded as: byte[5]=0x2A, params[2]=0x08, params[7]=0x64
|
||
_DATA_PARAMS = bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00])
|
||
log.debug("read_compliance_config: 1A data request offset=0x2A params[2]=0x08")
|
||
self._send(build_bw_frame(SUB_COMPLIANCE, 0x2A, _DATA_PARAMS))
|
||
|
||
# ── Multi-request accumulation ────────────────────────────────────────
|
||
#
|
||
# Full BW sequence (confirmed from raw_bw captures 3-11-26):
|
||
#
|
||
# Frame B: offset=0x0400 params[2]=0x00 → requests cfg bytes 0..1023
|
||
# Frame C: offset=0x0400 params[2]=0x04 → requests cfg bytes 1024..2047
|
||
# Frame D: offset=0x002A params[2]=0x08 → requests cfg bytes 2048..2089
|
||
#
|
||
# Total: 0x0400 + 0x0400 + 0x002A = 0x082A = 2090 bytes.
|
||
#
|
||
# The "offset" field in B and C encodes the chunk length (0x0400 = 1024),
|
||
# not a byte offset into the config. params[2] tracks cumulative pages
|
||
# (0x00 → 0x04 → 0x08; each page = 256 bytes → 0x04 pages = 1024 bytes).
|
||
#
|
||
# Each request gets its own E5 response with an 11-byte echo header.
|
||
# Devices that send the full block in a single frame (BE18189) may return
|
||
# the entire config from the last request alone — we handle both cases by
|
||
# trying each step and concatenating whatever arrives.
|
||
|
||
_DATA_PARAMS_B = bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00])
|
||
_DATA_PARAMS_C = bytes([0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00])
|
||
# _DATA_PARAMS_D already built above as _DATA_PARAMS
|
||
|
||
_STEPS = [
|
||
("B", 0x0400, _DATA_PARAMS_B),
|
||
("C", 0x0400, _DATA_PARAMS_C),
|
||
("D", 0x002A, _DATA_PARAMS), # _DATA_PARAMS built above
|
||
]
|
||
|
||
config = bytearray()
|
||
# Track (page_key, chunk_size) pairs to detect when frame D returns the
|
||
# same page/size as frame B — observed on BE11529 when the device is
|
||
# busy or connection state is fresh. Duplicate chunks inflate the cfg
|
||
# and mis-align field offsets, so we drop them.
|
||
seen_chunks: set[tuple[int, int]] = set()
|
||
|
||
for step_name, step_offset, step_params in _STEPS:
|
||
log.debug(
|
||
"read_compliance_config: sending frame %s offset=0x%04X params=%s",
|
||
step_name, step_offset, step_params.hex(),
|
||
)
|
||
self._send(build_bw_frame(SUB_COMPLIANCE, step_offset, step_params))
|
||
|
||
try:
|
||
data_rsp = self._recv_one(expected_sub=rsp_sub)
|
||
except TimeoutError:
|
||
log.warning(
|
||
"read_compliance_config: frame %s — no E5 response (timeout); "
|
||
"device may return all data on a later request",
|
||
step_name,
|
||
)
|
||
continue
|
||
|
||
chunk = data_rsp.data[11:]
|
||
page = data_rsp.page_key
|
||
key = (page, len(chunk))
|
||
|
||
if key in seen_chunks:
|
||
log.warning(
|
||
"read_compliance_config: frame %s page=0x%04X data=%d "
|
||
"cfg_chunk=%d DUPLICATE — skipping",
|
||
step_name, page, len(data_rsp.data), len(chunk),
|
||
)
|
||
continue
|
||
|
||
seen_chunks.add(key)
|
||
log.warning(
|
||
"read_compliance_config: frame %s page=0x%04X data=%d "
|
||
"cfg_chunk=%d running_total=%d",
|
||
step_name, page, len(data_rsp.data),
|
||
len(chunk), len(config) + len(chunk),
|
||
)
|
||
config.extend(chunk)
|
||
|
||
log.warning(
|
||
"read_compliance_config: done — %d cfg bytes total",
|
||
len(config),
|
||
)
|
||
|
||
# Hex dump first 128 bytes for field mapping
|
||
for row in range(0, min(len(config), 128), 16):
|
||
row_bytes = bytes(config[row:row + 16])
|
||
hex_part = ' '.join(f'{b:02x}' for b in row_bytes)
|
||
asc_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in row_bytes)
|
||
log.warning(" cfg[%04x]: %-48s %s", row, hex_part, asc_part)
|
||
|
||
return bytes(config)
|
||
|
||
# ── Internal helpers ──────────────────────────────────────────────────────
|
||
|
||
def _send(self, frame: bytes) -> None:
|
||
"""Write a pre-built frame to the transport."""
|
||
log.debug("TX %d bytes: %s", len(frame), frame.hex())
|
||
self._transport.write(frame)
|
||
|
||
def _recv_one(
|
||
self,
|
||
expected_sub: Optional[int] = None,
|
||
timeout: Optional[float] = None,
|
||
reset_parser: bool = True,
|
||
) -> S3Frame:
|
||
"""
|
||
Read bytes from the transport until one complete S3 frame is parsed.
|
||
|
||
Feeds bytes through the streaming S3FrameParser. Keeps reading until
|
||
a frame arrives or the deadline expires.
|
||
|
||
Args:
|
||
expected_sub: If provided, raises UnexpectedResponse if the
|
||
received frame's SUB doesn't match.
|
||
timeout: Seconds to wait. Defaults to self._recv_timeout.
|
||
reset_parser: If True (default), reset the parser before reading.
|
||
Pass False when accumulating multiple frames from a
|
||
single device response (e.g. chunked E5 replies) so
|
||
that bytes already buffered between frames are not lost.
|
||
|
||
Returns:
|
||
The first complete S3Frame received.
|
||
|
||
Raises:
|
||
TimeoutError: if no frame arrives within the timeout.
|
||
ChecksumError: if the frame has an invalid checksum.
|
||
UnexpectedResponse: if expected_sub is set and doesn't match.
|
||
"""
|
||
deadline = time.monotonic() + (timeout or self._recv_timeout)
|
||
if reset_parser:
|
||
self._parser.reset()
|
||
self._pending_frames.clear()
|
||
|
||
# If a prior read() parsed more frames than it returned (e.g. two frames
|
||
# arrived in one TCP chunk), return the buffered one immediately.
|
||
if self._pending_frames:
|
||
frame = self._pending_frames.pop(0)
|
||
self._validate_frame(frame, expected_sub)
|
||
return frame
|
||
|
||
while time.monotonic() < deadline:
|
||
chunk = self._transport.read(256)
|
||
if chunk:
|
||
log.debug("RX %d bytes: %s", len(chunk), chunk.hex())
|
||
frames = self._parser.feed(chunk)
|
||
if frames:
|
||
# Stash any extras so subsequent calls with reset_parser=False see them
|
||
self._pending_frames.extend(frames[1:])
|
||
frame = frames[0]
|
||
self._validate_frame(frame, expected_sub)
|
||
return frame
|
||
else:
|
||
time.sleep(0.005)
|
||
|
||
raise TimeoutError(
|
||
f"No S3 frame received within {timeout or self._recv_timeout:.1f}s"
|
||
+ (f" (expected SUB 0x{expected_sub:02X})" if expected_sub is not None else "")
|
||
)
|
||
|
||
@staticmethod
|
||
def _validate_frame(frame: S3Frame, expected_sub: Optional[int]) -> None:
|
||
"""Validate SUB; log but do not raise on bad checksum.
|
||
|
||
S3 response checksums frequently fail SUM8 validation due to inner-frame
|
||
delimiter bytes being captured as the checksum byte. The original
|
||
s3_parser.py deliberately never validates S3 checksums for exactly this
|
||
reason. We log a warning and continue.
|
||
"""
|
||
if not frame.checksum_valid:
|
||
# S3 checksums frequently fail SUM8 due to inner-frame delimiter bytes
|
||
# landing in the checksum position. Treat as informational only.
|
||
log.debug("S3 frame SUB=0x%02X: checksum mismatch (ignoring)", frame.sub)
|
||
if expected_sub is not None and frame.sub != expected_sub:
|
||
raise UnexpectedResponse(
|
||
f"Expected SUB=0x{expected_sub:02X}, got 0x{frame.sub:02X}"
|
||
)
|
||
|
||
def _drain_boot_string(self, drain_ms: int = 200) -> None:
|
||
"""
|
||
Read and discard any boot-string bytes ("Operating System") the device
|
||
may send before entering binary protocol mode.
|
||
|
||
We simply read with a short timeout and throw the bytes away. The
|
||
S3FrameParser's IDLE state already handles non-frame bytes gracefully,
|
||
but it's cleaner to drain them explicitly before the first real frame.
|
||
"""
|
||
deadline = time.monotonic() + (drain_ms / 1000)
|
||
discarded = 0
|
||
while time.monotonic() < deadline:
|
||
chunk = self._transport.read(256)
|
||
if chunk:
|
||
discarded += len(chunk)
|
||
else:
|
||
time.sleep(0.005)
|
||
if discarded:
|
||
log.debug("drain_boot_string: discarded %d bytes", discarded)
|