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 (
+ 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. +
+| {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).
+
+ 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.
+
{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.
+
+ 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.
+
{row.notes}
+{row.notes}
+{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.
+
{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.
+