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
8 changed files with 1081 additions and 8 deletions
Showing only changes of commit 3fb24e1895 - Show all commits
+51
View File
@@ -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
+97 -4
View File
@@ -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)** | **6883** | ✅ 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` | 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
- **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)
+118
View File
@@ -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: 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 |
| `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 | 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
Two timestamp wire formats are used:
+245
View File
@@ -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)
+72
View File
@@ -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
+89
View File
@@ -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:
+160 -1
View File
@@ -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")
+249 -3
View File
@@ -736,9 +736,10 @@
<!-- ── 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" data-tab="events" onclick="switchTab('events')">Events</button>
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</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="config" onclick="switchTab('config')">Config</button>
<button class="tab-btn" data-tab="call-home" onclick="switchTab('call-home')">Call Home</button>
</div>
<!-- ════════════════════════════════════════════════════════════════
@@ -909,6 +910,123 @@
</div><!-- end #tab-config -->
<!-- ════════════════════════════════════════════════════════════════
TAB: Call Home
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-call-home" class="tab-pane" style="display:none">
<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 -->
<!-- ════════════════════════════════════════════════════════════════
@@ -1494,6 +1612,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 ─────────────────────────────────────────────────────────────────────
function populateEventChips() {
const el = document.getElementById('event-chips');