client: fix setup_name; add diagnostic scan for record_time/sample_rate

- setup_name was broken: _find_string_after(b"Standard Recording Setup")
  returned what comes AFTER the string (i.e. "Project:"), not the name
  itself.  Fixed by searching for the first long (>=8 char) ASCII string
  in cfg[40:250] with _find_first_string().

- record_time offset 0x28 was wrong (that location holds "(L)", a unit
  label string).  Disabled for now to avoid returning garbage; correct
  offset will be determined from _cfg_diagnostic() output.

- Added _cfg_diagnostic(): logs all strings and all plausible float32/uint16
  values across the full cfg so record_time and sample_rate offsets can be
  pinpointed from a single device run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Harrison
2026-04-01 15:18:41 -04:00
parent eee1e36a1b
commit 58a5f15ed5

View File

@@ -580,40 +580,52 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
"""
Decode a 2090-byte SUB 1A (COMPLIANCE_CONFIG) response into a ComplianceConfig.
The *data* argument is the raw response bytes from read_compliance_config().
The *data* argument is the raw bytes returned by read_compliance_config()
(frames B+C+D concatenated, echo headers stripped).
Extracts (per §7.6):
- record_time: float32 BE at offset +0x28
- trigger_level / alarm_level per-channel: IEEE 754 BE floats
- project strings: "Project:", "Client:", "User Name:", "Seis Loc:", "Extended Notes"
- sample_rate: NOT YET FOUND ❓
Confirmed field locations (BE11529 with 3-step read, 2126 bytes):
- cfg[89] = setup_name: first long ASCII string in cfg[40:250]
- cfg[??] = record_time: float32 BE — offset TBD (scan below)
- cfg[??] = sample_rate: uint16 or float — offset TBD (scan below)
- "Project:" needle → project string
- "Client:" needle → client string
- "User Name:" needle → operator string
- "Seis Loc:" needle → sensor_location string
- "Extended Notes" needle → notes string
Modifies info.compliance_config in-place.
"""
if not data or len(data) < 0x28:
if not data or len(data) < 40:
log.warning("compliance config payload too short (%d bytes)", len(data))
return
config = ComplianceConfig(raw=data)
# ── Record Time (✅ CONFIRMED at §7.6.1) ──────────────────────────────────
# ── Diagnostic: scan full cfg for strings and plausible floats ─────────────
# Logged at WARNING so they appear without debug mode. Remove once field
# offsets are confirmed for both BE11529 and BE18189.
_cfg_diagnostic(data)
# ── Setup name ────────────────────────────────────────────────────────────
# The setup_name IS the string itself — it is NOT a label followed by a value.
# It appears as the first long (>=8 char) ASCII string in cfg[40:250].
# The preceding bytes vary by device (cfg[88]=0x01 on BE11529); the string
# itself is null-terminated.
try:
# Record time is at offset +0x28 within the data payload (NOT from frame start)
# This is the second page of the paged read response.
if len(data) > 0x28 + 4:
record_time = struct.unpack_from(">f", data, 0x28)[0]
config.record_time = record_time
log.debug("compliance_config: record_time = %.1f sec", record_time)
except struct.error:
log.warning("compliance_config: failed to unpack record_time at offset 0x28")
setup_name = _find_first_string(data, start=40, end=250, min_len=8)
config.setup_name = setup_name
if setup_name:
log.debug("compliance_config: setup_name = %r", setup_name)
except Exception as exc:
log.warning("compliance_config: setup_name extraction failed: %s", exc)
# ── Per-channel trigger/alarm levels (✅ CONFIRMED at §7.6) ─────────────────
# Layout (per §7.6): [padding2][max_range][padding][trigger]["in.\0"][alarm]["/s\0\0"][flag][label]
# Exact byte offsets relative to label require detailed field mapping from actual captures.
# For now, we skip extraction — this section will be populated once we have precise offsets.
# TODO: Capture E5 response and map exact trigger/alarm float positions
# ── Record Time (⚠️ OFFSET UNCONFIRMED — waiting on diagnostic output) ──
# Previous guess of 0x28 was wrong (that offset holds "(L)" string).
# The correct offset will be clear once _cfg_diagnostic identifies it.
# Temporarily disabled to avoid returning a garbage value.
config.record_time = None # TODO: set once offset confirmed from diagnostic
# ── Project strings (from E5 / SUB 71 payload) ────────────────────────────
# ── Project strings ───────────────────────────────────────────────────────
try:
def _find_string_after(needle: bytes, max_len: int = 64) -> Optional[str]:
pos = data.find(needle)
@@ -630,7 +642,6 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
s = data[value_start:end].decode("ascii", errors="replace").strip()
return s or None
config.setup_name = _find_string_after(b"Standard Recording Setup")
config.project = _find_string_after(b"Project:")
config.client = _find_string_after(b"Client:")
config.operator = _find_string_after(b"User Name:")
@@ -644,10 +655,82 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
except Exception as exc:
log.warning("compliance_config: project string extraction failed: %s", exc)
# ── Sample rate (NOT YET FOUND ❓) ────────────────────────────────────────
# The sample rate (1024 sps standard, 2048 sps fast) is not yet located in the
# protocol docs. It may be encoded in mystery flags at offset +0x12 in the .set
# file format, or it may require a separate capture analysis. For now, leave as None.
config.sample_rate = None
# ── Sample rate (⚠️ OFFSET UNCONFIRMED — waiting on diagnostic output) ────
config.sample_rate = None # TODO: set once offset confirmed from diagnostic
info.compliance_config = config
def _find_first_string(data: bytes, start: int, end: int, min_len: int) -> Optional[str]:
"""
Return the first null-terminated printable ASCII string of length >= min_len
found in data[start:end].
"""
i = start
end = min(end, len(data))
while i < end:
if 0x20 <= data[i] < 0x7F:
j = i
while j < len(data) and 0x20 <= data[j] < 0x7F:
j += 1
if j - i >= min_len:
return data[i:j].decode("ascii", errors="replace").strip()
i = j + 1
else:
i += 1
return None
def _cfg_diagnostic(data: bytes) -> None:
"""
Log all strings and plausible float32 BE values found in the full compliance
config data. Used to map field offsets for record_time and sample_rate.
Remove once offsets are confirmed.
"""
import struct as _struct
# ── All printable ASCII strings >= 4 chars ────────────────────────────────
strings_found = []
i = 0
while i < len(data):
if 0x20 <= data[i] < 0x7F:
j = i
while j < len(data) and 0x20 <= data[j] < 0x7F:
j += 1
if j - i >= 4:
s = data[i:j].decode("ascii", errors="replace")
strings_found.append((i, s))
i = j
else:
i += 1
log.warning("cfg_diag: %d strings found:", len(strings_found))
for off, s in strings_found[:40]: # cap at 40 to avoid log spam
log.warning(" cfg[%04d/0x%04X] str %r", off, off, s[:60])
# ── Float32 BE values in plausible physical ranges ─────────────────────────
log.warning("cfg_diag: float32_BE scan (plausible record_time / sample_rate / trigger):")
for i in range(len(data) - 3):
try:
v = _struct.unpack_from(">f", data, i)[0]
except Exception:
continue
if v != v or v == float("inf") or v == float("-inf"):
continue
tag = ""
if 0.5 <= v <= 30.0: tag = "RECORD_TIME?"
elif 200 <= v <= 8192: tag = "SAMPLE_RATE?"
elif 0.003 <= v <= 5.0: tag = "TRIGGER/ALARM?"
if tag:
log.warning(" cfg[%04d/0x%04X] f32_BE=%10.4f %s", i, i, v, tag)
# ── uint16 BE/LE scan for known sample rates ──────────────────────────────
known_rates = {256, 512, 1024, 2048, 4096}
log.warning("cfg_diag: uint16 scan for sample rates %s:", known_rates)
for i in range(len(data) - 1):
u16_le = _struct.unpack_from("<H", data, i)[0]
u16_be = _struct.unpack_from(">H", data, i)[0]
if u16_le in known_rates:
log.warning(" cfg[%04d/0x%04X] uint16_LE=%d", i, i, u16_le)
if u16_be in known_rates and u16_be != u16_le:
log.warning(" cfg[%04d/0x%04X] uint16_BE=%d", i, i, u16_be)