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:
+160
-1
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user