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

@@ -182,6 +182,83 @@ def token_params(token: int = 0) -> bytes:
return bytes(p)
def bulk_waveform_params(key4: bytes, counter: int, *, is_probe: bool = False) -> bytes:
"""
Build the 10-byte params block for SUB 5A (BULK_WAVEFORM_STREAM) requests.
Confirmed 2026-04-02 from 1-2-26 BW TX capture analysis:
Probe / first request (is_probe=True, counter=0):
params[0] = 0x00
params[1:5] = key4 (all 4 key bytes; counter overlaps key4[2:4] = 0x0000)
params[5:] = zeros
Regular chunk requests (is_probe=False):
params[0] = 0x00
params[1:3] = key4[0:2] (first 2 key bytes as session handle)
params[3:5] = counter (BE uint16) (chunk position, increments by 0x0400)
params[5:] = zeros
Termination request: DO NOT use this helper — see bulk_waveform_term_params().
Args:
key4: 4-byte waveform key from EVENT_HEADER (1E) response.
counter: Chunk position counter (uint16 BE). Pass 0 for probe.
is_probe: If True, embed full key4 (probe step only).
Returns:
10-byte params block.
"""
if len(key4) != 4:
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
p = bytearray(10)
p[0] = 0x00
p[1] = key4[0]
p[2] = key4[1]
if is_probe:
# Full key4; counter=0 is implied (overlaps with key4[2:4] which must be 0x0000)
p[3] = key4[2]
p[4] = key4[3]
else:
p[3] = (counter >> 8) & 0xFF
p[4] = counter & 0xFF
return bytes(p)
def bulk_waveform_term_params(key4: bytes, counter: int) -> bytes:
"""
Build the 10-byte params block for the SUB 5A termination request.
The termination request uses offset=0x005A and a DIFFERENT params layout —
the leading 0x00 byte is dropped, key4[0:2] shifts to params[0:2], and the
counter high byte is at params[2]:
params[0] = key4[0]
params[1] = key4[1]
params[2] = (counter >> 8) & 0xFF
params[3:] = zeros
Counter for the termination request = last_regular_counter + 0x0400.
Confirmed from 1-2-26 BW TX capture: final request (frame 83) uses
offset=0x005A, params[0:3] = key4[0:2] + term_counter_hi.
Args:
key4: 4-byte waveform key.
counter: Termination counter (= last regular counter + 0x0400).
Returns:
10-byte params block.
"""
if len(key4) != 4:
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
p = bytearray(10)
p[0] = key4[0]
p[1] = key4[1]
p[2] = (counter >> 8) & 0xFF
return bytes(p)
# ── Pre-built POLL frames ─────────────────────────────────────────────────────
#
# POLL (SUB 0x5B) uses the same two-step pattern as all other reads — the