docs(api-reference): add MQTT v2 topic spec, split Transports into V2/Legacy
Firmware rebuilt its MQTT topics to v2 (control/command+ack+reports, status/playback, system/alerts+info+logs+metrics, LWT, req_id, per-topic switches — see project-vesper's docs/reference/vesper_mqtt_topic_spec_v2.md and feature-catalog.md F-062). Mirrors that in the console's API Reference: - Transports tab now splits into V2 (fully documented: topic table with per-topic switches, req_id correlation, heartbeat/playback/reports/ alerts/boot-report/metrics payload cards) and Legacy (placeholder, to be filled in later) - mqtt namespace gains mqtt.get_topics / mqtt.set_topics command docs - mqtt.disable description updated for its new graceful-offline behavior - Message envelope docs (top of page) note the new optional req_id field Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,16 +11,23 @@ import Tabs from '@/components/ui/Tabs'
|
|||||||
// Data
|
// Data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const TRANSPORTS = [
|
// v2 MQTT topic spec — see docs/reference/vesper_mqtt_topic_spec_v2.md in the firmware repo.
|
||||||
{ transport: 'MQTT', address: 'vesper/{device_id}/control', direction: 'Inbound', notes: 'Commands in. Responses go to vesper/{device_id}/data' },
|
// Every topic has an independent ON/OFF publish switch (mqtt.set_topics), except
|
||||||
{ transport: 'MQTT', address: 'vesper/{device_id}/data', direction: 'Outbound', notes: 'Responses + events' },
|
// control/command + control/ack which share one switch ("command").
|
||||||
{ transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained', notes: 'Heartbeat every 30 s' },
|
const TRANSPORTS_V2 = [
|
||||||
{ 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}/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}/status/info', direction: 'Outbound, QoS 0', notes: 'Significant device events (playback start/stop, etc.).' },
|
{ 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: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', notes: 'Requires identify on connect' },
|
{ 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: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', notes: 'Web console endpoints' },
|
{ 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: 'UART', address: 'Hardware serial', direction: 'Bidirectional', notes: 'Restricted — whitelist only' },
|
{ 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: 'UDP', address: 'Port 32101', direction: 'Discovery', notes: 'Passive — device announces itself' },
|
{ 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 = [
|
const UART_WHITELIST = [
|
||||||
@@ -51,11 +58,13 @@ const NAMESPACES = [
|
|||||||
cmd: 'ping',
|
cmd: 'ping',
|
||||||
handler: 'SystemHandler',
|
handler: 'SystemHandler',
|
||||||
transports: ['All'],
|
transports: ['All'],
|
||||||
description: 'Connectivity check. No contents required.',
|
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: null,
|
contents: [
|
||||||
response: '{ "status": "SUCCESS", "type": "pong" }',
|
{ 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: [],
|
errors: [],
|
||||||
example: '{ "v": 2, "cmd": "ping" }',
|
example: '{ "v": 2, "cmd": "ping", "contents": { "ts": 1752600000123 } }',
|
||||||
warning: null,
|
warning: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -567,7 +576,7 @@ const NAMESPACES = [
|
|||||||
cmd: 'system.status',
|
cmd: 'system.status',
|
||||||
handler: 'SystemHandler',
|
handler: 'SystemHandler',
|
||||||
transports: ['All'],
|
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,
|
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}',
|
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: [],
|
errors: [],
|
||||||
@@ -604,7 +613,7 @@ const NAMESPACES = [
|
|||||||
transports: ['All'],
|
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.',
|
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,
|
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: [],
|
errors: [],
|
||||||
example: '{ "v": 2, "cmd": "system.get_all" }',
|
example: '{ "v": 2, "cmd": "system.get_all" }',
|
||||||
warning: null,
|
warning: null,
|
||||||
@@ -613,9 +622,9 @@ const NAMESPACES = [
|
|||||||
cmd: 'system.get_telemetry',
|
cmd: 'system.get_telemetry',
|
||||||
handler: 'SystemHandler',
|
handler: 'SystemHandler',
|
||||||
transports: ['All'],
|
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,
|
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: [],
|
errors: [],
|
||||||
example: '{ "v": 2, "cmd": "system.get_telemetry" }',
|
example: '{ "v": 2, "cmd": "system.get_telemetry" }',
|
||||||
warning: null,
|
warning: null,
|
||||||
@@ -1002,6 +1011,19 @@ const NAMESPACES = [
|
|||||||
example: '{ "v": 2, "cmd": "logs.download", "contents": { "file": "2026-07-14.log", "offset": 0 } }',
|
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.',
|
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',
|
cmd: 'mqtt.disable',
|
||||||
handler: 'MQTTHandler',
|
handler: 'MQTTHandler',
|
||||||
transports: ['All'],
|
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,
|
contents: null,
|
||||||
response: '{ "status": "SUCCESS", "type": "mqtt.disable", "message": "MQTT disabled and saved" }',
|
response: '{ "status": "SUCCESS", "type": "mqtt.disable", "message": "MQTT disabled and saved" }',
|
||||||
errors: [
|
errors: [
|
||||||
@@ -1046,6 +1068,40 @@ const NAMESPACES = [
|
|||||||
example: '{ "v": 2, "cmd": "mqtt.get_config" }',
|
example: '{ "v": 2, "cmd": "mqtt.get_config" }',
|
||||||
warning: null,
|
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',
|
cmd: 'telemetry.get_metrics',
|
||||||
handler: 'TelemetryHandler',
|
handler: 'TelemetryHandler',
|
||||||
transports: ['All'],
|
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,
|
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: [],
|
errors: [],
|
||||||
example: '{ "v": 2, "cmd": "telemetry.get_metrics" }',
|
example: '{ "v": 2, "cmd": "telemetry.get_metrics" }',
|
||||||
warning: null,
|
warning: null,
|
||||||
@@ -1142,6 +1198,45 @@ const NAMESPACES = [
|
|||||||
example: '{ "v": 2, "cmd": "telemetry.reset_metrics" }',
|
example: '{ "v": 2, "cmd": "telemetry.reset_metrics" }',
|
||||||
warning: 'Clears all historical runtime data. Cannot be undone.',
|
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() {
|
function TransportsTab() {
|
||||||
|
const [subTab, setSubTab] = useState('v2')
|
||||||
|
const SUB_TABS = [
|
||||||
|
{ key: 'v2', label: 'V2' },
|
||||||
|
{ key: 'legacy', label: 'Legacy' },
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
|
||||||
<Card title="Transport Channels" subtitle="All available communication interfaces">
|
<Tabs tabs={SUB_TABS} active={subTab} onChange={setSubTab} variant="pill" />
|
||||||
|
{subTab === 'v2' ? <TransportsV2 /> : <TransportsLegacy />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransportsLegacy() {
|
||||||
|
return (
|
||||||
|
<Card title="Legacy Transports" subtitle="Pre-v2 MQTT topic shape (control / data / status/alerts / status/info)">
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TransportsV2() {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||||||
|
<Card title="Transport Channels" subtitle="All available communication interfaces — MQTT topics per docs/reference/vesper_mqtt_topic_spec_v2.md">
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
|
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
|
||||||
{['Transport', 'Address / Topic', 'Direction', 'Notes'].map(h => (
|
{['Transport', 'Address / Topic', 'Direction', 'Switch', 'Notes'].map(h => (
|
||||||
<th key={h} style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', borderBottom: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}>{h}</th>
|
<th key={h} style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', borderBottom: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}>{h}</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{TRANSPORTS.map((row, i) => (
|
{TRANSPORTS_V2.map((row, i) => (
|
||||||
<tr key={i} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
|
<tr key={i} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
|
||||||
<td style={{ padding: 'var(--space-3) var(--space-4)' }}><TransportPill name={row.transport} /></td>
|
<td style={{ padding: 'var(--space-3) var(--space-4)' }}><TransportPill name={row.transport} /></td>
|
||||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{row.address}</td>
|
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{row.address}</td>
|
||||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>{row.direction}</td>
|
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>{row.direction}</td>
|
||||||
|
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: row.switch ? 'var(--color-primary)' : 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>{row.switch ?? '—'}</td>
|
||||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</td>
|
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ margin: 'var(--space-4) 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
Every switch above only takes effect while <code style={{ fontFamily: 'var(--font-family-mono)' }}>mqtt.enable</code> (the master connection switch) is on. Read current state with <code style={{ fontFamily: 'var(--font-family-mono)' }}>mqtt.get_topics</code>, change it with <code style={{ fontFamily: 'var(--font-family-mono)' }}>mqtt.set_topics</code> (Commands tab → mqtt namespace).
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Heartbeat Payload" subtitle="Published every 30 s to vesper/{device_id}/status/heartbeat — retained, QoS 1">
|
<Card title="req_id Correlation" subtitle="Optional field on control/command and control/ack">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
<MonoBlock code={'{\n "status": "INFO",\n "type": "heartbeat",\n "payload": {\n "device_id": "PV-26B18-VS01R-X7KQA",\n "firmware_version": "142",\n "timestamp": "Uptime: 5h 23m 45s",\n "ip_address": "10.0.0.3",\n "gateway": "10.0.0.1",\n "uptime_ms": 19425000,\n "rssi": -62\n }\n}'} />
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||||
|
<div>
|
||||||
|
<div style={LABEL_STYLE}>Command (control/command)</div>
|
||||||
|
<MonoBlock code={'{\n "v": 2,\n "cmd": "ping",\n "req_id": "abc123",\n "contents": {}\n}'} color="var(--color-success)" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={LABEL_STYLE}>Reply (control/ack)</div>
|
||||||
|
<MonoBlock code={'{\n "status": "SUCCESS",\n "type": "pong",\n "req_id": "abc123"\n}'} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
Always optional — a request without <code style={{ fontFamily: 'var(--font-family-mono)' }}>req_id</code> 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 <code style={{ fontFamily: 'var(--font-family-mono)' }}>req_id</code> today.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Heartbeat Payload" subtitle="Published every 30 s to vesper/{device_id}/status/heartbeat — retained, QoS 1. Also the LWT target.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
<MonoBlock code={'{\n "device_id": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "state": "idle",\n "ok": true,\n "fw_version": "2.2.0",\n "uptime_ms": 19425000,\n "uptime_human": "5h 23m 45s",\n "ip_address": "10.0.0.3",\n "gateway": "10.0.0.1",\n "rssi": -62,\n "free_heap": 178000\n}'} />
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
{[
|
{[
|
||||||
{ field: 'device_id', type: 'string', notes: 'Serial number from NVS.' },
|
{ field: 'device_id', type: 'string', notes: 'Serial number from NVS.' },
|
||||||
{ field: 'firmware_version', type: 'string', notes: 'FW_VERSION constant compiled into the binary.' },
|
{ field: 'state', type: 'string', notes: '"idle" | "playing" | "paused" | "error" | "booting" — independently derived from Player/HealthMonitor, never mirrored from status/playback or system/alerts.' },
|
||||||
{ field: 'timestamp', type: 'string', notes: 'Human-readable uptime string, e.g. "Uptime: 5h 23m 45s".' },
|
{ 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: 'ip_address', type: 'string', notes: 'Current WiFi IP address.' },
|
||||||
{ field: 'gateway', type: 'string', notes: 'Current gateway 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: '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 => (
|
||||||
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
<strong>LWT (Last Will and Testament):</strong> on an unclean disconnect (crash, power loss, network loss), the broker automatically publishes <code style={{ fontFamily: 'var(--font-family-mono)' }}>{'{"device_id":"...","state":"offline","ok":false}'}</code> here (retained), overwriting the last "online" heartbeat. For intentional disconnects (e.g. <code style={{ fontFamily: 'var(--font-family-mono)' }}>mqtt.disable</code>), the firmware publishes the same payload explicitly before disconnecting, since LWT doesn't fire on a clean disconnect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Playback Payload" subtitle="Published on every playback transition to vesper/{device_id}/status/playback — retained, QoS 1">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
<MonoBlock code={'{\n "action": "playing",\n "time_elapsed": 12,\n "projected_run_time": 45000\n}'} />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
].map(row => (
|
||||||
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||||
@@ -1592,7 +1762,121 @@ function TransportsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="UART Whitelist" subtitle="Commands permitted on the hardware serial interface">
|
<Card title="Control Reports Payload" subtitle="Critical, unsolicited, board-initiated events to vesper/{device_id}/control/reports — QoS 1, not retained">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
<MonoBlock code={'{\n "type": "bell_overload",\n "payload": {\n "bells": [2, 5],\n "loads": [54, 48],\n "severity": "WARNING"\n }\n}'} />
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
The opposite of control/command: unsolicited data the board sends because it needs to be read <em>now</em>. 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 <code style={{ fontFamily: 'var(--font-family-mono)' }}>bell_overload</code>, published when per-bell strike load exceeds configured thresholds.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Status Alerts Payload" subtitle="Published on subsystem state transitions to vesper/{device_id}/system/alerts — QoS 1, retained until cleared">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
<MonoBlock code={'{\n "subsystem": "System",\n "state": "WARNING",\n "msg": "Device reset due to fault: PANIC"\n}'} />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{[
|
||||||
|
{ 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: <reason>" — see reset_reason values in the Boot Report card below.' },
|
||||||
|
].map(row => (
|
||||||
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Boot Report Payload" subtitle="Published once per boot, on first MQTT connect, to vesper/{device_id}/system/info — QoS 1, retained">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
<MonoBlock code={'{\n "type": "boot_report",\n "payload": {\n "boot_count": 47,\n "reset_reason": "PANIC",\n "is_fault": true,\n "free_heap": 187234,\n "crash": {\n "task": "TelemetryTask",\n "pc": 1074111832,\n "exc_cause": 1,\n "exc_vaddr": 0\n }\n }\n}'} />
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
Note: two other boot counters exist in the firmware for unrelated purposes — <code style={{ fontFamily: 'var(--font-family-mono)' }}>firmware.status</code>'s <code style={{ fontFamily: 'var(--font-family-mono)' }}>boot_count</code> 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. <code style={{ fontFamily: 'var(--font-family-mono)' }}>boot_count</code> in this payload is the one to use for "how many times has this device rebooted."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Metrics Payload" subtitle="Published every 5 minutes to vesper/{device_id}/system/metrics — QoS 0, not retained. Fleet-health graphing, not consumed by the phone app.">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
|
<MonoBlock code={'{\n "cpu_temp": {\n "avg": 47.1,\n "min": 44.8,\n "max": 52.8,\n "samples": 20\n },\n "wifi_reconnects": {\n "lifetime_count": 2,\n "last_reason": "AUTH_EXPIRE",\n "last_at_uptime_ms": 3600000\n },\n "ota": {\n "current_version": "2.2.0",\n "update_available": false,\n "last_check_uptime_ms": 180000,\n "last_error": "NONE"\n },\n "stack_high_water": {\n "TelemetryTask": 1024,\n "HealthMonitor": 890\n },\n "bell_strikes": { "0": 1234, "1": 567 },\n "bell_loads": { "0": 12, "1": 0 },\n "cooling_active": false\n}'} />
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
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 <code style={{ fontFamily: 'var(--font-family-mono)' }}>diagnostics_report</code>) since it's quantitative telemetry, not a discrete event — and gained bell strike/heat data, matching this topic's explicit "bell heat ratings" purpose.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
|
Note: short-lived, fire-and-forget FreeRTOS tasks (mqttRecon, mqttHB, mqttConnect, netRecon, and others created without keeping a task handle) cannot appear in <code style={{ fontFamily: 'var(--font-family-mono)' }}>stack_high_water</code> — only tasks that keep a stored handle can be queried this way.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="UART Whitelist" subtitle="Commands permitted on the hardware serial interface — unaffected by the v2 MQTT rebuild">
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
|
||||||
{UART_WHITELIST.map(cmd => (
|
{UART_WHITELIST.map(cmd => (
|
||||||
<span key={cmd} style={{ display: 'inline-flex', alignItems: 'center', padding: 'var(--space-1) var(--space-3)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)', border: '1px solid var(--color-border)' }}>
|
<span key={cmd} style={{ display: 'inline-flex', alignItems: 'center', padding: 'var(--space-1) var(--space-3)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)', border: '1px solid var(--color-border)' }}>
|
||||||
@@ -1602,22 +1886,23 @@ function TransportsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Message Format" subtitle="Standard envelope for all v2 commands">
|
<Card title="Message Format" subtitle="Standard envelope for all v2 commands (control/command + control/ack)">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||||
<div>
|
<div>
|
||||||
<div style={LABEL_STYLE}>Request</div>
|
<div style={LABEL_STYLE}>Request</div>
|
||||||
<MonoBlock code={'{\n "v": 2,\n "cmd": "<namespace>.<command>",\n "contents": { ... } // optional\n}'} color="var(--color-success)" />
|
<MonoBlock code={'{\n "v": 2,\n "cmd": "<namespace>.<command>",\n "req_id": "abc123", // optional\n "contents": { ... } // optional\n}'} color="var(--color-success)" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div style={LABEL_STYLE}>Response</div>
|
<div style={LABEL_STYLE}>Response</div>
|
||||||
<MonoBlock code={'{\n "status": "SUCCESS" | "ERROR",\n "type": "<cmd echoed back>",\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} />
|
<MonoBlock code={'{\n "status": "SUCCESS" | "ERROR",\n "type": "<cmd echoed back>",\n "req_id": "abc123", // only if request sent one\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
{[
|
{[
|
||||||
{ field: 'v', type: 'int', notes: 'Protocol version. Always 2 for v2 firmware.' },
|
{ 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: '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.' },
|
{ field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' },
|
||||||
].map(row => (
|
].map(row => (
|
||||||
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user