3 Commits

4 changed files with 172 additions and 98 deletions
+30
View File
@@ -347,6 +347,36 @@ Do NOT use fixed absolute offsets for sample_rate or record_time.
Quiet Mode enabled. Parser handles this — do not strip it manually before feeding to Quiet Mode enabled. Parser handles this — do not strip it manually before feeding to
`S3FrameParser`. `S3FrameParser`.
**SUB 5A (bulk waveform) TCP frame splitting — confirmed 2026-04-27:**
Over TCP via cellular modem, each 5A chunk request that produces a single ~1100-byte
A5 response over direct RS-232 may arrive as **two separate, complete S3 frames** of
~550 bytes each ("2-frame mode"). The modem's Data Forwarding Timeout (~100-150 ms)
can split the RS-232 response into two TCP segments, each parsed as a complete S3 frame.
Under different modem/timing conditions the full ~1100-byte response arrives as **one
S3 frame** ("1-frame mode").
**Both modes require `extra_chunks_after_metadata=1`** (the extra chunk at metadata_counter
+ 0x0400). The device's waveform footer data lives at circular-buffer address 0x1C00 for
this event; the terminator frame must be sent at 0x1C00 (not 0x1800) to receive it.
Example for a 2-second Continuous event (BE11529, key=01110000) via TCP:
- **2-frame mode:** 1 probe frame (554 B) + 5 chunks × 2 frames (556-573 B) + 1 extra chunk × 2 frames + 1 terminator (208 B) = **14 A5 frames** → 6864-byte file
- **1-frame mode:** 1 probe frame (~1097 B) + 5 chunks × 1 frame (~1079-1113 B) + 1 extra chunk × 1 frame (smaller, tail of event) + 1 terminator → **8 A5 frames** → 6864-byte file
- All frames contribute body data; using all of them gives the correct file.
**Fix (confirmed 2026-04-27):** `_recv_5a_batch()` in `protocol.py` collects ALL
A5 frames per chunk request before the next request is sent, using a 0.5 s batch
timeout after the first frame to catch the ~150 ms delayed second frame. `write_blastware_file()`
includes ALL body frames without skipping — the extra chunk's frames are part of the
body data, NOT padding to be discarded.
**WRONG earlier hypothesis (do not re-introduce):** An attempt was made to auto-detect
1-frame vs 2-frame mode from the probe frame size and skip the extra chunk when
`probe_data_len >= 700`. This was wrong — the extra chunk is always needed to advance
the device's internal state to the footer address. The `_probe_is_large` branch was
removed 2026-04-27.
### Required ACEmanager settings (Sierra Wireless RV50/RV55) ### Required ACEmanager settings (Sierra Wireless RV50/RV55)
| Setting | Value | Why | | Setting | Value | Why |
+20 -50
View File
@@ -685,64 +685,34 @@ def write_blastware_file(
body_frames = a5_frames body_frames = a5_frames
term_frame = None term_frame = None
# ── Identify first metadata frame and skip "extra chunks" ───────────────
# When extra_chunks_after_metadata=1 in read_bulk_waveform_stream(), the
# frame list is: [probe, data..., metadata, extra_chunk, terminator].
# The extra_chunk is downloaded to prime the TCP terminator response — its
# ADC data is NOT part of the Blastware file body. Skip it.
#
# Rule: any frame at index strictly between first_metadata_fi and last_fi
# (the final frame) is an extra chunk and must be excluded.
#
# If no metadata frame exists (e.g. full_waveform download), first_metadata_fi
# is None and no frames are skipped — all frames contribute normally.
first_metadata_fi: Optional[int] = None
for _fi_scan, _frame_scan in enumerate(body_frames):
if _fi_scan > 0 and any(m in bytes(_frame_scan.data) for m in _METADATA_FRAME_MARKERS):
first_metadata_fi = _fi_scan
break
last_fi = len(body_frames) - 1
log.warning( log.warning(
"write_blastware_file: %d body_frames first_metadata_fi=%s last_fi=%d", "write_blastware_file: %d body_frames term_idx=%s",
len(body_frames), len(body_frames),
str(first_metadata_fi) if first_metadata_fi is not None else "None", str(term_idx) if term_idx is not None else "None",
last_fi,
) )
all_bytes = bytearray() all_bytes = bytearray()
for fi, frame in enumerate(body_frames): for fi, frame in enumerate(body_frames):
# Skip "extra chunk" frames: frames after the first metadata frame but # All body frames contribute to the waveform body — no frames are skipped.
# before the last frame (terminator). These prime the TCP terminator but
# their ADC data must NOT appear in the Blastware file body.
if (first_metadata_fi is not None
and fi > first_metadata_fi
and fi < last_fi):
log.warning(
"write_blastware_file: fi=%d SKIP (extra chunk after metadata fi=%d last_fi=%d)",
fi, first_metadata_fi, last_fi,
)
continue
if fi == 0:
# Probe frame: always process regardless of classification.
# It holds the STRT record; probe_skip positions us past it.
skip = probe_skip
else:
# ALL subsequent frames are included unconditionally — no filtering on
# frame type. In the A5 stream, frame 0 is always the probe response;
# frames 1+ are always data (waveform chunks, compliance config, or
# compliance continuation). Classification is for logging only.
# #
# DO NOT gate on classify_frame() here: # Over TCP via cellular modem, _recv_5a_batch() correctly collects all
# - "probe_or_strt" at fi>0 is always a false positive — ADC binary # A5 frames per chunk request (the device's ~1100-byte RS-232 response
# data can coincidentally contain b"STRT\xff\xfe" (confirmed from # is forwarded as ~2 TCP segments of ~550 bytes each, each parsed as a
# live capture: frames 1 and 5 matched on event key=01110000). # separate S3 frame). ALL of these frames contain ADC body data and
# - "metadata" frames must be included (compliance config body). # must be included in the file — confirmed from 4-27-26 TCP capture
# - The compliance block spans 2 frames; skipping either produces a # analysis: contributions from all 14 frames → 6821 bytes → file 6864 bytes.
# truncated file that Blastware rejects. #
skip = 13 if fi == 1 else 12 # Skip amounts (offsets into frame.data):
# fi=0 (probe): probe_skip — skips the type_tag header + STRT record
# fi=1: 13 — 7-byte frame.data prefix + 6 inner header bytes
# fi>=2: 12 — 7-byte frame.data prefix + 5 inner header bytes
if fi == 0:
skip = probe_skip
elif fi == 1:
skip = 13
else:
skip = 12
contribution = _frame_body_bytes(frame, skip) contribution = _frame_body_bytes(frame, skip)
log.warning("write_blastware_file: fi=%d skip=%d raw_data=%d contribution=%d", log.warning("write_blastware_file: fi=%d skip=%d raw_data=%d contribution=%d",
+106 -29
View File
@@ -599,7 +599,7 @@ class MiniMateProtocol:
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params)) self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
self._parser.reset() # reset bytes_fed counter before probe recv self._parser.reset() # reset bytes_fed counter before probe recv
try: try:
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False) probe_batch = self._recv_5a_batch(rsp_sub)
except TimeoutError: except TimeoutError:
log.warning( log.warning(
"5A probe TIMED OUT for key=%s" "5A probe TIMED OUT for key=%s"
@@ -607,8 +607,23 @@ class MiniMateProtocol:
key4.hex(), self._parser.bytes_fed, key4.hex(), self._parser.bytes_fed,
) )
raise raise
frames_data.append(rsp) frames_data.extend(probe_batch)
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data)) log.debug(
"5A probe: %d frame(s) page_keys=%s",
len(probe_batch),
[f"0x{f.page_key:04X}" for f in probe_batch],
)
# Log probe frame size for diagnostics.
# The device always needs extra_chunks_after_metadata chunks after the
# metadata frame before termination to prime the valid waveform footer.
# This holds regardless of TCP frame size (1-frame vs 2-frame mode).
_effective_extra_chunks = extra_chunks_after_metadata
log.warning(
"5A probe data_len=%d effective_extra_chunks=%d",
len(probe_batch[0].data),
_effective_extra_chunks,
)
# ── Step 2: chunk loop ─────────────────────────────────────────────── # ── Step 2: chunk loop ───────────────────────────────────────────────
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400 # Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
@@ -634,7 +649,12 @@ class MiniMateProtocol:
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params)) self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
self._parser.reset() # reset bytes_fed for accurate per-chunk count self._parser.reset() # reset bytes_fed for accurate per-chunk count
try: 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: except TimeoutError:
raw = self._parser.bytes_fed raw = self._parser.bytes_fed
log.warning( log.warning(
@@ -653,48 +673,48 @@ class MiniMateProtocol:
break break
raise raise
# Process all frames from this batch.
metadata_found = False
for rsp in batch:
log.warning( log.warning(
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s", "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, chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
) )
if rsp.page_key == 0x0000: if rsp.page_key == 0x0000:
# Device unexpectedly terminated mid-stream (no termination needed). # Device unexpectedly terminated mid-stream.
log.debug("5A A5[%d] page_key=0x0000 — device terminated early", chunk_num) log.debug("5A page_key=0x0000 — device terminated early")
if include_terminator: if include_terminator:
frames_data.append(rsp) frames_data.append(rsp)
return frames_data return frames_data
frames_data.append(rsp) frames_data.append(rsp)
if stop_after_metadata and b"Project:" in rsp.data: if stop_after_metadata and b"Project:" in rsp.data:
# Download exactly one more chunk after finding metadata — this is metadata_found = True
# what Blastware does. The extra chunk contains the tail ADC data
# and primes the device to return a valid footer in the termination if metadata_found:
# response. Without it, termination returns an empty ack with no # Download extra_chunks_after_metadata more chunks after metadata.
# footer bytes (confirmed 2026-04-23 from HxD comparison). # This primes the device to return the valid waveform footer in the
# Download extra_chunks_after_metadata more chunks past the # termination response — without it the terminator carries too few bytes
# metadata. The caller calculates this from record_time and # (confirmed 2026-04-23). The extra chunk data also belongs in the
# sample_rate so we download exactly the right amount of ADC # file body (confirmed from TCP capture analysis 2026-04-27).
# data — no more, no less — before terminating. log.debug("5A metadata found — fetching %d more chunk(s)",
# The device returns the footer in the termination response only _effective_extra_chunks)
# after the right amount of data has been consumed. for _extra_n in range(_effective_extra_chunks):
log.debug("5A A5[%d] metadata found — fetching %d more chunk(s)",
chunk_num, extra_chunks_after_metadata)
for _extra_n in range(extra_chunks_after_metadata):
chunk_num += 1 chunk_num += 1
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
params = bulk_waveform_params(key4, counter) params = bulk_waveform_params(key4, counter)
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params)) self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
try: try:
extra = self._recv_one(expected_sub=rsp_sub, timeout=10.0) extra_batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
log.debug("5A A5[%d] extra chunk page_key=0x%04X data_len=%d", for ef in extra_batch:
chunk_num, extra.page_key, len(extra.data)) log.debug(
if extra.page_key == 0x0000: "5A extra chunk page_key=0x%04X data_len=%d",
ef.page_key, len(ef.data),
)
if ef.page_key == 0x0000:
if include_terminator: if include_terminator:
frames_data.append(extra) frames_data.append(ef)
return frames_data return frames_data
frames_data.append(extra) frames_data.append(ef)
except TimeoutError: except TimeoutError:
log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1) log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1)
break break
@@ -1383,6 +1403,63 @@ class MiniMateProtocol:
log.debug("TX %d bytes: %s", len(frame), frame.hex()) log.debug("TX %d bytes: %s", len(frame), frame.hex())
self._transport.write(frame) 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( def _recv_one(
self, self,
expected_sub: Optional[int] = None, expected_sub: Optional[int] = None,
+7 -10
View File
@@ -886,17 +886,14 @@ def device_event_blastware_file(
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client: with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
info = client.connect() info = client.connect()
# Use stop_after_metadata=True (full_waveform=False) with 1 extra # Use stop_after_metadata=True (full_waveform=False) with 1 extra
# chunk after "Project:". The extra chunk is required to prime the # chunk after "Project:". The extra chunk primes the device so that
# device over TCP: termination at term_counter=metadata_counter+0x0400 # the termination response carries the full waveform footer bytes.
# returns only ~90 bytes (no useful footer) over TCP/cellular, but # Without it the terminator returns only ~90 bytes (no useful footer).
# termination at metadata_counter+0x0800 (one chunk later) returns
# the full 737-byte frame containing the footer.
# #
# Confirmed from 4-26-26 BW RS-232 capture: BW terminates at 0x1800 # The extra chunk's ADC data IS part of the Blastware file body —
# without an extra chunk (works on RS-232 but not TCP). # confirmed from 4-27-26 TCP capture: all 14 A5 frames (including the
# write_blastware_file() automatically skips the extra chunk's # extra chunk's 2 TCP sub-frames) contribute to the correct 6864-byte
# contribution — only the probe+ADC+metadata+terminator bytes appear # output. write_blastware_file() includes all frames unconditionally.
# in the output file.
# #
# full_waveform=True (natural end-of-stream) downloads ALL chunks # full_waveform=True (natural end-of-stream) downloads ALL chunks
# including post-event silence (35+ chunks for a 9-sec event at # including post-event silence (35+ chunks for a 9-sec event at