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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user