feat: fetch event-time metadata from SUB 5A bulk waveform stream

Add read_bulk_waveform_stream() to MiniMateProtocol and wire it into
get_events() so each event gets authoritative client/operator/sensor_location
from the A5 frames recorded at event-time, not the current compliance config.

- framing.py: bulk_waveform_params() and bulk_waveform_term_params() helpers
  (probe/chunk params and termination params for SUB 5A, confirmed from
  1-2-26 BW TX capture)
- protocol.py: read_bulk_waveform_stream(key4, stop_after_metadata=True) —
  probe + chunk loop (counter += 0x0400), early stop when b"Project:" found
  (A5[7] of 9), then sends termination at offset=0x005A
- client.py: _decode_a5_metadata_into() needle-searches concatenated A5 frame
  data for Project/Client/User Name/Seis Loc/Extended Notes; get_events() now
  calls SUB 5A after each SUB 0C, overwriting project_info with event-time values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Harrison
2026-04-02 16:08:21 -04:00
parent 9b1ed1f3a8
commit 9bf20803c2
3 changed files with 305 additions and 15 deletions

View File

@@ -166,22 +166,26 @@ class MiniMateClient:
def get_events(self, include_waveforms: bool = True, debug: bool = False) -> list[Event]:
"""
Download all stored events from the device using the confirmed
1E → 0A → 0C → 1F event-iterator protocol.
1E → 0A → 0C → 5A → 1F event-iterator protocol.
Sequence (confirmed from 3-31-26 Blastware capture):
Sequence (confirmed from 3-31-26 and 1-2-26 Blastware captures):
1. SUB 1E — get first waveform key
2. For each key until b'\\x00\\x00\\x00\\x00':
a. SUB 0A — waveform header (first event only, to confirm full record)
b. SUB 0C — full waveform record (peak values, project strings)
c. SUB 1Fadvance to next key (token=0xFE skips partial bins)
a. SUB 0A — waveform header (first event only, confirm full record)
b. SUB 0C — full waveform record (peak values, record type, timestamp)
c. SUB 5Abulk waveform stream (event-time metadata; stops early
after "Project:" is found, so only ~8 frames are fetched)
d. SUB 1F — advance to next key (token=0xFE skips partial bins)
Subsequent keys returned by 1F (token=0xFE) are guaranteed to be full
records, so 0A is only called for the first event. This exactly
matches Blastware's observed behaviour.
The SUB 5A fetch provides the authoritative event-time metadata:
"Project:", "Client:", "User Name:", "Seis Loc:", and "Extended Notes"
as they were configured AT THE TIME the event was recorded. This is
distinct from the current device compliance config (SUB 1A), which only
reflects the CURRENT setup.
Raw ADC waveform samples (SUB 5A bulk stream) are NOT downloaded
here — they are large (several MB per event) and fetched separately.
include_waveforms is reserved for a future call.
Raw ADC waveform samples (full bulk waveform payload, several MB) are
NOT downloaded by default. include_waveforms is reserved for a future
endpoint that fetches and stores the raw ADC channel data.
Returns:
List of Event objects, one per stored waveform record.
@@ -206,9 +210,7 @@ class MiniMateClient:
is_first = True
while key4 != b"\x00\x00\x00\x00":
log.info(
"get_events: record %d key=%s", idx, key4.hex()
)
log.info("get_events: record %d key=%s", idx, key4.hex())
ev = Event(index=idx)
# First event: call 0A to verify it's a full record (0x30 length).
@@ -233,7 +235,7 @@ class MiniMateClient:
is_first = False
if proceed:
# SUB 0C — full waveform record (peak values, project strings)
# SUB 0C — full waveform record (peak values, timestamp, "Project:" string)
try:
record = proto.read_waveform_record(key4)
if debug:
@@ -244,6 +246,27 @@ class MiniMateClient:
"get_events: 0C failed for key=%s: %s", key4.hex(), exc
)
# SUB 5A — bulk waveform stream: event-time metadata
# Stops early after "Project:" is found (typically in A5[7] of 9)
# so we fetch only ~8 frames rather than the full multi-MB stream.
# This is the authoritative source for client/operator/seis_loc/notes.
try:
a5_frames = proto.read_bulk_waveform_stream(
key4, stop_after_metadata=True
)
if a5_frames:
_decode_a5_metadata_into(a5_frames, ev)
log.debug(
"get_events: 5A metadata client=%r operator=%r",
ev.project_info.client if ev.project_info else None,
ev.project_info.operator if ev.project_info else None,
)
except ProtocolError as exc:
log.warning(
"get_events: 5A failed for key=%s: %s — event-time metadata unavailable",
key4.hex(), exc,
)
events.append(ev)
idx += 1
@@ -452,6 +475,74 @@ def _decode_waveform_record_into(data: bytes, event: Event) -> None:
log.warning("waveform record project strings decode failed: %s", exc)
def _decode_a5_metadata_into(frames_data: list[bytes], event: Event) -> None:
"""
Search A5 (BULK_WAVEFORM_STREAM) frame data for event-time metadata strings
and populate event.project_info.
This is the authoritative source for event-time metadata — it reflects the
device setup AT THE TIME the event was recorded, not the current device
configuration. The metadata lives in a middle A5 frame (confirmed: A5[7]
of 9 large frames for the 1-2-26 capture):
Confirmed needle locations in A5[7].data (2026-04-02 from 1-2-26 capture):
b"Project:" at data[626]
b"Client:" at data[676]
b"User Name:" at data[703]
b"Seis Loc:" at data[735]
b"Extended Notes" at data[774]
All frames are concatenated for a single-pass needle search. Fields already
set from the 0C waveform record are overwritten — A5 data is more complete
(the 210-byte 0C record only carries "Project:", not client/operator/etc.).
Modifies event in-place.
"""
combined = b"".join(frames_data)
def _find_string_after(needle: bytes, max_len: int = 64) -> Optional[str]:
pos = combined.find(needle)
if pos < 0:
return None
value_start = pos + len(needle)
while value_start < len(combined) and combined[value_start] == 0:
value_start += 1
if value_start >= len(combined):
return None
end = value_start
while end < len(combined) and combined[end] != 0 and (end - value_start) < max_len:
end += 1
s = combined[value_start:end].decode("ascii", errors="replace").strip()
return s or None
project = _find_string_after(b"Project:")
client = _find_string_after(b"Client:")
operator = _find_string_after(b"User Name:")
location = _find_string_after(b"Seis Loc:")
notes = _find_string_after(b"Extended Notes")
if not any([project, client, operator, location, notes]):
log.debug("a5 metadata: no project strings found in %d frames", len(frames_data))
return
if event.project_info is None:
event.project_info = ProjectInfo()
pi = event.project_info
# Overwrite with A5 values — they are event-time authoritative.
# 0C waveform record only carried "Project:"; A5 carries the full set.
if project: pi.project = project
if client: pi.client = client
if operator: pi.operator = operator
if location: pi.sensor_location = location
if notes: pi.notes = notes
log.debug(
"a5 metadata: project=%r client=%r operator=%r location=%r",
pi.project, pi.client, pi.operator, pi.sensor_location,
)
def _extract_record_type(data: bytes) -> Optional[str]:
"""
Decode the recording mode from byte[1] of the 210-byte waveform record.