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
+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');