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:
@@ -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 1F — advance 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 5A — bulk 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.
|
||||
|
||||
Reference in New Issue
Block a user