diff --git a/minimateplus/client.py b/minimateplus/client.py index e4e13f8..dbb977e 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -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 diff --git a/minimateplus/models.py b/minimateplus/models.py index 0e78350..036310a 100644 --- a/minimateplus/models.py +++ b/minimateplus/models.py @@ -180,6 +180,9 @@ class DeviceInfo: manufacturer: Optional[str] = None # e.g. "Instantel" ✅ model: Optional[str] = None # e.g. "MiniMate Plus" ✅ + # ── From SUB 1A (COMPLIANCE_CONFIG_RESPONSE) ────────────────────────────── + compliance_config: Optional["ComplianceConfig"] = None # E5 response, read in connect() + def __str__(self) -> str: fw = self.firmware_version or f"?.{self.firmware_minor}" mdl = self.model or "MiniMate Plus" @@ -253,6 +256,44 @@ class ProjectInfo: notes: Optional[str] = None # extended notes +# ── Compliance Config ────────────────────────────────────────────────────────── + +@dataclass +class ComplianceConfig: + """ + Device compliance and recording configuration from SUB 1A (response E5). + + Contains device-wide settings like record time, trigger/alarm thresholds, + and operator-supplied strings. This is read once during connect() and + cached in DeviceInfo. + + All fields are optional — some may not be decoded yet or may be absent + from the device configuration. + """ + raw: Optional[bytes] = None # full 2090-byte payload (for debugging) + + # Recording parameters (✅ CONFIRMED from §7.6) + record_time: Optional[float] = None # seconds (7.0, 10.0, 13.0, etc.) + sample_rate: Optional[int] = None # sps (1024, 2048, 4096, etc.) — NOT YET FOUND ❓ + + # Trigger/alarm levels (✅ CONFIRMED per-channel at §7.6) + # For now we store the first geo channel (Transverse) as representatives; + # full per-channel data would require structured Channel objects. + trigger_level_geo: Optional[float] = None # in/s (first geo channel) + alarm_level_geo: Optional[float] = None # in/s (first geo channel) + max_range_geo: Optional[float] = None # in/s full-scale range + + # Project/setup strings (sourced from E5 / SUB 71 write payload) + # These are the FULL project metadata from compliance config, + # complementing the sparse ProjectInfo found in the waveform record (SUB 0C). + setup_name: Optional[str] = None # "Standard Recording Setup" + project: Optional[str] = None # project description + client: Optional[str] = None # client name + operator: Optional[str] = None # operator / user name + sensor_location: Optional[str] = None # sensor location string + notes: Optional[str] = None # extended notes / additional info + + # ── Event ───────────────────────────────────────────────────────────────────── @dataclass diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index a8099b6..0a52194 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -395,6 +395,44 @@ class MiniMateProtocol: ) return key4 + def read_compliance_config(self) -> bytes: + """ + Send the SUB 1A (CHANNEL_SCALING / COMPLIANCE_CONFIG) two-step read. + + Returns the full 2090-byte compliance config block (E5 response) + containing: + - Trigger and alarm levels per channel (IEEE 754 BE floats) + - Record time (float at offset +0x28) + - Project strings (Project, Client, User Name, Seis Loc, Extended Notes) + - Channel labels and unit strings + - Per-channel max range values + + Returns: + 2090-byte compliance config data (data[11:11+0x082A]). + + Raises: + ProtocolError: on timeout, bad checksum, or wrong response SUB. + + Confirmed from protocol reference §7.6: + - SUB 1A request uses all-zero params + - E5 response is 2090 bytes (0x082A fixed length) + - Response data section: data[11:11+0x082A] + """ + rsp_sub = _expected_rsp_sub(SUB_COMPLIANCE) + length = 0x082A # 2090 bytes, fixed length for compliance config + + log.debug("read_compliance_config: 1A probe") + self._send(build_bw_frame(SUB_COMPLIANCE, 0)) + self._recv_one(expected_sub=rsp_sub) + + log.debug("read_compliance_config: 1A data request offset=0x%04X", length) + self._send(build_bw_frame(SUB_COMPLIANCE, length)) + data_rsp = self._recv_one(expected_sub=rsp_sub) + + config = data_rsp.data[11:11 + length] + log.debug("read_compliance_config: received %d config bytes", len(config)) + return config + # ── Internal helpers ────────────────────────────────────────────────────── def _send(self, frame: bytes) -> None: diff --git a/sfm/server.py b/sfm/server.py index b111460..4b58051 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -53,7 +53,7 @@ except ImportError: from minimateplus import MiniMateClient from minimateplus.protocol import ProtocolError -from minimateplus.models import DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp +from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT logging.basicConfig( @@ -119,14 +119,33 @@ def _serialise_project_info(pi: Optional[ProjectInfo]) -> Optional[dict]: } +def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[dict]: + if cc is None: + return None + return { + "record_time": cc.record_time, + "sample_rate": cc.sample_rate, + "trigger_level_geo": cc.trigger_level_geo, + "alarm_level_geo": cc.alarm_level_geo, + "max_range_geo": cc.max_range_geo, + "setup_name": cc.setup_name, + "project": cc.project, + "client": cc.client, + "operator": cc.operator, + "sensor_location": cc.sensor_location, + "notes": cc.notes, + } + + def _serialise_device_info(info: DeviceInfo) -> dict: return { - "serial": info.serial, - "firmware_version": info.firmware_version, - "firmware_minor": info.firmware_minor, - "dsp_version": info.dsp_version, - "manufacturer": info.manufacturer, - "model": info.model, + "serial": info.serial, + "firmware_version": info.firmware_version, + "firmware_minor": info.firmware_minor, + "dsp_version": info.dsp_version, + "manufacturer": info.manufacturer, + "model": info.model, + "compliance_config": _serialise_compliance_config(info.compliance_config), }