diff --git a/minimateplus/client.py b/minimateplus/client.py index a709d75..fb0ef16 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -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,12 +642,11 @@ 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:") + 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") + config.notes = _find_string_after(b"Extended Notes") if config.project: log.debug("compliance_config: project = %s", config.project) @@ -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] + 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)