feat(call-home): Implement Auto Call Home configuration management

- Added `CallHomeConfig` model to represent the Auto Call Home settings.
- Introduced methods in `MiniMateClient` for reading (`get_call_home_config`) and writing (`set_call_home_config`) the call home configuration.
- Updated `MiniMateProtocol` with new commands for call home operations (SUB 0x2C for read, SUB 0x7E for write, and SUB 0x7F for confirm).
- Created API endpoints for retrieving and updating call home settings in the server.
- Enhanced the web interface with a new "Call Home" tab for user interaction with call home settings.
- Implemented JavaScript functions for reading and writing call home configurations from the web app.
This commit is contained in:
2026-04-20 18:23:48 -04:00
parent 7bdd7c92f2
commit 3fb24e1895
8 changed files with 1081 additions and 8 deletions
+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)