fix: add new helper (_recv_5a_batch()) that helps with assembling chunks over TCP
This commit is contained in:
+105
-39
@@ -599,7 +599,7 @@ class MiniMateProtocol:
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed counter before probe recv
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False)
|
||||
probe_batch = self._recv_5a_batch(rsp_sub)
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"5A probe TIMED OUT for key=%s — "
|
||||
@@ -607,8 +607,12 @@ class MiniMateProtocol:
|
||||
key4.hex(), self._parser.bytes_fed,
|
||||
)
|
||||
raise
|
||||
frames_data.append(rsp)
|
||||
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data))
|
||||
frames_data.extend(probe_batch)
|
||||
log.debug(
|
||||
"5A probe: %d frame(s) page_keys=%s",
|
||||
len(probe_batch),
|
||||
[f"0x{f.page_key:04X}" for f in probe_batch],
|
||||
)
|
||||
|
||||
# ── Step 2: chunk loop ───────────────────────────────────────────────
|
||||
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
|
||||
@@ -634,7 +638,12 @@ class MiniMateProtocol:
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed for accurate per-chunk count
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False, timeout=10.0)
|
||||
# Collect ALL frames from this chunk response.
|
||||
# Over TCP via modem, a single large A5 device response (~1100 bytes
|
||||
# RS-232) is split across ~2 TCP segments, each parsed as its own
|
||||
# complete S3 frame. _recv_5a_batch gathers all of them so that
|
||||
# every subsequent chunk request is paired with the correct response.
|
||||
batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
|
||||
except TimeoutError:
|
||||
raw = self._parser.bytes_fed
|
||||
log.warning(
|
||||
@@ -653,48 +662,48 @@ class MiniMateProtocol:
|
||||
break
|
||||
raise
|
||||
|
||||
log.warning(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
|
||||
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
|
||||
)
|
||||
# Process all frames from this batch.
|
||||
metadata_found = False
|
||||
for rsp in batch:
|
||||
log.warning(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
|
||||
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
|
||||
)
|
||||
if rsp.page_key == 0x0000:
|
||||
# Device unexpectedly terminated mid-stream.
|
||||
log.debug("5A page_key=0x0000 — device terminated early")
|
||||
if include_terminator:
|
||||
frames_data.append(rsp)
|
||||
return frames_data
|
||||
frames_data.append(rsp)
|
||||
if stop_after_metadata and b"Project:" in rsp.data:
|
||||
metadata_found = True
|
||||
|
||||
if rsp.page_key == 0x0000:
|
||||
# Device unexpectedly terminated mid-stream (no termination needed).
|
||||
log.debug("5A A5[%d] page_key=0x0000 — device terminated early", chunk_num)
|
||||
if include_terminator:
|
||||
frames_data.append(rsp)
|
||||
return frames_data
|
||||
|
||||
frames_data.append(rsp)
|
||||
|
||||
if stop_after_metadata and b"Project:" in rsp.data:
|
||||
# Download exactly one more chunk after finding metadata — this is
|
||||
# what Blastware does. The extra chunk contains the tail ADC data
|
||||
# and primes the device to return a valid footer in the termination
|
||||
# response. Without it, termination returns an empty ack with no
|
||||
# footer bytes (confirmed 2026-04-23 from HxD comparison).
|
||||
# Download extra_chunks_after_metadata more chunks past the
|
||||
# metadata. The caller calculates this from record_time and
|
||||
# sample_rate so we download exactly the right amount of ADC
|
||||
# data — no more, no less — before terminating.
|
||||
# The device returns the footer in the termination response only
|
||||
# after the right amount of data has been consumed.
|
||||
log.debug("5A A5[%d] metadata found — fetching %d more chunk(s)",
|
||||
chunk_num, extra_chunks_after_metadata)
|
||||
if metadata_found:
|
||||
# Download extra_chunks_after_metadata more chunks after metadata.
|
||||
# This primes the device to return the valid waveform footer in the
|
||||
# termination response — without it the terminator carries too few bytes
|
||||
# (confirmed 2026-04-23). The extra chunk data also belongs in the
|
||||
# file body (confirmed from TCP capture analysis 2026-04-27).
|
||||
log.debug("5A metadata found — fetching %d more chunk(s)",
|
||||
extra_chunks_after_metadata)
|
||||
for _extra_n in range(extra_chunks_after_metadata):
|
||||
chunk_num += 1
|
||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
try:
|
||||
extra = self._recv_one(expected_sub=rsp_sub, timeout=10.0)
|
||||
log.debug("5A A5[%d] extra chunk page_key=0x%04X data_len=%d",
|
||||
chunk_num, extra.page_key, len(extra.data))
|
||||
if extra.page_key == 0x0000:
|
||||
if include_terminator:
|
||||
frames_data.append(extra)
|
||||
return frames_data
|
||||
frames_data.append(extra)
|
||||
extra_batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
|
||||
for ef in extra_batch:
|
||||
log.debug(
|
||||
"5A extra chunk page_key=0x%04X data_len=%d",
|
||||
ef.page_key, len(ef.data),
|
||||
)
|
||||
if ef.page_key == 0x0000:
|
||||
if include_terminator:
|
||||
frames_data.append(ef)
|
||||
return frames_data
|
||||
frames_data.append(ef)
|
||||
except TimeoutError:
|
||||
log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1)
|
||||
break
|
||||
@@ -1383,6 +1392,63 @@ class MiniMateProtocol:
|
||||
log.debug("TX %d bytes: %s", len(frame), frame.hex())
|
||||
self._transport.write(frame)
|
||||
|
||||
def _recv_5a_batch(
|
||||
self,
|
||||
expected_sub: int,
|
||||
first_timeout: float = 10.0,
|
||||
batch_timeout: float = 0.5,
|
||||
) -> list[S3Frame]:
|
||||
"""
|
||||
Collect all S3 frames that arrive as part of one device response.
|
||||
|
||||
Over TCP via cellular modem, a single device A5 response (~1100 bytes of
|
||||
RS-232 data) is forwarded in multiple TCP segments due to the modem's
|
||||
data-forwarding timeout (~100-150 ms per segment). Each TCP segment
|
||||
contains a complete, valid S3 frame (~550 bytes). Calling _recv_one()
|
||||
once returns only the first segment's frame and misses the rest, causing
|
||||
the chunk request/response pairing to cascade out of alignment.
|
||||
|
||||
This helper collects ALL frames before returning, by trying additional
|
||||
short-timeout receives after the first frame arrives.
|
||||
|
||||
The caller must call self._parser.reset() before this method to ensure
|
||||
bytes_fed is accurate; this method always uses reset_parser=False.
|
||||
|
||||
Args:
|
||||
expected_sub: Expected SUB byte for validation.
|
||||
first_timeout: Timeout for the mandatory first frame. Should be
|
||||
generous (default 10 s) since the device may be slow.
|
||||
batch_timeout: Short timeout for subsequent frames. Default 0.5 s
|
||||
— comfortably longer than the modem forwarding gap
|
||||
(~150 ms) but short enough to avoid stalling when
|
||||
only one frame is expected (probe, terminator).
|
||||
|
||||
Returns:
|
||||
List of S3Frame objects in arrival order (at least one).
|
||||
|
||||
Raises:
|
||||
TimeoutError: If no frame arrives within first_timeout.
|
||||
UnexpectedResponse: If any frame has the wrong SUB byte.
|
||||
"""
|
||||
frames: list[S3Frame] = []
|
||||
first = self._recv_one(
|
||||
expected_sub=expected_sub,
|
||||
reset_parser=False,
|
||||
timeout=first_timeout,
|
||||
)
|
||||
frames.append(first)
|
||||
while True:
|
||||
try:
|
||||
extra = self._recv_one(
|
||||
expected_sub=expected_sub,
|
||||
reset_parser=False,
|
||||
timeout=batch_timeout,
|
||||
)
|
||||
frames.append(extra)
|
||||
except TimeoutError:
|
||||
break
|
||||
return frames
|
||||
|
||||
def _recv_one(
|
||||
self,
|
||||
expected_sub: Optional[int] = None,
|
||||
|
||||
Reference in New Issue
Block a user