diff --git a/CHANGELOG.md b/CHANGELOG.md index 5076959..a0bf491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,85 @@ All notable changes to seismo-relay are documented here. --- +## v0.12.3 — 2026-04-20 + +### Added + +- **Auto Call Home config protocol** — Full read/write/decode/encode pipeline for the + device's Remote Access → Setup Unit ACH settings, confirmed from 4-20-26 call home + settings captures. + + **Protocol (new):** + - `SUB 0x2C` — Call Home Config READ (response `0xD3`); two-step read; data offset + `0x7C` = 124; raw payload 125 bytes (1-byte longer than DATA_LENGTH due to DLE-escaped + `\x10\x03` at raw[117:119] representing num_retries = 3) + - `SUB 0x7E` — Call Home Config WRITE (response `0x81`); 127-byte payload (125-byte read + payload + `\x00\x00`); offset = `data[1]+2 = 0x7E`; write format (DLE-aware checksum) + - `SUB 0x7F` — Call Home WRITE CONFIRM (response `0x80`); no data + + **Field map (confirmed from 10-frame BW TX diff):** + - `raw[5]` — auto_call_home_enabled (bool) + - `raw[6:46]` — dial_string (40-byte null-padded ASCII) + - `raw[87]` — after_event_recorded (bool) + - `raw[91]` — at_specified_times (bool) + - `raw[93]` — time1_enabled / `raw[101]` — time1_hour / `raw[102]` — time1_min + - `raw[95]` — time2_enabled / `raw[105]` — time2_hour / `raw[106]` — time2_min + - `raw[117:119]` — `\x10\x03` (DLE-escaped 0x03 = num_retries value 3) + - `raw[120]` — time_between_retries_sec / `raw[122]` — wait_for_connection_sec / `raw[124]` — warm_up_time_sec + + **Library (`minimateplus/`):** + - `models.py` — `CallHomeConfig` dataclass (14 fields; `raw` bytes preserved for + round-trip writes) + - `protocol.py` — `SUB_CALL_HOME = 0x2C`, `SUB_CALL_HOME_WRITE = 0x7E`, + `SUB_CALL_HOME_CONFIRM = 0x7F`; `read_call_home_config()`, `write_call_home_config()` + - `client.py` — `get_call_home_config()`, `set_call_home_config()`, + `_decode_call_home_config()` (handles DLE prefix at raw[117]), + `_encode_call_home_config()` (patches in-place; raises `ValueError` if hour/min = 3) + + **REST API (`sfm/server.py`):** + - `GET /device/call_home` — reads and decodes call home config from device + - `POST /device/call_home` — reads, patches specified fields, writes back to device + - `CallHomeConfigBody` Pydantic model with 9 optional writable fields + + **Web UI (`sfm/sfm_webapp.html`):** + - New "Call Home" tab with enable flag, dial string (read-only), after-event trigger, + at-specified-times flag, two time slots (enable + HH:MM each), and read-only retry + settings (num_retries, time_between_retries_sec, wait_for_connection_sec, + warm_up_time_sec) + - "Read from Device", "Write to Device", "Clear Form" action buttons + - Client-side guard: rejects hour or minute value equal to 3 with a clear message + explaining the DLE-encoding limitation + +--- + +## v0.12.2 — 2026-04-20 + +### Added / Fixed + +- **Geophone sensitivity / maximum range field confirmed** — 4-20-26 geo sensitivity + captures (1.25 in/s vs 10 in/s) diffed across all three SUB 71 write chunks and both + E5 read payloads. The `geo_range` uint8 field per channel is now fully confirmed: + - E5 read offset: `channel_label + 33`; SUB 71 write offset: `channel_label + 29` + - `0x00` = Normal 10.000 in/s (standard gain); `0x01` = Sensitive 1.250 in/s (high gain) + - **Correction:** previous hypothesis (`channel_label+20`, `0x01`=Normal) was wrong. + `channel_label+20` reads `0x01` on ALL captures regardless of range — not this field. + - `_decode_compliance_config_into`: read offset corrected from `tran_pos+20` → `tran_pos+33` + - `_encode_compliance_config`: added `geo_range` parameter; writes to Tran/Vert/Long at `+29` + - `apply_config`: added `geo_range` parameter + - `POST /device/config`: added `geo_range` to `DeviceConfigBody` + - Web UI Config tab: added "Maximum Range — Geo" select (Normal / Sensitive) + - Web UI Device tab: added "Max Range (geo)" row to compliance table + +- **`recording_mode` + `histogram_interval_sec` confirmed and implemented** (4-20-26 captures) + - `recording_mode`: uint8 at anchor−8 (E5 read) / anchor−7 (write); enum: 0x00=Single Shot, + 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous + - `histogram_interval_sec`: uint16 BE seconds at anchor−4; same offset in read & write; + valid: 2, 5, 15, 60, 300, 900 (matching Blastware dropdown: 2s, 5s, 15s, 1m, 5m, 15m) + - Both fields added to `ComplianceConfig`, `_decode_compliance_config_into`, + `_encode_compliance_config`, `apply_config`, REST API body, and web UI + +--- + ## v0.12.1 — 2026-04-16 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 39019cb..5ab92f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,9 @@ Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem -(Sierra Wireless RV50 / RV55). Current version: **v0.12.1**. +(Sierra Wireless RV50 / RV55). Current version: **v0.12.3**. + +When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document --- @@ -25,9 +27,9 @@ CHANGELOG.md ← version history --- -## Current implementation state (v0.10.0) +## Current implementation state (v0.12.3) -Full read pipeline + write pipeline + erase pipeline + monitor log working end-to-end over TCP/cellular: +Full read pipeline + write pipeline + erase pipeline + monitor log + call home config working end-to-end over TCP/cellular: | Step | SUB | Status | |---|---|---| @@ -43,7 +45,8 @@ Full read pipeline + write pipeline + erase pipeline + monitor log working end-t | Event advance / next key | 1F | ✅ | | **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 | | **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 | -| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ **new v0.10.0** | +| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ new v0.10.0 | +| **Auto Call Home config (read + write)** | **2C → 7E → 7F** | ✅ **new v0.12.3** | `get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F` @@ -305,10 +308,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. @@ -358,15 +367,43 @@ Do NOT use fixed absolute offsets for sample_rate or record_time. | Field | How to find it | |---|---| +| **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 | -| max_range_geo | float32 BE, adjacent to alarm_level_geo — **⚠ value and units UNKNOWN** (reads 6.206053 but doesn't match either UI range option; may not be the ADC full-scale — see GitHub issue) | +| geo_hardware_constant (adc_scale_factor) | float32 BE at **channel_label+28** in both read (E5) and write (SUB 71) payloads — reads **6.206053** on BOTH tested units (BE11529 and BE18189); identical across all geo channels (Tran/Vert/Long) and all captures. **Confirmed 2026-04-17 from Interface Handbook §4.5**: this is the **ADC-to-velocity scale factor** = 1/sensitivity = (in/s per V). Firmware uses it as: `PPV (in/s) = ADC_voltage × 6.206053`. Cross-check: `1.61133 V (ADC full-scale) × 6.206053 = 10.000 in/s` (Normal range ✅). Do NOT write this field — it is a hardware/firmware constant. | +| geo_range (sensitivity selector) | **uint8 at channel_label+33** in both read (E5) and write (SUB 71) payloads — **CONFIRMED 2026-04-20** from 4-20-26 geo sensitivity captures: `0x00` = Normal 10.000 in/s (standard gain), `0x01` = Sensitive 1.250 in/s (high gain). Present in all three geo channel blocks (Tran, Vert, Long). **NOTE: `channel_label+20` reads `0x01` on ALL captures regardless of range setting — it is NOT this field.** Note: the "SUB 71 write offset = +29" that appears in earlier analysis was an artifact of incorrect BW-style destuffing applied to write frame data — write frame data is RAW, so the literal `0x10` bytes in the channel block header are preserved, and the offset is the same as in the E5 read payload. | | 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): + +| Value | Mode | +|---|---| +| `0x00` | Single Shot | +| `0x01` | Continuous | +| `0x02` | ❓ not observed | +| `0x03` | Histogram | +| `0x04` | Histogram + Continuous | + +**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]) @@ -701,16 +738,16 @@ Fields visible in the Blastware Compliance Setup dialog — most are NOT YET dec offsets in the raw 1A/E5 payload. Only fields with `✅` have confirmed offsets in the code. **Recording Setup tab:** -- Recording Mode: Continuous / Single Shot / Histogram (enum) -- Record Stop Mode: Fixed Record Time / Auto / Manual Stop (enum) +- Recording Mode: Continuous / Single Shot / Histogram / Histogram+Continuous ✅ (uint8 at anchor−3 in write, anchor−4 in read; 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous) — confirmed 2026-04-20 +- 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) - Chan 1-3 Trigger Level (float, in/s) ✅ (`trigger_level_geo`) -- Chan 1-3 Maximum Range: Normal 10.000 / 1.25 in/s (enum) ❓ (`max_range_geo` — offset found, reads 6.206053 which matches neither UI value; units and meaning unknown — do NOT use as ADC full-scale) +- Chan 1-3 Maximum Range: Normal 10.000 / 1.25 in/s (enum) ✅ (`geo_range` uint8; **CONFIRMED 2026-04-20** from 4-20-26 geo sensitivity captures: offset = `channel_label+33` in both E5 read and SUB 71 write payloads (same bytes, round-tripped verbatim); `0x00` = Normal 10.000 in/s, `0x01` = Sensitive 1.250 in/s; applied to Tran/Vert/Long channel blocks). **IMPORTANT: `channel_label+20` reads `0x01` on ALL captures and is NOT this field** — it is a constant flag. The float32 at `channel_label+28` = 6.206053 is the ADC-to-velocity scale factor (hardware constant, do NOT write). - Microphone Channels: Enable all microphones (bool), Trigger Source (bool) - Chan 4 Trigger Level (dB or psi depending on units) @@ -935,12 +972,104 @@ call-home. --- +## Auto Call Home config (SUBs 0x2C / 0x7E / 0x7F) — confirmed 2026-04-20 + +Full read/write pipeline confirmed from `bridges/captures/4-20-26/call home settings/` +(10 BW TX write frames diffed against the S3 read response). + +Accessible in Blastware: **Remote Access → Setup Unit**. + +### Protocol + +**SUB 0x2C — Call Home Config READ (response 0xD3)** + +Standard two-step read: probe offset `0x0000`, data offset `0x007C` (124). +Returns 125 raw bytes (one more than DATA_LENGTH) because the device encodes +num_retries value `3` as `\x10\x03` on the wire — S3FrameParser preserves both +bytes literally, shifting all subsequent field positions by +1. + +**SUB 0x7E — Call Home Config WRITE (response 0x81)** + +Write format (only BW_CMD `0x10` doubled on wire; DLE-aware checksum). +Payload = 125-byte read payload + `\x00\x00` = 127 bytes. +Offset = `data[1] + 2 = 0x7C + 2 = 0x7E`. + +**SUB 0x7F — Call Home WRITE CONFIRM (response 0x80)** + +Confirm frame, no data payload. Required after SUB 0x7E. + +### Field map (raw 125-byte array from `data_rsp.data[11:]`) + +| Raw Offset | Field | Notes | +|---|---|---| +| `[5]` | `auto_call_home_enabled` | `0x00`=off, `0x01`=on | +| `[6:46]` | `dial_string` | 40-byte null-padded ASCII | +| `[87]` | `after_event_recorded` | bool | +| `[91]` | `at_specified_times` | bool | +| `[93]` | `time1_enabled` | bool | +| `[101]` | `time1_hour` | 0–23 | +| `[102]` | `time1_min` | 0–59 | +| `[95]` | `time2_enabled` | bool | +| `[105]` | `time2_hour` | 0–23 | +| `[106]` | `time2_min` | 0–59 | +| `[117]` | DLE prefix `0x10` | Part of `\x10\x03` (DLE-escaped ETX encoding value 3) | +| `[118]` | `num_retries` | Value = 3; detect via `raw[117] == 0x10` | +| `[120]` | `time_between_retries_sec` | Shifted +1 from logical 119 | +| `[122]` | `wait_for_connection_sec` | Shifted +1 from logical 121 | +| `[124]` | `warm_up_time_sec` | Shifted +1 from logical 123 | + +**DLE-escaped 0x03 at raw[117:119]:** The byte value `0x03` is indistinguishable from the +frame ETX terminator, so the device encodes it as `\x10\x03` (DLE + ETX inner-terminator). +S3FrameParser in `STATE_AFTER_DLE` on ETX appends both bytes as literal payload. The write +frame sends them verbatim — device accepts `\x10\x03` and interprets it as value 3. + +**Unconfirmed fields:** time slots 3 and 4 (offsets unknown), `modem_power_relay_enabled`. + +### `CallHomeConfig` model — models.py + +```python +@dataclass +class CallHomeConfig: + raw: Optional[bytes] = None # 125-byte raw read payload + auto_call_home_enabled: Optional[bool] = None # raw[5] + dial_string: Optional[str] = None # raw[6:46] + after_event_recorded: Optional[bool] = None # raw[87] + at_specified_times: Optional[bool] = None # raw[91] + time1_enabled: Optional[bool] = None # raw[93] + time1_hour: Optional[int] = None # raw[101] + time1_min: Optional[int] = None # raw[102] + time2_enabled: Optional[bool] = None # raw[95] + time2_hour: Optional[int] = None # raw[105] + time2_min: Optional[int] = None # raw[106] + num_retries: Optional[int] = None # raw[118] (DLE-prefixed) + time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1) + wait_for_connection_sec: Optional[int] = None # raw[122] (shifted +1) + warm_up_time_sec: Optional[int] = None # raw[124] (shifted +1) +``` + +### SFM REST API — sfm/server.py + +``` +GET /device/call_home?host=1.2.3.4&tcp_port=9034 ← read call home config +POST /device/call_home?host=1.2.3.4&tcp_port=9034 ← write call home config +``` + +POST body fields (all optional): `auto_call_home_enabled`, `after_event_recorded`, +`at_specified_times`, `time1_enabled`, `time1_hour`, `time1_min`, `time2_enabled`, +`time2_hour`, `time2_min`. + +**Note:** `dial_string` is read-only in the current implementation (omitted from POST +body) because writing a dial string may require DLE escaping for embedded control characters. + +--- + ## What's next - **Database** — SQLite store for events + monitor log entries; dedup by key; queryable - **Histograms** — decode histogram-mode A5 data (noise floor tracking) - Compliance config encoder — build raw write payloads from a `ComplianceConfig` object - Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring) +- Call Home — map time slots 3/4 offsets; add dial_string write support; confirm `modem_power_relay_enabled` - Modem manager — push RV50/RV55 configs via Sierra Wireless API - RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't - resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred) \ No newline at end of file + resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred) \ No newline at end of file diff --git a/bridges/ach_server.py b/bridges/ach_server.py index afafe4c..65fb9e9 100644 --- a/bridges/ach_server.py +++ b/bridges/ach_server.py @@ -561,7 +561,8 @@ def _device_info_to_dict(d: DeviceInfo) -> dict: "record_time": cc.record_time if cc else None, "trigger_level_geo": cc.trigger_level_geo if cc else None, "alarm_level_geo": cc.alarm_level_geo if cc else None, - "max_range_geo": cc.max_range_geo if cc else None, + "geo_adc_scale": cc.geo_adc_scale if cc else None, # hw scale factor (in/s)/V + "geo_range": cc.geo_range if cc else None, # 0x01=Normal 10in/s, 0x00=Sensitive 1.25in/s (unconfirmed) "project": cc.project if cc else None, "client": cc.client if cc else None, "operator": cc.operator if cc else None, diff --git a/bridges/s3-bridge/s3_bridge.py b/bridges/s3-bridge/s3_bridge.py index 4f1d46b..f3e1770 100644 --- a/bridges/s3-bridge/s3_bridge.py +++ b/bridges/s3-bridge/s3_bridge.py @@ -93,8 +93,11 @@ class SessionLogger: self._bin_fh = open(bin_path, "ab", buffering=0) self._lock = threading.Lock() # Optional pure-byte taps (no headers). BW=Blastware tx, S3=device tx. + # These can be opened/closed on demand via start_raw_capture/stop_raw_capture. self._raw_bw = open(raw_bw_path, "ab", buffering=0) if raw_bw_path else None self._raw_s3 = open(raw_s3_path, "ab", buffering=0) if raw_s3_path else None + self._cap_bw_path: Optional[str] = raw_bw_path + self._cap_s3_path: Optional[str] = raw_s3_path def log_line(self, line: str) -> None: with self._lock: @@ -124,6 +127,43 @@ class SessionLogger: self.log_line(f"[{ts}] [INFO] {msg}") self.bin_write_record(REC_INFO, msg.encode("utf-8", errors="replace")) + def start_raw_capture(self, label: str, logdir: str) -> tuple: + """Open new raw tap files for a named capture. Returns (bw_path, s3_path).""" + ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S") + safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in label)[:40] if label else "" + suffix = f"_{safe}" if safe else "" + bw_path = os.path.join(logdir, f"raw_bw_{ts}{suffix}.bin") + s3_path = os.path.join(logdir, f"raw_s3_{ts}{suffix}.bin") + with self._lock: + # Close any previously open taps first + if self._raw_bw: + self._raw_bw.close() + if self._raw_s3: + self._raw_s3.close() + self._raw_bw = open(bw_path, "ab", buffering=0) + self._raw_s3 = open(s3_path, "ab", buffering=0) + self._cap_bw_path = bw_path + self._cap_s3_path = s3_path + self.log_info(f"raw capture started: label={label!r} bw={bw_path} s3={s3_path}") + return bw_path, s3_path + + def stop_raw_capture(self) -> tuple: + """Close raw tap files. Returns (bw_path, s3_path) for the capture just closed.""" + with self._lock: + bw = self._cap_bw_path + s3 = self._cap_s3_path + if self._raw_bw: + self._raw_bw.close() + self._raw_bw = None + if self._raw_s3: + self._raw_s3.close() + self._raw_s3 = None + self._cap_bw_path = None + self._cap_s3_path = None + if bw: + self.log_info(f"raw capture stopped: bw={bw} s3={s3}") + return bw, s3 + def close(self) -> None: with self._lock: try: @@ -291,8 +331,18 @@ def forward_loop( time.sleep(0.002) -def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None: - print("[MARK] Type 'm' + Enter to annotate the capture. Ctrl+C to stop.") +def annotation_loop(logger: SessionLogger, logdir: str, stop: threading.Event) -> None: + """ + Reads stdin commands while the bridge runs. + + Commands: + m — prompt for a mark label (interactive) + CAP_START: