protocol: accumulate multiple E5 frames for chunked compliance config
BE11529 sends the compliance config (SUB 1A / E5 response) as a stream of small frames (~44 bytes each) rather than one large frame like BE18189. Changes: - read_compliance_config(): loop calling _recv_one() until 0x082A bytes accumulated or 2s inter-frame gap; first frame strips 11-byte echo header, subsequent frames logged in full for structure analysis - _recv_one(): add reset_parser=False option to preserve parser state and _pending_frames buffer between consecutive reads from one device response; also stash any extra frames parsed in a single TCP chunk so they are not lost Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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:]
|
||||
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),
|
||||
# ── 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: 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.
|
||||
@@ -476,6 +531,10 @@ class MiniMateProtocol:
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user