big refactor of waveform protocol.
This commit is contained in:
+192
-162
@@ -136,9 +136,10 @@ DATA_LENGTHS: dict[int, int] = {
|
||||
# existing blastware_file.py builder relies on the 0x0400-step frame structure
|
||||
# to produce valid files. Switching to BW's 0x0200 step is a separate task
|
||||
# that also requires updating the file builder.
|
||||
_BULK_CHUNK_OFFSET = 0x1004 # offset_word for probe + all chunk requests
|
||||
_BULK_TERM_OFFSET = 0x005A # offset_word for the legacy terminator
|
||||
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment
|
||||
# BW-exact protocol values (v0.14.0). Verified against 4-27-26 + 5-1-26 captures.
|
||||
_BULK_CHUNK_OFFSET = 0x1002 # offset_word for probe + all chunk requests
|
||||
_BULK_TERM_OFFSET = 0x005A # offset_word for the legacy terminator (fallback only)
|
||||
_BULK_COUNTER_STEP = 0x0200 # chunk counter increment (matches chunk payload size)
|
||||
|
||||
# Default timeout values (seconds).
|
||||
# MiniMate Plus is a slow device — keep these generous.
|
||||
@@ -533,231 +534,260 @@ class MiniMateProtocol:
|
||||
self,
|
||||
key4: bytes,
|
||||
*,
|
||||
stop_after_metadata: bool = True,
|
||||
max_chunks: int = 32,
|
||||
stop_after_metadata: bool = True, # DEPRECATED — no-op under BW-exact walk
|
||||
max_chunks: int = 256, # safety cap only; loop is bounded by end_offset
|
||||
include_terminator: bool = False,
|
||||
extra_chunks_after_metadata: int = 1,
|
||||
extra_chunks_after_metadata: int = 1, # DEPRECATED — no-op
|
||||
) -> list[S3Frame]:
|
||||
"""
|
||||
Download the SUB 5A (BULK_WAVEFORM_STREAM) A5 frames for one event.
|
||||
Download the SUB 5A (BULK_WAVEFORM_STREAM) A5 frames for one event using
|
||||
Blastware's exact protocol. REWRITTEN 2026-05-02 (v0.14.0).
|
||||
|
||||
The bulk waveform stream carries both raw ADC samples (large) and
|
||||
event-time metadata strings ("Project:", "Client:", "User Name:",
|
||||
"Seis Loc:", "Extended Notes") embedded in one of the middle frames
|
||||
(confirmed: A5[7] of 9 for 1-2-26 capture).
|
||||
Algorithm (matches BW captures across 2-sec / 3-sec / event-2):
|
||||
|
||||
Protocol is request-per-chunk, NOT a continuous stream:
|
||||
1. Probe (offset=_BULK_CHUNK_OFFSET, is_probe=True, counter=0x0000)
|
||||
2. Chunks (offset=_BULK_CHUNK_OFFSET, is_probe=False, counter+=0x0400)
|
||||
3. Loop until metadata found (stop_after_metadata=True) or max_chunks
|
||||
4. Termination (offset=_BULK_TERM_OFFSET, counter=last+_BULK_COUNTER_STEP)
|
||||
Device responds with a final A5 frame (page_key=0x0000).
|
||||
1. Probe
|
||||
- For events at start_key[2:4] = 0x0000 (first event after erase
|
||||
/ wrap): probe at counter=0x0000 with full key in params.
|
||||
- For continuation events (start_key[2:4] != 0): first chunk at
|
||||
counter = start_key[2:4] + 0x0046; acts as both probe and
|
||||
first sample chunk; response carries STRT.
|
||||
|
||||
By default the termination frame (page_key=0x0000) is NOT included in the
|
||||
returned list. Pass include_terminator=True to append it; the blastware_file
|
||||
writer needs the terminator frame's body to reconstruct the waveform file footer.
|
||||
2. Parse end_offset from STRT record at data[23:27] of the probe response.
|
||||
|
||||
Args:
|
||||
key4: 4-byte waveform key from EVENT_HEADER (1E).
|
||||
stop_after_metadata: If True (default), send termination as soon as
|
||||
b"Project:" is found in a frame's data — avoids
|
||||
downloading the full ADC waveform payload (several
|
||||
hundred KB). Set False to download everything.
|
||||
max_chunks: Safety cap on the number of chunk requests sent
|
||||
(default 32; a typical event uses 9 large frames).
|
||||
include_terminator: If True, append the terminator A5 frame
|
||||
(page_key=0x0000) to the returned list. The
|
||||
terminator carries the waveform file footer bytes.
|
||||
Default False preserves existing caller behaviour.
|
||||
3. Read two fixed metadata pages at counter=0x1002 and counter=0x1004
|
||||
— global session metadata (Project / Client / User Name / Seis Loc
|
||||
/ Extended Notes ASCII strings). Event 1 only; continuation
|
||||
events skip these (BW caches them across the session).
|
||||
|
||||
4. Walk sample chunks at 0x0200 increments, starting from 0x0600 for
|
||||
event 1 or `start + 0x0046 + 0x0200` for continuation events.
|
||||
Stop when `next_chunk + 0x0200 > end_offset`.
|
||||
|
||||
5. Send TERM frame with offset_word and params computed by
|
||||
`bulk_waveform_term_v2(key4, end_offset, last_chunk_counter)`.
|
||||
The TERM response contains the partial last chunk (residual =
|
||||
end_offset - next_boundary) including the 26-byte 0e 08 file
|
||||
footer.
|
||||
|
||||
Returns:
|
||||
List of S3Frame objects from each A5 response frame. Frame indices
|
||||
match the request sequence: index 0 = probe response, index 1 = first
|
||||
chunk, etc. If include_terminator=True, the last element is the
|
||||
terminator frame (page_key=0x0000).
|
||||
List of S3Frame objects from each A5 response (probe, metadata
|
||||
pages, sample chunks, optional TERM response). Caller passes
|
||||
`include_terminator=True` (e.g. write_blastware_file) to keep the
|
||||
TERM response in the list — it's required to reconstruct the
|
||||
file footer.
|
||||
|
||||
Deprecated kwargs:
|
||||
stop_after_metadata: legacy "Project:"-string-based stop condition.
|
||||
No-op under the BW-exact walk; the loop is
|
||||
deterministically bounded by end_offset from
|
||||
STRT. Accepted for backward compat.
|
||||
extra_chunks_after_metadata: same.
|
||||
|
||||
Raises:
|
||||
ProtocolError: on timeout, bad checksum, or unexpected SUB.
|
||||
|
||||
Confirmed from 1-2-26 BW TX/RX captures (2026-04-02):
|
||||
- probe + 8 regular chunks + 1 termination = 10 TX frames
|
||||
- 9 large A5 responses + 1 terminator A5 = 10 RX frames
|
||||
- page_key=0x0010 on large frames; page_key=0x0000 on terminator ✅
|
||||
- "Project:" metadata at A5[7].data[626] ✅
|
||||
ProtocolError: on timeout / bad checksum / unexpected SUB.
|
||||
"""
|
||||
if len(key4) != 4:
|
||||
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||
|
||||
rsp_sub = _expected_rsp_sub(SUB_BULK_WAVEFORM) # 0xFF - 0x5A = 0xA5
|
||||
# Quietly accept and warn on deprecated kwargs.
|
||||
if not stop_after_metadata:
|
||||
log.debug("5A: stop_after_metadata=False is no-op under BW-exact walk")
|
||||
if extra_chunks_after_metadata not in (0, 1):
|
||||
log.debug("5A: extra_chunks_after_metadata=%d is no-op under BW-exact walk",
|
||||
extra_chunks_after_metadata)
|
||||
|
||||
rsp_sub = _expected_rsp_sub(SUB_BULK_WAVEFORM) # 0xA5
|
||||
frames_data: list[S3Frame] = []
|
||||
counter = 0
|
||||
|
||||
# BW counter formula (confirmed from 4-3-26 capture for key 0111245a,
|
||||
# and empirical live-device test 2026-04-06 for key 01110000):
|
||||
# counter for chunk n = max(key4[2:4], 0x0400) + (n - 1) * 0x0400
|
||||
# key4[2:4] is the event's circular-buffer base offset. The max() guard
|
||||
# ensures chunk 1 never uses counter=0x0000 (which equals the probe address
|
||||
# and causes the device to re-return STRT record data for the first chunk).
|
||||
_key4_offset = (key4[2] << 8) | key4[3]
|
||||
start_offset = (key4[2] << 8) | key4[3]
|
||||
is_event_1 = (start_offset == 0)
|
||||
|
||||
# ── Step 1: probe ────────────────────────────────────────────────────
|
||||
log.debug("5A probe key=%s key4_offset=0x%04X", key4.hex(), _key4_offset)
|
||||
params = bulk_waveform_params(key4, 0, is_probe=True)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed counter before probe recv
|
||||
# ── Step 1: probe / first chunk ──────────────────────────────────────
|
||||
if is_event_1:
|
||||
probe_counter = 0
|
||||
probe_params = bulk_waveform_params(key4, 0, is_probe=True)
|
||||
log.debug("5A probe (event-1) key=%s counter=0x0000", key4.hex())
|
||||
else:
|
||||
# Continuation events: first 5A request lands at start+0x0046,
|
||||
# acting as both probe and first sample chunk. Confirmed from
|
||||
# 5-1-26 "copy 2nd address event" capture.
|
||||
probe_counter = start_offset + 0x0046
|
||||
probe_params = bulk_waveform_params(key4, probe_counter)
|
||||
log.debug(
|
||||
"5A probe (event-N) key=%s counter=0x%04X (start+0x46)",
|
||||
key4.hex(), probe_counter,
|
||||
)
|
||||
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, probe_params))
|
||||
self._parser.reset()
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False)
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"5A probe TIMED OUT for key=%s — "
|
||||
"%d raw bytes received (no complete A5 frame assembled)",
|
||||
"5A probe TIMED OUT for key=%s — %d raw bytes received",
|
||||
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))
|
||||
|
||||
# ── Parse STRT end_offset from probe response (NEW 2026-05-01) ────────
|
||||
# The first A5 response contains a STRT record at data[17:]. The
|
||||
# bytes at data[23:27] are the event's end-key, whose low 16 bits
|
||||
# are the absolute device-buffer address where the event ends. Use
|
||||
# this to bound the chunk loop and stop the over-read past event end.
|
||||
# See docs/instantel_protocol_reference.md §7.8.5 and CLAUDE.md
|
||||
# "SUB 5A — STRT record encodes end_offset".
|
||||
_end_offset = parse_strt_end_offset(rsp.data)
|
||||
if _end_offset is None:
|
||||
# Defensive fallback — let max_chunks cap the walk.
|
||||
log.warning("5A: STRT not found in probe; cannot bound chunk loop")
|
||||
_end_offset = 0xFFFF
|
||||
frames_data.append(rsp)
|
||||
log.debug("5A A5[0] (probe) page_key=0x%04X %d bytes",
|
||||
rsp.page_key, len(rsp.data))
|
||||
|
||||
# ── Step 2: parse STRT end_offset from probe response ────────────────
|
||||
end_offset = parse_strt_end_offset(rsp.data)
|
||||
if end_offset is None:
|
||||
log.warning(
|
||||
"5A probe response did not contain a STRT record; "
|
||||
"cannot bound chunk loop — falling back to max_chunks=%d cap",
|
||||
max_chunks,
|
||||
)
|
||||
end_offset = 0xFFFF # impossible value → loop runs to max_chunks
|
||||
else:
|
||||
log.debug(
|
||||
log.info(
|
||||
"5A STRT start_offset=0x%04X end_offset=0x%04X size=0x%04X",
|
||||
_key4_offset, _end_offset, _end_offset - _key4_offset,
|
||||
start_offset, end_offset, end_offset - start_offset,
|
||||
)
|
||||
|
||||
# ── Step 2: chunk loop ───────────────────────────────────────────────
|
||||
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
|
||||
# where _chunk_base = max(key4[2:4], 0x0400).
|
||||
#
|
||||
# For events with key4[2:4] != 0 (e.g. key 0111245a, offset 0x245a):
|
||||
# _chunk_base = 0x245a → chunk 1=0x245a, chunk 2=0x285a, ...
|
||||
# Confirmed from 4-3-26 capture.
|
||||
#
|
||||
# For events with key4[2:4] == 0 (e.g. key 01110000):
|
||||
# _chunk_base = max(0, 0x0400) = 0x0400
|
||||
# → chunk 1=0x0400, chunk 2=0x0800, ... (= old chunk_num*0x0400)
|
||||
# CRITICAL: counter=0x0000 (same as the probe) causes the device to
|
||||
# re-return the STRT record data for chunk 1, making frame 1 look like
|
||||
# a second probe response (confirmed from server log: frame 1 len=1097,
|
||||
# contains STRT\xff\xfe, contributes zero body bytes after DLE-strip).
|
||||
# counter=0x0400 for chunk 1 confirmed working (empirical test 2026-04-06).
|
||||
_chunk_base = max(_key4_offset, _BULK_COUNTER_STEP)
|
||||
for chunk_num in range(1, max_chunks + 1):
|
||||
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
|
||||
# Stop when we'd step past the event end (NEW 2026-05-01). Without
|
||||
# this, the device returns post-event circular-buffer data which
|
||||
# corrupts the reconstructed file for events ≥ 2 sec.
|
||||
if counter >= _end_offset:
|
||||
# ── Step 3: metadata pages 0x1002 + 0x1004 (event 1 only) ────────────
|
||||
# Confirmed from BW captures: BW reads these two fixed device-buffer
|
||||
# pages immediately after the probe for events at start_key[2:4]=0.
|
||||
# Continuation events skip them (BW caches across the session).
|
||||
# Their content is global compliance-setup metadata: Project, Client,
|
||||
# User Name, Seis Loc, Extended Notes.
|
||||
if is_event_1:
|
||||
for meta_counter in (0x1002, 0x1004):
|
||||
# Metadata page params have an extra trailing 0x00 byte
|
||||
# (12-byte params instead of 11) — empirical from BW captures.
|
||||
# Checksum-neutral but matches BW byte-for-byte.
|
||||
meta_params = bytes([
|
||||
0x00,
|
||||
key4[0], key4[1],
|
||||
(meta_counter >> 8) & 0xFF,
|
||||
meta_counter & 0xFF,
|
||||
0, 0, 0, 0, 0, 0, 0,
|
||||
])
|
||||
log.debug("5A metadata page counter=0x%04X", meta_counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, meta_params))
|
||||
self._parser.reset()
|
||||
try:
|
||||
meta_rsp = self._recv_one(
|
||||
expected_sub=rsp_sub, reset_parser=False, timeout=10.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
log.warning(
|
||||
"5A metadata page 0x%04X TIMED OUT — continuing",
|
||||
meta_counter,
|
||||
)
|
||||
continue
|
||||
frames_data.append(meta_rsp)
|
||||
log.debug(
|
||||
"5A meta@0x%04X page_key=0x%04X %d bytes",
|
||||
meta_counter, meta_rsp.page_key, len(meta_rsp.data),
|
||||
)
|
||||
|
||||
# ── Step 4: sample chunk loop, bounded by end_offset ─────────────────
|
||||
# Sample chunks start at:
|
||||
# event 1: counter = 0x0600
|
||||
# event N (>0): counter = probe_counter + 0x0200
|
||||
# (probe was the first sample chunk)
|
||||
if is_event_1:
|
||||
counter = 0x0600
|
||||
else:
|
||||
counter = probe_counter + _BULK_COUNTER_STEP
|
||||
|
||||
last_chunk_counter: Optional[int] = (
|
||||
probe_counter if not is_event_1 else None
|
||||
)
|
||||
chunks_fetched = 0
|
||||
|
||||
while chunks_fetched < max_chunks:
|
||||
# Stop when next chunk would straddle the event end.
|
||||
if counter + _BULK_COUNTER_STEP > end_offset:
|
||||
log.debug(
|
||||
"5A chunk loop done at counter=0x%04X (end=0x%04X); "
|
||||
"%d chunks fetched",
|
||||
counter, _end_offset, len(frames_data),
|
||||
counter, end_offset, chunks_fetched,
|
||||
)
|
||||
break
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
log.debug("5A chunk %d counter=0x%04X", chunk_num, counter)
|
||||
|
||||
params = bulk_waveform_params(key4, counter)
|
||||
log.debug("5A chunk #%d counter=0x%04X", chunks_fetched + 1, counter)
|
||||
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
|
||||
self._parser.reset() # reset bytes_fed for accurate per-chunk count
|
||||
self._parser.reset()
|
||||
try:
|
||||
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False, timeout=10.0)
|
||||
rsp = self._recv_one(
|
||||
expected_sub=rsp_sub, reset_parser=False, timeout=10.0,
|
||||
)
|
||||
except TimeoutError:
|
||||
raw = self._parser.bytes_fed
|
||||
log.warning(
|
||||
"5A TIMEOUT chunk=%d counter=0x%04X raw_bytes=%d",
|
||||
chunk_num, counter, raw,
|
||||
chunks_fetched + 1, counter, raw,
|
||||
)
|
||||
if raw > 0 and frames_data:
|
||||
# Device sent a partial byte (likely a bare DLE/ETX end-of-stream
|
||||
# signal) but never completed a full frame. Treat as graceful
|
||||
# stream end and fall through to the termination step.
|
||||
log.warning(
|
||||
"5A end-of-stream detected at chunk=%d (raw_bytes=%d, "
|
||||
"frames_collected=%d) — proceeding to termination",
|
||||
chunk_num, raw, len(frames_data),
|
||||
"5A unexpected end-of-stream — proceeding to TERM",
|
||||
)
|
||||
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,
|
||||
log.debug(
|
||||
"5A RX chunk=%d page_key=0x%04X data_len=%d",
|
||||
chunks_fetched + 1, rsp.page_key, len(rsp.data),
|
||||
)
|
||||
|
||||
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)
|
||||
# Device terminated mid-stream unexpectedly.
|
||||
log.warning(
|
||||
"5A unexpected page_key=0x0000 mid-stream at counter=0x%04X",
|
||||
counter,
|
||||
)
|
||||
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)
|
||||
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)
|
||||
except TimeoutError:
|
||||
log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1)
|
||||
break
|
||||
break
|
||||
last_chunk_counter = counter
|
||||
counter += _BULK_COUNTER_STEP
|
||||
chunks_fetched += 1
|
||||
else:
|
||||
log.warning(
|
||||
"5A reached max_chunks=%d without end-of-stream; sending termination",
|
||||
max_chunks,
|
||||
"5A reached max_chunks=%d at counter=0x%04X (end=0x%04X)",
|
||||
max_chunks, counter, end_offset,
|
||||
)
|
||||
|
||||
# ── Step 3: termination ──────────────────────────────────────────────
|
||||
term_counter = counter + _BULK_COUNTER_STEP
|
||||
term_params = bulk_waveform_term_params(key4, term_counter)
|
||||
log.debug(
|
||||
"5A termination term_counter=0x%04X offset=0x%04X",
|
||||
term_counter, _BULK_TERM_OFFSET,
|
||||
)
|
||||
self._send(build_5a_frame(_BULK_TERM_OFFSET, term_params))
|
||||
try:
|
||||
term_rsp = self._recv_one(expected_sub=rsp_sub)
|
||||
# ── Step 5: TERM with proper end_offset-derived formula ──────────────
|
||||
if last_chunk_counter is None or end_offset == 0xFFFF:
|
||||
# No STRT or no chunks fetched — fall back to legacy TERM.
|
||||
log.warning(
|
||||
"5A using legacy TERM (offset_word=0x005A); "
|
||||
"end_offset unavailable or no chunks fetched",
|
||||
)
|
||||
legacy_counter = (last_chunk_counter or probe_counter) + _BULK_COUNTER_STEP
|
||||
term_offset_word = _BULK_TERM_OFFSET # 0x005A
|
||||
term_params = bulk_waveform_term_params(key4, legacy_counter)
|
||||
else:
|
||||
term_offset_word, term_params = bulk_waveform_term_v2(
|
||||
key4, end_offset, last_chunk_counter,
|
||||
)
|
||||
log.debug(
|
||||
"5A termination response page_key=0x%04X %d bytes",
|
||||
"5A TERM offset_word=0x%04X params[2:4]=%s end=0x%04X "
|
||||
"last_chunk=0x%04X",
|
||||
term_offset_word, term_params[2:4].hex(),
|
||||
end_offset, last_chunk_counter,
|
||||
)
|
||||
|
||||
self._send(build_5a_frame(term_offset_word, term_params))
|
||||
try:
|
||||
term_rsp = self._recv_one(expected_sub=rsp_sub, timeout=10.0)
|
||||
log.info(
|
||||
"5A TERM response page_key=0x%04X %d bytes",
|
||||
term_rsp.page_key, len(term_rsp.data),
|
||||
)
|
||||
if include_terminator:
|
||||
frames_data.append(term_rsp)
|
||||
except TimeoutError:
|
||||
log.debug("5A no termination response — device may have already closed")
|
||||
log.warning("5A no TERM response (timeout)")
|
||||
|
||||
return frames_data
|
||||
|
||||
|
||||
Reference in New Issue
Block a user