merge protocol-exp 0.12.3 to main #5

Merged
serversdown merged 14 commits from protocol-exp into main 2026-04-21 00:22:25 -04:00
13 changed files with 1788 additions and 181 deletions
+79
View File
@@ -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 anchor8 (E5 read) / anchor7 (write); enum: 0x00=Single Shot,
0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
- `histogram_interval_sec`: uint16 BE seconds at anchor4; 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 ## v0.12.1 — 2026-04-16
### Added ### Added
+143 -14
View File
@@ -2,7 +2,9 @@
Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for
managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem 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 | | Step | SUB | Status |
|---|---|---| |---|---|---|
@@ -43,7 +45,8 @@ Full read pipeline + write pipeline + erase pipeline + monitor log working end-t
| Event advance / next key | 1F | ✅ | | Event advance / next key | 1F | ✅ |
| **Write commands (push config to device)** | **6883** | ✅ new v0.8.0 | | **Write commands (push config to device)** | **6883** | ✅ new v0.8.0 |
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.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` `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 ### SUB 1A — anchor search range
`_decode_compliance_config_into()` locates sample_rate and record_time via the anchor `_decode_compliance_config_into()` locates fields via the **6-byte stable anchor**
`b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`. Search range is `cfg[0:150]`. `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 the orphaned-send bug was prepending a 44-byte spurious header, pushing the anchor from
its real position (cfg[11]) into the 40100 window. its real position (cfg[11]) into the 40100 window.
@@ -358,15 +367,43 @@ Do NOT use fixed absolute offsets for sample_rate or record_time.
| Field | How to find it | | 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 | | 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 | | record_time | float32 BE at anchor + 10 |
| trigger_level_geo | float32 BE, located in channel block | | trigger_level_geo | float32 BE, located in channel block |
| alarm_level_geo | float32 BE, adjacent to trigger_level_geo | | 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 | | setup_name | ASCII, null-padded, in cfg body |
| project / client / operator / sensor_location | ASCII, label-value pairs | | 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 anchor7 |
| 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]) ### 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. offsets in the raw 1A/E5 payload. Only fields with `✅` have confirmed offsets in the code.
**Recording Setup tab:** **Recording Setup tab:**
- Recording Mode: Continuous / Single Shot / Histogram (enum) - Recording Mode: Continuous / Single Shot / Histogram / Histogram+Continuous ✅ (uint8 at anchor3 in write, anchor4 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) - 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 ✅ (anchor2) - Sample Rate: Standard 1024 / Fast 2048 / Faster 4096 sps ✅ (anchor2)
- Record Time: float, seconds ✅ (anchor+10) - 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 anchor4, same in read & write; mode-gated to Histogram/Histogram+Continuous) — confirmed 2026-04-20
- Storage Mode: Save All Data / Save Triggered (enum) - Storage Mode: Save All Data / Save Triggered (enum)
- Geophone Type: Standard Triaxial / 4.5 Hz (bool/enum) - Geophone Type: Standard Triaxial / 4.5 Hz (bool/enum)
- Geophone Channels: Enable all geophones (bool), Trigger Source (bool) - Geophone Channels: Enable all geophones (bool), Trigger Source (bool)
- Chan 1-3 Trigger Level (float, in/s) ✅ (`trigger_level_geo`) - 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) - Microphone Channels: Enable all microphones (bool), Trigger Source (bool)
- Chan 4 Trigger Level (dB or psi depending on units) - 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` | 023 |
| `[102]` | `time1_min` | 059 |
| `[95]` | `time2_enabled` | bool |
| `[105]` | `time2_hour` | 023 |
| `[106]` | `time2_min` | 059 |
| `[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 ## What's next
- **Database** — SQLite store for events + monitor log entries; dedup by key; queryable - **Database** — SQLite store for events + monitor log entries; dedup by key; queryable
- **Histograms** — decode histogram-mode A5 data (noise floor tracking) - **Histograms** — decode histogram-mode A5 data (noise floor tracking)
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object - 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) - 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 - 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 - 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) resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred)
+2 -1
View File
@@ -561,7 +561,8 @@ def _device_info_to_dict(d: DeviceInfo) -> dict:
"record_time": cc.record_time if cc else None, "record_time": cc.record_time if cc else None,
"trigger_level_geo": cc.trigger_level_geo 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, "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, "project": cc.project if cc else None,
"client": cc.client if cc else None, "client": cc.client if cc else None,
"operator": cc.operator if cc else None, "operator": cc.operator if cc else None,
+70 -5
View File
@@ -93,8 +93,11 @@ class SessionLogger:
self._bin_fh = open(bin_path, "ab", buffering=0) self._bin_fh = open(bin_path, "ab", buffering=0)
self._lock = threading.Lock() self._lock = threading.Lock()
# Optional pure-byte taps (no headers). BW=Blastware tx, S3=device tx. # 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_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._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: def log_line(self, line: str) -> None:
with self._lock: with self._lock:
@@ -124,6 +127,43 @@ class SessionLogger:
self.log_line(f"[{ts}] [INFO] {msg}") self.log_line(f"[{ts}] [INFO] {msg}")
self.bin_write_record(REC_INFO, msg.encode("utf-8", errors="replace")) 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: def close(self) -> None:
with self._lock: with self._lock:
try: try:
@@ -291,8 +331,18 @@ def forward_loop(
time.sleep(0.002) time.sleep(0.002)
def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None: def annotation_loop(logger: SessionLogger, logdir: str, stop: threading.Event) -> None:
print("[MARK] Type 'm' + Enter to annotate the capture. Ctrl+C to stop.") """
Reads stdin commands while the bridge runs.
Commands:
m prompt for a mark label (interactive)
CAP_START:<label> begin a raw tap capture with the given label
CAP_STOP stop the current raw tap capture
Responses (printed to stdout, parsed by the GUI):
[CAP_START] <bw_path>\\t<s3_path>
[CAP_STOP] <bw_path>\\t<s3_path>
"""
while not stop.is_set(): while not stop.is_set():
try: try:
line = input() line = input()
@@ -303,7 +353,21 @@ def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
if not line: if not line:
continue continue
if line.lower() == "m": if line.startswith("CAP_START:"):
label = line[10:].strip()
bw_path, s3_path = logger.start_raw_capture(label, logdir)
print(f"[CAP_START] {bw_path}\t{s3_path}")
sys.stdout.flush()
elif line == "CAP_STOP":
bw_path, s3_path = logger.stop_raw_capture()
if bw_path:
print(f"[CAP_STOP] {bw_path}\t{s3_path}")
else:
print("[CAP_STOP] no active capture")
sys.stdout.flush()
elif line.lower() == "m":
try: try:
sys.stdout.write(" Label: ") sys.stdout.write(" Label: ")
sys.stdout.flush() sys.stdout.flush()
@@ -315,8 +379,9 @@ def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
print(f" [MARK written] {label}") print(f" [MARK written] {label}")
else: else:
print(" (empty label — mark cancelled)") print(" (empty label — mark cancelled)")
else: else:
print(" (type 'm' + Enter to annotate)") print(f" (unknown command: {line!r})")
def main() -> int: def main() -> int:
@@ -391,7 +456,7 @@ def main() -> int:
t_ann = threading.Thread( t_ann = threading.Thread(
target=annotation_loop, target=annotation_loop,
name="Annotator", name="Annotator",
args=(logger, stop), args=(logger, args.logdir, stop),
daemon=True, daemon=True,
) )
+191 -15
View File
@@ -36,7 +36,7 @@
| 2026-03-02 | §7.4 Event Index Block | **NEW:** `Monitoring LCD Cycle` identified at offsets +84/+85 as uint16 BE. Default value = 65500 (0xFFDC) = effectively disabled / maximum. Confirmed from operator manual §3.13.1g. | | 2026-03-02 | §7.4 Event Index Block | **NEW:** `Monitoring LCD Cycle` identified at offsets +84/+85 as uint16 BE. Default value = 65500 (0xFFDC) = effectively disabled / maximum. Confirmed from operator manual §3.13.1g. |
| 2026-03-02 | §7.4 Event Index Block | **UPDATED:** Backlight confirmed as uint8 range 0255 seconds per operator manual §3.13.1e ("adjustable timer, 0 to 255 seconds"). Power save unit confirmed as minutes per operator manual §3.13.1f. | | 2026-03-02 | §7.4 Event Index Block | **UPDATED:** Backlight confirmed as uint8 range 0255 seconds per operator manual §3.13.1e ("adjustable timer, 0 to 255 seconds"). Power save unit confirmed as minutes per operator manual §3.13.1f. |
| 2026-03-02 | Global | **NEW SOURCE:** Operator manual (716U0101 Rev 15) added as reference. Cross-referencing settings definitions, ranges, and units. Header updated. | | 2026-03-02 | Global | **NEW SOURCE:** Operator manual (716U0101 Rev 15) added as reference. Cross-referencing settings definitions, ranges, and units. Header updated. |
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but this is NOT confirmed. Using it as ADC full-scale produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. | | 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but was NOT confirmed at this time. Using it directly as the range produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. **RESOLVED 2026-04-17 — see §7.6.2 and changelog entry.** |
| 2026-03-02 | §14 Open Questions | `0x082A` hypothesis refined: 2090 decimal. At 1024 sps, 2 sec record = 2048 samples. Possible that 0x082A = total samples including 0.25s pre-trigger (256 samples) at some adjusted rate. Needs capture with different record time. | | 2026-03-02 | §14 Open Questions | `0x082A` hypothesis refined: 2090 decimal. At 1024 sps, 2 sec record = 2048 samples. Possible that 0x082A = total samples including 0.25s pre-trigger (256 samples) at some adjusted rate. Needs capture with different record time. |
| 2026-03-02 | §14 Open Questions | **NEW items added:** Trigger sample width (default=2), Auto Window (1-9 sec), Aux Trigger (enabled/disabled) — all confirmed settings from operator manual not yet mapped in protocol. | | 2026-03-02 | §14 Open Questions | **NEW items added:** Trigger sample width (default=2), Auto Window (1-9 sec), Aux Trigger (enabled/disabled) — all confirmed settings from operator manual not yet mapped in protocol. |
| 2026-03-02 | §14 Open Questions | Monitoring LCD Cycle resolved — removed from open questions. | | 2026-03-02 | §14 Open Questions | Monitoring LCD Cycle resolved — removed from open questions. |
@@ -92,7 +92,7 @@
| 2026-04-06 | §7.8.4 | **NEW — 5A end-of-stream signalling confirmed.** After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent for the full recv timeout. This byte is NOT a complete DLE-framed A5 response — the frame parser accumulates it as `bytes_fed=1` and never assembles a frame. This is the device's natural end-of-stream signal. Handling: on TimeoutError, if `bytes_fed > 0` AND prior chunks were received, treat as graceful end and proceed to the termination frame. A `bytes_fed=0` timeout with no prior chunks is a genuine transport failure and must still raise. | | 2026-04-06 | §7.8.4 | **NEW — 5A end-of-stream signalling confirmed.** After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent for the full recv timeout. This byte is NOT a complete DLE-framed A5 response — the frame parser accumulates it as `bytes_fed=1` and never assembles a frame. This is the device's natural end-of-stream signal. Handling: on TimeoutError, if `bytes_fed > 0` AND prior chunks were received, treat as graceful end and proceed to the termination frame. A `bytes_fed=0` timeout with no prior chunks is a genuine transport failure and must still raise. |
| 2026-04-06 | §7.8.4 | **NEW — 5A chunk timing and count (empirical, BE11529 at 1024 sps).** Each chunk response arrives within ~1 second over TCP/cellular. A 9,306-sample event (≈9.1 s at 1024 sps) produces **35 chunks** before end-of-stream. Chunks 116 have varying data lengths (10361123 bytes); chunks 1735 are uniformly 1036 bytes each (post-event silence, all-zero ADC samples). Safe recv timeout for chunk loop: **10 s** (10× typical response time). Default transport timeout (120 s) results in a ~2-minute stall per event at end-of-stream. | | 2026-04-06 | §7.8.4 | **NEW — 5A chunk timing and count (empirical, BE11529 at 1024 sps).** Each chunk response arrives within ~1 second over TCP/cellular. A 9,306-sample event (≈9.1 s at 1024 sps) produces **35 chunks** before end-of-stream. Chunks 116 have varying data lengths (10361123 bytes); chunks 1735 are uniformly 1036 bytes each (post-event silence, all-zero ADC samples). Safe recv timeout for chunk loop: **10 s** (10× typical response time). Default transport timeout (120 s) results in a ~2-minute stall per event at end-of-stream. |
| 2026-04-06 | §7.8.3 | **KNOWN ISSUE — `_decode_a5_waveform` hardcoded fi==9 skip.** The decoder contains `elif fi == 9: continue` which was written for the 9-frame original blast capture where frame 9 was a device terminator. For streams with >9 frames (current device produces 35+), frame index 9 is live waveform data — this skip discards ~1,070 bytes (~133 sample-sets) per event. The terminator is now detected via `page_key == 0x0000`, not by frame index. The fi==9 skip should be removed. | | 2026-04-06 | §7.8.3 | **KNOWN ISSUE — `_decode_a5_waveform` hardcoded fi==9 skip.** The decoder contains `elif fi == 9: continue` which was written for the 9-frame original blast capture where frame 9 was a device terminator. For streams with >9 frames (current device produces 35+), frame index 9 is live waveform data — this skip discards ~1,070 bytes (~133 sample-sets) per event. The terminator is now detected via `page_key == 0x0000`, not by frame index. The fi==9 skip should be removed. |
| 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value is UNKNOWN. The compliance config field labeled `max_range_geo` reads 6.206053 (bytes `40 C6 97 FD`), which does NOT match either user-selectable range shown in Blastware UI (1.25 or 10.000 in/s). The meaning and units of the 6.206053 value are unresolved — it may not be the ADC full-scale at all. See open question in §14. | | 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value was UNKNOWN at time of writing. **UPDATED 2026-04-17:** `max_range_geo` = 6.206053 is confirmed as the ADC-to-velocity scale factor (inverse sensitivity, (in/s)/V). The correct conversion is therefore: `PPV (in/s) = counts × (1.61133 / 32767) × 6.206053` = `counts × 4.982e-5` in/s per count. The earlier ~9× overread from using 6.206053 directly as the range was because the range IS 1.61133 × 6.206053 = 10.000 in/s, not 6.206053. See §7.6.2 for the confirmed field layout. |
| 2026-04-08 | §5.1, §7.10, §12 | **NEW — Monitoring commands confirmed.** SUB 0x1C (monitor status), 0x96 (start monitoring), 0x97 (stop monitoring) all confirmed from 4-8-26/2ndtry capture. SESSION_RESET (`41 03`) required before POLL to wake a monitoring unit. | | 2026-04-08 | §5.1, §7.10, §12 | **NEW — Monitoring commands confirmed.** SUB 0x1C (monitor status), 0x96 (start monitoring), 0x97 (stop monitoring) all confirmed from 4-8-26/2ndtry capture. SESSION_RESET (`41 03`) required before POLL to wake a monitoring unit. |
| 2026-04-09 | §7.10 | **CORRECTED — monitoring flag and battery/memory offsets.** `section[1] == 0x10` is the monitoring flag (100% accurate across 144 data frames in 2ndtry capture). Previous note claiming `section[6]` was wrong — section[6] has device-specific non-binary values (0xea/0x07). Battery/memory offsets corrected: `section[-10:-8]` (battery×100), `section[-8:-4]` (memory_total), `section[-4:]` (memory_free). NOTE: `frame.data` has checksum stripped by parser — earlier offsets of `[-11:-9]`/`[-9:-5]`/`[-5:-1]` were wrong because they assumed a trailing checksum byte that isn't there. | | 2026-04-09 | §7.10 | **CORRECTED — monitoring flag and battery/memory offsets.** `section[1] == 0x10` is the monitoring flag (100% accurate across 144 data frames in 2ndtry capture). Previous note claiming `section[6]` was wrong — section[6] has device-specific non-binary values (0xea/0x07). Battery/memory offsets corrected: `section[-10:-8]` (battery×100), `section[-8:-4]` (memory_total), `section[-4:]` (memory_free). NOTE: `frame.data` has checksum stripped by parser — earlier offsets of `[-11:-9]`/`[-9:-5]`/`[-5:-1]` were wrong because they assumed a trailing checksum byte that isn't there. |
| 2026-04-08 | §7.10 | **NEW — SUBs 0x0E (channel sensor data) and 0x98 (trigger test) observed** in 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check). SUB 0x0E: 2-step read with channel selector in `params[6:8]`, data length 0x0A per channel, RSP SUB = 0xF1. SUB 0x98: single probe frame with `params[0] = 0xFF`, RSP SUB = 0x67; sent twice per test cycle. Not yet implemented in SFM. | | 2026-04-08 | §7.10 | **NEW — SUBs 0x0E (channel sensor data) and 0x98 (trigger test) observed** in 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check). SUB 0x0E: 2-step read with channel selector in `params[6:8]`, data length 0x0A per channel, RSP SUB = 0xF1. SUB 0x98: single probe frame with `params[0] = 0xFF`, RSP SUB = 0x67; sent twice per test cycle. Not yet implemented in SFM. |
@@ -103,6 +103,10 @@
| 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes 8:4) and last stored event key (bytes 4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. | | 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes 8:4) and last stored event key (bytes 4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. |
| 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. | | 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. |
| 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. | | 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. |
| 2026-04-17 | §7.6.2, §14 | **RESOLVED — Float 6.206053 at channel_label+28 is the ADC-to-velocity scale factor.** Confirmed from Series III Interface Handbook §4.5 formula: `Range (×1) = 1.61133 V / Sensitivity (V/unit)`. For the standard Instantel geophone at Normal range (10.000 in/s): Sensitivity = 1.61133 / 10 = 0.161133 V/(in/s). The stored value is the **inverse sensitivity** = 1/0.161133 = **6.206053 (in/s)/V**. Cross-check: 1.61133 V × 6.206053 = 10.000 in/s ✅. The firmware uses it as: `PPV (in/s) = ADC_voltage (V) × 6.206053`. Value is identical on all Instantel standard geophones — it is a hardware/firmware constant, NOT a user-configurable setting. Do NOT write this field. Open question §14 item "Max Geo Range float 6.2061" is now **RESOLVED**. |
| 2026-04-20 | §7.6.4 (NEW), §7.9, Appendix B | **CONFIRMED — Recording Mode byte location.** Three targeted captures (4-20-26) confirmed `recording_mode` at **`cfg[5]`** in the SUB 71 write payload (3-chunk compliance write). Method: single Blastware session, one initial E5 config pull, then three sequential "Send to unit" writes changing Recording Mode only. Diff of SUB 71 chunk-1 payloads: only `cfg[5]` and `cfg[1024]` changed; `cfg[1024]` delta exactly equals `cfg[5]` delta (chunk running checksum). In the E5 read response (sub-frame 1, page=0x0010), the field is at **`data[17]`** (= **anchor 4** from the 10-byte anchor), one position earlier than in the write payload due to an extra `0x10` byte at `data[18]` present only in the read format. Enum: `0x00`=Single Shot, `0x01`=Continuous, `0x03`=Histogram, `0x04`=Histogram+Continuous. `0x02` value not yet observed. See §7.6.4 for full details. |
| 2026-04-20 | §7.6.2, §7.9, Appendix B | **CONFIRMED — Geophone maximum range / sensitivity selector byte location.** Two targeted captures (4-20-26, geo sensitivity folder): one at Normal 10.000 in/s, one at Sensitive 1.250 in/s. E5 read payload diff: exactly 3 bytes differ at channel_label+33 for Tran/Vert/Long. Values: `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. Same offset applies to the SUB 71 write payload (which is the same 2126-byte E5-format buffer round-tripped verbatim). **`channel_label+20` reads `0x01` in ALL captures regardless of range setting — it is NOT this field.** Previous hypothesis (uint8 at Tran+20, 0x01=Normal) was WRONG. Stored as `geo_range` in `ComplianceConfig`. Encoded to all three geo channel blocks (Tran/Vert/Long) at label+33. |
| 2026-04-20 | §5.1, §5.3, §7.12 (NEW) | **NEW — Auto Call Home config protocol confirmed from 4-20-26 call home settings captures.** SUB 0x2C (Call Home Config READ, response 0xD3, data offset 0x7C=124) and SUB 0x7E/0x7F (WRITE + CONFIRM, response 0x81/0x80) confirmed. Write payload = read payload (125 bytes) + `\x00\x00` (127 bytes total). **DLE-escaped ETX at raw[117:119]:** the device returns logical value 0x03 (num_retries=3) as `\x10\x03` on the wire — S3FrameParser preserves both bytes as two literals, causing a +1 byte shift for all subsequent fields. Write frame sends these bytes verbatim (device interprets `\x10\x03` as literal value 3). Field map confirmed from 10-frame BW TX diff. See §7.12 for full layout. |
--- ---
@@ -257,13 +261,14 @@ Step 4 — Device sends actual data payload:
| `24` | **WAVEFORM PAGE A?** | Paged waveform read, possibly channel group A. | 🔶 INFERRED | | `24` | **WAVEFORM PAGE A?** | Paged waveform read, possibly channel group A. | 🔶 INFERRED |
| `25` | **WAVEFORM PAGE B?** | Paged waveform read, possibly channel group B. | 🔶 INFERRED | | `25` | **WAVEFORM PAGE B?** | Paged waveform read, possibly channel group B. | 🔶 INFERRED |
| `09` | **UNKNOWN READ A** | Read command, response (`F6`) returns 0xCA (202) bytes. Purpose unknown. | 🔶 INFERRED | | `09` | **UNKNOWN READ A** | Read command, response (`F6`) returns 0xCA (202) bytes. Purpose unknown. | 🔶 INFERRED |
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries sample_rate (uint16 BE at anchor2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. | ✅ CONFIRMED 2026-04-02 | | `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries recording_mode (uint8 at anchor4 in E5 sf1), sample_rate (uint16 BE at anchor2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. See §7.6.4 for recording_mode enum. | ✅ CONFIRMED 2026-04-02; recording_mode added 2026-04-20 |
| `2E` | **UNKNOWN READ B** | Read command, response (`D1`) returns 0x1A (26) bytes. Purpose unknown. | 🔶 INFERRED | | `2E` | **UNKNOWN READ B** | Read command, response (`D1`) returns 0x1A (26) bytes. Purpose unknown. | 🔶 INFERRED |
| `0E` | **CHANNEL SENSOR DATA** | Real-time sensor reading for one channel. Two-step read, data length 0x0A (10 bytes). Channel selector in params[6:8] (0x00000x0007 for 8 channels). Response (F1) carries amplitude, frequency, overswing data for that channel. Used by Blastware "Unit Channel Test" comms check. | ✅ CONFIRMED 2026-04-08 | | `0E` | **CHANNEL SENSOR DATA** | Real-time sensor reading for one channel. Two-step read, data length 0x0A (10 bytes). Channel selector in params[6:8] (0x00000x0007 for 8 channels). Response (F1) carries amplitude, frequency, overswing data for that channel. Used by Blastware "Unit Channel Test" comms check. | ✅ CONFIRMED 2026-04-08 |
| `98` | **TRIGGER TEST** | Trigger-test command. Single probe frame; `params[0] = 0xFF`. Response (0x67) is all-zero data. Sent twice per Blastware comms-check cycle. Not a full POLL, no monitor state change. | ✅ CONFIRMED 2026-04-08 | | `98` | **TRIGGER TEST** | Trigger-test command. Single probe frame; `params[0] = 0xFF`. Response (0x67) is all-zero data. Sent twice per Blastware comms-check cycle. Not a full POLL, no monitor state change. | ✅ CONFIRMED 2026-04-08 |
| `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 4647 bytes IDLE, 4849 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 | | `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 4647 bytes IDLE, 4849 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 |
| `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 | | `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 |
| `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 | | `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 |
| `2C` | **CALL HOME CONFIG READ** | Two-step read, data offset 0x7C (124 bytes + 1-byte DLE artefact = 125 raw bytes). Returns Auto Call Home configuration: enable flag, dial string, scheduled call times, retry settings, modem timing. Response SUB = 0xD3. **DLE note:** logical value 0x03 (num_retries) is returned as `\x10\x03` on the wire, which S3FrameParser preserves as two literal bytes — this shifts all subsequent field positions by +1. See §7.12 for full field map. | ✅ CONFIRMED 2026-04-20 |
| `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 | | `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 |
| `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 | | `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 |
@@ -293,6 +298,7 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which,
| `98` | `67` | ✅ CONFIRMED 2026-04-08 | | `98` | `67` | ✅ CONFIRMED 2026-04-08 |
| `96` | `69` | ✅ CONFIRMED 2026-04-08 | | `96` | `69` | ✅ CONFIRMED 2026-04-08 |
| `97` | `68` | ✅ CONFIRMED 2026-04-08 | | `97` | `68` | ✅ CONFIRMED 2026-04-08 |
| `2C` | `D3` | ✅ CONFIRMED 2026-04-20 |
| `A3` | `5C` | ✅ CONFIRMED 2026-04-11 | | `A3` | `5C` | ✅ CONFIRMED 2026-04-11 |
| `A2` | `5D` | ✅ CONFIRMED 2026-04-11 | | `A2` | `5D` | ✅ CONFIRMED 2026-04-11 |
@@ -314,6 +320,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
| `72` | **WRITE CONFIRM A** | Short frame, no data. Likely commit/confirm step after `71`. | `8D` | ✅ CONFIRMED | | `72` | **WRITE CONFIRM A** | Short frame, no data. Likely commit/confirm step after `71`. | `8D` | ✅ CONFIRMED |
| `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ CONFIRMED | | `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ CONFIRMED |
| `74` | **WRITE CONFIRM C** | Short frame, no data. | `8B` | ✅ CONFIRMED | | `74` | **WRITE CONFIRM C** | Short frame, no data. | `8B` | ✅ CONFIRMED |
| `7E` | **CALL HOME CONFIG WRITE** | Writes Auto Call Home configuration (127 bytes: 125-byte read payload + `\x00\x00`). Offset = data[1]+2 = 0x7E. Write format (DLE-aware checksum, only BW_CMD `0x10` doubled on wire). Response SUB = 0x81. Must be followed by SUB 0x7F confirm. | `81` | ✅ CONFIRMED 2026-04-20 |
| `7F` | **CALL HOME WRITE CONFIRM** | Short frame, no data. Commits call home config write from SUB 0x7E. Response SUB = 0x80. | `80` | ✅ CONFIRMED 2026-04-20 |
| `82` | **TRIGGER CONFIG WRITE** | Writes trigger config block (0x1C bytes, mirrors SUB `1C` read). | `7D` | ✅ CONFIRMED | | `82` | **TRIGGER CONFIG WRITE** | Writes trigger config block (0x1C bytes, mirrors SUB `1C` read). | `7D` | ✅ CONFIRMED |
| `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED | | `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED |
@@ -327,6 +335,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
| `72` | `8D` | | `72` | `8D` |
| `73` | `8C` | | `73` | `8C` |
| `74` | `8B` | | `74` | `8B` |
| `7E` | `81` |
| `7F` | `80` |
| `82` | `7D` | | `82` | `7D` |
| `83` | `7C` | | `83` | `7C` |
@@ -528,7 +538,7 @@ The SUB `1A` read response (`E5`) and SUB `71` write block contain per-channel t
| Field | Example bytes | Decoded | Certainty | | Field | Example bytes | Decoded | Certainty |
|---|---|---|---| |---|---|---|---|
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED | | `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| Max range float | `40 C6 97 FD` | 6.206**value confirmed, meaning and units UNKNOWN** (does NOT match UI range options 1.25/10.000 in/s; not confirmed as ADC full-scale) | ❓ UNKNOWN | | ADC scale factor | `40 C6 97 FD` | **6.206053 (in/s)/V — CONFIRMED 2026-04-17.** This is the inverse sensitivity of the standard Instantel geophone = 1/0.161133. Interface Handbook §4.5: `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Used by firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant — do NOT write. | ✅ CONFIRMED |
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED | | `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| **Trigger level** | `3F 19 99 9A` | **0.600 in/s** — IEEE 754 BE float | ✅ CONFIRMED | | **Trigger level** | `3F 19 99 9A` | **0.600 in/s** — IEEE 754 BE float | ✅ CONFIRMED |
| Unit string | `69 6E 2E 00` | `"in.\0"` | ✅ CONFIRMED | | Unit string | `69 6E 2E 00` | `"in.\0"` | ✅ CONFIRMED |
@@ -620,6 +630,53 @@ The sample rate bytes sit immediately before a `0x10` (DLE) prefix byte in the r
--- ---
### 7.6.4 Recording Mode
> ✅ **CONFIRMED — 2026-04-20** (BE11529 / firmware S338.17). Three targeted captures in a single Blastware session (4-20-26 directory), changing Recording Mode only between each write.
Recording mode is stored as a **uint8** with different anchor-relative positions depending on whether you are reading from a device response or constructing a write payload.
**In the SUB 71 write payload (3-chunk compliance write, `cfg[5]`):**
| Enum | Mode |
|---|---|
| `0x00` | Single Shot |
| `0x01` | Continuous |
| `0x02` | Unknown (not yet observed) |
| `0x03` | Histogram |
| `0x04` | Histogram + Continuous (combined mode) |
Anchor-relative position: **anchor 3** (3 bytes before the 10-byte anchor in the write payload). The write payload layout in the region around the anchor:
```
cfg[anchor - 3] = recording_mode (uint8)
cfg[anchor - 2] = sample_rate_hi (uint8, MSB of uint16 BE)
cfg[anchor - 1] = sample_rate_lo (uint8, LSB of uint16 BE)
cfg[anchor:anchor+10] = \x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00 ← anchor
cfg[anchor + 10:anchor + 14] = record_time (float32 BE)
```
**In the E5 read response (sub-frame 1, page=`0x0010`, `data[17]`):**
The anchor appears at `data[21]` in this sub-frame. Recording mode is at `data[17]` = **anchor 4** (one position earlier than in the write payload). This is because an extra `0x10` byte is present at `data[18]` in the read format (between recording_mode and sample_rate), which is NOT present in the write payload. The read-format layout:
```
data[17] = recording_mode (uint8)
data[18] = 0x10 ← extra byte present in E5 read only; absent in SUB 71 write
data[19] = sample_rate_hi (uint8, MSB of uint16 BE)
data[20] = sample_rate_lo (uint8, LSB of uint16 BE)
data[21:31] = anchor (\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00)
data[31:35] = record_time (float32 BE)
```
**Chunk checksum at `cfg[1024]`:** The first of the three SUB 71 write chunks (1027 bytes) contains a running checksum byte at `cfg[1024]` whose delta exactly equals the delta of `cfg[5]` (recording_mode). This byte reflects the cumulative change from `recording_mode` through to its position and should not be mistaken for a second copy of the recording_mode field.
**Decode path (`_decode_compliance_config_into`):** use `data[anchor_pos - 4]` where `anchor_pos` is the index of the first byte of the anchor in the assembled E5 cfg bytes.
**Encode path (`_encode_compliance_config`):** use `cfg[anchor_pos - 3]` = recording_mode value (write-payload offset; no extra `0x10` byte).
---
### 7.7 Blastware `.set` File Format ### 7.7 Blastware `.set` File Format
> 🔶 **INFERRED — 2026-03-01** from `Standard_Recording_Setup.set` cross-referenced against known wire payloads. > 🔶 **INFERRED — 2026-03-01** from `Standard_Recording_Setup.set` cross-referenced against known wire payloads.
@@ -655,7 +712,7 @@ offset size type value (Tran example) meaning
+10 2 uint16 0x0015 = 21 unknown +10 2 uint16 0x0015 = 21 unknown
+12 4 bytes 03 02 04 01 flags (recording mode etc.) +12 4 bytes 03 02 04 01 flags (recording mode etc.)
+16 4 uint32 0x00000003 record time in seconds ✅ CONFIRMED +16 4 uint32 0x00000003 record time in seconds ✅ CONFIRMED
+1A 4 float32 6.2061 ❓ UNKNOWN field — value 6.2061 confirmed; meaning/units unresolved (NOT confirmed as max range or ADC full-scale) +1A 4 float32 6.206053 ✅ CONFIRMED 2026-04-17 — ADC-to-velocity scale factor (= 1/sensitivity = (in/s)/V). Interface Handbook §4.5: Range = 1.61133 V × 6.206053 = 10.000 in/s (Normal range). Firmware uses: PPV (in/s) = ADC_voltage × 6.206053. Hardware constant — identical on all tested units. Do NOT write.
+1E 2 00 00 padding +1E 2 00 00 padding
+20 4 float32 0.6000 trigger level ✅ CONFIRMED +20 4 float32 0.6000 trigger level ✅ CONFIRMED
+24 4 char[4] "in.\0" / "psi\0" unit string (geo vs mic) +24 4 char[4] "in.\0" / "psi\0" unit string (geo vs mic)
@@ -1235,13 +1292,19 @@ TimeoutError caught:
Chunks with uniform 1,036-byte payload (chunks 1735 in the observed event) contain all-zero ADC samples — the device continues recording silence until the configured record time expires before terminating the stream. Chunks with uniform 1,036-byte payload (chunks 1735 in the observed event) contain all-zero ADC samples — the device continues recording silence until the configured record time expires before terminating the stream.
**ADC count-to-physical conversion — ⚠ SCALING UNKNOWN:** **ADC count-to-physical conversion — ✅ CONFIRMED 2026-04-17:**
Raw samples are signed 16-bit integers (32,768 to +32,767). Source: Interface Handbook §4.5.
**CONFIRMED 2026-04-17** — The `max_range_geo` field (float32 = 6.206053, bytes `40 C6 97 FD`) is the **ADC-to-velocity scale factor** (inverse sensitivity, (in/s)/V) for the standard Instantel geophone, confirmed from Interface Handbook §4.5. The correct conversion formula is:
Raw samples are signed 16-bit integers (32,768 to +32,767). The conversion formula is believed to be:
``` ```
value_in_s (in/s) = counts × (geo_range / 32767) PPV (in/s) = ADC_voltage (V) × 6.206053
= counts × (1.61133 / 32767) × 6.206053
= counts × 4.982e-5 (in/s per count at full scale)
``` ```
However, the correct value of `geo_range` is **unknown**. The compliance config field `max_range_geo` reads 6.206053 (`40 C6 97 FD`) which does NOT match either user-selectable range (1.25 or 10.000 in/s) and produces ~9× too large PPV values compared to the on-device 0C record. Do not use 6.206053 or 10.000 as the scale factor until this is resolved. See §14 open question. Mic channel uses psi units with its own range (also unresolved).
where `geo_range = 1.61133 V × 6.206053 = 10.000 in/s` is the Normal (Gain=1) full-scale range. The earlier ~9× overread was caused by mistakenly using 6.206053 as the range directly — it is actually the scale factor, and the range itself is `ADC_fullscale × scale_factor = 1.61133 × 6.206053 = 10.000 in/s`. Mic channel uses psi units with its own range (still unresolved).
**Known decoder issue — fi==9 hardcoded skip:** **Known decoder issue — fi==9 hardcoded skip:**
@@ -1257,8 +1320,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
| Field | Values / Type | Status | | Field | Values / Type | Status |
|---|---|---| |---|---|---|
| Recording Mode | Continuous / Single Shot / Histogram | ❓ | | Recording Mode | Single Shot (`0x00`) / Continuous (`0x01`) / Histogram (`0x03`) / Histogram+Continuous (`0x04`) | ✅ `recording_mode` — write: `cfg[anchor3]`; read E5 sf1: `data[anchor4]` — confirmed 2026-04-20 |
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ | | Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ Hint: `data[40]` in E5 sf1 changed `01 7F``00 00` alongside Continuous → Single Shot; may be related but unconfirmed independently |
| Sample Rate | Standard 1024 / Fast 2048 / Faster 4096 sps | ✅ `sample_rate` (anchor2) | | Sample Rate | Standard 1024 / Fast 2048 / Faster 4096 sps | ✅ `sample_rate` (anchor2) |
| Record Time | float, seconds (3, 5, 8, 10, 13…) | ✅ `record_time` (anchor+10) | | Record Time | float, seconds (3, 5, 8, 10, 13…) | ✅ `record_time` (anchor+10) |
| Histogram Interval | 5 / 15 / 30 / 60 min (mode-gated behind Histogram mode) | ❓ | | Histogram Interval | 5 / 15 / 30 / 60 min (mode-gated behind Histogram mode) | ❓ |
@@ -1267,7 +1330,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
| Geophone — Enable all | bool | ❓ | | Geophone — Enable all | bool | ❓ |
| Geophone — Trigger Source | bool | ❓ | | Geophone — Trigger Source | bool | ❓ |
| Chan 1-3 Trigger Level | float, in/s | ✅ `trigger_level_geo` | | Chan 1-3 Trigger Level | float, in/s | ✅ `trigger_level_geo` |
| Chan 1-3 Maximum Range | Normal 10.000 / 1.25 in/s | `max_range_geo` offset found, value=6.206053 — does NOT match UI values; meaning unknown | | Chan 1-3 Maximum Range (range selector) | Normal 10.000 / 1.25 in/s | `geo_range` uint8 — **CONFIRMED 2026-04-20.** Offset = Tran+33 (same in E5 read and SUB 71 write — 2126-byte buffer is round-tripped verbatim). `0x00`=Normal 10 in/s, `0x01`=Sensitive 1.25 in/s. Applied to Tran/Vert/Long. **`Tran+20` is NOT this field** (constant 0x01 on all captures). |
| Chan 1-3 ADC Scale Factor | 6.206053 (in/s)/V | ✅ `geo_adc_scale` float32 — **CONFIRMED 2026-04-17.** Offset = Tran+28 (same in E5 read and SUB 71 write). Inverse sensitivity = 1/0.161133. Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s. Hardware constant — do NOT write. |
| Microphone — Enable all | bool | ❓ | | Microphone — Enable all | bool | ❓ |
| Microphone — Trigger Source | bool | ❓ | | Microphone — Trigger Source | bool | ❓ |
| Chan 4 Trigger Level | float, dB or psi | ❓ | | Chan 4 Trigger Level | float, dB or psi | ❓ |
@@ -1465,6 +1529,117 @@ and applies this heuristic on every call-home.
--- ---
### 7.12 Auto Call Home Config (SUB 0x2C / 0x7E / 0x7F) — ✅ CONFIRMED 2026-04-20
> Confirmed from `bridges/captures/4-20-26/call home settings/` — 10 BW TX write frames
> diffed against the S3 read payload. Accessible in Blastware via Remote Access → Setup Unit.
#### 7.12.1 Read Protocol — SUB 0x2C → Response 0xD3
Standard two-step read:
| Step | Offset | Purpose |
|---|---|---|
| Probe | `0x0000` | Get ack (no data returned) |
| Data | `0x007C` (124) | Receive 125-byte raw payload |
`DATA_LENGTHS[SUB_CALL_HOME] = 0x7C`
The raw payload is accessed as `data_rsp.data[11:]` — this is 125 bytes (not 124) because
the device returns logical value 0x03 (num_retries=3) as the two-byte wire sequence
`\x10\x03`. S3FrameParser is in `STATE_IN_FRAME` when it sees `0x10`, transitions to
`STATE_AFTER_DLE`, and then on `0x03` (ETX qualifier) it would normally end the frame —
but in the `_IN_FRAME_DLE` state it instead appends **both** the `0x10` and the `0x03`
literally to the payload. The result: `raw[117] = 0x10`, `raw[118] = 0x03`, and all
subsequent fields are shifted +1 from their logical positions.
#### 7.12.2 Raw Payload Field Map (125 bytes, from `data_rsp.data[11:]`)
> All offsets are into the 125-byte raw array. Offsets ≥ 119 are shifted +1 from logical
> due to the DLE-escaped 0x03 at raw[117:119].
| Raw Offset | Field | Type | Notes |
|---|---|---|---|
| `[5]` | `auto_call_home_enabled` | uint8 | `0x00` = disabled, `0x01` = enabled |
| `[6:46]` | `dial_string` | ASCII | 40-byte null-padded, e.g. `"12345"` or phone number |
| `[87]` | `after_event_recorded` | uint8 | `0x00` = off, `0x01` = on |
| `[91]` | `at_specified_times` | uint8 | `0x00` = off, `0x01` = on |
| `[93]` | `time1_enabled` | uint8 | `0x00` = off, `0x01` = on |
| `[101]` | `time1_hour` | uint8 | 023 |
| `[102]` | `time1_min` | uint8 | 059 |
| `[95]` | `time2_enabled` | uint8 | `0x00` = off, `0x01` = on |
| `[105]` | `time2_hour` | uint8 | 023 |
| `[106]` | `time2_min` | uint8 | 059 |
| `[117]` | DLE prefix `0x10` | — | Part of `\x10\x03` wire encoding for num_retries value 3 |
| `[118]` | `num_retries` (value = 3) | uint8 | Logical value 0x03; check `raw[117] == 0x10` to detect DLE prefix |
| `[120]` | `time_between_retries_sec` | uint8 | Shift +1 from logical 119 |
| `[122]` | `wait_for_connection_sec` | uint8 | Shift +1 from logical 121 |
| `[124]` | `warm_up_time_sec` | uint8 | Shift +1 from logical 123 |
**Unconfirmed fields** (offsets not yet mapped from captures):
- Time slots 3 and 4 (if they exist — Blastware UI only shows 2 time slots in observed sessions)
- `modem_power_relay_enabled` (bool)
- `storage_mode` (call home trigger on all events vs. triggered only?)
#### 7.12.3 DLE-Escaped 0x03 — Critical Detail
The `\x10\x03` sequence at raw[117:119] is **not** a DLE stuffing artifact in the usual
sense. Standard DLE stuffing escapes `\x10``\x10\x10`. But here the device is encoding
the integer value `3` in a position where the byte `\x03` would be indistinguishable from
the frame ETX terminator. The device therefore sends `\x10\x03` (DLE + ETX = "inner-frame
terminator" in S3 inner-frame syntax). S3FrameParser correctly handles this: in
`STATE_AFTER_DLE`, seeing `\x03` (ETX) while **inside** an outer frame causes it to
append both `\x10` and `\x03` as literal bytes rather than ending the frame. The outer
frame only terminates on a **bare** `\x03` (without the DLE prefix).
The write frame sends these bytes verbatim — the device accepts `\x10\x03` in the write
payload and interprets it as the value 3. No transformation is needed in
`_encode_call_home_config()`.
**Limitation:** Any field that needs to encode the value `3` (0x03) requires this DLE
prefix. The current encoder raises `ValueError` if any hour or minute field equals 3,
since the encoder does not yet implement DLE-prefixed writes for arbitrary field positions.
In practice, 3:00 AM / 3 minutes past are unlikely scheduled call times.
#### 7.12.4 Write Protocol — SUB 0x7E → 0x7F
Write format (same as other write commands — only BW_CMD `0x10` doubled on wire;
all other bytes written raw; DLE-aware checksum):
| Step | SUB | Payload | Offset | Response |
|---|---|---|---|---|
| Data write | `0x7E` | 127 bytes (125-byte read payload + `\x00\x00`) | `data[1]+2 = 0x7E` (126) | `0x81` |
| Confirm | `0x7F` | empty | `0x00` | `0x80` |
**Write payload construction:**
```python
write_payload = bytearray(raw_125_bytes)
write_payload.append(0x00)
write_payload.append(0x00)
# patch fields in-place, then pass bytes(write_payload) to build_bw_write_frame
```
**Offset formula:** `write_payload[1] = 0x7C` (same as DATA_LENGTH).
`offset = write_payload[1] + 2 = 0x7C + 2 = 0x7E = 126`.
This follows the identical pattern as SUB 0x68 (event index write) and SUB 0x69 (waveform write).
**No preceding 0x2C read required** — Blastware sends SUB 0x7E directly using cached
state. The `seismo-relay` implementation always reads first (`get_call_home_config()`)
before writing for safety.
#### 7.12.5 Implementation Notes
- `MiniMateProtocol.read_call_home_config()` — standard two-step read; returns `data_rsp.data[11:]` (125 bytes raw)
- `MiniMateProtocol.write_call_home_config(data)` — sends SUB 0x7E (127-byte payload) then SUB 0x7F confirm
- `MiniMateClient.get_call_home_config()``CallHomeConfig` dataclass
- `MiniMateClient.set_call_home_config(...)` — reads current config, patches via `_encode_call_home_config()`, writes back
- `_decode_call_home_config(raw)` — handles DLE prefix detection at raw[117]
- `_encode_call_home_config(raw, ...)` — patches in-place, appends 2 trailing zeros; raises `ValueError` if any hour/min == 3
- REST API: `GET /device/call_home` and `POST /device/call_home` in `sfm/server.py`
- Web UI: "Call Home" tab in `sfm/sfm_webapp.html`
---
## 8. Timestamp Format ## 8. Timestamp Format
Two timestamp wire formats are used: Two timestamp wire formats are used:
@@ -1933,7 +2108,7 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
| **Auxiliary Trigger read location****RESOLVED:** SUB `FE` offset `0x0109`, uint8, `0x00`=disabled, `0x01`=enabled. Confirmed 2026-03-11 via controlled toggle capture. | RESOLVED | 2026-03-02 | Resolved 2026-03-11 | | **Auxiliary Trigger read location****RESOLVED:** SUB `FE` offset `0x0109`, uint8, `0x00`=disabled, `0x01`=enabled. Confirmed 2026-03-11 via controlled toggle capture. | RESOLVED | 2026-03-02 | Resolved 2026-03-11 |
| **Auxiliary Trigger write path** — Write command not yet captured in a clean session. Inner frame handshake visible in A4 (multiple WRITE_CONFIRM_RESPONSE SUBs appear, TRIGGER_CONFIG_RESPONSE removed), but the BW→S3 write command itself was in a partial session. Likely SUB `15` or similar. Deferred for clean capture. | LOW | 2026-03-11 | NEW | | **Auxiliary Trigger write path** — Write command not yet captured in a clean session. Inner frame handshake visible in A4 (multiple WRITE_CONFIRM_RESPONSE SUBs appear, TRIGGER_CONFIG_RESPONSE removed), but the BW→S3 write command itself was in a partial session. Likely SUB `15` or similar. Deferred for clean capture. | LOW | 2026-03-11 | NEW |
| ~~**SUB `6E` response to SUB `1C`**~~~~RESOLVED 2026-04-08: This was a misidentification.~~ The `1C → 6E` "exception" was misread — likely an inner A4 sub-frame. Confirmed from 4-8-26 capture (338 frames): SUB 0x1C always → 0xE3. No exceptions to the `0xFF SUB` rule are known. | RESOLVED | 2026-04-08 | CLOSED | | ~~**SUB `6E` response to SUB `1C`**~~~~RESOLVED 2026-04-08: This was a misidentification.~~ The `1C → 6E` "exception" was misread — likely an inner A4 sub-frame. Confirmed from 4-8-26 capture (338 frames): SUB 0x1C always → 0xE3. No exceptions to the `0xFF SUB` rule are known. | RESOLVED | 2026-04-08 | CLOSED |
| **Max Geo Range float 6.2061** — offset confirmed in channel block (`+1A`, `40 C6 97 FD`). Meaning and units are UNKNOWN. Value does NOT match either user-selectable range (1.25 / 10.0 in/s). Using it as ADC full-scale produces ~9× PPV overread vs on-device 0C values. Not simply metric vs imperial (25.4 factor doesn't reconcile). Needs investigation: examine surrounding channel block bytes, compare with a Blastware waveform CSV export to back-calculate the correct scale. Upgraded to HIGH priority. | HIGH | 2026-02-26 | Upgraded 2026-04-16 | | ~~**Max Geo Range float 6.2061**~~**RESOLVED 2026-04-17.** Confirmed as the **ADC-to-velocity scale factor** = inverse sensitivity = 1/0.161133 = **6.206053 (in/s)/V**. Source: Interface Handbook §4.5 formula `Range = 1.61133 V / Sensitivity`. For standard Instantel geo at Normal (Gain=1) range: Sensitivity = 1.61133/10 = 0.161133 V/(in/s), scale = 6.206053. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. The earlier ~9× overread was from using 6.206053 directly as range instead of as scale factor (range = 1.61133 V × 6.206053 = 10.000 in/s). Hardware constant — do NOT write. | RESOLVED | 2026-02-26 | Resolved 2026-04-17 |
| MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | | | MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | |
| Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | | | Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | |
| Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | | | Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | |
@@ -1962,10 +2137,11 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
| Trigger Level (Mic) | §3.8.6 | Channel block, float | float32 BE | 100148 dB in 1 dB steps | | Trigger Level (Mic) | §3.8.6 | Channel block, float | float32 BE | 100148 dB in 1 dB steps |
| Alarm Level (Mic) | §3.9.10 | Channel block, float | float32 BE | higher than mic trigger | | Alarm Level (Mic) | §3.9.10 | Channel block, float | float32 BE | higher than mic trigger |
| Record Time | §3.8.9 | cfg anchor+10, float32 BE (wire); `.set` +16, uint32 LE (file) | float32 BE (wire) | 1105 s; confirmed 3→`40400000`, 5→`40A00000`, 8→`41000000`, 13→`41500000`. Use anchor §7.6.1/§7.6.3 — NOT fixed offset. | | Record Time | §3.8.9 | cfg anchor+10, float32 BE (wire); `.set` +16, uint32 LE (file) | float32 BE (wire) | 1105 s; confirmed 3→`40400000`, 5→`40A00000`, 8→`41000000`, 13→`41500000`. Use anchor §7.6.1/§7.6.3 — NOT fixed offset. |
| Max Geo Range | §3.8.4 | Channel block, float | float32 BE | ❓ UNKNOWN — value 6.2061 confirmed at offset, but meaning/units unresolved. Does NOT equal 1.25 or 10.0 in/s. Do NOT use as ADC full-scale. | | ADC Scale Factor (geo_adc_scale) | §3.8.4 / Interface Handbook §4.5 | Channel block, Tran+28 (same in E5 read and SUB 71 write), float32 BE | float32 BE = 6.206053 | ✅ CONFIRMED 2026-04-17 — inverse sensitivity (in/s)/V. `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant, identical on all units. Do NOT write. |
| Max Geo Range (geo_range) | §3.8.4 | Channel block, Tran+33 (same in E5 read and SUB 71 write), uint8; applied to Tran/Vert/Long | uint8 | ✅ CONFIRMED 2026-04-20 — `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. **NOTE: `Tran+20` reads `0x01` on ALL captures regardless of range — it is NOT this field.** |
| Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` | | Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` |
| Sample Rate | §3.8.2 | cfg anchor2, uint16 BE — anchor=`\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100] | uint16 BE | Normal=1024, Fast=2048, Faster=4096 ✅ CONFIRMED 2026-04-01 (BE11529 S338.17). Anchor required — see §7.6.3 DLE jitter. | | Sample Rate | §3.8.2 | cfg anchor2, uint16 BE — anchor=`\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100] | uint16 BE | Normal=1024, Fast=2048, Faster=4096 ✅ CONFIRMED 2026-04-01 (BE11529 S338.17). Anchor required — see §7.6.3 DLE jitter. |
| Record Mode | §3.8.1 | Unknown | — | Single Shot, Continuous, Manual, Histogram, Histogram Combo | | Record Mode | §3.8.1 | Write: `cfg[anchor3]`, uint8. Read (E5 sf1): `data[anchor4]`, uint8. Note: extra `0x10` byte at read `data[anchor3]` shifts offset by 1 vs write. | uint8 | `0x00`=Single Shot, `0x01`=Continuous, `0x02`=unknown, `0x03`=Histogram, `0x04`=Histogram+Continuous. ✅ CONFIRMED 2026-04-20 |
| Trigger Sample Width | §3.13.1h | BW→S3 SUB `0x82` write frame, destuffed `[22]`, uint8 | uint8 | Default=2; confirmed 4=`0x04`, 3=`0x03`. **BW-side write only** — not visible in S3 compliance reads. Mode-gated: only sent in Compliance/Single-Shot/Fixed mode. | | Trigger Sample Width | §3.13.1h | BW→S3 SUB `0x82` write frame, destuffed `[22]`, uint8 | uint8 | Default=2; confirmed 4=`0x04`, 3=`0x03`. **BW-side write only** — not visible in S3 compliance reads. Mode-gated: only sent in Compliance/Single-Shot/Fixed mode. |
| Auto Window | §3.13.1b | **Mode-gated — NOT YET MAPPED** | uint8? | 19 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. | | Auto Window | §3.13.1b | **Mode-gated — NOT YET MAPPED** | uint8? | 19 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. |
| Auxiliary Trigger | §3.13.1d | SUB `FE` (FULL_CONFIG_RESPONSE) offset `0x0109` (read); write path not yet isolated | uint8 (bool) | `0x00`=disabled, `0x01`=enabled; confirmed 2026-03-11 | | Auxiliary Trigger | §3.13.1d | SUB `FE` (FULL_CONFIG_RESPONSE) offset `0x0109` (read); write path not yet isolated | uint8 (bool) | `0x00`=disabled, `0x01`=enabled; confirmed 2026-03-11 |
+407 -58
View File
@@ -35,6 +35,7 @@ from typing import Optional
from .framing import S3Frame from .framing import S3Frame
from .models import ( from .models import (
CallHomeConfig,
ComplianceConfig, ComplianceConfig,
DeviceInfo, DeviceInfo,
Event, Event,
@@ -847,12 +848,14 @@ class MiniMateClient:
self, self,
*, *,
# Recording parameters # Recording parameters
sample_rate: Optional[int] = None, recording_mode: Optional[int] = None,
record_time: Optional[float] = None, sample_rate: Optional[int] = None,
record_time: Optional[float] = None,
histogram_interval_sec: Optional[int] = None,
# Threshold parameters (geo channels, in/s) # Threshold parameters (geo channels, in/s)
trigger_level_geo: Optional[float] = None, trigger_level_geo: Optional[float] = None,
alarm_level_geo: Optional[float] = None, alarm_level_geo: Optional[float] = None,
max_range_geo: Optional[float] = None, geo_range: Optional[int] = None, # 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
# Project / operator strings # Project / operator strings
project: Optional[str] = None, project: Optional[str] = None,
client_name: Optional[str] = None, client_name: Optional[str] = None,
@@ -870,14 +873,15 @@ class MiniMateClient:
Configurable fields Configurable fields
------------------- -------------------
Recording parameters: Recording parameters:
recording_mode : int 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
sample_rate : int samples/sec; valid values: 1024, 2048, 4096 sample_rate : int samples/sec; valid values: 1024, 2048, 4096
record_time : float record duration in seconds (e.g. 2.0, 3.0) record_time : float record duration in seconds (e.g. 2.0, 3.0)
Trigger/alarm thresholds (geo channels, in/s): Trigger/alarm thresholds and range (geo channels):
trigger_level_geo : float trigger threshold (e.g. 0.5) trigger_level_geo : float trigger threshold in/s (e.g. 0.5)
alarm_level_geo : float alarm threshold (e.g. 1.0) alarm_level_geo : float alarm threshold in/s (e.g. 1.0)
max_range_geo : float full-scale calibration constant (e.g. 6.206) geo_range : int 0x00=Normal 10.000 in/s, 0x01=Sensitive 1.250 in/s
rarely changed only set if you know what you're doing (written to Tran/Vert/Long channel blocks)
Project / operator strings (max 41 ASCII characters each): Project / operator strings (max 41 ASCII characters each):
project : str project : str
@@ -891,14 +895,14 @@ class MiniMateClient:
Write payloads: Write payloads:
event_index_data : 88 bytes read live via SUB 08 event_index_data : 88 bytes read live via SUB 08
compliance_data : 2128 bytes read live via SUB 1A (2126 bytes) + \\x00\\x00 footer compliance_data : ~2128 bytes read live via SUB 1A (~2126 bytes, varies ±1-2) + \\x00\\x00 footer
trigger_data : 29 bytes hardcoded from 3-11-26 capture trigger_data : 29 bytes hardcoded from 3-11-26 capture
waveform_data : 204 bytes read live via SUB 09 waveform_data : 204 bytes read live via SUB 09
Raises: Raises:
RuntimeError: if not connected. RuntimeError: if not connected.
ProtocolError: if any read or write step fails. ProtocolError: if any read or write step fails.
ValueError: if compliance buffer is not the expected 2126 bytes. ValueError: if compliance buffer is shorter than the 2082-byte write minimum.
""" """
proto = self._require_proto() proto = self._require_proto()
@@ -907,7 +911,7 @@ class MiniMateClient:
event_index_data = proto.read_event_index() event_index_data = proto.read_event_index()
log.info("apply_config: reading compliance config (SUB 1A)") log.info("apply_config: reading compliance config (SUB 1A)")
compliance_raw = proto.read_compliance_config() # 2126 bytes compliance_raw = proto.read_compliance_config() # ~2126 bytes (varies ±1-2 by DLE jitter)
log.info("apply_config: reading waveform data (SUB 09)") log.info("apply_config: reading waveform data (SUB 09)")
waveform_data = proto.read_waveform_data_raw() # 204 bytes waveform_data = proto.read_waveform_data_raw() # 204 bytes
@@ -917,11 +921,13 @@ class MiniMateClient:
# 2. Patch the compliance buffer and build the 2128-byte write payload # 2. Patch the compliance buffer and build the 2128-byte write payload
compliance_data = _encode_compliance_config( compliance_data = _encode_compliance_config(
compliance_raw, compliance_raw,
recording_mode=recording_mode,
sample_rate=sample_rate, sample_rate=sample_rate,
record_time=record_time, record_time=record_time,
histogram_interval_sec=histogram_interval_sec,
trigger_level_geo=trigger_level_geo, trigger_level_geo=trigger_level_geo,
alarm_level_geo=alarm_level_geo, alarm_level_geo=alarm_level_geo,
max_range_geo=max_range_geo, geo_range=geo_range,
project=project, project=project,
client_name=client_name, client_name=client_name,
operator=operator, operator=operator,
@@ -951,6 +957,93 @@ class MiniMateClient:
notes=notes, notes=notes,
) )
# ── Call home config ──────────────────────────────────────────────────────
def get_call_home_config(self) -> CallHomeConfig:
"""
Read the auto call home (ACH) configuration from the device.
Sends SUB 0x2C (two-step read) and decodes the raw 125-byte payload
into a CallHomeConfig object.
Returns:
CallHomeConfig with all confirmed fields populated.
Raises:
RuntimeError: if not connected.
ProtocolError: on timeout or wrong response SUB.
"""
proto = self._require_proto()
raw = proto.read_call_home_config()
return _decode_call_home_config(raw)
def set_call_home_config(
self,
*,
auto_call_home_enabled: Optional[bool] = None,
after_event_recorded: Optional[bool] = None,
at_specified_times: Optional[bool] = None,
time1_enabled: Optional[bool] = None,
time1_hour: Optional[int] = None,
time1_min: Optional[int] = None,
time2_enabled: Optional[bool] = None,
time2_hour: Optional[int] = None,
time2_min: Optional[int] = None,
) -> None:
"""
Read the current call home config, apply any supplied changes, and
write the updated config back to the device.
Only non-None arguments are modified. All other bytes are round-tripped
verbatim from the device.
Configurable fields
-------------------
auto_call_home_enabled : bool master enable for ACH
after_event_recorded : bool call home after each triggered event
at_specified_times : bool call home at scheduled times
time1_enabled : bool enable time slot 1
time1_hour : int hour for time slot 1 (0-23)
time1_min : int minute for time slot 1 (0-59)
time2_enabled : bool enable time slot 2
time2_hour : int hour for time slot 2 (0-23)
time2_min : int minute for time slot 2 (0-59)
Write sequence (confirmed from 4-20-26 call home settings captures):
SUB 0x2C (read, 2-step) 125-byte raw payload
patch fields in-place
SUB 0x7E (write, 127-byte payload) ack 0x81
SUB 0x7F (confirm) ack 0x80
Raises:
RuntimeError: if not connected.
ProtocolError: if any read or write step fails.
"""
proto = self._require_proto()
# 1. Read current config
log.info("set_call_home_config: reading current config (SUB 0x2C)")
raw = proto.read_call_home_config()
# 2. Patch fields and build write payload
write_data = _encode_call_home_config(
raw,
auto_call_home_enabled=auto_call_home_enabled,
after_event_recorded=after_event_recorded,
at_specified_times=at_specified_times,
time1_enabled=time1_enabled,
time1_hour=time1_hour,
time1_min=time1_min,
time2_enabled=time2_enabled,
time2_hour=time2_hour,
time2_min=time2_min,
)
# 3. Write back
log.info("set_call_home_config: writing updated config (SUB 0x7E + 0x7F)")
proto.write_call_home_config(write_data)
log.info("set_call_home_config: complete")
def poll(self) -> None: def poll(self) -> None:
""" """
Perform just the POLL startup handshake no config reads. Perform just the POLL startup handshake no config reads.
@@ -1654,16 +1747,18 @@ def _extract_project_strings(data: bytes) -> Optional[ProjectInfo]:
def _encode_compliance_config( def _encode_compliance_config(
raw: bytes, raw: bytes,
*, *,
recording_mode: Optional[int] = None,
sample_rate: Optional[int] = None, sample_rate: Optional[int] = None,
record_time: Optional[float] = None, record_time: Optional[float] = None,
trigger_level_geo: Optional[float] = None, trigger_level_geo: Optional[float] = None,
alarm_level_geo: Optional[float] = None, alarm_level_geo: Optional[float] = None,
max_range_geo: Optional[float] = None, geo_range: Optional[int] = None, # 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
project: Optional[str] = None, histogram_interval_sec: Optional[int] = None,
client_name: Optional[str] = None, project: Optional[str] = None,
operator: Optional[str] = None, client_name: Optional[str] = None,
seis_loc: Optional[str] = None, operator: Optional[str] = None,
notes: Optional[str] = None, seis_loc: Optional[str] = None,
notes: Optional[str] = None,
) -> bytes: ) -> bytes:
""" """
Patch a live 2126-byte compliance buffer (read from the device) with any Patch a live 2126-byte compliance buffer (read from the device) with any
@@ -1675,13 +1770,28 @@ def _encode_compliance_config(
DLE-jitter shifts): DLE-jitter shifts):
Anchor: b'\\xbe\\x80\\x00\\x00\\x00\\x00' (confirmed stable, both BE11529 and BE18189) Anchor: b'\\xbe\\x80\\x00\\x00\\x00\\x00' (confirmed stable, both BE11529 and BE18189)
sample_rate uint16 BE at anchor_pos - 6 recording_mode uint8 at anchor_pos - 7 (write payload)
record_time float32 BE at anchor_pos + 6 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): Channel block (anchored on b"Tran" with unit-string guard):
max_range_geo float32 BE at tran_pos + 28 geo_range uint8 at tran_pos + 33 (confirmed 2026-04-20)
0x00 = Normal 10.000 in/s, 0x01 = Sensitive 1.250 in/s
Written to Tran, Vert, Long channel blocks (all three).
adc_scale_factor float32 BE at tran_pos + 28 (= 6.206053; do NOT write)
trigger_level_geo float32 BE at tran_pos + 34 trigger_level_geo float32 BE at tran_pos + 34
"in.\\x00" unit string at tran_pos + 38 (layout guard)
alarm_level_geo float32 BE at tran_pos + 42 alarm_level_geo float32 BE at tran_pos + 42
"/s\\x00\\x00" unit string at tran_pos + 46 (layout guard)
NOTE: tran_pos+28 (float32 = 6.206053) is the ADC-to-velocity scale factor
(= 1/sensitivity, (in/s)/V Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s).
This is a hardware/firmware constant common to all MiniMate Plus S3 units.
It must NOT be written do not add it back as a parameter.
String field locations (64-byte slots, label+22 format): String field locations (64-byte slots, label+22 format):
b"Project:" value at label_pos + 22, max 41 chars + null b"Project:" value at label_pos + 22, max 41 chars + null
@@ -1696,17 +1806,41 @@ def _encode_compliance_config(
by the device in POC test 2026-04-07.) by the device in POC test 2026-04-07.)
Raises: Raises:
ValueError: if raw is not exactly 2126 bytes. ValueError: if raw is shorter than the minimum needed for the 3-chunk write.
""" """
if len(raw) != 2126: # Total size is nominally ~2126 bytes but varies by ±1-2 bytes depending on
raise ValueError(f"_encode_compliance_config: expected 2126 bytes, got {len(raw)}") # DLE jitter in the E5 read response (0x10 bytes in the config data cause
# 1-byte expansions per occurrence during DLE stuffing/unstuffing). The
# anchor-based field access and the chunk splitter (fixed chunk1=1027,
# chunk2=1055, chunk3=remainder) both handle variable length correctly.
# Only enforce a minimum — must have at least chunk1+chunk2 bytes of content.
_MIN_COMPLIANCE_LEN = 1027 + 1055 # = 2082
if len(raw) < _MIN_COMPLIANCE_LEN:
raise ValueError(
f"_encode_compliance_config: compliance buffer too short "
f"({len(raw)} bytes, need at least {_MIN_COMPLIANCE_LEN})"
)
if len(raw) not in range(2124, 2132):
log.warning(
"_encode_compliance_config: unusual compliance buffer length %d "
"(expected ~2126); proceeding with anchor-based access",
len(raw),
)
buf = bytearray(raw) buf = bytearray(raw)
# ── Numeric: sample_rate + record_time (anchor-relative) ───────────────── # ── Numeric: recording_mode + sample_rate + record_time (anchor-relative) ──
_ANC = b'\xbe\x80\x00\x00\x00\x00' _ANC = b'\xbe\x80\x00\x00\x00\x00'
_anc = buf.find(_ANC, 0, 150) _anc = buf.find(_ANC, 0, 150)
if recording_mode is not None:
if _anc < 7:
log.warning("_encode_compliance_config: anchor not found — cannot write recording_mode")
else:
buf[_anc - 7] = recording_mode & 0xFF
log.debug("_encode_compliance_config: recording_mode=0x%02X -> offset %d",
recording_mode, _anc - 7)
if sample_rate is not None: if sample_rate is not None:
if _anc < 6: if _anc < 6:
log.warning("_encode_compliance_config: anchor not found — cannot write sample_rate") log.warning("_encode_compliance_config: anchor not found — cannot write sample_rate")
@@ -1714,6 +1848,14 @@ def _encode_compliance_config(
struct.pack_into(">H", buf, _anc - 6, sample_rate) struct.pack_into(">H", buf, _anc - 6, sample_rate)
log.debug("_encode_compliance_config: sample_rate=%d -> offset %d", sample_rate, _anc - 6) 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 record_time is not None:
if _anc < 0 or _anc + 10 > len(buf): if _anc < 0 or _anc + 10 > len(buf):
log.warning("_encode_compliance_config: anchor not found — cannot write record_time") log.warning("_encode_compliance_config: anchor not found — cannot write record_time")
@@ -1722,9 +1864,11 @@ def _encode_compliance_config(
log.debug("_encode_compliance_config: record_time=%.3f -> offset %d", record_time, _anc + 6) log.debug("_encode_compliance_config: record_time=%.3f -> offset %d", record_time, _anc + 6)
# ── Numeric: channel block (Tran label + unit-string guard) ─────────────── # ── Numeric: channel block (Tran label + unit-string guard) ───────────────
_needs_channel = any( # NOTE: tran_pos+24 (write format) or tran_pos+28 (E5 read format) is the
v is not None for v in (trigger_level_geo, alarm_level_geo, max_range_geo) # ADC-to-velocity scale factor (6.206053, hardware constant — never written).
) # geo_range is written to ALL THREE geo channel blocks (Tran, Vert, Long),
# confirmed from 4-20-26 captures showing the byte at label+29 in each block.
_needs_channel = any(v is not None for v in (trigger_level_geo, alarm_level_geo, geo_range))
if _needs_channel: if _needs_channel:
_tran = buf.find(b"Tran", 44) _tran = buf.find(b"Tran", 44)
_valid = ( _valid = (
@@ -1737,18 +1881,28 @@ def _encode_compliance_config(
if not _valid: if not _valid:
log.warning( log.warning(
"_encode_compliance_config: 'Tran' channel block not found or unit " "_encode_compliance_config: 'Tran' channel block not found or unit "
"guard failed — trigger/alarm/max_range will not be written" "guard failed — trigger/alarm/geo_range will not be written"
) )
else: else:
if max_range_geo is not None:
struct.pack_into(">f", buf, _tran + 28, max_range_geo)
log.debug("_encode_compliance_config: max_range_geo=%.4f -> offset %d", max_range_geo, _tran + 28)
if trigger_level_geo is not None: if trigger_level_geo is not None:
struct.pack_into(">f", buf, _tran + 34, trigger_level_geo) struct.pack_into(">f", buf, _tran + 34, trigger_level_geo)
log.debug("_encode_compliance_config: trigger_level_geo=%.4f -> offset %d", trigger_level_geo, _tran + 34) log.debug("_encode_compliance_config: trigger_level_geo=%.4f -> offset %d", trigger_level_geo, _tran + 34)
if alarm_level_geo is not None: if alarm_level_geo is not None:
struct.pack_into(">f", buf, _tran + 42, alarm_level_geo) struct.pack_into(">f", buf, _tran + 42, alarm_level_geo)
log.debug("_encode_compliance_config: alarm_level_geo=%.4f -> offset %d", alarm_level_geo, _tran + 42) log.debug("_encode_compliance_config: alarm_level_geo=%.4f -> offset %d", alarm_level_geo, _tran + 42)
if geo_range is not None:
# Write geo_range to all three geo channel blocks (Tran, Vert, Long).
# Field at label+33 in the E5-format compliance bytes (same in read and write
# since the 2126-byte payload is round-tripped verbatim).
# 0x00 = Normal 10.000 in/s, 0x01 = Sensitive 1.250 in/s.
for _ch_label in (b"Tran", b"Vert", b"Long"):
_ch = buf.find(_ch_label, 44)
if _ch >= 0 and buf[_ch + 4 : _ch + 5] != b"2" and _ch + 34 <= len(buf):
buf[_ch + 33] = geo_range & 0xFF
log.debug(
"_encode_compliance_config: geo_range=0x%02X -> %s+33 offset %d",
geo_range, _ch_label.decode(), _ch + 33,
)
# ── ASCII strings (64-byte slot, value at label_pos+22) ─────────────────── # ── ASCII strings (64-byte slot, value at label_pos+22) ───────────────────
def _set_string(label: bytes, value: Optional[str]) -> None: def _set_string(label: bytes, value: Optional[str]) -> None:
@@ -1800,7 +1954,7 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
Channel block layout ( confirmed 2026-04-02 from 3-11-26 E5 frame 78 Channel block layout ( confirmed 2026-04-02 from 3-11-26 E5 frame 78
and 1-2-26 A5 frame 77): and 1-2-26 A5 frame 77):
"Tran" label at tran_pos "Tran" label at tran_pos
tran_pos + 28 = max_range float32_BE (e.g. 6.206053 in/s) tran_pos + 28 = scale_factor float32_BE (= 1/sensitivity = 6.206053 (in/s)/V ADC scale; NOT a UI setting)
tran_pos + 34 = trigger_level float32_BE (e.g. 0.600000 in/s) tran_pos + 34 = trigger_level float32_BE (e.g. 0.600000 in/s)
tran_pos + 38 = "in.\\x00" (unit string anchor) tran_pos + 38 = "in.\\x00" (unit string anchor)
tran_pos + 42 = alarm_level float32_BE (e.g. 1.250000 in/s) tran_pos + 42 = alarm_level float32_BE (e.g. 1.250000 in/s)
@@ -1827,21 +1981,34 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
except Exception as exc: except Exception as exc:
log.warning("compliance_config: setup_name extraction failed: %s", exc) log.warning("compliance_config: setup_name extraction failed: %s", exc)
# ── Record time + sample rate — anchor-relative ─────────────────────────── # ── recording_mode / sample_rate / histogram_interval / record_time ─────────
# The 10-byte anchor sits between sample_rate and record_time in the cfg. # 6-byte stable anchor: b'\xbe\x80\x00\x00\x00\x00' — confirmed across BE11529
# Absolute offsets are NOT reliable because sample_rate = 4096 (0x1000) is # and BE18189. The 4 bytes immediately before the anchor are NOT constant:
# DLE-escaped in the raw S3 frame (10 10 00 → 10 00 after unstuffing), # bytes -4:-2 are the histogram_interval (uint16 BE, seconds), and bytes -2:0
# making frame C 1 byte shorter than for 1024/2048 and shifting everything. # are zero padding. The old "10-byte anchor" (\x01\x2c\x00\x00 prefix) was
# sample_rate: uint16_BE at anchor - 2 # only constant when the histogram interval happened to be 5 min (0x012C = 300).
# record_time: float32_BE at anchor + 10 #
# 6-byte suffix anchor — confirmed stable across BE11529 and bench unit (BE18189). # E5 read format layout relative to 6-byte anchor:
# The preceding 4 bytes (old anchor prefix 01 2c / 00 3c) vary by unit config; # _anchor - 8 : recording_mode (uint8)
# only be 80 00 00 00 00 is constant. # _anchor - 7 : 0x10 (extra byte E5 read only; absent in SUB 71 write)
# sample_rate : uint16 BE at anchor_pos - 6 # _anchor - 6 : sample_rate_hi (uint16 BE, MSB)
# record_time : float32 BE at anchor_pos + 6 # _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 = b'\xbe\x80\x00\x00\x00\x00'
_anchor = data.find(_ANCHOR, 0, 150) _anchor = data.find(_ANCHOR, 0, 150)
if _anchor >= 6 and _anchor + 10 <= len(data): if _anchor >= 8 and _anchor + 10 <= len(data):
try:
config.recording_mode = data[_anchor - 8]
log.debug(
"compliance_config: recording_mode = 0x%02X (anchor@%d)", config.recording_mode, _anchor
)
except Exception as exc:
log.warning("compliance_config: recording_mode extraction failed: %s", exc)
try: try:
config.sample_rate = struct.unpack_from(">H", data, _anchor - 6)[0] config.sample_rate = struct.unpack_from(">H", data, _anchor - 6)[0]
log.debug( log.debug(
@@ -1849,6 +2016,14 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
) )
except Exception as exc: except Exception as exc:
log.warning("compliance_config: sample_rate extraction failed: %s", 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: try:
config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0] config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0]
log.debug( log.debug(
@@ -1856,10 +2031,21 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
) )
except Exception as exc: except Exception as exc:
log.warning("compliance_config: record_time extraction failed: %s", exc) log.warning("compliance_config: record_time extraction failed: %s", exc)
elif _anchor >= 6 and _anchor + 10 <= len(data):
# Fallback: anchor found but not enough bytes before it for recording_mode
log.warning("compliance_config: anchor too close to start (anchor@%d) — skipping recording_mode", _anchor)
try:
config.sample_rate = struct.unpack_from(">H", data, _anchor - 6)[0]
except Exception:
pass
try:
config.record_time = struct.unpack_from(">f", data, _anchor + 6)[0]
except Exception:
pass
else: else:
log.warning( log.warning(
"compliance_config: anchor %s not found in cfg[0:150] (len=%d) " "compliance_config: anchor %s not found in cfg[0:150] (len=%d) "
"— sample_rate and record_time will be None", "— sample_rate, record_time and recording_mode will be None",
_ANCHOR.hex(), len(data), _ANCHOR.hex(), len(data),
) )
@@ -1893,18 +2079,22 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
except Exception as exc: except Exception as exc:
log.warning("compliance_config: project string extraction failed: %s", exc) log.warning("compliance_config: project string extraction failed: %s", exc)
# ── Channel block: trigger_level_geo, alarm_level_geo, max_range_geo ───── # ── Channel block: trigger_level_geo, alarm_level_geo, geo_range, geo_adc_scale ──
# The channel block is only present in the full cfg (frame D delivered, # The channel block is only present in the full cfg (frame D delivered,
# ~2126 bytes). Layout confirmed 2026-04-02 from both E5 frame 78 of the # ~2126 bytes). Layout confirmed 2026-04-02 from both E5 frame 78 of the
# 3-11-26 compliance-config capture and A5 frame 77 of the 1-2-26 event # 3-11-26 compliance-config capture and A5 frame 77 of the 1-2-26 event
# download capture: # download capture. Cross-checked 2026-04-17 across both BE11529 and BE18189.
# #
# "Tran" label at tran_pos (+0 to +3) # "Tran" label at tran_pos (+0 to +3)
# max_range float32_BE at tran_pos + 28 (e.g. 6.206053 in/s) # adc_scale float32_BE at tran_pos + 28 (= 1/sensitivity = 6.206053 (in/s)/V; hardware constant — do NOT write)
# trigger float32_BE at tran_pos + 34 (e.g. 0.600000 in/s) # geo_range uint8 at tran_pos + 33 CONFIRMED 2026-04-20
# "in.\x00" unit string at tran_pos + 38 ✅ confirmed # 0x00 = Normal 10.000 in/s, 0x01 = Sensitive 1.250 in/s
# alarm float32_BE at tran_pos + 42 (e.g. 1.250000 in/s) # Same offset in E5 read and SUB 71 write (bytes are round-tripped verbatim).
# "/s\x00\x00" unit string at tran_pos + 46 ✅ confirmed # NOTE: tran_pos+20 reads 0x01 on ALL captures — constant flag, NOT range field.
# trigger float32_BE at tran_pos + 34 (e.g. 0.600000 in/s) ✅
# "in.\x00" unit string at tran_pos + 38 ✅ confirmed (layout guard)
# alarm float32_BE at tran_pos + 42 (e.g. 1.250000 in/s) ✅
# "/s\x00\x00" unit string at tran_pos + 46 ✅ confirmed (layout guard)
# #
# Unit strings serve as layout anchors — if they match, the float offsets # Unit strings serve as layout anchors — if they match, the float offsets
# are reliable. Skip "Tran2" (a later repeated label) via the +4 check. # are reliable. Skip "Tran2" (a later repeated label) via the +4 check.
@@ -1917,12 +2107,14 @@ def _decode_compliance_config_into(data: bytes, info: DeviceInfo) -> None:
and data[tran_pos + 38 : tran_pos + 42] == b"in.\x00" and data[tran_pos + 38 : tran_pos + 42] == b"in.\x00"
and data[tran_pos + 46 : tran_pos + 50] == b"/s\x00\x00" and data[tran_pos + 46 : tran_pos + 50] == b"/s\x00\x00"
): ):
config.max_range_geo = struct.unpack_from(">f", data, tran_pos + 28)[0] config.geo_range = data[tran_pos + 33] # CONFIRMED 2026-04-20: 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
config.geo_adc_scale = struct.unpack_from(">f", data, tran_pos + 28)[0] # hw scale factor (in/s)/V — do NOT write
config.trigger_level_geo = struct.unpack_from(">f", data, tran_pos + 34)[0] config.trigger_level_geo = struct.unpack_from(">f", data, tran_pos + 34)[0]
config.alarm_level_geo = struct.unpack_from(">f", data, tran_pos + 42)[0] config.alarm_level_geo = struct.unpack_from(">f", data, tran_pos + 42)[0]
log.debug( log.debug(
"compliance_config: trigger=%.4f alarm=%.4f max_range=%.4f in/s", "compliance_config: trigger=%.4f alarm=%.4f geo_range=0x%02X geo_adc_scale=%.6f",
config.trigger_level_geo, config.alarm_level_geo, config.max_range_geo, config.trigger_level_geo, config.alarm_level_geo,
config.geo_range, config.geo_adc_scale,
) )
elif tran_pos >= 0: elif tran_pos >= 0:
log.warning( log.warning(
@@ -2144,3 +2336,160 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus:
memory_total=memory_total, memory_total=memory_total,
memory_free=memory_free, memory_free=memory_free,
) )
def _decode_call_home_config(raw: bytes) -> CallHomeConfig:
"""
Decode the raw 125-byte call home config payload into a CallHomeConfig.
*raw* is data[11:] from the SUB 0xD3 data response frame.
Field offsets (confirmed from 4-20-26 captures, all 11 BW+S3 pairs):
[5] auto_call_home_enabled (0x00=off, 0x01=on)
[6:46] dial_string 40-byte null-padded ASCII
[87] after_event_recorded (0x01=on, 0x00=off)
[91] at_specified_times (0x01=on, 0x00=off)
[93] time1_enabled (0x01=on, 0x00=off)
[95] time2_enabled (0x01=on, 0x00=off)
[101] time1_hour uint8 decimal 0-23
[102] time1_min uint8 decimal 0-59
[105] time2_hour uint8 decimal 0-23
[106] time2_min uint8 decimal 0-59
[117:119] 10 03 = DLE-escaped num_retries=3 (logical value = 0x03)
[120] time_between_retries_sec (shifted +1 from logical by DLE prefix)
[122] wait_for_connection_sec
[124] warm_up_time_sec
The DLE-escaped ETX at raw[117:119] = b'\\x10\\x03' means the logical value
0x03 (3 retries) is stored there. The S3FrameParser keeps both bytes verbatim.
Subsequent fields are at logical_offset + 1 in the raw byte array.
"""
cfg = CallHomeConfig(raw=raw)
if len(raw) < 10:
return cfg
# Simple boolean and string fields — direct reads, no DLE complications
if len(raw) > 5:
cfg.auto_call_home_enabled = bool(raw[5])
if len(raw) >= 46:
ds = raw[6:46]
cfg.dial_string = ds.split(b"\x00", 1)[0].decode("ascii", errors="replace") or None
if len(raw) > 87:
cfg.after_event_recorded = bool(raw[87])
if len(raw) > 91:
cfg.at_specified_times = bool(raw[91])
if len(raw) > 93:
cfg.time1_enabled = bool(raw[93])
if len(raw) > 95:
cfg.time2_enabled = bool(raw[95])
if len(raw) > 102:
cfg.time1_hour = raw[101]
cfg.time1_min = raw[102]
if len(raw) > 106:
cfg.time2_hour = raw[105]
cfg.time2_min = raw[106]
# num_retries: raw[117]=0x10 (DLE prefix), raw[118]=0x03 (value)
# Subsequent fields shift by +1 from logical positions.
if len(raw) > 118 and raw[117] == 0x10:
cfg.num_retries = raw[118] # 0x03 = 3
if len(raw) > 120:
cfg.time_between_retries_sec = raw[120] # logical 119, shifted to 120
if len(raw) > 122:
cfg.wait_for_connection_sec = raw[122] # logical 121, shifted to 122
if len(raw) > 124:
cfg.warm_up_time_sec = raw[124] # logical 123, shifted to 124
elif len(raw) > 117:
# Fallback: no DLE prefix (num_retries is not 0x03)
cfg.num_retries = raw[117]
if len(raw) > 119:
cfg.time_between_retries_sec = raw[119]
if len(raw) > 121:
cfg.wait_for_connection_sec = raw[121]
if len(raw) > 123:
cfg.warm_up_time_sec = raw[123]
log.debug(
"_decode_call_home_config: enabled=%s dial=%r after_event=%s at_times=%s "
"t1=%s %02d:%02d t2=%s %02d:%02d retries=%s gap=%s wait=%s warmup=%s",
cfg.auto_call_home_enabled, cfg.dial_string,
cfg.after_event_recorded, cfg.at_specified_times,
cfg.time1_enabled, cfg.time1_hour or 0, cfg.time1_min or 0,
cfg.time2_enabled, cfg.time2_hour or 0, cfg.time2_min or 0,
cfg.num_retries, cfg.time_between_retries_sec,
cfg.wait_for_connection_sec, cfg.warm_up_time_sec,
)
return cfg
def _encode_call_home_config(
raw: bytes,
*,
auto_call_home_enabled: Optional[bool] = None,
after_event_recorded: Optional[bool] = None,
at_specified_times: Optional[bool] = None,
time1_enabled: Optional[bool] = None,
time1_hour: Optional[int] = None,
time1_min: Optional[int] = None,
time2_enabled: Optional[bool] = None,
time2_hour: Optional[int] = None,
time2_min: Optional[int] = None,
) -> bytes:
"""
Patch specific fields in the 125-byte raw call home payload and return
the 127-byte write payload (raw + b'\\x00\\x00' footer).
Only non-None arguments are modified. All other bytes including the
DLE-escaped \\x10\\x03 at [117:119] are preserved verbatim for round-trip.
The write payload footer (2 trailing zeros) matches Blastware's confirmed
write frame format from the 4-20-26 captures.
CAUTION: hour and minute values must not equal 0x03 (3) such values would
require DLE-escaping that this encoder does not implement. Values 0x03 in
hour/minute slots are rejected with ValueError.
"""
if len(raw) < 107:
raise ValueError(
f"call home raw payload too short: {len(raw)} bytes (need ≥107)"
)
buf = bytearray(raw) # 125 bytes
def _set_bool(offset: int, value: Optional[bool]) -> None:
if value is not None:
buf[offset] = 0x01 if value else 0x00
def _set_uint8(offset: int, value: Optional[int], name: str) -> None:
if value is None:
return
if value == 0x03:
raise ValueError(
f"{name}={value} (0x03) requires DLE escaping — "
"not supported by this encoder; avoid using 3 for hour/minute fields"
)
buf[offset] = value & 0xFF
_set_bool(5, auto_call_home_enabled)
_set_bool(87, after_event_recorded)
_set_bool(91, at_specified_times)
_set_bool(93, time1_enabled)
_set_bool(95, time2_enabled)
_set_uint8(101, time1_hour, "time1_hour")
_set_uint8(102, time1_min, "time1_min")
_set_uint8(105, time2_hour, "time2_hour")
_set_uint8(106, time2_min, "time2_min")
# num_retries, time_between_retries_sec, wait_for_connection_sec, warm_up_time_sec
# are not writable via this encoder — they're preserved verbatim including the
# DLE-escaped 0x03 at [117:119].
log.debug(
"_encode_call_home_config: patched fields: "
"enabled=%s after_event=%s at_times=%s "
"t1=%s %s:%s t2=%s %s:%s",
auto_call_home_enabled, after_event_recorded, at_specified_times,
time1_enabled, time1_hour, time1_min,
time2_enabled, time2_hour, time2_min,
)
return bytes(buf) + b"\x00\x00" # append 2-byte footer (confirmed BW pattern)
+97 -6
View File
@@ -269,7 +269,7 @@ class ChannelConfig:
label: str # e.g. "Tran", "Vert", "Long", "MicL" ✅ label: str # e.g. "Tran", "Vert", "Long", "MicL" ✅
trigger_level: float # in/s (geo) or psi (MicL) ✅ trigger_level: float # in/s (geo) or psi (MicL) ✅
alarm_level: float # in/s (geo) or psi (MicL) ✅ alarm_level: float # in/s (geo) or psi (MicL) ✅
max_range: float # full-scale calibration constant (e.g. 6.206) 🔶 max_range: float # hardware/firmware sensitivity constant (e.g. 6.206053) ✅ confirmed same on all units
unit_label: str # e.g. "in./s" or "psi" ✅ unit_label: str # e.g. "in./s" or "psi" ✅
@@ -338,15 +338,34 @@ class ComplianceConfig:
raw: Optional[bytes] = None # full 2090-byte payload (for debugging) raw: Optional[bytes] = None # full 2090-byte payload (for debugging)
# Recording parameters (✅ CONFIRMED from §7.6) # Recording parameters (✅ CONFIRMED from §7.6)
record_time: Optional[float] = None # seconds (7.0, 10.0, 13.0, etc.) recording_mode: Optional[int] = None # uint8: 0x00=Single Shot, 0x01=Continuous,
sample_rate: Optional[int] = None # sps (1024, 2048, 4096, etc.) — NOT YET FOUND ❓ # 0x03=Histogram, 0x04=Histogram+Continuous ✅ confirmed 2026-04-20
# 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) # Trigger/alarm levels (✅ CONFIRMED per-channel at §7.6)
# For now we store the first geo channel (Transverse) as representatives; # For now we store the first geo channel (Transverse) as representatives;
# full per-channel data would require structured Channel objects. # full per-channel data would require structured Channel objects.
trigger_level_geo: Optional[float] = None # in/s (first geo channel) trigger_level_geo: Optional[float] = None # in/s (first geo channel)
alarm_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 geo_adc_scale: Optional[float] = None # ADC-to-velocity scale factor (float32 at Tran+28) ✅
# = inverse sensitivity = 1/sensitivity (in/s per V)
# Formula (Interface Handbook §4.5): Range = 1.61133 V × scale_factor
# → 1.61133 × 6.206053 = 10.000 in/s (Normal range) ✅
# Firmware uses: PPV (in/s) = ADC_voltage (V) × 6.206053
# Identical on BE11529 and BE18189 — same Instantel geophone hardware.
# NOT a user-configurable setting. Must NOT be written.
geo_range: Optional[int] = None # range/sensitivity selector — CONFIRMED 2026-04-20
# 0x00 = Normal 10.000 in/s (standard gain)
# 0x01 = Sensitive 1.250 in/s (high gain)
# Offset: Tran+33 in both E5 read and SUB 71 write payloads
# (same 2126-byte buffer is round-tripped; applied to Tran/Vert/Long)
# Project/setup strings (sourced from E5 / SUB 71 write payload) # Project/setup strings (sourced from E5 / SUB 71 write payload)
# These are the FULL project metadata from compliance config, # These are the FULL project metadata from compliance config,
@@ -359,6 +378,78 @@ class ComplianceConfig:
notes: Optional[str] = None # extended notes / additional info notes: Optional[str] = None # extended notes / additional info
# ── Call Home Config ──────────────────────────────────────────────────────────
@dataclass
class CallHomeConfig:
"""
Auto Call Home (ACH) configuration from SUB 0x2C (response 0xD3).
Read with a standard two-step protocol (probe offset=0x00, data offset=0x7C).
Written via SUB 0x7E (write, 127-byte payload) + SUB 0x7F (confirm).
Confirmed from 4-20-26 call home settings captures (11 BW + S3 capture pairs).
Raw payload layout (data[11:] from S3 response, 125 bytes):
[0] 0x00 header byte
[1] 0x7C = 124 inner length (= offset for SUB 0x7E write - 2)
[2] 0xDC constant
[3:5] 0x00 0x00 padding
[5] auto_call_home_enabled (0x00=off, 0x01=on)
[6:46] dial_string 40-byte null-padded ASCII
[46:87] auto_answer_raw AT command strings (not decoded) present
[87] after_event_recorded (0x01=on, 0x00=off)
[91] at_specified_times (0x01=on, 0x00=off)
[93] time1_enabled (0x01=on, 0x00=off)
[95] time2_enabled (0x01=on, 0x00=off)
[101] time1_hour uint8 decimal 0-23
[102] time1_min uint8 decimal 0-59
[105] time2_hour uint8 decimal 0-23
[106] time2_min uint8 decimal 0-59
[117] DLE prefix (0x10) DLE-escaped num_retries=3 (0x03)
[118] 0x03 device stores/returns 0x03 DLE-escaped
[120] time_between_retries_sec uint8 (= 0x0F = 15 s default)
[122] wait_for_connection_sec uint8 (= 0x3C = 60 s default)
[124] warm_up_time_sec uint8 (= 0x3C = 60 s default)
Write payload = raw 125 bytes + b'\\x00\\x00' (2 trailing zeros) = 127 bytes.
Offset for SUB 0x7E: data[1] + 2 = 0x7C + 2 = 0x7E = 126.
Note on DLE-escaped 0x03: The device's S3 response DLE-escapes ETX (0x03)
bytes as \\x10\\x03. The S3FrameParser preserves both bytes in frame.data.
Subsequent fields after offset 117 are therefore at raw_offset = logical+1.
The raw payload must be round-tripped verbatim in write; do NOT reapply DLE
destuffing or stripping.
"""
raw: Optional[bytes] = None # raw 125-byte read payload (for round-trip write)
# ── Main enable ──────────────────────────────────────────────────────────
auto_call_home_enabled: Optional[bool] = None # raw[5] ✅
# ── Dial string ──────────────────────────────────────────────────────────
dial_string: Optional[str] = None # raw[6:46] 40-byte null-padded ASCII ✅
# ── When to call ─────────────────────────────────────────────────────────
after_event_recorded: Optional[bool] = None # raw[87] ✅
at_specified_times: Optional[bool] = None # raw[91] ✅
# ── Time slot 1 ──────────────────────────────────────────────────────────
time1_enabled: Optional[bool] = None # raw[93] ✅
time1_hour: Optional[int] = None # raw[101] 0-23 ✅
time1_min: Optional[int] = None # raw[102] 0-59 ✅
# ── Time slot 2 ──────────────────────────────────────────────────────────
time2_enabled: Optional[bool] = None # raw[95] ✅
time2_hour: Optional[int] = None # raw[105] 0-23 ✅
time2_min: Optional[int] = None # raw[106] 0-59 ✅
# ── Retry / timeout settings (read-only; not writable via set_call_home_config) ──
num_retries: Optional[int] = None # raw[117:119]=10 03 → value 3 ✅
time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1 by DLE) ✅
wait_for_connection_sec: Optional[int] = None # raw[122] ✅
warm_up_time_sec: Optional[int] = None # raw[124] ✅
# ── Event ───────────────────────────────────────────────────────────────────── # ── Event ─────────────────────────────────────────────────────────────────────
@dataclass @dataclass
+89
View File
@@ -65,6 +65,7 @@ SUB_WAVEFORM_HEADER = 0x0A
SUB_WAVEFORM_RECORD = 0x0C SUB_WAVEFORM_RECORD = 0x0C
SUB_BULK_WAVEFORM = 0x5A SUB_BULK_WAVEFORM = 0x5A
SUB_COMPLIANCE = 0x1A SUB_COMPLIANCE = 0x1A
SUB_CALL_HOME = 0x2C # Call home config read → response 0xD3 ✅
SUB_UNKNOWN_2E = 0x2E SUB_UNKNOWN_2E = 0x2E
# Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26) # Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26)
@@ -78,6 +79,10 @@ SUB_WRITE_CONFIRM_C = 0x74 # Confirm C — sent after 69 ✅
SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅ SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅
SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅ SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅
# Call home write SUBs (confirmed from 4-20-26 call home settings captures)
SUB_CALL_HOME_WRITE = 0x7E # Write call home config → response 0x81 ✅
SUB_CALL_HOME_CONFIRM = 0x7F # Confirm call home write → response 0x80 ✅
# Monitoring control SUBs (confirmed from 4-8-26/2ndtry BW TX capture) # Monitoring control SUBs (confirmed from 4-8-26/2ndtry BW TX capture)
SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅ SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅
SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅ SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅
@@ -109,6 +114,7 @@ DATA_LENGTHS: dict[int, int] = {
# SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response # SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response
# data[4]. Do NOT add it here; use read_waveform_header() instead. ✅ # data[4]. Do NOT add it here; use read_waveform_header() instead. ✅
SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅ SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅
SUB_CALL_HOME: 0x7C, # 124-byte call home config ✅ (confirmed 4-20-26)
SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶 SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶
0x09: 0xCA, # 202 bytes, purpose TBD 🔶 0x09: 0xCA, # 202 bytes, purpose TBD 🔶
# SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total; # SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total;
@@ -1087,6 +1093,89 @@ class MiniMateProtocol:
self._send(frame) self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub) return self.recv_write_ack(expected_sub=rsp_sub)
# ── Call home config (SUBs 0x2C / 0x7E / 0x7F) ──────────────────────────
def read_call_home_config(self) -> bytes:
"""
Read the auto call home configuration (SUB 0x2C response 0xD3).
Standard two-step read: probe (offset=0x00) then data (offset=0x7C=124).
Returns the raw 125-byte payload (data[11:] of the data response).
Confirmed from 4-20-26 call home settings capture:
- Probe response: data[4]=0x7C (confirms data length = 124)
- Data response: 136 bytes total (11-byte echo header + 125 bytes payload)
- Payload[0:3] = 0x00 0x7C 0xDC (header: zero, inner-length, constant)
- Payload[5] = auto_call_home_enabled
- Payload[6:46] = dial_string (40-byte null-padded ASCII "RADIO RING")
Returns:
Raw 125-byte call home config payload (data[11:]).
Suitable for round-trip write (append \\x00\\x00 127-byte write payload).
Raises:
ProtocolError: on timeout or wrong response SUB.
"""
rsp_sub = _expected_rsp_sub(SUB_CALL_HOME) # 0xFF - 0x2C = 0xD3
length = DATA_LENGTHS[SUB_CALL_HOME] # 0x7C = 124
log.debug("read_call_home_config: 0x2C probe")
self._send(build_bw_frame(SUB_CALL_HOME, 0))
self._recv_one(expected_sub=rsp_sub)
log.debug("read_call_home_config: 0x2C data request offset=0x%02X", length)
self._send(build_bw_frame(SUB_CALL_HOME, length))
data_rsp = self._recv_one(expected_sub=rsp_sub)
payload = data_rsp.data[11:]
log.debug("read_call_home_config: received %d payload bytes", len(payload))
return payload
def write_call_home_config(self, data: bytes) -> None:
"""
Write the auto call home configuration (SUB 0x7E 0x7F confirm).
Write sequence (confirmed from 4-20-26 call home settings captures):
SUB 0x7E write 127-byte payload device acks SUB 0x81
SUB 0x7F confirm (no data) device acks SUB 0x80
The 127-byte write payload = 125-byte read payload + b'\\x00\\x00'.
The offset field = data[1] + 2 = 0x7C + 2 = 0x7E = 126.
Write frame format: build_bw_write_frame (minimal DLE stuffing only
BW_CMD is doubled; all other bytes are RAW). The \\x10\\x03 sequence
within the payload is preserved as-is (device interprets DLE+ETX as the
literal value 0x03 per the inner-frame terminator convention).
Args:
data: 127-byte write payload (read payload + \\x00\\x00 footer).
Must start with [0x00][0x7C][...] (standard header).
Raises:
ValueError: if data is not exactly 127 bytes or lacks expected header.
ProtocolError: on timeout or wrong response SUB.
"""
if len(data) < 2:
raise ValueError(f"call home write payload must be at least 2 bytes, got {len(data)}")
rsp_sub_write = _expected_rsp_sub(SUB_CALL_HOME_WRITE) # 0xFF - 0x7E = 0x81
rsp_sub_confirm = _expected_rsp_sub(SUB_CALL_HOME_CONFIRM) # 0xFF - 0x7F = 0x80
# Offset formula: data[1] + 2 (same pattern as other single-chunk writes)
offset = data[1] + 2 # 0x7C + 2 = 0x7E = 126
frame = build_bw_write_frame(SUB_CALL_HOME_WRITE, data, offset=offset)
log.debug(
"write_call_home_config: %d bytes data[1]=0x%02X offset=0x%04X",
len(data), data[1], offset,
)
self._send(frame)
self.recv_write_ack(expected_sub=rsp_sub_write)
log.debug("write_call_home_config: write acked; sending confirm 0x7F")
confirm_frame = build_bw_write_frame(SUB_CALL_HOME_CONFIRM, b"")
self._send(confirm_frame)
self.recv_write_ack(expected_sub=rsp_sub_confirm)
log.debug("write_call_home_config: confirm acked — done")
# ── Monitoring ──────────────────────────────────────────────────────────── # ── Monitoring ────────────────────────────────────────────────────────────
def read_monitor_status(self) -> S3Frame: def read_monitor_status(self) -> S3Frame:
+28 -10
View File
@@ -33,7 +33,7 @@ STX = 0x02
ETX = 0x03 ETX = 0x03
ACK = 0x41 ACK = 0x41
__version__ = "0.2.2" __version__ = "0.2.3"
@dataclass @dataclass
@@ -227,17 +227,32 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]:
trailer_end = trailer_start + trailer_len trailer_end = trailer_start + trailer_len
trailer = blob[trailer_start:trailer_end] trailer = blob[trailer_start:trailer_end]
# For S3 mode we don't assume checksum type here yet. chk_valid = None
chk_type = None
chk_hex = None
payload = bytes(body)
if len(body) >= 1:
received_chk = body[-1]
computed_chk = checksum8_sum(bytes(body[:-1]))
if computed_chk == received_chk:
chk_valid = True
chk_type = "SUM8"
chk_hex = f"{received_chk:02x}"
payload = bytes(body[:-1])
else:
chk_valid = False
frames.append(Frame( frames.append(Frame(
index=idx, index=idx,
start_offset=start_offset, start_offset=start_offset,
end_offset=end_offset, end_offset=end_offset,
payload_raw=bytes(body), payload_raw=bytes(body),
payload=bytes(body), payload=payload,
trailer=trailer, trailer=trailer,
checksum_valid=None, checksum_valid=chk_valid,
checksum_type=None, checksum_type=chk_type,
checksum_hex=None checksum_hex=chk_hex
)) ))
idx += 1 idx += 1
@@ -298,10 +313,13 @@ def parse_bw(blob: bytes, trailer_len: int, validate_checksum: bool) -> List[Fra
if b == ETX: if b == ETX:
# Candidate end-of-frame. # Candidate end-of-frame.
# Accept ETX if the next bytes look like a real next-frame start (ACK+STX), # Skip any SESSION_RESET (41 03) sequences — sent before POLL to wake
# or we're at EOF. This prevents chopping on in-payload 0x03. # monitoring units — to find the real next frame start (ACK+STX).
next_is_start = (i + 2 < n and blob[i + 1] == ACK and blob[i + 2] == STX) j = i + 1
at_eof = (i == n - 1) while j + 1 < n and blob[j] == ACK and blob[j + 1] == ETX:
j += 2
next_is_start = (j + 1 < n and blob[j] == ACK and blob[j + 1] == STX)
at_eof = (i == n - 1) or (j >= n)
if not (next_is_start or at_eof): if not (next_is_start or at_eof):
# Not a real boundary -> payload byte # Not a real boundary -> payload byte
+189 -34
View File
@@ -97,16 +97,24 @@ class AnalyzerState:
class BridgePanel(tk.Frame): class BridgePanel(tk.Frame):
""" """
All bridge controls and live log output. All bridge controls and live log output.
Calls on_bridge_started(raw_bw_path, raw_s3_path) when the bridge starts Calls on_bridge_started(struct_bin_path) when the bridge starts.
so the parent can wire up the Analyzer. Calls on_capture_started(bw_path, s3_path, label) when a capture begins.
Calls on_capture_complete(bw_path, s3_path, label) when a capture ends.
""" """
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw): def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
on_capture_started=None, on_capture_complete=None, **kw):
super().__init__(parent, bg=BG2, **kw) super().__init__(parent, bg=BG2, **kw)
self._on_started = on_bridge_started # signature: (raw_bw, raw_s3, struct_bin) self._on_started = on_bridge_started # signature: (struct_bin)
self._on_stopped = on_bridge_stopped self._on_stopped = on_bridge_stopped
self._on_cap_started = on_capture_started # (bw, s3, label)
self._on_cap_complete = on_capture_complete # (bw, s3, label)
self.process: Optional[subprocess.Popen] = None self.process: Optional[subprocess.Popen] = None
self._stdout_q: queue.Queue[str] = queue.Queue() self._stdout_q: queue.Queue[str] = queue.Queue()
# Capture state
self._capturing = False
self._cap_label: Optional[str] = None
self._cap_history: list[dict] = [] # {label, status, bw, s3}
self._build() self._build()
self._poll_stdout() self._poll_stdout()
@@ -146,17 +154,7 @@ class BridgePanel(tk.Frame):
tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2", tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2",
font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad) font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad)
# Row 2: raw taps (always enabled — timestamped names generated at start) # Row 2: buttons + status
self._raw_bw_on = tk.BooleanVar(value=True)
self._raw_s3_on = tk.BooleanVar(value=True)
tk.Checkbutton(cfg, text="Capture BW->S3 raw", variable=self._raw_bw_on,
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad)
tk.Checkbutton(cfg, text="Capture S3->BW raw", variable=self._raw_s3_on,
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad)
# Row 3: buttons + status
btn_row = tk.Frame(self, bg=BG2) btn_row = tk.Frame(self, bg=BG2)
btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2) btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2)
@@ -170,6 +168,18 @@ class BridgePanel(tk.Frame):
command=self.stop_bridge, state="disabled") command=self.stop_bridge, state="disabled")
self.stop_btn.pack(side=tk.LEFT, padx=4) self.stop_btn.pack(side=tk.LEFT, padx=4)
tk.Frame(btn_row, bg=BG2, width=16).pack(side=tk.LEFT) # spacer
self.cap_btn = tk.Button(btn_row, text="⬤ New Capture", bg=ORANGE, fg="#000000",
relief="flat", padx=10, cursor="hand2", font=MONO_B,
command=self._start_capture, state="disabled")
self.cap_btn.pack(side=tk.LEFT, padx=4)
self.stop_cap_btn = tk.Button(btn_row, text="■ Stop Capture", bg=BG3, fg=RED,
relief="flat", padx=10, cursor="hand2", font=MONO_B,
command=self._stop_capture, state="disabled")
self.stop_cap_btn.pack(side=tk.LEFT, padx=4)
self.mark_btn = tk.Button(btn_row, text="Add Mark", bg=BG3, fg=FG, self.mark_btn = tk.Button(btn_row, text="Add Mark", bg=BG3, fg=FG,
relief="flat", padx=10, cursor="hand2", font=MONO, relief="flat", padx=10, cursor="hand2", font=MONO,
command=self.add_mark, state="disabled") command=self.add_mark, state="disabled")
@@ -179,9 +189,34 @@ class BridgePanel(tk.Frame):
tk.Label(btn_row, textvariable=self.status_var, tk.Label(btn_row, textvariable=self.status_var,
bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10) bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10)
# Capture history panel
hist_outer = tk.Frame(self, bg=BG2)
hist_outer.pack(side=tk.TOP, fill=tk.X, padx=4, pady=(2, 0))
tk.Label(hist_outer, text="Captures:", bg=BG2, fg=FG_DIM,
font=MONO_SM, anchor="w").pack(side=tk.LEFT, padx=(4, 6))
hist_inner = tk.Frame(hist_outer, bg=BG2)
hist_inner.pack(side=tk.LEFT, fill=tk.X, expand=True)
self._hist_lb = tk.Listbox(
hist_inner, bg=BG3, fg=FG, font=MONO_SM,
height=3, relief="flat", selectbackground=BG,
selectforeground=ACCENT, activestyle="none",
highlightthickness=0,
)
hist_vsb = ttk.Scrollbar(hist_inner, orient="vertical", command=self._hist_lb.yview)
self._hist_lb.configure(yscrollcommand=hist_vsb.set)
hist_vsb.pack(side=tk.RIGHT, fill=tk.Y)
self._hist_lb.pack(side=tk.LEFT, fill=tk.X, expand=True)
self._hist_lb.bind("<Double-Button-1>", self._on_hist_dblclick)
tk.Label(hist_outer, text="dbl-click to reload", bg=BG2, fg=FG_DIM,
font=MONO_SM, anchor="e").pack(side=tk.RIGHT, padx=6)
# Log output # Log output
self.log_view = scrolledtext.ScrolledText( self.log_view = scrolledtext.ScrolledText(
self, height=18, font=MONO_SM, self, height=14, font=MONO_SM,
bg=BG, fg=FG, insertbackground=FG, bg=BG, fg=FG, insertbackground=FG,
relief="flat", state="disabled", relief="flat", state="disabled",
) )
@@ -221,14 +256,8 @@ class BridgePanel(tk.Frame):
args = [sys.executable, str(BRIDGE_PATH), args = [sys.executable, str(BRIDGE_PATH),
"--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir] "--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
# Raw BW/S3 taps are NOT opened at bridge start.
raw_bw_path = raw_s3_path = None # Use "New Capture" to start a labeled tap on demand.
if self._raw_bw_on.get():
raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin")
args += ["--raw-bw", raw_bw_path]
if self._raw_s3_on.get():
raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin")
args += ["--raw-s3", raw_s3_path]
# Structured bin path — written by bridge automatically, named by ts # Structured bin path — written by bridge automatically, named by ts
struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin") struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin")
@@ -250,11 +279,12 @@ class BridgePanel(tk.Frame):
self.status_var.set(f"Running — {bw} <-> {s3}") self.status_var.set(f"Running — {bw} <-> {s3}")
self.start_btn.configure(state="disabled") self.start_btn.configure(state="disabled")
self.stop_btn.configure(state="normal", bg=RED) self.stop_btn.configure(state="normal", bg=RED)
self.mark_btn.configure(state="normal") self.cap_btn.configure(state="normal")
self._append_log(f"== Bridge started [{ts}] ==\n") self._append_log(f"== Bridge started [{ts}] ==\n")
self._append_log(" Click 'New Capture' when ready to record a setting change.\n")
# Notify parent so Analyzer can wire up live mode # Notify parent — no raw files yet, just the structured bin path
self._on_started(raw_bw_path, raw_s3_path, struct_bin_path) self._on_started(struct_bin_path)
def stop_bridge(self) -> None: def stop_bridge(self) -> None:
if self.process and self.process.poll() is None: if self.process and self.process.poll() is None:
@@ -270,7 +300,11 @@ class BridgePanel(tk.Frame):
self.status_var.set("Stopped") self.status_var.set("Stopped")
self.start_btn.configure(state="normal") self.start_btn.configure(state="normal")
self.stop_btn.configure(state="disabled", bg=BG3) self.stop_btn.configure(state="disabled", bg=BG3)
self.cap_btn.configure(state="disabled")
self.stop_cap_btn.configure(state="disabled", bg=BG3)
self.mark_btn.configure(state="disabled") self.mark_btn.configure(state="disabled")
self._capturing = False
self._cap_label = None
self._append_log("== Bridge stopped ==\n") self._append_log("== Bridge stopped ==\n")
def _reader_thread(self) -> None: def _reader_thread(self) -> None:
@@ -288,12 +322,120 @@ class BridgePanel(tk.Frame):
self._bridge_ended() self._bridge_ended()
self._on_stopped() self._on_stopped()
break break
stripped = line.strip()
# Handle capture lifecycle events from bridge
if stripped.startswith("[CAP_START] ") and "\t" in stripped:
parts = stripped[12:].split("\t", 1)
if len(parts) == 2:
bw_path, s3_path = parts[0].strip(), parts[1].strip()
self._on_cap_started_msg(bw_path, s3_path)
elif stripped.startswith("[CAP_STOP] ") and "\t" in stripped:
parts = stripped[11:].split("\t", 1)
if len(parts) == 2:
bw_path, s3_path = parts[0].strip(), parts[1].strip()
self._on_cap_stopped_msg(bw_path, s3_path)
self._append_log(line) self._append_log(line)
except queue.Empty: except queue.Empty:
pass pass
finally: finally:
self.after(100, self._poll_stdout) self.after(100, self._poll_stdout)
# ── capture control ───────────────────────────────────────────────────
def _start_capture(self) -> None:
"""Ask for a label and tell the bridge to start writing raw tap files."""
if not self.process or self.process.poll() is not None:
return
label = simpledialog.askstring(
"New Capture",
"Label for this capture\n(e.g. 'recording_mode_continuous').\nLeave blank for timestamp only:",
parent=self,
)
if label is None:
return # user hit Cancel
label = label.strip()
try:
self.process.stdin.write(f"CAP_START:{label}\n")
self.process.stdin.flush()
except Exception as e:
messagebox.showerror("Error", f"Failed to start capture:\n{e}")
return
self._capturing = True
self._cap_label = label or datetime.datetime.now().strftime("%H%M%S")
self.cap_btn.configure(state="disabled")
self.stop_cap_btn.configure(state="normal", bg=RED)
self.mark_btn.configure(state="normal")
self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n")
# Add to history as recording (paths filled in when [CAP_START] arrives)
self._cap_history.append({"label": self._cap_label, "status": "recording",
"bw": None, "s3": None})
self._refresh_hist()
def _stop_capture(self) -> None:
"""Tell the bridge to flush and close the current raw tap files."""
if not self.process or self.process.poll() is not None:
return
try:
self.process.stdin.write("CAP_STOP\n")
self.process.stdin.flush()
except Exception as e:
messagebox.showerror("Error", f"Failed to stop capture:\n{e}")
# UI is updated when [CAP_STOP] arrives in stdout
def _on_cap_started_msg(self, bw_path: str, s3_path: str) -> None:
"""Called when bridge confirms capture has started (files are open)."""
# Fill in paths for the last 'recording' history entry
for entry in reversed(self._cap_history):
if entry["status"] == "recording" and entry["bw"] is None:
entry["bw"] = bw_path
entry["s3"] = s3_path
break
if self._on_cap_started:
self._on_cap_started(bw_path, s3_path, self._cap_label or "")
def _on_cap_stopped_msg(self, bw_path: str, s3_path: str) -> None:
"""Called when bridge confirms capture has stopped (files are closed)."""
label = self._cap_label or "capture"
# Mark history entry as done
for entry in reversed(self._cap_history):
if entry["status"] == "recording":
entry["status"] = "done"
entry["bw"] = bw_path
entry["s3"] = s3_path
break
self._refresh_hist()
self._capturing = False
self._cap_label = None
self.cap_btn.configure(state="normal")
self.stop_cap_btn.configure(state="disabled", bg=BG3)
self._append_log(f"[CAPTURE] Done: {label!r} — ready in Analyzer\n")
if self._on_cap_complete:
self._on_cap_complete(bw_path, s3_path, label)
def _refresh_hist(self) -> None:
self._hist_lb.delete(0, tk.END)
for entry in self._cap_history:
icon = "🔴" if entry["status"] == "recording" else ""
label = entry["label"] or "(unlabeled)"
self._hist_lb.insert(tk.END, f" {icon} {label}")
if self._cap_history:
self._hist_lb.see(tk.END)
def _on_hist_dblclick(self, _e=None) -> None:
sel = self._hist_lb.curselection()
if not sel:
return
entry = self._cap_history[sel[0]]
if entry["status"] == "done" and entry["bw"] and entry["s3"]:
if self._on_cap_complete:
self._on_cap_complete(entry["bw"], entry["s3"], entry["label"])
# ── mark ──────────────────────────────────────────────────────────────
def add_mark(self) -> None: def add_mark(self) -> None:
if not self.process or not self.process.stdin or self.process.poll() is not None: if not self.process or not self.process.stdin or self.process.poll() is not None:
return return
@@ -1884,6 +2026,8 @@ class SeismoLab(tk.Tk):
nb, nb,
on_bridge_started=self._on_bridge_started, on_bridge_started=self._on_bridge_started,
on_bridge_stopped=self._on_bridge_stopped, on_bridge_stopped=self._on_bridge_stopped,
on_capture_started=self._on_capture_started,
on_capture_complete=self._on_capture_complete,
) )
nb.add(self._bridge_panel, text=" Bridge ") nb.add(self._bridge_panel, text=" Bridge ")
@@ -1905,16 +2049,27 @@ class SeismoLab(tk.Tk):
self._nb = nb self._nb = nb
self.protocol("WM_DELETE_WINDOW", self._on_close) self.protocol("WM_DELETE_WINDOW", self._on_close)
def _on_bridge_started(self, raw_bw: Optional[str], raw_s3: Optional[str], def _on_bridge_started(self, struct_bin: Optional[str] = None) -> None:
struct_bin: Optional[str] = None) -> None: """Bridge started — stash the structured bin path; stay on Bridge tab."""
"""Bridge started — inject paths into analyzer and start live mode.""" if struct_bin:
self._analyzer_panel.set_live_files(raw_bw, raw_s3, struct_bin) self._analyzer_panel.bin_var.set(struct_bin)
# Switch to Analyzer tab so the user can watch it update
self._nb.select(1)
def _on_bridge_stopped(self) -> None: def _on_bridge_stopped(self) -> None:
self._analyzer_panel.stop_live() self._analyzer_panel.stop_live()
def _on_capture_started(self, bw_path: str, s3_path: str, label: str) -> None:
"""A capture began — wire up live mode in the Analyzer and switch tabs."""
self._analyzer_panel.set_live_files(bw_path, s3_path)
self._nb.select(1)
def _on_capture_complete(self, bw_path: str, s3_path: str, label: str) -> None:
"""A capture stopped — stop live mode, run full analysis, switch to Analyzer."""
self._analyzer_panel.stop_live()
self._analyzer_panel.s3_var.set(s3_path)
self._analyzer_panel.bw_var.set(bw_path)
self._analyzer_panel._run_analyze()
self._nb.select(1)
def _on_console_send_to_analyzer(self, raw_s3_path: str) -> None: def _on_console_send_to_analyzer(self, raw_s3_path: str) -> None:
"""Console captured bytes → inject into Analyzer S3 field and switch tab.""" """Console captured bytes → inject into Analyzer S3 field and switch tab."""
self._analyzer_panel.s3_var.set(raw_s3_path) self._analyzer_panel.s3_var.set(raw_s3_path)
+182 -16
View File
@@ -59,7 +59,7 @@ except ImportError:
from minimateplus import MiniMateClient from minimateplus import MiniMateClient
from minimateplus.protocol import ProtocolError from minimateplus.protocol import ProtocolError
from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp from minimateplus.models import CallHomeConfig, ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
from sfm.cache import SFMCache, get_cache from sfm.cache import SFMCache, get_cache
from sfm.database import SeismoDb from sfm.database import SeismoDb
@@ -285,11 +285,14 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
if cc is None: if cc is None:
return None return None
return { return {
"record_time": cc.record_time, "recording_mode": cc.recording_mode, # 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
"sample_rate": cc.sample_rate, "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, "trigger_level_geo": cc.trigger_level_geo,
"alarm_level_geo": cc.alarm_level_geo, "alarm_level_geo": cc.alarm_level_geo,
"max_range_geo": cc.max_range_geo, "geo_adc_scale": cc.geo_adc_scale, # hw scale factor (in/s)/V — informational only, do not write
"geo_range": cc.geo_range, # CONFIRMED 2026-04-20: 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
"setup_name": cc.setup_name, "setup_name": cc.setup_name,
"project": cc.project, "project": cc.project,
"client": cc.client, "client": cc.client,
@@ -299,6 +302,27 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
} }
def _serialise_call_home_config(ch: Optional["CallHomeConfig"]) -> Optional[dict]:
if ch is None:
return None
return {
"auto_call_home_enabled": ch.auto_call_home_enabled,
"dial_string": ch.dial_string,
"after_event_recorded": ch.after_event_recorded,
"at_specified_times": ch.at_specified_times,
"time1_enabled": ch.time1_enabled,
"time1_hour": ch.time1_hour,
"time1_min": ch.time1_min,
"time2_enabled": ch.time2_enabled,
"time2_hour": ch.time2_hour,
"time2_min": ch.time2_min,
"num_retries": ch.num_retries,
"time_between_retries_sec": ch.time_between_retries_sec,
"wait_for_connection_sec": ch.wait_for_connection_sec,
"warm_up_time_sec": ch.warm_up_time_sec,
}
def _serialise_device_info(info: DeviceInfo) -> dict: def _serialise_device_info(info: DeviceInfo) -> dict:
return { return {
"serial": info.serial, "serial": info.serial,
@@ -835,16 +859,15 @@ class DeviceConfigBody(BaseModel):
Recording parameters Recording parameters
-------------------- --------------------
sample_rate : Samples per second. Valid values: 1024, 2048, 4096. recording_mode : Recording mode enum. Values: 0=Single Shot, 1=Continuous, 3=Histogram, 4=Histogram+Continuous.
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0). sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
Trigger / alarm thresholds (geo channels, in/s) Trigger / alarm thresholds and range (geo channels)
------------------------------------------------ ----------------------------------------------------
trigger_level_geo : Trigger threshold in in/s (e.g. 0.5). trigger_level_geo : Trigger threshold in in/s (e.g. 0.5).
alarm_level_geo : Alarm threshold in in/s (e.g. 1.0). alarm_level_geo : Alarm threshold in in/s (e.g. 1.0).
max_range_geo : Full-scale calibration constant (e.g. 6.206). geo_range : Geophone range/sensitivity. 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s.
Rarely changed only set if you know what you're doing.
Project / operator strings (max 41 ASCII characters each) Project / operator strings (max 41 ASCII characters each)
---------------------------- ----------------------------
project : Project description. project : Project description.
@@ -854,12 +877,14 @@ class DeviceConfigBody(BaseModel):
notes : Extended notes. notes : Extended notes.
""" """
# Recording parameters # Recording parameters
sample_rate: Optional[int] = None recording_mode: Optional[int] = None
record_time: Optional[float] = None sample_rate: Optional[int] = None
# Threshold parameters record_time: Optional[float] = None
histogram_interval_sec: Optional[int] = None # seconds: 2, 5, 15, 60, 300, 900 (mode-gated)
# Threshold parameters / geo range
trigger_level_geo: Optional[float] = None trigger_level_geo: Optional[float] = None
alarm_level_geo: Optional[float] = None alarm_level_geo: Optional[float] = None
max_range_geo: Optional[float] = None geo_range: Optional[int] = None # 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s
# Project / operator strings # Project / operator strings
project: Optional[str] = None project: Optional[str] = None
client_name: Optional[str] = None client_name: Optional[str] = None
@@ -887,6 +912,7 @@ def device_config(
Example body (all fields optional include only what you want to change): Example body (all fields optional include only what you want to change):
{ {
"recording_mode": 1,
"sample_rate": 1024, "sample_rate": 1024,
"record_time": 3.0, "record_time": 3.0,
"trigger_level_geo": 0.5, "trigger_level_geo": 0.5,
@@ -914,11 +940,13 @@ def device_config(
with _build_client(port, baud, host, tcp_port) as client: with _build_client(port, baud, host, tcp_port) as client:
client.connect() client.connect()
client.apply_config( client.apply_config(
recording_mode=body.recording_mode,
sample_rate=body.sample_rate, sample_rate=body.sample_rate,
record_time=body.record_time, record_time=body.record_time,
histogram_interval_sec=body.histogram_interval_sec,
trigger_level_geo=body.trigger_level_geo, trigger_level_geo=body.trigger_level_geo,
alarm_level_geo=body.alarm_level_geo, alarm_level_geo=body.alarm_level_geo,
max_range_geo=body.max_range_geo, geo_range=body.geo_range,
project=body.project, project=body.project,
client_name=body.client_name, client_name=body.client_name,
operator=body.operator, operator=body.operator,
@@ -1068,6 +1096,144 @@ def device_monitor_stop(
return {"status": "stopped"} return {"status": "stopped"}
# ── Call home config endpoints ───────────────────────────────────────────────
@app.get("/device/call_home")
def device_call_home_get(
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
) -> dict:
"""
Read the Auto Call Home (ACH) configuration from the device.
Sends SUB 0x2C (two-step read) and returns the decoded call home config.
Confirmed from 4-20-26 call home settings captures (BE11529).
Returns:
{
"auto_call_home_enabled": true/false,
"dial_string": "RADIO RING",
"after_event_recorded": true/false,
"at_specified_times": true/false,
"time1_enabled": true/false, "time1_hour": 19, "time1_min": 55,
"time2_enabled": false, "time2_hour": 0, "time2_min": 0,
"num_retries": 3,
"time_between_retries_sec": 15,
"wait_for_connection_sec": 60,
"warm_up_time_sec": 60
}
"""
try:
def _do():
with _build_client(port, baud, host, tcp_port) as client:
client.poll()
return client.get_call_home_config()
ch_config = _run_with_retry(_do, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
except OSError as exc:
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
return _serialise_call_home_config(ch_config) or {}
class CallHomeConfigBody(BaseModel):
"""
Request body for POST /device/call_home.
All fields are optional only supplied (non-null) fields are modified.
All other call home config bytes are round-tripped verbatim from the device.
Confirmed writable fields (4-20-26 captures):
auto_call_home_enabled : bool master enable for auto call home
after_event_recorded : bool call home after each triggered event
at_specified_times : bool enable time-based scheduled calls
time1_enabled : bool enable time slot 1
time1_hour : int hour for slot 1 (0-23; avoid 3 DLE escape limitation)
time1_min : int minute for slot 1 (0-59; avoid 3)
time2_enabled : bool enable time slot 2
time2_hour : int hour for slot 2 (0-23; avoid 3)
time2_min : int minute for slot 2 (0-59; avoid 3)
Read-only fields (not writable via this endpoint):
dial_string, num_retries, time_between_retries_sec,
wait_for_connection_sec, warm_up_time_sec
"""
auto_call_home_enabled: Optional[bool] = None
after_event_recorded: Optional[bool] = None
at_specified_times: Optional[bool] = None
time1_enabled: Optional[bool] = None
time1_hour: Optional[int] = None
time1_min: Optional[int] = None
time2_enabled: Optional[bool] = None
time2_hour: Optional[int] = None
time2_min: Optional[int] = None
@app.post("/device/call_home")
def device_call_home_set(
body: CallHomeConfigBody,
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
) -> dict:
"""
Read the current call home config, apply supplied changes, and write back.
Only non-null fields are modified. All other bytes round-trip verbatim.
Write sequence (confirmed from 4-20-26 call home settings captures):
SUB 0x2C (read 2-step) 125-byte raw payload
patch fields
SUB 0x7E (write 127-byte payload) ack 0x81
SUB 0x7F (confirm) ack 0x80
Example body:
{ "auto_call_home_enabled": true, "after_event_recorded": true,
"time1_enabled": true, "time1_hour": 20, "time1_min": 0 }
"""
changed = body.model_dump(exclude_none=True)
log.info("POST /device/call_home port=%s host=%s fields=%s", port, host, list(changed.keys()))
try:
def _do():
with _build_client(port, baud, host, tcp_port) as client:
client.poll()
client.set_call_home_config(
auto_call_home_enabled=body.auto_call_home_enabled,
after_event_recorded=body.after_event_recorded,
at_specified_times=body.at_specified_times,
time1_enabled=body.time1_enabled,
time1_hour=body.time1_hour,
time1_min=body.time1_min,
time2_enabled=body.time2_enabled,
time2_hour=body.time2_hour,
time2_min=body.time2_min,
)
_run_with_retry(_do, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
except OSError as exc:
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
return {"status": "ok", "updated_fields": changed}
# ── Cache management endpoints ──────────────────────────────────────────────── # ── Cache management endpoints ────────────────────────────────────────────────
@app.get("/cache/stats") @app.get("/cache/stats")
+308 -19
View File
@@ -736,9 +736,10 @@
<!-- ── Live tab bar ───────────────────────────────────────────────── --> <!-- ── Live tab bar ───────────────────────────────────────────────── -->
<div class="tab-bar" id="live-tab-bar"> <div class="tab-bar" id="live-tab-bar">
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button> <button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button> <button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button> <button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
<button class="tab-btn" data-tab="call-home" onclick="switchTab('call-home')">Call Home</button>
</div> </div>
<!-- ════════════════════════════════════════════════════════════════ <!-- ════════════════════════════════════════════════════════════════
@@ -803,6 +804,17 @@
<div class="cfg-section"> <div class="cfg-section">
<div class="cfg-section-title">Recording</div> <div class="cfg-section-title">Recording</div>
<div class="cfg-field">
<label>Recording Mode</label>
<select id="cfg-recording-mode">
<option value="">— unchanged —</option>
<option value="0">Single Shot</option>
<option value="1">Continuous</option>
<option value="3">Histogram</option>
<option value="4">Histogram + Continuous</option>
</select>
</div>
<div class="cfg-field"> <div class="cfg-field">
<label>Sample Rate</label> <label>Sample Rate</label>
<select id="cfg-sample-rate"> <select id="cfg-sample-rate">
@@ -813,6 +825,20 @@
</select> </select>
</div> </div>
<div class="cfg-field">
<label>Histogram Interval</label>
<select id="cfg-histogram-interval">
<option value="">— unchanged —</option>
<option value="2">2 seconds</option>
<option value="5">5 seconds</option>
<option value="15">15 seconds</option>
<option value="60">1 minute</option>
<option value="300">5 minutes</option>
<option value="900">15 minutes</option>
</select>
<div class="hint">Only active in Histogram / Histogram + Continuous mode</div>
</div>
<div class="cfg-field"> <div class="cfg-field">
<label>Record Time (seconds)</label> <label>Record Time (seconds)</label>
<input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" /> <input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" />
@@ -832,10 +858,15 @@
</div> </div>
<div class="cfg-field"> <div class="cfg-field">
<label>Max Range — Geo (in/s)</label> <label>Maximum Range — Geo</label>
<input type="number" id="cfg-max-range" step="0.001" min="0.001" placeholder="e.g. 6.206" /> <select id="cfg-geo-range">
<div class="hint">Full-scale calibration constant — only change if you have a cal cert</div> <option value="">— unchanged —</option>
<option value="0">Normal — 10.000 in/s</option>
<option value="1">Sensitive — 1.250 in/s</option>
</select>
<div class="hint">Geophone sensitivity (applies to Tran / Vert / Long channels)</div>
</div> </div>
</div> </div>
<!-- Project / operator strings --> <!-- Project / operator strings -->
@@ -879,6 +910,123 @@
</div><!-- end #tab-config --> </div><!-- end #tab-config -->
<!-- ════════════════════════════════════════════════════════════════
TAB: Call Home
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-call-home" class="tab-pane">
<div class="cfg-grid">
<!-- Enable / dial -->
<div class="cfg-section">
<div class="cfg-section-title">Auto Call Home</div>
<div class="cfg-field">
<label>Enable Auto Call Home</label>
<select id="ch-enabled">
<option value="">— unchanged —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="cfg-field">
<label>Dial String</label>
<input type="text" id="ch-dial-string" disabled placeholder="Read-only (e.g. RADIO RING)" />
<div class="hint">Read from device — not writable via this interface</div>
</div>
<div class="cfg-section-title" style="margin-top:16px">When to Call</div>
<div class="cfg-field">
<label>After Event Recorded</label>
<select id="ch-after-event">
<option value="">— unchanged —</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="cfg-field">
<label>At Specified Times</label>
<select id="ch-at-times">
<option value="">— unchanged —</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<!-- Scheduled call times -->
<div class="cfg-section">
<div class="cfg-section-title">Scheduled Call Times</div>
<div class="cfg-field">
<label>Time Slot 1</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="ch-t1-enabled" style="width:120px">
<option value="">— enable —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
<input type="number" id="ch-t1-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
<span>:</span>
<input type="number" id="ch-t1-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
</div>
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
</div>
<div class="cfg-field">
<label>Time Slot 2</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="ch-t2-enabled" style="width:120px">
<option value="">— enable —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
<input type="number" id="ch-t2-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
<span>:</span>
<input type="number" id="ch-t2-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
</div>
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
</div>
<div class="cfg-section-title" style="margin-top:16px">Retry Settings (read-only)</div>
<div class="cfg-field">
<label>Number of Retries</label>
<input type="text" id="ch-num-retries" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Time Between Retries (s)</label>
<input type="text" id="ch-retry-gap" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Wait for Connection (s)</label>
<input type="text" id="ch-wait-conn" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Warm-up Time (s)</label>
<input type="text" id="ch-warmup" disabled placeholder="—" />
</div>
</div>
</div>
<div class="cfg-actions">
<button class="btn btn-ghost" id="ch-read-btn" onclick="readCallHome()" disabled>Read from Device</button>
<button class="btn btn-success" id="ch-write-btn" onclick="writeCallHome()" disabled>Write to Device</button>
<button class="btn btn-ghost" onclick="clearCallHomeForm()">Clear Form</button>
<span id="ch-status"></span>
</div>
</div><!-- end #tab-call-home -->
</div><!-- end #section-live --> </div><!-- end #section-live -->
<!-- ════════════════════════════════════════════════════════════════ <!-- ════════════════════════════════════════════════════════════════
@@ -1037,7 +1185,7 @@ let unitInfo = null;
let eventList = []; let eventList = [];
let currentEvent = 0; let currentEvent = 0;
let charts = {}; let charts = {};
let geoRange = 6.206; let geoAdcScale = 6.206;
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' }; const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
@@ -1162,6 +1310,8 @@ async function connectUnit() {
document.getElementById('next-btn').disabled = eventList.length <= 1; document.getElementById('next-btn').disabled = eventList.length <= 1;
document.getElementById('cfg-read-btn').disabled = false; document.getElementById('cfg-read-btn').disabled = false;
document.getElementById('cfg-write-btn').disabled = false; document.getElementById('cfg-write-btn').disabled = false;
document.getElementById('ch-read-btn').disabled = false;
document.getElementById('ch-write-btn').disabled = false;
btn.disabled = false; btn.textContent = 'Reconnect'; btn.disabled = false; btn.textContent = 'Reconnect';
@@ -1189,7 +1339,7 @@ function populateDeviceBar() {
qs('di-project').textContent = cc.project || '—'; qs('di-project').textContent = cc.project || '—';
qs('di-client').textContent = cc.client || '—'; qs('di-client').textContent = cc.client || '—';
qs('di-operator').textContent = cc.operator || '—'; qs('di-operator').textContent = cc.operator || '—';
geoRange = cc.max_range_geo ?? 6.206; geoAdcScale = cc.geo_adc_scale ?? 6.206;
} }
// ── Monitoring ───────────────────────────────────────────────────────────────── // ── Monitoring ─────────────────────────────────────────────────────────────────
@@ -1326,12 +1476,16 @@ function populateDeviceTab() {
// Compliance table // Compliance table
const cc = unitInfo.compliance_config || {}; const cc = unitInfo.compliance_config || {};
const RECORDING_MODE_LABELS = {0: 'Single Shot', 1: 'Continuous', 3: 'Histogram', 4: 'Histogram + Continuous'};
const complianceRows = [ const complianceRows = [
['Recording Mode', cc.recording_mode != null ? (RECORDING_MODE_LABELS[cc.recording_mode] || `0x${cc.recording_mode.toString(16).padStart(2,'0')}`) : '—'],
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'], ['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
['Histogram Interval', cc.histogram_interval_sec != null ? (() => { const s = cc.histogram_interval_sec; return s < 60 ? `${s}s` : `${s/60}m`; })() : ''],
['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'], ['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'],
['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'], ['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'],
['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'], ['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'],
['Max Range (geo)', cc.max_range_geo != null ? `${cc.max_range_geo.toFixed(4)} in/s` : '—'], ['Max Range (geo)', cc.geo_range != null ? (cc.geo_range === 0 ? 'Normal — 10.000 in/s' : cc.geo_range === 1 ? 'Sensitive — 1.250 in/s' : `0x${cc.geo_range.toString(16).padStart(2,'0')}`) : '—'],
['ADC Scale Factor (geo)', cc.geo_adc_scale != null ? `${cc.geo_adc_scale.toFixed(4)} in/s` : '—'],
['Setup Name', cc.setup_name || '—'], ['Setup Name', cc.setup_name || '—'],
]; ];
renderTable('compliance-table', complianceRows); renderTable('compliance-table', complianceRows);
@@ -1362,11 +1516,13 @@ function renderTable(id, rows) {
function populateConfigFromDeviceInfo() { function populateConfigFromDeviceInfo() {
if (!unitInfo) return; if (!unitInfo) return;
const cc = unitInfo.compliance_config || {}; const cc = unitInfo.compliance_config || {};
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate)); if (cc.recording_mode != null) qs('cfg-recording-mode', String(cc.recording_mode));
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1)); if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4)); if (cc.histogram_interval_sec != null) qs('cfg-histogram-interval', String(cc.histogram_interval_sec));
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4)); if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
if (cc.max_range_geo != null) qs('cfg-max-range',cc.max_range_geo.toFixed(4)); if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
if (cc.geo_range != null) qs('cfg-geo-range', String(cc.geo_range));
if (cc.project) qs('cfg-project', cc.project); if (cc.project) qs('cfg-project', cc.project);
if (cc.client) qs('cfg-client', cc.client); if (cc.client) qs('cfg-client', cc.client);
if (cc.operator) qs('cfg-operator', cc.operator); if (cc.operator) qs('cfg-operator', cc.operator);
@@ -1375,8 +1531,9 @@ function populateConfigFromDeviceInfo() {
} }
function clearConfigForm() { function clearConfigForm() {
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm','cfg-max-range', ['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm',
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes'] 'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes',
'cfg-recording-mode','cfg-histogram-interval','cfg-geo-range']
.forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; }); .forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; });
setCfgStatus(''); setCfgStatus('');
} }
@@ -1405,16 +1562,20 @@ async function writeConfig() {
// Build body — only include fields that have values // Build body — only include fields that have values
const body = {}; const body = {};
const rm = qs('cfg-recording-mode').value;
if (rm !== '') body.recording_mode = parseInt(rm, 10);
const sr = qs('cfg-sample-rate').value; const sr = qs('cfg-sample-rate').value;
if (sr) body.sample_rate = parseInt(sr, 10); if (sr) body.sample_rate = parseInt(sr, 10);
const hi = qs('cfg-histogram-interval').value;
if (hi !== '') body.histogram_interval_sec = parseInt(hi, 10);
const rt = qs('cfg-record-time').value; const rt = qs('cfg-record-time').value;
if (rt) body.record_time = parseFloat(rt); if (rt) body.record_time = parseFloat(rt);
const trig = qs('cfg-trigger').value; const trig = qs('cfg-trigger').value;
if (trig) body.trigger_level_geo = parseFloat(trig); if (trig) body.trigger_level_geo = parseFloat(trig);
const alarm = qs('cfg-alarm').value; const alarm = qs('cfg-alarm').value;
if (alarm) body.alarm_level_geo = parseFloat(alarm); if (alarm) body.alarm_level_geo = parseFloat(alarm);
const mr = qs('cfg-max-range').value; const gr = qs('cfg-geo-range').value;
if (mr) body.max_range_geo = parseFloat(mr); if (gr !== '') body.geo_range = parseInt(gr, 10);
const proj = qs('cfg-project').value.trim(); const proj = qs('cfg-project').value.trim();
if (proj) body.project = proj; if (proj) body.project = proj;
const cli = qs('cfg-client').value.trim(); const cli = qs('cfg-client').value.trim();
@@ -1453,6 +1614,134 @@ async function writeConfig() {
} }
} }
// ── Call Home form ─────────────────────────────────────────────────────────────
function setChStatus(msg, type) {
const el = document.getElementById('ch-status');
el.textContent = msg;
el.style.color = type === 'ok' ? '#4caf50' : type === 'error' ? '#f44336' : '#aaa';
}
function populateCallHomeForm(ch) {
if (!ch) return;
const qs2 = id => document.getElementById(id);
// Read-only display fields
if (ch.dial_string != null) qs2('ch-dial-string').value = ch.dial_string || '';
if (ch.num_retries != null) qs2('ch-num-retries').value = ch.num_retries;
if (ch.time_between_retries_sec != null) qs2('ch-retry-gap').value = ch.time_between_retries_sec;
if (ch.wait_for_connection_sec != null) qs2('ch-wait-conn').value = ch.wait_for_connection_sec;
if (ch.warm_up_time_sec != null) qs2('ch-warmup').value = ch.warm_up_time_sec;
// Editable select/input fields (use "" for "unchanged" state when value is null)
function setBool(id, val) {
if (val != null) document.getElementById(id).value = val ? 'true' : 'false';
}
setBool('ch-enabled', ch.auto_call_home_enabled);
setBool('ch-after-event', ch.after_event_recorded);
setBool('ch-at-times', ch.at_specified_times);
setBool('ch-t1-enabled', ch.time1_enabled);
setBool('ch-t2-enabled', ch.time2_enabled);
if (ch.time1_hour != null) qs2('ch-t1-hour').value = ch.time1_hour;
if (ch.time1_min != null) qs2('ch-t1-min').value = ch.time1_min;
if (ch.time2_hour != null) qs2('ch-t2-hour').value = ch.time2_hour;
if (ch.time2_min != null) qs2('ch-t2-min').value = ch.time2_min;
}
function clearCallHomeForm() {
['ch-enabled','ch-after-event','ch-at-times','ch-t1-enabled','ch-t2-enabled']
.forEach(id => { document.getElementById(id).selectedIndex = 0; });
['ch-t1-hour','ch-t1-min','ch-t2-hour','ch-t2-min']
.forEach(id => { document.getElementById(id).value = ''; });
// Keep read-only display fields but clear them too
['ch-dial-string','ch-num-retries','ch-retry-gap','ch-wait-conn','ch-warmup']
.forEach(id => { document.getElementById(id).value = ''; });
setChStatus('');
}
async function readCallHome() {
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
setChStatus('Reading call home config from device…');
document.getElementById('ch-read-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const ch = await r.json();
populateCallHomeForm(ch);
setChStatus('Call home config loaded from device.', 'ok');
} catch(e) {
setChStatus(`Read failed: ${e.message}`, 'error');
} finally {
document.getElementById('ch-read-btn').disabled = false;
}
}
async function writeCallHome() {
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
// Build body — only include fields that have values
const body = {};
function getBool(id) {
const v = document.getElementById(id).value;
return v === '' ? null : v === 'true';
}
function getIntField(id) {
const v = document.getElementById(id).value.trim();
return v === '' ? null : parseInt(v, 10);
}
const en = getBool('ch-enabled');
if (en !== null) body.auto_call_home_enabled = en;
const ae = getBool('ch-after-event');
if (ae !== null) body.after_event_recorded = ae;
const at = getBool('ch-at-times');
if (at !== null) body.at_specified_times = at;
const t1e = getBool('ch-t1-enabled');
if (t1e !== null) body.time1_enabled = t1e;
const t1h = getIntField('ch-t1-hour');
if (t1h !== null) body.time1_hour = t1h;
const t1m = getIntField('ch-t1-min');
if (t1m !== null) body.time1_min = t1m;
const t2e = getBool('ch-t2-enabled');
if (t2e !== null) body.time2_enabled = t2e;
const t2h = getIntField('ch-t2-hour');
if (t2h !== null) body.time2_hour = t2h;
const t2m = getIntField('ch-t2-min');
if (t2m !== null) body.time2_min = t2m;
if (Object.keys(body).length === 0) {
setChStatus('No fields to write — change at least one field.', 'error');
return;
}
// Warn about value 3 in hour/min fields
const hourMinFields = [body.time1_hour, body.time1_min, body.time2_hour, body.time2_min];
if (hourMinFields.some(v => v === 3)) {
setChStatus('Error: value 3 in hour/minute fields is not supported (DLE protocol limitation).', 'error');
return;
}
const fieldsStr = Object.keys(body).join(', ');
setChStatus(`Writing ${Object.keys(body).length} field(s)…`);
document.getElementById('ch-write-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
setChStatus(`Written: ${fieldsStr}`, 'ok');
// Re-read to confirm changes
await readCallHome();
} catch(e) {
setChStatus(`Write failed: ${e.message}`, 'error');
} finally {
document.getElementById('ch-write-btn').disabled = false;
}
}
// ── Events ───────────────────────────────────────────────────────────────────── // ── Events ─────────────────────────────────────────────────────────────────────
function populateEventChips() { function populateEventChips() {
const el = document.getElementById('event-chips'); const el = document.getElementById('event-chips');
@@ -1565,7 +1854,7 @@ function renderWaveform(data) {
let plotData, peakLabel, yUnit, ttFmt, tickFmt; let plotData, peakLabel, yUnit, ttFmt, tickFmt;
if (isGeo) { if (isGeo) {
const scale = geoRange / 32767; const scale = geoAdcScale / 32767;
plotData = samples.map(s => s * scale); plotData = samples.map(s => s * scale);
// Use the device-recorded peak from the 0C waveform record — authoritative // Use the device-recorded peak from the 0C waveform record — authoritative
// and matches Blastware. Computing from raw samples can catch rogue // and matches Blastware. Computing from raw samples can catch rogue
+3 -3
View File
@@ -240,7 +240,7 @@
let charts = {}; let charts = {};
let lastData = null; let lastData = null;
let unitInfo = null; let unitInfo = null;
let geoRange = 10.0; // in/s full-scale for geo channels; updated on connect let geoAdcScale = 10.0; // in/s full-scale for geo channels; updated on connect
let eventList = []; // populated from /device/events after connect let eventList = []; // populated from /device/events after connect
let currentEventIndex = 0; let currentEventIndex = 0;
@@ -278,7 +278,7 @@
throw new Error(err.detail || resp.statusText); throw new Error(err.detail || resp.statusText);
} }
unitInfo = await resp.json(); unitInfo = await resp.json();
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0; geoAdcScale = unitInfo.compliance_config?.geo_adc_scale ?? 10.0;
} catch (e) { } catch (e) {
setStatus(`Error: ${e.message}`, 'error'); setStatus(`Error: ${e.message}`, 'error');
btn.disabled = false; btn.disabled = false;
@@ -457,7 +457,7 @@
if (isGeo) { if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s // Geo channels: counts × (range / 32767) → in/s
const scale = geoRange / 32767; const scale = geoAdcScale / 32767;
plotSamples = samples.map(c => c * scale); plotSamples = samples.map(c => c * scale);
const peakIns = Math.max(...plotSamples.map(Math.abs)); const peakIns = Math.max(...plotSamples.map(Math.abs));
peakLabel = `${peakIns.toFixed(5)} in/s`; peakLabel = `${peakIns.toFixed(5)} in/s`;