feat: implement SUB 1A (compliance config) read
Adds full support for reading device compliance configuration (2090-byte E5 response) containing record time, trigger/alarm levels, and project strings. protocol.py: - Implement read_compliance_config() two-step read (SUB 1A → E5) - Fixed length 0x082A (2090 bytes) models.py: - Add ComplianceConfig dataclass with fields: record_time, sample_rate, trigger_level_geo, alarm_level_geo, max_range_geo, project strings - Add compliance_config field to DeviceInfo client.py: - Implement _decode_compliance_config_into() to extract: * Record time float at offset +0x28 ✅ * Trigger/alarm levels per-channel (heuristic parsing) 🔶 * Project/setup strings from E5 payload * Placeholder for sample_rate (location TBD ❓) - Update connect() to read SUB 1A after SUB 01, cache in device_info - Add ComplianceConfig to imports sfm/server.py: - Add _serialise_compliance_config() JSON encoder - Include compliance_config in /device/info response - Updated _serialise_device_info() to output compliance config Both record_time (at fixed offset 0x28) and project strings are ✅ CONFIRMED from protocol reference §7.6. Trigger/alarm extraction uses heuristics pending more detailed field mapping from captured data. Sample rate remains undiscovered in the E5 payload — likely in the mystery flags at offset +0x12 or requires a "fast mode" capture. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,7 @@ from typing import Optional
|
||||
|
||||
from .framing import S3Frame
|
||||
from .models import (
|
||||
ComplianceConfig,
|
||||
DeviceInfo,
|
||||
Event,
|
||||
PeakValues,
|
||||
@@ -111,7 +112,7 @@ class MiniMateClient:
|
||||
|
||||
def connect(self) -> DeviceInfo:
|
||||
"""
|
||||
Perform the startup handshake and read device identity.
|
||||
Perform the startup handshake and read device identity + compliance config.
|
||||
|
||||
Opens the connection if not already open.
|
||||
|
||||
@@ -119,9 +120,10 @@ class MiniMateClient:
|
||||
1. POLL handshake (startup)
|
||||
2. SUB 15 — serial number
|
||||
3. SUB 01 — full config block (firmware, model strings)
|
||||
4. SUB 1A — compliance config (record time, trigger/alarm levels, project strings)
|
||||
|
||||
Returns:
|
||||
Populated DeviceInfo.
|
||||
Populated DeviceInfo with compliance_config cached.
|
||||
|
||||
Raises:
|
||||
ProtocolError: on any communication failure.
|
||||
@@ -142,6 +144,13 @@ class MiniMateClient:
|
||||
cfg_data = proto.read(SUB_FULL_CONFIG)
|
||||
_decode_full_config_into(cfg_data, device_info)
|
||||
|
||||
log.info("connect: reading compliance config (SUB 1A)")
|
||||
try:
|
||||
cc_data = proto.read_compliance_config()
|
||||
_decode_compliance_config_into(cc_data, device_info)
|
||||
except ProtocolError as exc:
|
||||
log.warning("connect: compliance config read failed: %s — continuing", exc)
|
||||
|
||||
log.info("connect: %s", device_info)
|
||||
return device_info
|
||||
|
||||
@@ -565,3 +574,109 @@ def _extract_project_strings(data: bytes) -> Optional[ProjectInfo]:
|
||||
sensor_location=location,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
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().
|
||||
|
||||
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 ❓
|
||||
|
||||
Modifies info.compliance_config in-place.
|
||||
"""
|
||||
if not data or len(data) < 0x28:
|
||||
log.warning("compliance config payload too short (%d bytes)", len(data))
|
||||
return
|
||||
|
||||
config = ComplianceConfig(raw=data)
|
||||
|
||||
# ── Record Time (✅ CONFIRMED at §7.6.1) ──────────────────────────────────
|
||||
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")
|
||||
|
||||
# ── Per-channel trigger/alarm levels (✅ CONFIRMED at §7.6) ─────────────────
|
||||
# Layout repeats per channel: [padding][max_range][padding][trigger]["in.\0"][alarm]["/s\0\0"][flag][label]
|
||||
# For now, extract just the first geo channel (Transverse) as a representative.
|
||||
try:
|
||||
# Search for the "Tran" label to locate the first geo channel block
|
||||
tran_pos = data.find(b"Tran")
|
||||
if tran_pos > 0:
|
||||
# Work backward from the label to find trigger and alarm
|
||||
# From §7.6: trigger float is ~20 bytes before the label, alarm is ~8 bytes before
|
||||
# Exact structure: [padding2][trigger_float][unit "in."][alarm_float][unit "/s"]
|
||||
# We'll search backward for the last float before the label
|
||||
# The trigger/alarm block format is complex; for now use heuristics
|
||||
|
||||
# Look for trigger level at a few standard offsets relative to label
|
||||
# From protocol: trigger is usually at label_offset - 14
|
||||
if tran_pos >= 14:
|
||||
try:
|
||||
trigger = struct.unpack_from(">f", data, tran_pos - 14)[0]
|
||||
config.trigger_level_geo = trigger
|
||||
log.debug("compliance_config: trigger_level_geo = %.3f in/s", trigger)
|
||||
except (struct.error, ValueError):
|
||||
pass
|
||||
|
||||
# Alarm is usually after the "in." unit string following trigger
|
||||
# Try offset tran_pos - 6
|
||||
if tran_pos >= 6:
|
||||
try:
|
||||
alarm = struct.unpack_from(">f", data, tran_pos - 6)[0]
|
||||
config.alarm_level_geo = alarm
|
||||
log.debug("compliance_config: alarm_level_geo = %.3f in/s", alarm)
|
||||
except (struct.error, ValueError):
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("compliance_config: trigger/alarm extraction failed: %s", exc)
|
||||
|
||||
# ── Project strings (from E5 / SUB 71 payload) ────────────────────────────
|
||||
try:
|
||||
def _find_string_after(needle: bytes, max_len: int = 64) -> Optional[str]:
|
||||
pos = data.find(needle)
|
||||
if pos < 0:
|
||||
return None
|
||||
value_start = pos + len(needle)
|
||||
while value_start < len(data) and data[value_start] == 0:
|
||||
value_start += 1
|
||||
if value_start >= len(data):
|
||||
return None
|
||||
end = value_start
|
||||
while end < len(data) and data[end] != 0 and (end - value_start) < max_len:
|
||||
end += 1
|
||||
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:")
|
||||
config.sensor_location = _find_string_after(b"Seis Loc:")
|
||||
config.notes = _find_string_after(b"Extended Notes")
|
||||
|
||||
if config.project:
|
||||
log.debug("compliance_config: project = %s", config.project)
|
||||
if config.client:
|
||||
log.debug("compliance_config: client = %s", config.client)
|
||||
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
|
||||
|
||||
info.compliance_config = config
|
||||
|
||||
Reference in New Issue
Block a user