diff --git a/CLAUDE.md b/CLAUDE.md index 90e4248..d4740d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,10 +307,16 @@ producing only ~1071 bytes instead of ~2126. ### SUB 1A — anchor search range -`_decode_compliance_config_into()` locates sample_rate and record_time via the anchor -`b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`. Search range is `cfg[0:150]`. +`_decode_compliance_config_into()` locates fields via the **6-byte stable anchor** +`b'\xbe\x80\x00\x00\x00\x00'`. Search range is `cfg[0:150]`. -Do not narrow this to `cfg[40:100]` — the old range was only accidentally correct because +**IMPORTANT — the "10-byte anchor" `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` is NOT fully constant.** +The first 2 bytes (`\x01\x2c` = 300) are the `histogram_interval_sec` field (uint16 BE, seconds) — +the value 300 is just the 5-minute default. When histogram interval is set to a different value +(e.g. 15min = 0x0384 = `\x03\x84`), those bytes change. Only the 6-byte suffix +`\xbe\x80\x00\x00\x00\x00` is truly constant. The code already uses the 6-byte anchor. + +Do not narrow the search range to `cfg[40:100]` — the old range was only accidentally correct because the orphaned-send bug was prepending a 44-byte spurious header, pushing the anchor from its real position (cfg[11]) into the 40–100 window. @@ -360,8 +366,9 @@ Do NOT use fixed absolute offsets for sample_rate or record_time. | Field | How to find it | |---|---| -| **recording_mode** | **uint8 at anchor − 3 (write payload) / anchor − 4 (read response)** ✅ confirmed 2026-04-20 | +| **recording_mode** | **uint8 at anchor − 3 (write) / anchor − 4 (read)** ✅ confirmed 2026-04-20 | | sample_rate | uint16 BE at anchor − 2 | +| **histogram_interval_sec** | **uint16 BE at anchor − 4 (seconds); same offset in read & write** ✅ confirmed 2026-04-20 | | record_time | float32 BE at anchor + 10 | | trigger_level_geo | float32 BE, located in channel block | | alarm_level_geo | float32 BE, adjacent to trigger_level_geo | @@ -370,7 +377,20 @@ Do NOT use fixed absolute offsets for sample_rate or record_time. | setup_name | ASCII, null-padded, in cfg body | | project / client / operator / sensor_location | ASCII, label-value pairs | -Anchor: `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`, search `cfg[0:150]` +**True stable anchor: `b'\xbe\x80\x00\x00\x00\x00'` (6-byte suffix), search `cfg[0:150]`.** +The old "10-byte anchor" `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'` is partially variable: +bytes `\x01\x2c` = 300 (5-minute default histogram interval); changes when interval changes. + +**Field layout relative to the 6-byte anchor (write payload / E5 read — noted where different):** + +| Offset | Field | Format | Notes | +|---|---|---|---| +| anchor − 7 (write) / anchor − 8 (read) | recording_mode | uint8 | E5 read has extra `0x10` at anchor−7 | +| anchor − 6 | sample_rate | uint16 BE | same in read & write | +| anchor − 4 | histogram_interval_sec | uint16 BE | seconds; same in read & write ✅ 2026-04-20 | +| anchor − 2 | `0x00 0x00` | padding | | +| anchor | `\xbe\x80\x00\x00\x00\x00` | anchor | | +| anchor + 6 | record_time | float32 BE | same in read & write | **recording_mode enum** (confirmed 2026-04-20 from 4-20-26 captures): @@ -382,7 +402,7 @@ Anchor: `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`, search `cfg[0:150]` | `0x03` | Histogram | | `0x04` | Histogram + Continuous | -**Offset note:** The write payload (SUB 71 cfg) has recording_mode 3 bytes before the anchor (`anchor_pos − 3`). The E5 read response has it 4 bytes before (`anchor_pos − 4`), with an extra `0x10` byte sitting between recording_mode and sample_rate in the read format. Use `anchor_pos − 3` when encoding writes; use `anchor_pos − 4` when decoding reads. +**DLE escaping in write frames — CONFIRMED 2026-04-20:** Write frame data payloads DO escape `0x03` (ETX) bytes with a `0x10` DLE prefix. For histogram_interval = 900 (0x0384), the wire carries `10 03 84` — the `0x03` high byte is preceded by a DLE escape. After DLE destuffing (`10 XX → XX`), the logical field value is correctly `03 84` = 900. The CLAUDE.md claim that write frame data is "written RAW" was incorrect; at minimum ETX (0x03) bytes are escaped. S3FrameParser handles this transparently so the decoded `compliance_raw` always contains logical (destuffed) bytes. ### SUB 0C — Waveform Record (210 bytes = data[11:11+0xD2]) @@ -721,7 +741,7 @@ offsets in the raw 1A/E5 payload. Only fields with `✅` have confirmed offsets - Record Stop Mode: Fixed Record Time / Auto / Manual Stop (enum) ❓ (byte near recording_mode; data[40] in E5 sf1 changed 0x01→0x00 alongside Continuous→Single Shot — may be this field) - Sample Rate: Standard 1024 / Fast 2048 / Faster 4096 sps ✅ (anchor−2) - Record Time: float, seconds ✅ (anchor+10) -- Histogram Interval: 5 / 15 / 30 / 60 minutes (enum, mode-gated) +- Histogram Interval: 2s / 5s / 15s / 1m / 5m / 15m ✅ (uint16 BE seconds at anchor−4, same in read & write; mode-gated to Histogram/Histogram+Continuous) — confirmed 2026-04-20 - Storage Mode: Save All Data / Save Triggered (enum) - Geophone Type: Standard Triaxial / 4.5 Hz (bool/enum) - Geophone Channels: Enable all geophones (bool), Trigger Source (bool) diff --git a/minimateplus/client.py b/minimateplus/client.py index 62fb9c3..6e4d435 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -847,9 +847,10 @@ class MiniMateClient: self, *, # Recording parameters - recording_mode: Optional[int] = None, - sample_rate: Optional[int] = None, - record_time: Optional[float] = None, + recording_mode: Optional[int] = None, + sample_rate: Optional[int] = None, + record_time: Optional[float] = None, + histogram_interval_sec: Optional[int] = None, # Threshold parameters (geo channels, in/s) trigger_level_geo: Optional[float] = None, alarm_level_geo: Optional[float] = None, @@ -919,6 +920,7 @@ class MiniMateClient: recording_mode=recording_mode, sample_rate=sample_rate, record_time=record_time, + histogram_interval_sec=histogram_interval_sec, trigger_level_geo=trigger_level_geo, alarm_level_geo=alarm_level_geo, project=project, @@ -1657,12 +1659,13 @@ def _encode_compliance_config( sample_rate: Optional[int] = None, record_time: Optional[float] = None, trigger_level_geo: Optional[float] = None, - alarm_level_geo: Optional[float] = None, - project: Optional[str] = None, - client_name: Optional[str] = None, - operator: Optional[str] = None, - seis_loc: Optional[str] = None, - notes: Optional[str] = None, + alarm_level_geo: Optional[float] = None, + histogram_interval_sec: Optional[int] = None, + project: Optional[str] = None, + client_name: Optional[str] = None, + operator: Optional[str] = None, + seis_loc: Optional[str] = None, + notes: Optional[str] = None, ) -> bytes: """ Patch a live 2126-byte compliance buffer (read from the device) with any @@ -1674,13 +1677,14 @@ def _encode_compliance_config( DLE-jitter shifts): Anchor: b'\\xbe\\x80\\x00\\x00\\x00\\x00' (confirmed stable, both BE11529 and BE18189) - recording_mode → uint8 at anchor_pos - 7 (write payload; one byte earlier than read format) - Values: 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous - NOTE: In the E5 read response the field is at anchor_pos - 8 due to an extra - 0x10 byte at anchor_pos - 7 present only in the read format. The write - payload does NOT have this extra byte — use anchor_pos - 7 here. - sample_rate → uint16 BE at anchor_pos - 6 - record_time → float32 BE at anchor_pos + 6 + recording_mode → uint8 at anchor_pos - 7 (write payload) + Values: 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous + NOTE: In the E5 read response (decode) field is at anchor_pos - 8 due to an + extra 0x10 byte at read anchor_pos - 7. Write payload has no extra byte. + sample_rate → uint16 BE at anchor_pos - 6 + histogram_interval_sec → uint16 BE at anchor_pos - 4 (seconds; mode-gated to Histogram/Histogram+Continuous) + Valid values: 2, 5, 15, 60, 300, 900 (= 2s, 5s, 15s, 1m, 5m, 15m) + record_time → float32 BE at anchor_pos + 6 Channel block (anchored on b"Tran" with unit-string guard): trigger_level_geo → float32 BE at tran_pos + 34 @@ -1730,6 +1734,14 @@ def _encode_compliance_config( struct.pack_into(">H", buf, _anc - 6, sample_rate) log.debug("_encode_compliance_config: sample_rate=%d -> offset %d", sample_rate, _anc - 6) + if histogram_interval_sec is not None: + if _anc < 4: + log.warning("_encode_compliance_config: anchor not found — cannot write histogram_interval") + else: + struct.pack_into(">H", buf, _anc - 4, histogram_interval_sec) + log.debug("_encode_compliance_config: histogram_interval=%ds -> offset %d", + histogram_interval_sec, _anc - 4) + if record_time is not None: if _anc < 0 or _anc + 10 > len(buf): log.warning("_encode_compliance_config: anchor not found — cannot write record_time") @@ -1841,29 +1853,27 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None: except Exception as exc: log.warning("compliance_config: setup_name extraction failed: %s", exc) - # ── Record time + sample rate — anchor-relative ─────────────────────────── - # The 10-byte anchor sits between sample_rate and record_time in the cfg. - # Absolute offsets are NOT reliable because sample_rate = 4096 (0x1000) is - # DLE-escaped in the raw S3 frame (10 10 00 → 10 00 after unstuffing), - # making frame C 1 byte shorter than for 1024/2048 and shifting everything. - # sample_rate: uint16_BE at anchor - 2 - # record_time: float32_BE at anchor + 10 - # 6-byte suffix anchor — confirmed stable across BE11529 and bench unit (BE18189). - # The preceding 4 bytes (old anchor prefix 01 2c / 00 3c) vary by unit config; - # only be 80 00 00 00 00 is constant. - # sample_rate : uint16 BE at anchor_pos - 6 - # record_time : float32 BE at anchor_pos + 6 + # ── recording_mode / sample_rate / histogram_interval / record_time ───────── + # 6-byte stable anchor: b'\xbe\x80\x00\x00\x00\x00' — confirmed across BE11529 + # and BE18189. The 4 bytes immediately before the anchor are NOT constant: + # bytes -4:-2 are the histogram_interval (uint16 BE, seconds), and bytes -2:0 + # are zero padding. The old "10-byte anchor" (\x01\x2c\x00\x00 prefix) was + # only constant when the histogram interval happened to be 5 min (0x012C = 300). + # + # E5 read format layout relative to 6-byte anchor: + # _anchor - 8 : recording_mode (uint8) + # _anchor - 7 : 0x10 (extra byte E5 read only; absent in SUB 71 write) + # _anchor - 6 : sample_rate_hi (uint16 BE, MSB) + # _anchor - 5 : sample_rate_lo (uint16 BE, LSB) + # _anchor - 4 : histogram_interval_hi (uint16 BE, seconds, MSB) + # _anchor - 3 : histogram_interval_lo (uint16 BE, seconds, LSB) + # _anchor - 2 : 0x00 (padding) + # _anchor - 1 : 0x00 (padding) + # _anchor : \xbe\x80\x00\x00\x00\x00 (6-byte anchor) + # _anchor + 6 : record_time (float32 BE) _ANCHOR = b'\xbe\x80\x00\x00\x00\x00' _anchor = data.find(_ANCHOR, 0, 150) if _anchor >= 8 and _anchor + 10 <= len(data): - # Layout (E5 read format, relative to 6-byte anchor suffix): - # _anchor - 8 : recording_mode (uint8) - # _anchor - 7 : 0x10 (extra byte present in E5 read only; absent in SUB 71 write) - # _anchor - 6 : sample_rate_hi (uint8, MSB of uint16 BE) - # _anchor - 5 : sample_rate_lo (uint8, LSB of uint16 BE) - # _anchor - 4 : \x01\x2c\x00\x00 (10-byte anchor prefix) - # _anchor : \xbe\x80\x00\x00\x00\x00 (6-byte anchor suffix) - # _anchor + 6 : record_time (float32 BE) try: config.recording_mode = data[_anchor - 8] log.debug( @@ -1878,6 +1888,14 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None: ) except Exception as exc: log.warning("compliance_config: sample_rate extraction failed: %s", exc) + try: + config.histogram_interval_sec = struct.unpack_from(">H", data, _anchor - 4)[0] + log.debug( + "compliance_config: histogram_interval = %d s (anchor@%d)", + config.histogram_interval_sec, _anchor + ) + except Exception as exc: + log.warning("compliance_config: histogram_interval extraction failed: %s", exc) try: config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0] log.debug( diff --git a/minimateplus/models.py b/minimateplus/models.py index 9f7f9a4..a3d144f 100644 --- a/minimateplus/models.py +++ b/minimateplus/models.py @@ -340,10 +340,14 @@ class ComplianceConfig: # Recording parameters (✅ CONFIRMED from §7.6) recording_mode: Optional[int] = None # uint8: 0x00=Single Shot, 0x01=Continuous, # 0x03=Histogram, 0x04=Histogram+Continuous ✅ confirmed 2026-04-20 - # Read (E5 sf1): data[anchor_pos - 4] - # Write (SUB 71 payload): cfg[anchor_pos - 3] - record_time: Optional[float] = None # seconds (7.0, 10.0, 13.0, etc.) - sample_rate: Optional[int] = None # sps (1024, 2048, 4096, etc.) + # Read (E5): data[anchor_pos - 8] (6-byte anchor) + # Write (SUB 71): data[anchor_pos - 7] + sample_rate: Optional[int] = None # sps (1024, 2048, 4096) + histogram_interval_sec: Optional[int] = None # uint16 BE, seconds ✅ confirmed 2026-04-20 + # anchor_pos - 4 (same offset in read & write) + # Valid values: 2, 5, 15, 60, 300, 900 + # Mode-gated: only active in Histogram/Histogram+Continuous + record_time: Optional[float] = None # seconds (e.g. 3.0, 5.0, 8.0, 10.0) # Trigger/alarm levels (✅ CONFIRMED per-channel at §7.6) # For now we store the first geo channel (Transverse) as representatives; diff --git a/sfm/server.py b/sfm/server.py index 3f9ca5a..912e95d 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -285,9 +285,10 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d if cc is None: return None return { - "recording_mode": cc.recording_mode, # 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous - "record_time": cc.record_time, - "sample_rate": cc.sample_rate, + "recording_mode": cc.recording_mode, # 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous + "sample_rate": cc.sample_rate, + "histogram_interval_sec": cc.histogram_interval_sec, # seconds; None if not Histogram mode + "record_time": cc.record_time, "trigger_level_geo": cc.trigger_level_geo, "alarm_level_geo": cc.alarm_level_geo, "geo_adc_scale": cc.geo_adc_scale, # hw scale factor (in/s)/V — informational only, do not write @@ -854,9 +855,10 @@ class DeviceConfigBody(BaseModel): notes : Extended notes. """ # Recording parameters - recording_mode: Optional[int] = None - sample_rate: Optional[int] = None - record_time: Optional[float] = None + recording_mode: Optional[int] = None + sample_rate: Optional[int] = None + record_time: Optional[float] = None + histogram_interval_sec: Optional[int] = None # seconds: 2, 5, 15, 60, 300, 900 (mode-gated) # Threshold parameters trigger_level_geo: Optional[float] = None alarm_level_geo: Optional[float] = None @@ -918,6 +920,7 @@ def device_config( recording_mode=body.recording_mode, sample_rate=body.sample_rate, record_time=body.record_time, + histogram_interval_sec=body.histogram_interval_sec, trigger_level_geo=body.trigger_level_geo, alarm_level_geo=body.alarm_level_geo, project=body.project, diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index 94aeafe..a575857 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -824,6 +824,20 @@ +