diff --git a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx index 28e5e2f..3a7da93 100644 --- a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx +++ b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx @@ -11,16 +11,23 @@ import Tabs from '@/components/ui/Tabs' // Data // --------------------------------------------------------------------------- -const TRANSPORTS = [ - { transport: 'MQTT', address: 'vesper/{device_id}/control', direction: 'Inbound', notes: 'Commands in. Responses go to vesper/{device_id}/data' }, - { transport: 'MQTT', address: 'vesper/{device_id}/data', direction: 'Outbound', notes: 'Responses + events' }, - { transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained', notes: 'Heartbeat every 30 s' }, - { transport: 'MQTT', address: 'vesper/{device_id}/status/alerts', direction: 'Outbound, QoS 1', notes: 'Subsystem state changes (WARNING / CRITICAL / FAILED / CLEARED). Published on transition only.' }, - { transport: 'MQTT', address: 'vesper/{device_id}/status/info', direction: 'Outbound, QoS 0', notes: 'Significant device events (playback start/stop, etc.).' }, - { transport: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', notes: 'Requires identify on connect' }, - { transport: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', notes: 'Web console endpoints' }, - { transport: 'UART', address: 'Hardware serial', direction: 'Bidirectional', notes: 'Restricted — whitelist only' }, - { transport: 'UDP', address: 'Port 32101', direction: 'Discovery', notes: 'Passive — device announces itself' }, +// v2 MQTT topic spec — see docs/reference/vesper_mqtt_topic_spec_v2.md in the firmware repo. +// Every topic has an independent ON/OFF publish switch (mqtt.set_topics), except +// control/command + control/ack which share one switch ("command"). +const TRANSPORTS_V2 = [ + { transport: 'MQTT', address: 'vesper/{device_id}/control/command', direction: 'Inbound', switch: 'command', notes: 'Read-only from the board\'s perspective — commands in. Replies go to control/ack. Optional req_id for correlation.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/control/ack', direction: 'Outbound, QoS 1', switch: 'command', notes: 'Strictly replies to commands (incl. pong) — nothing unsolicited is ever published here. Echoes req_id when the request sent one.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/control/reports', direction: 'Outbound, QoS 1', switch: 'reports', notes: 'Critical, unsolicited, time-sensitive board-initiated events (e.g. bell_overload). Not retained.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained, QoS 1', switch: 'heartbeat', notes: 'Every 30 s. Also carries LWT — broker publishes {"state":"offline","ok":false} on an unclean disconnect.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/status/playback', direction: 'Outbound, retained, QoS 1', switch: 'playback', notes: 'Playback state transitions only (playing/paused/idle). Published on transition, not polled.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/system/alerts', direction: 'Outbound, retained until cleared, QoS 1', switch: 'alerts', notes: 'Subsystem state changes (WARNING / CRITICAL / FAILED / CLEARED). Published on transition only.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/system/info', direction: 'Outbound, retained, QoS 1', switch: 'info', notes: 'Discrete system events, e.g. boot_report on every boot.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/system/logs', direction: 'Outbound, QoS 0', switch: 'logs', notes: 'Full log stream, mirrors serial output. Also gated by the log.set_mqtt level, independently of this switch.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/system/metrics', direction: 'Outbound, QoS 0', switch: 'metrics', notes: 'Structured telemetry every 5 min — CPU temp, WiFi/OTA state, stack high-water marks, bell strike/heat data.' }, + { transport: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', switch: null, notes: 'Requires identify on connect. Untouched by the v2 MQTT rebuild.' }, + { transport: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', switch: null, notes: 'Web console endpoints. Untouched by the v2 MQTT rebuild.' }, + { transport: 'UART', address: 'Hardware serial', direction: 'Bidirectional', switch: null, notes: 'Restricted — whitelist only. Untouched by the v2 MQTT rebuild.' }, + { transport: 'UDP', address: 'Port 32101', direction: 'Discovery', switch: null, notes: 'Passive — device announces itself. Untouched by the v2 MQTT rebuild.' }, ] const UART_WHITELIST = [ @@ -51,11 +58,13 @@ const NAMESPACES = [ cmd: 'ping', handler: 'SystemHandler', transports: ['All'], - description: 'Connectivity check. No contents required.', - contents: null, - response: '{ "status": "SUCCESS", "type": "pong" }', + description: 'Connectivity check + round-trip latency measurement. Echoes an optional client timestamp back unchanged so the caller can compute RTT as (time of response receipt) − ts, without needing synchronized clocks. Only available on RTC-equipped variants (SystemHandler.cpp is compiled out on agnus/agnus-mini).', + contents: [ + { field: 'ts', type: 'int', required: false, notes: 'Caller’s own epoch-millis timestamp at send time. Omit for a plain liveness ack — the response will omit ts too.' }, + ], + response: '{ "status": "SUCCESS", "type": "pong", "data": { "ts": 1752600000123, "device_uptime_ms": 184213 } }', errors: [], - example: '{ "v": 2, "cmd": "ping" }', + example: '{ "v": 2, "cmd": "ping", "contents": { "ts": 1752600000123 } }', warning: null, }, { @@ -567,7 +576,7 @@ const NAMESPACES = [ cmd: 'system.status', handler: 'SystemHandler', transports: ['All'], - description: 'Get current player status, projected run time, and per-bell strike counters.', + description: 'Get current player status, projected run time, and per-bell strike counters. Note: a caller whose original request arrived as legacy v1 (no "v" field, or "v":1 — see the Legacy Adapter table below) receives this reply translated back into the v1 shape by LegacyResponseAdapter: type "current_status", fields under "payload" (not "data"), lowercase player_status ("playing"/"paused"/"stopping"/"idle" instead of "PLAYING"/... /"STOPPED"), "time_elapsed" (not "time_elapsed_ms"), and strike_counters as an array (not an object keyed "0".."15"). This is temporary migration scaffolding — see docs/architecture/legacy-response-adapter.md in the firmware repo — and only applies to v1-originated requests; v2 callers always get the shape below unchanged.', contents: null, response: '{\n "status": "SUCCESS",\n "type": "system.status",\n "data": {\n "player_status": "STOPPED",\n "time_elapsed_ms": 0,\n "projected_run_time": 0,\n "strike_counters": { "0": 1234, "1": 567, ... }\n }\n}', errors: [], @@ -604,7 +613,7 @@ const NAMESPACES = [ transports: ['All'], description: 'Single-shot fetch of everything: device identity + full config + live telemetry + player status. Use on initial console load to avoid multiple round trips.', contents: null, - response: '{\n "status": "SUCCESS",\n "type": "system.get_all",\n "data": {\n "device": {\n "serial": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "hw_family": "VS", "hw_revision": "01",\n "fw_version": "143",\n "uptime_ms": 123456, "free_heap": 180000\n },\n "config": { ... },\n "telemetry": {\n "strikes": {...}, "loads": {...}, "max_loads": {...},\n "cooling_active": false, "guard_enabled": true,\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200\n },\n "player": { "status": "STOPPED" }\n }\n}', + response: '{\n "status": "SUCCESS",\n "type": "system.get_all",\n "data": {\n "device": {\n "serial": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "hw_family": "VS", "hw_revision": "01",\n "fw_version": "143",\n "uptime_ms": 123456, "free_heap": 180000\n },\n "config": { ... },\n "telemetry": {\n "strikes": {...}, "loads": {...}, "max_loads": {...},\n "cooling_active": false, "guard_enabled": true,\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "lifetime_boot_count": 47\n },\n "player": { "status": "STOPPED" }\n }\n}', errors: [], example: '{ "v": 2, "cmd": "system.get_all" }', warning: null, @@ -613,9 +622,9 @@ const NAMESPACES = [ cmd: 'system.get_telemetry', handler: 'SystemHandler', transports: ['All'], - description: 'Live telemetry snapshot: strike counters, bell loads, guard state, runtime metrics, uptime, free heap. Read-only — no config included.', + description: 'Live telemetry snapshot: strike counters, bell loads, guard state, runtime metrics, lifetime boot count, uptime, free heap. Read-only — no config included.', contents: null, - response: '{\n "status": "SUCCESS",\n "type": "system.get_telemetry",\n "data": {\n "strikes": { "0": 1234, ... },\n "loads": { "0": 42, ... },\n "max_loads":{ "0": 500, ... },\n "cooling_active": false,\n "guard_enabled": true,\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "uptime_ms": 12345678,\n "free_heap": 178000\n }\n}', + response: '{\n "status": "SUCCESS",\n "type": "system.get_telemetry",\n "data": {\n "strikes": { "0": 1234, ... },\n "loads": { "0": 42, ... },\n "max_loads":{ "0": 500, ... },\n "cooling_active": false,\n "guard_enabled": true,\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "lifetime_boot_count": 47,\n "uptime_ms": 12345678,\n "free_heap": 178000\n }\n}', errors: [], example: '{ "v": 2, "cmd": "system.get_telemetry" }', warning: null, @@ -1002,6 +1011,19 @@ const NAMESPACES = [ example: '{ "v": 2, "cmd": "logs.download", "contents": { "file": "2026-07-14.log", "offset": 0 } }', warning: 'Loop by feeding the previous response\'s next_offset back in as offset until eof is true — a single call never returns a whole large file.', }, + { + cmd: 'logs.clear', + handler: 'LoggingHandler', + transports: ['All'], + description: 'Deletes every day-rotated SD debug log file. A QA-to-shipping reset (see the console\'s "Reset Device Stats" flow) — not part of normal day-rotation, which only trims oldest files once over budget and never wipes everything.', + contents: null, + response: '{ "status": "SUCCESS", "type": "logs.clear", "data": { "deleted": 4 } }', + errors: [ + { message: 'SD card not available on this device', condition: 'Device has no SD card, or FileManager was not wired up' }, + ], + example: '{ "v": 2, "cmd": "logs.clear" }', + warning: 'Irreversible — deletes all SD debug log history for this device, not just old files.', + }, ], }, { @@ -1026,7 +1048,7 @@ const NAMESPACES = [ cmd: 'mqtt.disable', handler: 'MQTTHandler', transports: ['All'], - description: 'Disable MQTT connectivity. Persists to SD and disconnects immediately.', + description: 'Disable MQTT connectivity (master switch). Persists to SD and disconnects gracefully — publishes an offline heartbeat ({"state":"offline","ok":false}, retained) to status/heartbeat before disconnecting, since the broker\'s LWT does not fire on a clean/intentional disconnect.', contents: null, response: '{ "status": "SUCCESS", "type": "mqtt.disable", "message": "MQTT disabled and saved" }', errors: [ @@ -1046,6 +1068,40 @@ const NAMESPACES = [ example: '{ "v": 2, "cmd": "mqtt.get_config" }', warning: null, }, + { + cmd: 'mqtt.get_topics', + handler: 'MQTTHandler', + transports: ['All'], + description: 'Read the current state of all 8 per-topic MQTT publish switches (v2 topic spec). Each switch only takes effect while mqtt.enable (the master switch) is on — see the Transports tab → V2 for the full switch/topic mapping.', + contents: null, + response: '{\n "status": "SUCCESS",\n "type": "mqtt.get_topics",\n "data": {\n "command": true,\n "heartbeat": true,\n "playback": true,\n "reports": true,\n "alerts": true,\n "info": true,\n "logs": true,\n "metrics": true\n }\n}', + errors: [], + example: '{ "v": 2, "cmd": "mqtt.get_topics" }', + warning: null, + }, + { + cmd: 'mqtt.set_topics', + handler: 'MQTTHandler', + transports: ['All'], + description: 'Partial update of the per-topic MQTT publish switches — only send the fields you want to change. "command" gates both control/command (subscription) and control/ack (replies) together, and applies live without waiting for a reconnect; the other 7 switches are checked on each publish attempt. Lets a device "breathe" by turning off topics it doesn\'t need (e.g. a fleet-monitoring-only install can turn off "command" entirely).', + contents: [ + { field: 'command', type: 'bool', required: false, notes: 'Gates control/command (subscribe) + control/ack (replies) together.' }, + { field: 'heartbeat', type: 'bool', required: false, notes: 'Gates status/heartbeat.' }, + { field: 'playback', type: 'bool', required: false, notes: 'Gates status/playback.' }, + { field: 'reports', type: 'bool', required: false, notes: 'Gates control/reports.' }, + { field: 'alerts', type: 'bool', required: false, notes: 'Gates system/alerts.' }, + { field: 'info', type: 'bool', required: false, notes: 'Gates system/info.' }, + { field: 'logs', type: 'bool', required: false, notes: 'Gates system/logs — independent of the log.set_mqtt level, which stays applied when this is re-enabled.' }, + { field: 'metrics', type: 'bool', required: false, notes: 'Gates system/metrics. At least one of these 8 fields is required.' }, + ], + response: '{ "status": "SUCCESS", "type": "mqtt.set_topics", "message": "MQTT topic switches updated and saved" }', + errors: [ + { message: 'No recognised topic fields in contents (command, heartbeat, playback, reports, alerts, info, logs, metrics)', condition: 'contents has none of the 8 valid boolean fields' }, + { message: 'Topic switches changed but failed to save to SD', condition: 'SD config write failed — state applied in RAM only' }, + ], + example: '{ "v": 2, "cmd": "mqtt.set_topics", "contents": { "heartbeat": true, "metrics": false } }', + warning: null, + }, ], }, { @@ -1124,9 +1180,9 @@ const NAMESPACES = [ cmd: 'telemetry.get_metrics', handler: 'TelemetryHandler', transports: ['All'], - description: 'Read runtime metrics: lifetime seconds, current session seconds, total playback count, and guard state.', + description: 'Read runtime metrics: lifetime seconds, current session seconds, total playback count, guard state, and lifetime boot count.', contents: null, - response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_metrics",\n "data": {\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "guard_enabled": true\n }\n}', + response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_metrics",\n "data": {\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "guard_enabled": true,\n "lifetime_boot_count": 47\n }\n}', errors: [], example: '{ "v": 2, "cmd": "telemetry.get_metrics" }', warning: null, @@ -1142,6 +1198,45 @@ const NAMESPACES = [ example: '{ "v": 2, "cmd": "telemetry.reset_metrics" }', warning: 'Clears all historical runtime data. Cannot be undone.', }, + { + cmd: 'telemetry.get_diagnostics', + handler: 'TelemetryHandler', + transports: ['All'], + description: 'On-demand mirror of the periodic diagnostics_report published to status/info every 5 minutes (see the Diagnostics Report Payload card below) — CPU temperature, WiFi reconnects, OTA state, and per-task stack high-water marks. Unlike the periodic report, this does NOT reset the temperature min/max/avg accumulator — it is a peek, not a consuming read, so polling this command does not rob the next scheduled report of its sampling window.', + contents: null, + response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_diagnostics",\n "data": {\n "cpu_temp": { "avg": 47.1, "min": 44.8, "max": 52.8, "samples": 20 },\n "wifi_reconnects": { "lifetime_count": 2, "last_reason": "AUTH_EXPIRE", "last_at_uptime_ms": 3600000 },\n "ota": { "current_version": "2.7.3", "update_available": false, "last_check_uptime_ms": 180000, "last_error": "NONE" },\n "stack_high_water": { "TelemetryTask": 1024, "HealthMonitor": 890 }\n }\n}', + errors: [], + example: '{ "v": 2, "cmd": "telemetry.get_diagnostics" }', + warning: null, + }, + { + cmd: 'telemetry.get_boot_history', + handler: 'TelemetryHandler', + transports: ['All'], + description: 'Returns recent entries from the device\'s own rotating SD-side boot log (/telemetry_boot_log.json, up to 200 entries), oldest-of-the-selection-first. This is the on-device history — the console\'s own device_boot_events table (populated live from each boot\'s MQTT boot_report) is the primary source for the Health tab; this command is mainly useful for backfilling history the console never saw live, or auditing what the device itself has recorded.', + contents: [ + { field: 'limit', type: 'int', required: false, notes: 'How many of the most recent entries to return. Omit for the full stored history (up to 200). Clamped server-side to [1, 200] — 0 or missing is treated as "no limit" (200).' }, + ], + response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_boot_history",\n "data": {\n "count": 3,\n "boots": [\n { "ts": 1752599000, "boot": 45, "reason": "POWERON" },\n { "ts": 1752599800, "boot": 46, "reason": "SOFTWARE" },\n { "ts": 1752600000, "boot": 47, "reason": "PANIC", "crash": { "task": "TelemetryTask", "pc": 1074111832, "exc_cause": 1, "exc_vaddr": 0 } }\n ]\n }\n}', + errors: [ + { message: 'No boot history available (no SD card, or no boot has completed NTP sync yet)', condition: 'No FileManager reference, no SD card present/initialized, or the file has never been written' }, + ], + example: '{ "v": 2, "cmd": "telemetry.get_boot_history" }\n{ "v": 2, "cmd": "telemetry.get_boot_history", "contents": { "limit": 20 } }', + warning: null, + }, + { + cmd: 'telemetry.reset_boot_data', + handler: 'TelemetryHandler', + transports: ['All'], + description: 'A QA-to-shipping reset (see the console\'s "Reset Device Stats" flow): zeroes the lifetime boot counter (NVS namespace vesper_boot) and deletes the SD boot history log (/telemetry_boot_log.json). Does NOT touch FirmwareValidator\'s separate NVS namespace (fw_validator) — that\'s rollback-safety bookkeeping, not history, and is deliberately left alone.', + contents: null, + response: '{ "status": "SUCCESS", "type": "telemetry.reset_boot_data", "message": "Lifetime boot count and SD boot log reset" }', + errors: [ + { message: 'Failed to reset lifetime boot count in NVS', condition: 'The NVS write itself failed. SD-side deletion is best-effort and does not cause this error on its own.' }, + ], + example: '{ "v": 2, "cmd": "telemetry.reset_boot_data" }', + warning: 'Irreversible — permanently zeroes the device\'s lifetime boot count and deletes its on-device boot history.', + }, ], }, { @@ -1538,47 +1633,122 @@ function NamespaceSection({ ns, searchQuery }) { } // --------------------------------------------------------------------------- -// Tab: Transports +// Tab: Transports (split into Legacy / V2 sub-tabs) // --------------------------------------------------------------------------- function TransportsTab() { + const [subTab, setSubTab] = useState('v2') + const SUB_TABS = [ + { key: 'v2', label: 'V2' }, + { key: 'legacy', label: 'Legacy' }, + ] + return (
- + + {subTab === 'v2' ? : } +
+ ) +} + +function TransportsLegacy() { + return ( + +

+ Not documented yet. The v2 topic rebuild (see the V2 tab) fully replaced the legacy topic set — there was no dual-publish + migration period. This tab is reserved for historical reference on the pre-v2 shape and will be filled in later. +

+
+ ) +} + +function TransportsV2() { + return ( +
+
- {['Transport', 'Address / Topic', 'Direction', 'Notes'].map(h => ( + {['Transport', 'Address / Topic', 'Direction', 'Switch', 'Notes'].map(h => ( ))} - {TRANSPORTS.map((row, i) => ( + {TRANSPORTS_V2.map((row, i) => ( 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}> + ))}
{h}
{row.address} {row.direction}{row.switch ?? '—'} {row.notes}
+

+ Every switch above only takes effect while mqtt.enable (the master connection switch) is on. Read current state with mqtt.get_topics, change it with mqtt.set_topics (Commands tab → mqtt namespace). +

- +
- +
+
+
Command (control/command)
+ +
+
+
Reply (control/ack)
+ +
+
+

+ Always optional — a request without req_id gets a reply without one, so v1 clients and any existing caller are unaffected. Implemented bus-wide (not MQTT-only) so a client fanning requests out over one shared channel — multiple devices on one broker — can match replies to requests. No access control yet: any caller can set any req_id today. +

+
+
+ + +
+
{[ - { field: 'device_id', type: 'string', notes: 'Serial number from NVS.' }, - { field: 'firmware_version', type: 'string', notes: 'FW_VERSION constant compiled into the binary.' }, - { field: 'timestamp', type: 'string', notes: 'Human-readable uptime string, e.g. "Uptime: 5h 23m 45s".' }, - { field: 'ip_address', type: 'string', notes: 'Current WiFi IP address.' }, - { field: 'gateway', type: 'string', notes: 'Current gateway IP address.' }, - { field: 'uptime_ms', type: 'int', notes: 'Milliseconds since last boot (millis()).' }, - { field: 'rssi', type: 'int', notes: 'WiFi signal strength in dBm. Typical range: -30 (excellent) to -90 (weak).' }, + { field: 'device_id', type: 'string', notes: 'Serial number from NVS.' }, + { field: 'state', type: 'string', notes: '"idle" | "playing" | "paused" | "error" | "booting" — independently derived from Player/HealthMonitor, never mirrored from status/playback or system/alerts.' }, + { field: 'ok', type: 'bool', notes: 'Overall device health (HealthMonitor::isFirmwareStable()), independent of playback state.' }, + { field: 'fw_version', type: 'string', notes: 'FW_VERSION constant compiled into the binary.' }, + { field: 'uptime_ms', type: 'int', notes: 'Milliseconds since last boot (millis()).' }, + { field: 'uptime_human', type: 'string', notes: 'Human-readable uptime string, e.g. "5h 23m 45s".' }, + { field: 'ip_address', type: 'string', notes: 'Current WiFi IP address.' }, + { field: 'gateway', type: 'string', notes: 'Current gateway IP address.' }, + { field: 'rssi', type: 'int', notes: 'WiFi signal strength in dBm. Typical range: -30 (excellent) to -90 (weak).' }, + { field: 'free_heap', type: 'int', notes: 'ESP.getFreeHeap() in bytes, sampled every 30s alongside RSSI — lets a console chart heap trend over time instead of the single per-boot sample in boot_report.' }, + ].map(row => ( +
+
+ {row.field} + {row.type} +
+

{row.notes}

+
+ ))} +
+

+ LWT (Last Will and Testament): on an unclean disconnect (crash, power loss, network loss), the broker automatically publishes {'{"device_id":"...","state":"offline","ok":false}'} here (retained), overwriting the last "online" heartbeat. For intentional disconnects (e.g. mqtt.disable), the firmware publishes the same payload explicitly before disconnecting, since LWT doesn't fire on a clean disconnect. +

+
+
+ + +
+ +
+ {[ + { field: 'action', type: 'string', notes: '"playing" | "paused" | "idle". Published on transition, not polled.' }, + { field: 'time_elapsed', type: 'int', notes: 'Seconds since playback started (0 for "idle").' }, + { field: 'projected_run_time', type: 'int', notes: 'Projected total run time in ms (0 if not applicable).' }, ].map(row => (
@@ -1592,7 +1762,121 @@ function TransportsTab() {
- + +
+ +

+ The opposite of control/command: unsolicited data the board sends because it needs to be read now. Not a reply to anything — never carries req_id. Not retained, since it's an event stream, not a status snapshot. Currently the only event type is bell_overload, published when per-bell strike load exceeds configured thresholds. +

+
+ {[ + { field: 'payload.bells', type: 'int[]', notes: '1-indexed bell channel numbers that are overloaded.' }, + { field: 'payload.loads', type: 'int[]', notes: 'Strike count load per bell channel.' }, + { field: 'payload.severity', type: 'string', notes: '"WARNING" (≥60% load) or "CRITICAL" (≥90% load).' }, + ].map(row => ( +
+
+ {row.field} + {row.type} +
+

{row.notes}

+
+ ))} +
+
+
+ + +
+ +
+ {[ + { field: 'subsystem', type: 'string', notes: 'Subsystem name, e.g. "System", "BellEngine", "Networking". HealthMonitor-driven subsystems use their own name; boot-fault alerts use "System".' }, + { field: 'state', type: 'string', notes: '"WARNING" | "CRITICAL" | "FAILED" | "CLEARED". Published only on transition, not on every health check.' }, + { field: 'msg', type: 'string?', notes: 'Human-readable detail. Omitted entirely on "CLEARED". For a fault reset, reads "Device reset due to fault: " — see reset_reason values in the Boot Report card below.' }, + ].map(row => ( +
+
+ {row.field} + {row.type} +
+

{row.notes}

+
+ ))} +
+
+
+ + +
+ +
+ {[ + { field: 'boot_count', type: 'int', notes: 'Lifetime boot counter (NVS-backed, survives SD failure/removal). The authoritative "how many times has this device rebooted, ever" — also readable on demand via telemetry.get_metrics / system.get_telemetry / system.get_all as lifetime_boot_count.' }, + { field: 'reset_reason', type: 'string', notes: 'esp_reset_reason() as a string: PANIC, TASK_WATCHDOG, INTERRUPT_WATCHDOG, OTHER_WATCHDOG, BROWNOUT, SOFTWARE, POWERON, DEEPSLEEP_WAKE, EXTERNAL_PIN, SDIO, UNKNOWN.' }, + { field: 'is_fault', type: 'bool', notes: 'true for PANIC / *_WATCHDOG / BROWNOUT. When true, this same reset also triggers a "Device reset due to fault" WARNING on system/alerts (see above).' }, + { field: 'free_heap', type: 'int', notes: 'ESP.getFreeHeap() at the moment this report was built, in bytes.' }, + { field: 'crash', type: 'object?', notes: 'Present only when a coredump was found for the crash that caused this boot. Absent on a clean reset, or on a fault reset with no coredump (e.g. brownout).' }, + { field: 'crash.task', type: 'string', notes: 'Name of the FreeRTOS task that was running when the crash occurred, e.g. "TelemetryTask".' }, + { field: 'crash.pc', type: 'int', notes: 'Program counter at the point of the exception.' }, + { field: 'crash.exc_cause', type: 'int', notes: 'Xtensa exception cause code (matches EXCCAUSE in the serial register dump).' }, + { field: 'crash.exc_vaddr', type: 'int', notes: 'Faulting virtual address (matches EXCVADDR in the serial register dump).' }, + ].map(row => ( +
+
+ {row.field} + {row.type} +
+

{row.notes}

+
+ ))} +
+

+ Note: two other boot counters exist in the firmware for unrelated purposes — firmware.status's boot_count tracks boots since the current OTA slot was flashed (rollback-safety bookkeeping only), and the device also keeps a rotating SD-side history of the last 200 boots with timestamps. boot_count in this payload is the one to use for "how many times has this device rebooted." +

+
+
+ + +
+ +

+ Deliberately separate from the 30s heartbeat — none of these fields need 30-second freshness, and the heartbeat already fires forever, per device, at fleet scale. CPU temperature is sampled locally every 15s and reduced to min/max/avg between reports (so a short thermal spike doesn't get averaged away or missed entirely); the other fields are monotonic or rarely-changing, so a single read at report time loses no information. Moved here from system/info (was diagnostics_report) since it's quantitative telemetry, not a discrete event — and gained bell strike/heat data, matching this topic's explicit "bell heat ratings" purpose. +

+
+ {[ + { field: 'cpu_temp', type: 'object?', notes: 'Omitted entirely if no samples were taken yet. Uses the ESP32\'s internal die-temperature sensor — coarse, not ambient, not a calibrated instrument.' }, + { field: 'cpu_temp.avg/min/max', type: 'float', notes: 'Degrees Celsius, accumulated since the previous report (or since boot, for the first report).' }, + { field: 'cpu_temp.samples', type: 'int', notes: 'Number of 15s samples folded into avg/min/max this period. Roughly 20 for a full 5-minute window.' }, + { field: 'wifi_reconnects.lifetime_count', type: 'int', notes: 'WiFi reconnects THIS BOOT ONLY — resets to 0 on reboot. Not to be confused with boot_report\'s boot_count (device lifetime boots, NVS-backed).' }, + { field: 'wifi_reconnects.last_reason', type: 'string', notes: 'Human-readable WiFi disconnect reason for the most recent drop, e.g. "AUTH_EXPIRE". Empty string if no disconnect yet this boot.' }, + { field: 'wifi_reconnects.last_at_uptime_ms', type: 'int', notes: 'millis() at the time of the most recent disconnect. 0 if none yet this boot.' }, + { field: 'ota.current_version', type: 'string', notes: 'SemVer string of the firmware currently running.' }, + { field: 'ota.update_available', type: 'bool', notes: 'True if a newer version was found on the last check.' }, + { field: 'ota.available_version', type: 'string?', notes: 'Present only when update_available is true.' }, + { field: 'ota.last_check_uptime_ms', type: 'int', notes: 'millis() at the start of the most recent update-check attempt. 0 if no check has run yet this boot.' }, + { field: 'ota.last_error', type: 'string', notes: '"NONE" if the last check/update succeeded or none has run. Otherwise an OTAManager error code.' }, + { field: 'stack_high_water', type: 'object', notes: '{"TaskName": bytesFree, ...} — free stack headroom per monitored task, in bytes.' }, + { field: 'bell_strikes', type: 'object', notes: '{"0": count, ...} — cumulative strike count per bell channel (0–15).' }, + { field: 'bell_loads', type: 'object', notes: '{"0": load, ...} — current heat-load accumulator per bell channel.' }, + { field: 'cooling_active', type: 'bool', notes: 'True if any bell still has residual heat.' }, + ].map(row => ( +
+
+ {row.field} + {row.type} +
+

{row.notes}

+
+ ))} +
+

+ Note: short-lived, fire-and-forget FreeRTOS tasks (mqttRecon, mqttHB, mqttConnect, netRecon, and others created without keeping a task handle) cannot appear in stack_high_water — only tasks that keep a stored handle can be queried this way. +

+
+
+ +
{UART_WHITELIST.map(cmd => ( @@ -1602,22 +1886,23 @@ function TransportsTab() {
- +
Request
- .",\n "contents": { ... } // optional\n}'} color="var(--color-success)" /> + .",\n "req_id": "abc123", // optional\n "contents": { ... } // optional\n}'} color="var(--color-success)" />
Response
- ",\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} /> + ",\n "req_id": "abc123", // only if request sent one\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} />
{[ { field: 'v', type: 'int', notes: 'Protocol version. Always 2 for v2 firmware.' }, { field: 'cmd', type: 'string', notes: 'Command name, e.g. "relay.set_durations"' }, + { field: 'req_id', type: 'string?', notes: 'Optional correlation ID, echoed back on the reply. Omit for no req_id on the reply.' }, { field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' }, ].map(row => (