diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3627d..a0bf491 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,57 @@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index eb56aca..5ab92f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem -(Sierra Wireless RV50 / RV55). Current version: **v0.12.2**. +(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 @@ -27,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 | |---|---|---| @@ -45,7 +45,8 @@ Full read pipeline + write pipeline + erase pipeline + monitor log working end-t | Event advance / next key | 1F | ✅ | | **Write commands (push config to device)** | **68–83** | ✅ new v0.8.0 | | **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 | -| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ **new v0.10.0** | +| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ new v0.10.0 | +| **Auto Call Home config (read + write)** | **2C → 7E → 7F** | ✅ **new v0.12.3** | `get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F` @@ -971,12 +972,104 @@ call-home. --- +## Auto Call Home config (SUBs 0x2C / 0x7E / 0x7F) — confirmed 2026-04-20 + +Full read/write pipeline confirmed from `bridges/captures/4-20-26/call home settings/` +(10 BW TX write frames diffed against the S3 read response). + +Accessible in Blastware: **Remote Access → Setup Unit**. + +### Protocol + +**SUB 0x2C — Call Home Config READ (response 0xD3)** + +Standard two-step read: probe offset `0x0000`, data offset `0x007C` (124). +Returns 125 raw bytes (one more than DATA_LENGTH) because the device encodes +num_retries value `3` as `\x10\x03` on the wire — S3FrameParser preserves both +bytes literally, shifting all subsequent field positions by +1. + +**SUB 0x7E — Call Home Config WRITE (response 0x81)** + +Write format (only BW_CMD `0x10` doubled on wire; DLE-aware checksum). +Payload = 125-byte read payload + `\x00\x00` = 127 bytes. +Offset = `data[1] + 2 = 0x7C + 2 = 0x7E`. + +**SUB 0x7F — Call Home WRITE CONFIRM (response 0x80)** + +Confirm frame, no data payload. Required after SUB 0x7E. + +### Field map (raw 125-byte array from `data_rsp.data[11:]`) + +| Raw Offset | Field | Notes | +|---|---|---| +| `[5]` | `auto_call_home_enabled` | `0x00`=off, `0x01`=on | +| `[6:46]` | `dial_string` | 40-byte null-padded ASCII | +| `[87]` | `after_event_recorded` | bool | +| `[91]` | `at_specified_times` | bool | +| `[93]` | `time1_enabled` | bool | +| `[101]` | `time1_hour` | 0–23 | +| `[102]` | `time1_min` | 0–59 | +| `[95]` | `time2_enabled` | bool | +| `[105]` | `time2_hour` | 0–23 | +| `[106]` | `time2_min` | 0–59 | +| `[117]` | DLE prefix `0x10` | Part of `\x10\x03` (DLE-escaped ETX encoding value 3) | +| `[118]` | `num_retries` | Value = 3; detect via `raw[117] == 0x10` | +| `[120]` | `time_between_retries_sec` | Shifted +1 from logical 119 | +| `[122]` | `wait_for_connection_sec` | Shifted +1 from logical 121 | +| `[124]` | `warm_up_time_sec` | Shifted +1 from logical 123 | + +**DLE-escaped 0x03 at raw[117:119]:** The byte value `0x03` is indistinguishable from the +frame ETX terminator, so the device encodes it as `\x10\x03` (DLE + ETX inner-terminator). +S3FrameParser in `STATE_AFTER_DLE` on ETX appends both bytes as literal payload. The write +frame sends them verbatim — device accepts `\x10\x03` and interprets it as value 3. + +**Unconfirmed fields:** time slots 3 and 4 (offsets unknown), `modem_power_relay_enabled`. + +### `CallHomeConfig` model — models.py + +```python +@dataclass +class CallHomeConfig: + raw: Optional[bytes] = None # 125-byte raw read payload + auto_call_home_enabled: Optional[bool] = None # raw[5] + dial_string: Optional[str] = None # raw[6:46] + after_event_recorded: Optional[bool] = None # raw[87] + at_specified_times: Optional[bool] = None # raw[91] + time1_enabled: Optional[bool] = None # raw[93] + time1_hour: Optional[int] = None # raw[101] + time1_min: Optional[int] = None # raw[102] + time2_enabled: Optional[bool] = None # raw[95] + time2_hour: Optional[int] = None # raw[105] + time2_min: Optional[int] = None # raw[106] + num_retries: Optional[int] = None # raw[118] (DLE-prefixed) + time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1) + wait_for_connection_sec: Optional[int] = None # raw[122] (shifted +1) + warm_up_time_sec: Optional[int] = None # raw[124] (shifted +1) +``` + +### SFM REST API — sfm/server.py + +``` +GET /device/call_home?host=1.2.3.4&tcp_port=9034 ← read call home config +POST /device/call_home?host=1.2.3.4&tcp_port=9034 ← write call home config +``` + +POST body fields (all optional): `auto_call_home_enabled`, `after_event_recorded`, +`at_specified_times`, `time1_enabled`, `time1_hour`, `time1_min`, `time2_enabled`, +`time2_hour`, `time2_min`. + +**Note:** `dial_string` is read-only in the current implementation (omitted from POST +body) because writing a dial string may require DLE escaping for embedded control characters. + +--- + ## What's next - **Database** — SQLite store for events + monitor log entries; dedup by key; queryable - **Histograms** — decode histogram-mode A5 data (noise floor tracking) - Compliance config encoder — build raw write payloads from a `ComplianceConfig` object - Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring) +- Call Home — map time slots 3/4 offsets; add dial_string write support; confirm `modem_power_relay_enabled` - Modem manager — push RV50/RV55 configs via Sierra Wireless API - RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred) \ No newline at end of file diff --git a/docs/instantel_protocol_reference.md b/docs/instantel_protocol_reference.md index 4b05488..7b49bcd 100644 --- a/docs/instantel_protocol_reference.md +++ b/docs/instantel_protocol_reference.md @@ -106,6 +106,7 @@ | 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. | --- @@ -267,6 +268,7 @@ Step 4 — Device sends actual data payload: | `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: 46–47 bytes IDLE, 48–49 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 | | `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 | | `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 | @@ -296,6 +298,7 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which, | `98` | `67` | ✅ CONFIRMED 2026-04-08 | | `96` | `69` | ✅ CONFIRMED 2026-04-08 | | `97` | `68` | ✅ CONFIRMED 2026-04-08 | +| `2C` | `D3` | ✅ CONFIRMED 2026-04-20 | | `A3` | `5C` | ✅ CONFIRMED 2026-04-11 | | `A2` | `5D` | ✅ CONFIRMED 2026-04-11 | @@ -317,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 | | `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ 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 | | `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED | @@ -330,6 +335,8 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0 | `72` | `8D` | | `73` | `8C` | | `74` | `8B` | +| `7E` | `81` | +| `7F` | `80` | | `82` | `7D` | | `83` | `7C` | @@ -1522,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 | 0–23 | +| `[102]` | `time1_min` | uint8 | 0–59 | +| `[95]` | `time2_enabled` | uint8 | `0x00` = off, `0x01` = on | +| `[105]` | `time2_hour` | uint8 | 0–23 | +| `[106]` | `time2_min` | uint8 | 0–59 | +| `[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 Two timestamp wire formats are used: diff --git a/minimateplus/client.py b/minimateplus/client.py index de3a850..471a266 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -35,6 +35,7 @@ from typing import Optional from .framing import S3Frame from .models import ( + CallHomeConfig, ComplianceConfig, DeviceInfo, Event, @@ -956,6 +957,93 @@ class MiniMateClient: 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: """ Perform just the POLL startup handshake — no config reads. @@ -2232,3 +2320,160 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus: memory_total=memory_total, 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) diff --git a/minimateplus/models.py b/minimateplus/models.py index 6cd8e74..cdb74d1 100644 --- a/minimateplus/models.py +++ b/minimateplus/models.py @@ -378,6 +378,78 @@ class ComplianceConfig: 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 ───────────────────────────────────────────────────────────────────── @dataclass diff --git a/minimateplus/protocol.py b/minimateplus/protocol.py index 35e99cf..0e2f048 100644 --- a/minimateplus/protocol.py +++ b/minimateplus/protocol.py @@ -65,6 +65,7 @@ SUB_WAVEFORM_HEADER = 0x0A SUB_WAVEFORM_RECORD = 0x0C SUB_BULK_WAVEFORM = 0x5A SUB_COMPLIANCE = 0x1A +SUB_CALL_HOME = 0x2C # Call home config read → response 0xD3 ✅ SUB_UNKNOWN_2E = 0x2E # 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_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) SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅ 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 # data[4]. Do NOT add it here; use read_waveform_header() instead. ✅ 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 🔶 0x09: 0xCA, # 202 bytes, purpose TBD 🔶 # SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total; @@ -1087,6 +1093,89 @@ class MiniMateProtocol: self._send(frame) 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 ──────────────────────────────────────────────────────────── def read_monitor_status(self) -> S3Frame: diff --git a/sfm/server.py b/sfm/server.py index fa9e696..407c680 100644 --- a/sfm/server.py +++ b/sfm/server.py @@ -59,7 +59,7 @@ except ImportError: from minimateplus import MiniMateClient 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 sfm.cache import SFMCache, get_cache from sfm.database import SeismoDb @@ -302,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: return { "serial": info.serial, @@ -1075,6 +1096,144 @@ def device_monitor_stop( 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 ──────────────────────────────────────────────── @app.get("/cache/stats") diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index a6c8f72..869c8e6 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -736,9 +736,10 @@
+ + +