diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index bc1b3ea..b3a32c6 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -135,6 +135,9 @@ class MiniMateProtocol: 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 ──────────────────────────────────────────────────────────── @@ -397,32 +400,28 @@ class MiniMateProtocol: def read_compliance_config(self) -> bytes: """ - Send the SUB 1A (COMPLIANCE_CONFIG) two-step read. + Send the SUB 1A (COMPLIANCE_CONFIG) multi-step read and accumulate + all E5 response frames into a single config byte string. - Returns the full 2090-byte compliance config block (E5 response) - containing: - - Trigger and alarm levels per channel (IEEE 754 BE floats) - - Record time (float at offset +0x28) - - Project strings (Project, Client, User Name, Seis Loc, Extended Notes) - - Channel labels and unit strings - - Per-channel max range values + 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: - 2090-byte compliance config data (data[11:11+0x082A]). + 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: on timeout, bad checksum, or wrong response SUB. - - Confirmed from raw BW captures (3-11-26): - Probe (Frame A): [5]=0x00, params[7]=0x64 - Data req (Frame D): [5]=0x2A, params[2]=0x08, params[7]=0x64 - - The 16-bit length 0x082A is split across two fields: - byte[5] = 0x2A (offset/length low byte, as in all two-step reads) - params[2] = 0x08 (length high byte, packed into params rather than byte[4]) - - params[7] = 0x64 = 100 is required in both probe and data request. - Purpose unknown — possibly max channel count or backlight timeout. + ProtocolError: if the very first E5 frame is not received (hard timeout). """ rsp_sub = _expected_rsp_sub(SUB_COMPLIANCE) @@ -433,26 +432,81 @@ class MiniMateProtocol: self._recv_one(expected_sub=rsp_sub) # Data request — 0x082A encoded as: byte[5]=0x2A, params[2]=0x08, params[7]=0x64 - # NOT as a uint16 split across bytes[4:5] — confirmed from BW capture 3-11-26. _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)) - data_rsp = self._recv_one(expected_sub=rsp_sub) - # Slice off the 11-byte echo header; take ALL remaining data (not 0x082A limit — - # actual E5 payload is ~4245 bytes, varying by firmware/config). - config = data_rsp.data[11:] + # ── Accumulate E5 frames ────────────────────────────────────────────── + # BE18189 sends one large frame; BE11529 may chunk into many small ones. + # Loop until target bytes received or inter-frame gap expires. + _TARGET = 0x082A # 2090 bytes — known total from BW captures + _INTER_FRAME_TIMEOUT = 2.0 # seconds; give up if no new frame within this + config = bytearray() + frame_count = 0 + + while len(config) < _TARGET: + timeout = self._recv_timeout if frame_count == 0 else _INTER_FRAME_TIMEOUT + # reset_parser=True only for the first frame; subsequent frames must not + # discard bytes already buffered from the device's chunked response stream. + try: + data_rsp = self._recv_one( + expected_sub=rsp_sub, + timeout=timeout, + reset_parser=(frame_count == 0), + ) + except TimeoutError: + if frame_count == 0: + raise # hard fail — device never responded at all + # Inter-frame gap expired — device finished sending + log.warning( + "read_compliance_config: inter-frame timeout after %d frames " + "(%d / %d cfg bytes received)", + frame_count, len(config), _TARGET, + ) + break + + frame_count += 1 + + if frame_count == 1: + # First frame: strip 11-byte echo header (length + key echo + padding) + chunk = data_rsp.data[11:] + log.warning( + "read_compliance_config: frame %d page=0x%04X " + "data=%d cfg_chunk=%d (header stripped)", + frame_count, data_rsp.page_key, len(data_rsp.data), len(chunk), + ) + else: + # Subsequent frames: log raw prefix to determine structure, + # then strip the same 11-byte header (assumption — verify from logs). + raw = data_rsp.data + log.warning( + "read_compliance_config: frame %d page=0x%04X data=%d " + "raw[0:16]=%s", + frame_count, data_rsp.page_key, len(raw), + raw[:16].hex() if len(raw) >= 16 else raw.hex(), + ) + # Strip header if frame is long enough; otherwise accumulate all + chunk = raw[11:] if len(raw) > 11 else raw + + config.extend(chunk) + log.warning( + "read_compliance_config: running total=%d / %d", + len(config), _TARGET, + ) + log.warning( - "read_compliance_config: E5 response total=%d data=%d cfg=%d bytes", - len(data_rsp.data) + 5, len(data_rsp.data), len(config), + "read_compliance_config: done — %d frame(s), %d cfg bytes total", + frame_count, len(config), ) + # Hex dump first 128 bytes for field mapping for row in range(0, min(len(config), 128), 16): - chunk = config[row:row+16] - hex_part = ' '.join(f'{b:02x}' for b in chunk) - asc_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk) + 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 config + + return bytes(config) # ── Internal helpers ────────────────────────────────────────────────────── @@ -465,6 +519,7 @@ class MiniMateProtocol: 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. @@ -473,9 +528,13 @@ class MiniMateProtocol: 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. + 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. @@ -486,7 +545,16 @@ class MiniMateProtocol: UnexpectedResponse: if expected_sub is set and doesn't match. """ deadline = time.monotonic() + (timeout or self._recv_timeout) - self._parser.reset() + 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) @@ -494,6 +562,8 @@ class MiniMateProtocol: 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