docs(api-reference): sync with firmware handler audit — playback, rf namespace, clock/ota/files fixes

Mirrors the firmware repo's api-reference.md audit (project-vesper
docs/reference/api-reference.md):

- playback.play: correct field semantics (url not download_url, speed boot
  default 500 / 0→300, note_assignments value = 1-based bell, sticky fields,
  legacy total_duration ignored without continuous_loop); playback.stop has
  no error path.
- UART whitelist now matches UARTTransport::WHITELIST (5 raw cmd strings).
- Add the missing rf namespace (set_slot/clear_slot/get_slots/enable) with
  every error string.
- Fix clock.set_alerts (camelCase), clock.set_silence (nested), clock.set_time
  offsets, relay.test_bell missing error, ota.update default channel,
  ota.set_channel description, files.download download_url, list_builtin
  "pid" key, logs.list extra error, telemetry boot-history limit=0,
  diagnostics topic, network.set_config AP-mode messages, ping data.type.
- Legacy map now matches LegacyAdapter's tables; message-format card and
  req_id notes reflect MQTT-only req_id and per-transport "v" defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:02:58 +03:00
co-authored by Claude Opus 5.5
parent 0c0dd9d0e9
commit c56344a6dc
@@ -24,19 +24,20 @@ const TRANSPORTS_V2 = [
{ 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/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/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: '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: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', switch: null, notes: 'Send identify on connect. Missing "v" defaults to v1 (legacy adapter). req_id is ignored — replies never carry it.' },
{ transport: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', switch: null, notes: 'Web console endpoints. Untouched by the v2 MQTT rebuild.' }, { transport: 'HTTP', address: 'POST http://{device_ip}/api/command', direction: 'Request/response', switch: null, notes: 'Body = command envelope. Also GET /api/ping and GET /api/status (= system.status). Missing "v" defaults to v2. req_id is ignored.' },
{ transport: 'UART', address: 'Hardware serial', direction: 'Bidirectional', switch: null, notes: 'Restricted — whitelist only. Untouched by the v2 MQTT rebuild.' }, { transport: 'UART', address: 'Serial2 (GPIO12 TX / GPIO13 RX)', direction: 'Bidirectional', switch: null, notes: 'LCD/button slave board. Whitelist only (raw cmd string, checked before legacy translation). Missing "v" defaults to v1. req_id is ignored.' },
{ transport: 'UDP', address: 'Port 32101', direction: 'Discovery', switch: null, notes: 'Passive — device announces itself. 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.' },
] ]
// Mirrors UARTTransport::WHITELIST in the firmware. Matched against the RAW cmd
// string before legacy translation — so "playback" (v1 form, contents.action =
// play|stop) is allowed, while "playback.play"/"playback.stop" by name are dropped.
const UART_WHITELIST = [ const UART_WHITELIST = [
'ping', 'identify', 'ping',
'playback.play', 'playback.stop', 'playback',
'relay.test_bell', 'relay.test_batch', 'relay.test_output', 'clock.pause', 'clock.resume',
'clock.pause', 'clock.resume', 'clock.test_c1', 'clock.test_c2', 'system.get_time',
'system.get_time', 'system.status', 'system.get_config', 'system.get_telemetry',
'bells.enable', 'bells.disable',
] ]
// Every command has: // Every command has:
@@ -44,9 +45,10 @@ const UART_WHITELIST = [
// errors — array of { message, condition } matching every CommandResult::err() path // errors — array of { message, condition } matching every CommandResult::err() path
// //
// Wire envelope for all replies: // Wire envelope for all replies:
// { "status": "SUCCESS"|"ERROR", "type": "<cmd>", "message": "...", "data": {...} } // { "status": "SUCCESS"|"ERROR", "type": "<cmd>", "req_id": "...", "message": "...", "data": {...} }
// "message" is present on plain-text ok(); "data" is present on ok(doc). // "message" is present on plain-text ok(); "data" is present on ok(doc) — never both.
// Errors always have "message". // Errors always have "message". There is no "payload" key in v2 replies.
// req_id only on MQTT replies, and only when the request sent one.
const NAMESPACES = [ const NAMESPACES = [
{ {
@@ -62,7 +64,7 @@ const NAMESPACES = [
contents: [ 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.' }, { 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 } }', response: '{ "status": "SUCCESS", "type": "pong", "data": { "type": "pong", "ts": 1752600000123, "device_uptime_ms": 184213 } }',
errors: [], errors: [],
example: '{ "v": 2, "cmd": "ping", "contents": { "ts": 1752600000123 } }', example: '{ "v": 2, "cmd": "ping", "contents": { "ts": 1752600000123 } }',
warning: null, warning: null,
@@ -89,44 +91,42 @@ const NAMESPACES = [
{ {
id: 'playback', id: 'playback',
label: 'playback', label: 'playback',
description: 'Melody playback control. Handler: PlaybackHandler.', description: 'Melody playback control. Handler: PlaybackHandler. The registered commands are playback.play and playback.stop — there is no v2 cmd "playback" with an action field (a v1 request { "cmd": "playback", "contents": { "action": "play"|"stop" } } is translated by the Legacy Adapter; v2 cmd "playback" returns "Unknown command: playback"). Both reply with type "playback", not the command name.',
commands: [ commands: [
{ {
cmd: 'playback.play', cmd: 'playback.play',
handler: 'PlaybackHandler', handler: 'PlaybackHandler',
transports: ['All'], transports: ['MQTT', 'WebSocket', 'HTTP'],
description: 'Load and play a melody. pid (or uid) is the only field every caller needs — everything else is optional and, if omitted, carries over unchanged from the previous playback.play. Rejected if playback is already active (send playback.stop first).', description: 'Load and play a melody. SUCCESS means the board accepted the command — status/playback is the source of truth for what it is actually doing. Rejected while playback is active (playing / paused / stopping — send playback.stop first). Fields are STICKY: every field except mode/continuous_loop keeps its value from the previous playback.play (or boot default) when omitted — including pid, url, speed and the tail of note_assignments — so send every field you care about on every play. UART: only the v1 form { "cmd": "playback", "contents": { "action": "play", ... } } is whitelisted.',
contents: [ contents: [
{ field: 'pid', type: 'string', required: true, notes: 'Melody ID — matched against the built-in library first, then /melodies/{pid} on SD card. One of pid/uid is required; command is rejected if both are absent (or "-").' }, { field: 'pid', type: 'string', required: true, notes: 'Melody ID — looked up in the built-in library, then /melodies/{pid} on SD, then downloaded from url. If omitted, the previous pid is reused; the command fails if that is empty (or "-").' },
{ field: 'uid', type: 'string', required: false, notes: 'Alias for pid, read only when pid is absent. The firmware treats them identically — it does not distinguish archetype vs. melody-instance semantics the way the console does elsewhere. Prefer pid in new code.' }, { field: 'uid', type: 'string', required: false, notes: 'Alias for pid, read only when pid is absent. Prefer pid in new code.' },
{ field: 'name', type: 'string', required: false, notes: 'Human-readable label. Informational only — echoed in status broadcasts, never used for playback logic.' }, { field: 'name', type: 'string', required: false, notes: 'Display name. Informational only.' },
{ field: 'url', type: 'string', required: false, notes: 'Download URL, used only as a fallback when pid isn\'t a built-in and isn\'t already on the SD card. Required in that case — if the melody can\'t be found locally and url is empty, the command is rejected.' }, { field: 'url', type: 'string', required: false, notes: 'Download URL, used only when the melody is neither built-in nor on SD. The field is "url" — "download_url" is NOT read by playback.play (that name belongs to files.download). Sticky: a url from an earlier play is reused if omitted.' },
{ field: 'speed', type: 'int', required: false, notes: 'Inter-note delay in ms. Default 300 — a value of 0 is coerced back to 300 rather than treated as "instant". No documented upper bound.' }, { field: 'speed', type: 'int', required: false, notes: 'ms per beat. Boot default 500. 0 is coerced to 300.' },
{ field: 'note_assignments', type: 'int[]', required: false, notes: 'Up to 16 entries, one per melody note (1-indexed note → output index, 0 = unused/no output). Extra entries beyond 16 are dropped; sending fewer than 16 leaves the tail of the previously-stored array untouched rather than zeroing it.' }, { field: 'note_assignments', type: 'int[]', required: false, notes: 'Up to 16 entries. Index = melody note (0-based); value = 1-based BELL channel to ring for that note (0 = silent) — not a relay output. Boot default [1,2,…,16]. Entries past 16 are ignored; a shorter array leaves the remaining entries at their previous values.' },
{ field: 'mode', type: 'string', required: false, notes: 'V2 field — omit entirely for V1 callers (see warning below). One of "single" (default) | "timed" | "interval" | "infinite". Selects which of the fields below are read; any unrecognized value falls back to "single".' }, { field: 'mode', type: 'string', required: false, notes: '"single" | "timed" | "interval" | "infinite". Selects the timing model (see warning). Unrecognised values behave as "single". When present, segment_duration and continuous_loop are ignored.' },
{ field: 'duration', type: 'int', required: false, notes: 'V2, read only when mode is present. Segment length in ms for mode "timed" or "interval" — ignored for "single"/"infinite".' }, { field: 'duration', type: 'int', required: false, notes: 'ms. Read only when mode is present. "timed": loop until duration elapses, finish the current loop, stop (0 behaves like "single"). "interval": length of each play segment. Ignored for "single"/"infinite".' },
{ field: 'segment_duration', type: 'int', required: false, notes: 'V1/legacy field — read only when mode is absent. Duration in ms of one loop segment before an internal pause. Ignored whenever mode is present (use duration instead).' }, { field: 'pause_duration', type: 'int', required: false, notes: 'ms between segments. Read for mode "interval", or when mode is absent.' },
{ field: 'pause_duration', type: 'int', required: false, notes: 'Pause between segments in ms. Read when mode is absent (legacy), or when mode is exactly "interval" — ignored for every other mode value.' }, { field: 'total_duration', type: 'int', required: false, notes: 'ms overall cap. Read for mode "interval" (0 = forever), or when mode is absent AND continuous_loop is true. With mode absent and continuous_loop false it is IGNORED (overwritten with segment_duration).' },
{ field: 'total_duration', type: 'int', required: false, notes: 'Overall playback cap in ms. Only meaningfully controllable via mode "interval". In the legacy (no-mode) path it is derived from segment_duration/continuous_loop rather than taken at face value — see warning below.' }, { field: 'segment_duration', type: 'int', required: false, notes: 'Legacy — read only when mode is absent. With continuous_loop false this is the whole run length (sticky, boot default 15000; 0 = play once). With continuous_loop true it is the segment length.' },
{ field: 'continuous_loop', type: 'bool', required: false, notes: 'V1/legacy field — read only when mode is absent. Default false even if omitted (never carries over "true" from a previous command, to avoid a melody looping forever by accident). Ignored whenever mode is present.' }, { field: 'continuous_loop', type: 'bool', required: false, notes: 'Legacy — read only when mode is absent. Defaults to false on every command (not sticky).' },
], ],
response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }', response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }',
errors: [ errors: [
{ message: 'Playback command failed', condition: 'Player rejected the command (e.g. already playing, missing pid/uid, melody not found and no url given)' }, { message: 'Playback command failed', condition: 'Any rejection: playback already active (playing / paused / stopping); pid empty or "-"; melody file on SD could not be loaded; melody not built-in/on SD and no url. Known gaps: when a download is needed the reply is SUCCESS and a failed download is only logged; with bells.disable in effect the command succeeds and status reports "playing" but no bell rings.' },
], ],
example: '// V2 — single play (default mode, loops once and stops)\n{ "v": 2, "req_id": "nrev034094nv", "cmd": "playback.play", "contents": { "pid": "westminster", "speed": 400, "mode": "single" } }\n\n// V2 — timed: one segment, stop after 2 minutes\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "ABC123", "mode": "timed", "duration": 120000 } }\n\n// V2 — interval: play 15 s, pause 5 s, repeat for 3 min total\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "ABC123", "mode": "interval", "duration": 15000, "pause_duration": 5000, "total_duration": 180000 } }\n\n// V1 — legacy caller, no "mode" field at all\n{ "cmd": "playback.play", "contents": { "pid": "ABC123", "segment_duration": 15000, "pause_duration": 5000, "continuous_loop": true } }', example: '// single — play once and stop\n{ "v": 2, "req_id": "a1", "cmd": "playback.play", "contents": { "pid": "westminster", "speed": 400, "mode": "single" } }\n\n// timed — loop for 2 minutes, finish the loop, stop (downloads first if needed)\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "ABC123", "url": "https://example.com/ABC123.bin", "mode": "timed", "duration": 120000 } }\n\n// interval — play 15 s, pause 5 s, repeat for 3 min total\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "ABC123", "mode": "interval", "duration": 15000, "pause_duration": 5000, "total_duration": 180000 } }\n\n// legacy timing (no mode)\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "ABC123", "segment_duration": 15000, "pause_duration": 5000, "continuous_loop": true } }',
warning: 'mode is the V2/V1 switch, not just another field: a V2 caller sends mode and the firmware reads duration (+ pause_duration for "interval") — segment_duration, total_duration, and continuous_loop are ignored outright even if present in the same payload. A V1 caller omits mode entirely, and the firmware falls back to reading segment_duration / pause_duration / continuous_loop directly, deriving total_duration from them. Never send mode alongside segment_duration/pause_duration/total_duration/continuous_loop expecting both to apply — only one path is read, based solely on whether mode is present.', warning: 'mode decides which timing fields are read. mode present: duration (+ pause_duration / total_duration for "interval") — segment_duration and continuous_loop are ignored. mode absent (legacy): continuous_loop false → run for segment_duration, total_duration ignored; continuous_loop true → segment_duration segments with pause_duration gaps, stopping after total_duration if given this command, else segment_duration if given, else the previous total_duration (0 = forever). Never mix the two sets expecting both to apply.',
}, },
{ {
cmd: 'playback.stop', cmd: 'playback.stop',
handler: 'PlaybackHandler', handler: 'PlaybackHandler',
transports: ['All'], transports: ['MQTT', 'WebSocket', 'HTTP'],
description: 'Stop playback immediately. Safe to call even when player is already stopped.', description: 'Stop playback immediately (bells cut mid-loop, status goes straight to idle). Sending it while already idle also returns SUCCESS with the same message. UART: v1 form { "cmd": "playback", "contents": { "action": "stop" } } only.',
contents: null, contents: null,
response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }', response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }',
errors: [ errors: [],
{ message: 'Playback command failed', condition: 'Player rejected the stop (internal error)' },
],
example: '{ "v": 2, "cmd": "playback.stop" }', example: '{ "v": 2, "cmd": "playback.stop" }',
warning: null, warning: null,
}, },
@@ -143,11 +143,11 @@ const NAMESPACES = [
transports: ['All'], transports: ['All'],
description: 'Set strike duration (ms) for one or more bell channels. Partial updates accepted.', description: 'Set strike duration (ms) for one or more bell channels. Partial updates accepted.',
contents: [ contents: [
{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → duration ms. e.g. { "0": 95, "2": 110 }' }, { field: 'bells', type: 'object', required: true, notes: 'Map of channel index (0-based) → duration ms. e.g. { "0": 95, "2": 110 }. The v1 flat form ({ "b1": 95 } directly in contents, 1-based keys) is also accepted.' },
], ],
response: '{ "status": "SUCCESS", "type": "relay.set_durations", "message": "Bell durations updated and saved" }', response: '{ "status": "SUCCESS", "type": "relay.set_durations", "message": "Bell durations updated and saved" }',
errors: [ errors: [
{ message: 'Missing or invalid \'bells\' object', condition: 'contents.bells is absent or not an object' }, { message: 'Missing or invalid \'bells\' object', condition: 'contents is empty/absent (a non-object "bells" falls back to the v1 flat form, which silently matches nothing)' },
{ message: 'Durations updated but failed to save to SD', condition: 'SD card write failed — values are applied in RAM but will not survive reboot' }, { message: 'Durations updated but failed to save to SD', condition: 'SD card write failed — values are applied in RAM but will not survive reboot' },
], ],
example: '{\n "v": 2,\n "cmd": "relay.set_durations",\n "contents": { "bells": { "0": 95, "1": 100, "2": 110 } }\n}', example: '{\n "v": 2,\n "cmd": "relay.set_durations",\n "contents": { "bells": { "0": 95, "1": 100, "2": 110 } }\n}',
@@ -163,7 +163,7 @@ const NAMESPACES = [
], ],
response: '{ "status": "SUCCESS", "type": "relay.set_outputs", "message": "Bell outputs updated and saved" }', response: '{ "status": "SUCCESS", "type": "relay.set_outputs", "message": "Bell outputs updated and saved" }',
errors: [ errors: [
{ message: 'Missing or invalid \'bells\' object', condition: 'contents.bells is absent or not an object' }, { message: 'Missing or invalid \'bells\' object', condition: 'contents is empty/absent (v1 flat form { "b1": 1 } is also accepted)' },
{ message: 'Outputs updated but failed to save to SD', condition: 'SD card write failed' }, { message: 'Outputs updated but failed to save to SD', condition: 'SD card write failed' },
], ],
example: '{\n "v": 2,\n "cmd": "relay.set_outputs",\n "contents": { "bells": { "0": 2, "1": 3, "2": 4 } }\n}', example: '{\n "v": 2,\n "cmd": "relay.set_outputs",\n "contents": { "bells": { "0": 2, "1": 3, "2": 4 } }\n}',
@@ -230,6 +230,7 @@ const NAMESPACES = [
errors: [ errors: [
{ message: 'Missing required field: bell (0-based bell index)', condition: 'contents.bell is absent' }, { message: 'Missing required field: bell (0-based bell index)', condition: 'contents.bell is absent' },
{ message: 'bell must be 0-15', condition: 'bell is outside 0–15 range' }, { message: 'bell must be 0-15', condition: 'bell is outside 0–15 range' },
{ message: 'Bell <n> is disabled or unconfigured', condition: 'The bell has no relay output assigned' },
], ],
example: '// Fire bell 0\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 0 } }\n\n// Rapid succession\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 0 } }\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 1 } }', example: '// Fire bell 0\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 0 } }\n\n// Rapid succession\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 0 } }\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 1 } }',
warning: null, warning: null,
@@ -246,7 +247,7 @@ const NAMESPACES = [
errors: [ errors: [
{ message: 'Missing required field: bells (array of 0-based bell indices)', condition: 'contents.bells is absent or not an array' }, { message: 'Missing required field: bells (array of 0-based bell indices)', condition: 'contents.bells is absent or not an array' },
{ message: 'bells array must not be empty', condition: 'contents.bells is an empty array' }, { message: 'bells array must not be empty', condition: 'contents.bells is an empty array' },
{ message: 'No valid bell indices in array', condition: 'All entries in the array are out of range (0–15) or non-integer' }, { message: 'No valid bell indices in array', condition: 'Nothing was fired — every entry was out of range (0–15), non-integer, or a bell with no output assigned' },
], ],
example: '{\n "v": 2,\n "cmd": "relay.test_batch",\n "contents": { "bells": [0, 2, 4] }\n}', example: '{\n "v": 2,\n "cmd": "relay.test_batch",\n "contents": { "bells": [0, 2, 4] }\n}',
warning: null, warning: null,
@@ -256,16 +257,18 @@ const NAMESPACES = [
{ {
id: 'clock', id: 'clock',
label: 'clock', label: 'clock',
description: 'Clock strike configuration, RTC time, face position, timezone, and silence periods. Handler: ClockHandler.', description: 'Clock strike configuration, RTC time, face position, timezone, and silence periods. Handler: ClockHandler. Only registered on RTC builds — returns "Unknown command" on agnus/agnus-mini.',
commands: [ commands: [
{ {
cmd: 'clock.set_outputs', cmd: 'clock.set_outputs',
handler: 'ClockHandler', handler: 'ClockHandler',
transports: ['All'], transports: ['All'],
description: 'Set which relay outputs the clock strikes use.', description: 'Set which relay outputs the clock strikes use. All fields optional; unknown or missing fields are silently ignored (there is no "nothing to update" error).',
contents: [ contents: [
{ field: 'c1', type: 'int', required: false, notes: 'Relay output for C1 (ODD strikes)' }, { field: 'c1', type: 'int', required: false, notes: 'Relay output for C1 (ODD strikes)' },
{ field: 'c2', type: 'int', required: false, notes: 'Relay output for C2 (EVEN strikes). At least one of c1/c2 required.' }, { field: 'c2', type: 'int', required: false, notes: 'Relay output for C2 (EVEN strikes).' },
{ field: 'pulseDuration', type: 'int', required: false, notes: 'camelCase. Pulse length in ms (same as clock.set_timings pulse_duration).' },
{ field: 'pauseDuration', type: 'int', required: false, notes: 'camelCase. Pause between pulses in ms.' },
], ],
response: '{ "status": "SUCCESS", "type": "clock.set_outputs", "message": "Clock outputs updated and saved" }', response: '{ "status": "SUCCESS", "type": "clock.set_outputs", "message": "Clock outputs updated and saved" }',
errors: [ errors: [
@@ -295,20 +298,20 @@ const NAMESPACES = [
cmd: 'clock.set_alerts', cmd: 'clock.set_alerts',
handler: 'ClockHandler', handler: 'ClockHandler',
transports: ['All'], transports: ['All'],
description: 'Configure hourly/quarter alert behavior. Partial updates accepted.', description: 'Configure hourly/quarter alert behavior. Partial updates accepted. Field names are camelCase (passed straight to ConfigManager::updateClockAlerts) — snake_case names are silently ignored and the command still returns SUCCESS. Use clock.set_alerts_config for the snake_case form.',
contents: [ contents: [
{ field: 'alert_type', type: 'string', required: false, notes: '"OFF" | "SINGLE" | "HOURS" — OFF disables alerts; SINGLE fires once per trigger; HOURS fires N times equal to the current hour count.' }, { field: 'alertType', type: 'string', required: false, notes: '"OFF" | "SINGLE" | "HOURS" — OFF disables alerts; SINGLE fires once per trigger; HOURS fires N times equal to the current hour count.' },
{ field: 'alert_interval', type: 'int', required: false, notes: 'Ring count per trigger (SINGLE mode).' }, { field: 'alertRingInterval', type: 'int', required: false, notes: 'Ring count per trigger (SINGLE mode).' },
{ field: 'hour_bell', type: 'int', required: false, notes: 'Bell number to ring on the hour (1-based). The bell fires on whichever output it is assigned to in relay config. 255 = disabled.' }, { field: 'hourBell', type: 'int', required: false, notes: 'Bell number to ring on the hour (1-based). The bell fires on whichever output it is assigned to in relay config. 255 = disabled.' },
{ field: 'half_bell', type: 'int', required: false, notes: 'Bell number to ring at half past (1-based). 255 = disabled.' }, { field: 'halfBell', type: 'int', required: false, notes: 'Bell number to ring at half past (1-based). 255 = disabled.' },
{ field: 'quarter_bell', type: 'int', required: false, notes: 'Bell number to ring at quarter past/to (1-based). 255 = disabled.' }, { field: 'quarterBell', type: 'int', required: false, notes: 'Bell number to ring at quarter past/to (1-based). 255 = disabled.' },
], ],
response: '{ "status": "SUCCESS", "type": "clock.set_alerts", "message": "Clock alerts updated and saved" }', response: '{ "status": "SUCCESS", "type": "clock.set_alerts", "message": "Clock alerts updated and saved" }',
errors: [ errors: [
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' }, { message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
], ],
example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { "alert_type": "HOURS", "hour_bell": 5 } }', example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { "alertType": "HOURS", "hourBell": 5 } }',
warning: null, warning: 'camelCase only — { "alert_type": ... } is silently ignored here. Prefer clock.set_alerts_config in new code.',
}, },
{ {
cmd: 'clock.set_backlight', cmd: 'clock.set_backlight',
@@ -332,21 +335,17 @@ const NAMESPACES = [
cmd: 'clock.set_silence', cmd: 'clock.set_silence',
handler: 'ClockHandler', handler: 'ClockHandler',
transports: ['All'], transports: ['All'],
description: 'Configure silence periods (quiet hours). Daytime and nighttime windows are independent. Partial updates accepted.', description: 'Configure silence periods (quiet hours). Daytime and nighttime windows are independent. Partial updates accepted. NESTED shape (passed straight to ConfigManager::updateClockSilence) — flat snake_case fields are silently ignored and the command still returns SUCCESS. Use clock.set_alerts_config for the flat form.',
contents: [ contents: [
{ field: 'daytime_silence', type: 'bool', required: false, notes: 'Enable daytime silence window.' }, { field: 'daytime', type: 'object', required: false, notes: '{ "enabled": bool, "onTime": "HH:MM", "offTime": "HH:MM" } — each key optional.' },
{ field: 'daytime_on', type: 'string', required: false, notes: 'Start of daytime silence "HH:MM".' }, { field: 'nighttime', type: 'object', required: false, notes: '{ "enabled": bool, "onTime": "HH:MM", "offTime": "HH:MM" } — each key optional.' },
{ field: 'daytime_off', type: 'string', required: false, notes: 'End of daytime silence "HH:MM".' },
{ field: 'night_silence', type: 'bool', required: false, notes: 'Enable nighttime silence window.' },
{ field: 'night_on', type: 'string', required: false, notes: 'Start of nighttime silence "HH:MM".' },
{ field: 'night_off', type: 'string', required: false, notes: 'End of nighttime silence "HH:MM".' },
], ],
response: '{ "status": "SUCCESS", "type": "clock.set_silence", "message": "Clock silence periods updated and saved" }', response: '{ "status": "SUCCESS", "type": "clock.set_silence", "message": "Clock silence periods updated and saved" }',
errors: [ errors: [
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' }, { message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
], ],
example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { "night_silence": true, "night_on": "22:00", "night_off": "07:00" } }', example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { "nighttime": { "enabled": true, "onTime": "22:00", "offTime": "07:00" } } }',
warning: null, warning: 'Nested objects only — { "night_silence": true } is silently ignored here. Prefer clock.set_alerts_config in new code.',
}, },
{ {
cmd: 'clock.set_config', cmd: 'clock.set_config',
@@ -398,11 +397,11 @@ const NAMESPACES = [
cmd: 'clock.set_time', cmd: 'clock.set_time',
handler: 'ClockHandler', handler: 'ClockHandler',
transports: ['All'], transports: ['All'],
description: 'Set the RTC time directly.', description: 'Set the RTC time directly. timezone_offset and dst_offset are only applied when BOTH are present (they also update the stored timezone); with only one, or neither, timestamp is written to the RTC as-is.',
contents: [ contents: [
{ field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp (UTC or local depending on whether offsets are provided)' }, { field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp (UTC when both offsets are sent; written as-is otherwise)' },
{ field: 'timezone_offset', type: 'int', required: false, notes: 'Total UTC offset in seconds (e.g. 7200 for UTC+2). Provide with dst_offset to use the combined-offset path.' }, { field: 'timezone_offset', type: 'int', required: false, notes: 'Total UTC offset in seconds incl. DST (e.g. 10800 for UTC+2 with DST). Ignored unless dst_offset is also sent.' },
{ field: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds. Provide together with timezone_offset.' }, { field: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds. Ignored unless timezone_offset is also sent.' },
], ],
response: '{ "status": "SUCCESS", "type": "clock.set_time", "message": "RTC time updated successfully" }', response: '{ "status": "SUCCESS", "type": "clock.set_time", "message": "RTC time updated successfully" }',
errors: [ errors: [
@@ -464,7 +463,7 @@ const NAMESPACES = [
cmd: 'clock.sync_ntp', cmd: 'clock.sync_ntp',
handler: 'ClockHandler', handler: 'ClockHandler',
transports: ['All'], transports: ['All'],
description: 'Force an immediate NTP time sync.', description: 'Run an NTP time sync (blocks up to ~5 s). Always returns SUCCESS — the outcome is only logged.',
contents: null, contents: null,
response: '{ "status": "SUCCESS", "type": "clock.sync_ntp", "message": "NTP sync initiated" }', response: '{ "status": "SUCCESS", "type": "clock.sync_ntp", "message": "NTP sync initiated" }',
errors: [], errors: [],
@@ -572,7 +571,7 @@ const NAMESPACES = [
{ {
id: 'system', id: 'system',
label: 'system', label: 'system',
description: 'Device status, settings, health, and control. Handler: SystemHandler.', description: 'Device status, settings, health, and control. Handler: SystemHandler. Only registered on RTC builds (as are ping/identify) — returns "Unknown command" on agnus/agnus-mini.',
commands: [ commands: [
{ {
cmd: 'system.status', cmd: 'system.status',
@@ -753,13 +752,13 @@ const NAMESPACES = [
cmd: 'ota.update', cmd: 'ota.update',
handler: 'FirmwareHandler', handler: 'FirmwareHandler',
transports: ['All'], transports: ['All'],
description: 'Trigger OTA update from the VPS update server using the configured channel. Device reboots after flashing.', description: 'Trigger OTA update from the VPS update server. Device reboots after flashing.',
contents: [ contents: [
{ field: 'channel', type: 'string', required: false, notes: '"stable", "beta", or "development". If omitted, uses the channel saved in config.' }, { field: 'channel', type: 'string', required: false, notes: '"stable", "beta", or "development". If omitted, defaults to "stable" — NOT the channel saved with ota.set_channel.' },
], ],
response: '{ "status": "SUCCESS", "type": "ota.update", "message": "OTA update started from channel: stable" }', response: '{ "status": "SUCCESS", "type": "ota.update", "message": "OTA update started from channel: stable" }',
errors: [ errors: [
{ message: 'Cannot update while playback is active', condition: 'Player is currently PLAYING' }, { message: 'Cannot update while playback is active', condition: 'Player is currently PLAYING (paused/stopping are not checked)' },
], ],
example: '{ "v": 2, "cmd": "ota.update" }\n{ "v": 2, "cmd": "ota.update", "contents": { "channel": "stable" } }', example: '{ "v": 2, "cmd": "ota.update" }\n{ "v": 2, "cmd": "ota.update", "contents": { "channel": "stable" } }',
warning: 'Device will reboot after flashing. Ensure playback is stopped first.', warning: 'Device will reboot after flashing. Ensure playback is stopped first.',
@@ -787,7 +786,7 @@ const NAMESPACES = [
cmd: 'ota.set_channel', cmd: 'ota.set_channel',
handler: 'FirmwareHandler', handler: 'FirmwareHandler',
transports: ['All'], transports: ['All'],
description: 'Persist the OTA update channel. Subsequent ota.update calls without a channel argument will use this value.', description: 'Persist the OTA update channel (used by the scheduled update check and reported by firmware.status). Note: ota.update without a channel argument does NOT use this value — it defaults to "stable".',
contents: [ contents: [
{ field: 'channel', type: 'string', required: true, notes: '"stable", "beta", or "development"' }, { field: 'channel', type: 'string', required: true, notes: '"stable", "beta", or "development"' },
], ],
@@ -843,7 +842,7 @@ const NAMESPACES = [
{ field: 'dns2', type: 'string', required: false, notes: 'Secondary DNS' }, { field: 'dns2', type: 'string', required: false, notes: 'Secondary DNS' },
{ field: 'permanent_ap_mode', type: 'bool', required: false, notes: 'Enable/disable permanent AP mode. If present, all other fields are ignored.' }, { field: 'permanent_ap_mode', type: 'bool', required: false, notes: 'Enable/disable permanent AP mode. If present, all other fields are ignored.' },
], ],
response: '{ "status": "SUCCESS", "type": "network.set_config", "message": "Network configuration saved. Restart device to apply changes." }', response: '{ "status": "SUCCESS", "type": "network.set_config", "message": "Network configuration saved. Restart device to apply changes." }\n\n// when permanent_ap_mode is sent:\n{ "status": "SUCCESS", "type": "network.set_config", "message": "AP mode saved. Restart to apply." }\n{ "status": "SUCCESS", "type": "network.set_config", "message": "Station mode saved. Restart to apply." }',
errors: [ errors: [
{ message: 'No network parameters provided', condition: 'Contents contains none of: hostname, useStaticIP, permanent_ap_mode' }, { message: 'No network parameters provided', condition: 'Contents contains none of: hostname, useStaticIP, permanent_ap_mode' },
{ message: 'Hostname must be 1-32 characters', condition: 'hostname is empty or longer than 32 chars' }, { message: 'Hostname must be 1-32 characters', condition: 'hostname is empty or longer than 32 chars' },
@@ -881,7 +880,7 @@ const NAMESPACES = [
transports: ['All'], transports: ['All'],
description: 'List all built-in melodies compiled into the firmware.', description: 'List all built-in melodies compiled into the firmware.',
contents: null, contents: null,
response: '{\n "status": "SUCCESS",\n "type": "files.list_builtin",\n "data": [\n { "name": "Westminster", "uid": "westminster" },\n ...\n ]\n}', response: '{\n "status": "SUCCESS",\n "type": "files.list_builtin",\n "data": [\n { "name": "Westminster", "pid": "westminster" },\n ...\n ]\n}',
errors: [ errors: [
{ message: 'Failed to read built-in melody list', condition: 'Internal JSON parse error (should never happen)' }, { message: 'Failed to read built-in melody list', condition: 'Internal JSON parse error (should never happen)' },
], ],
@@ -892,15 +891,16 @@ const NAMESPACES = [
cmd: 'files.download', cmd: 'files.download',
handler: 'FileHandler', handler: 'FileHandler',
transports: ['All'], transports: ['All'],
description: 'Download a melody from the server to the SD card.', description: 'Download a melody to /melodies/{melodys_uid} on the SD card. Built-in melody IDs are skipped and reported as success. Blocks until the download finishes.',
contents: [ contents: [
{ field: 'melodys_uid', type: 'string', required: true, notes: 'UID of the melody to download' }, { field: 'melodys_uid', type: 'string', required: true, notes: 'Melody ID — becomes the filename on SD (and the pid used by playback.play).' },
{ field: 'download_url', type: 'string', required: true, notes: 'URL of the melody binary.' },
], ],
response: '{ "status": "SUCCESS", "type": "files.download", "message": "Melody downloaded: ABC123" }', response: '{ "status": "SUCCESS", "type": "files.download", "message": "Melody downloaded: ABC123" }',
errors: [ errors: [
{ message: 'Failed to download melody: <uid>', condition: 'HTTP request failed, SD write failed, or melody not found on server' }, { message: 'Failed to download melody: <uid>', condition: 'melodys_uid or download_url missing, HTTP request failed, or SD write failed' },
], ],
example: '{ "v": 2, "cmd": "files.download", "contents": { "melodys_uid": "ABC123" } }', example: '{ "v": 2, "cmd": "files.download", "contents": { "melodys_uid": "ABC123", "download_url": "https://example.com/ABC123.bin" } }',
warning: null, warning: null,
}, },
{ {
@@ -991,6 +991,7 @@ const NAMESPACES = [
response: '{\n "status": "SUCCESS",\n "type": "logs.list",\n "data": {\n "files": [\n { "name": "2026-07-13.log", "size": 48213 },\n { "name": "2026-07-14.log", "size": 9120 }\n ]\n }\n}', response: '{\n "status": "SUCCESS",\n "type": "logs.list",\n "data": {\n "files": [\n { "name": "2026-07-13.log", "size": 48213 },\n { "name": "2026-07-14.log", "size": 9120 }\n ]\n }\n}',
errors: [ errors: [
{ message: 'SD card not available on this device', condition: 'Device has no SD card, or FileManager was not wired up' }, { message: 'SD card not available on this device', condition: 'Device has no SD card, or FileManager was not wired up' },
{ message: 'Failed to read log directory', condition: 'The /logs listing could not be parsed' },
], ],
example: '{ "v": 2, "cmd": "logs.list" }', example: '{ "v": 2, "cmd": "logs.list" }',
warning: null, warning: null,
@@ -1153,9 +1154,9 @@ const NAMESPACES = [
description: 'Set overload threshold. Use "all" to set all channels at once, or "loads" for per-channel values. At least one is required.', description: 'Set overload threshold. Use "all" to set all channels at once, or "loads" for per-channel values. At least one is required.',
contents: [ contents: [
{ field: 'all', type: 'int', required: false, notes: 'Set all 16 channels to this value. Must be > 0.' }, { field: 'all', type: 'int', required: false, notes: 'Set all 16 channels to this value. Must be > 0.' },
{ field: 'loads', type: 'object', required: false, notes: 'Per-channel map: { "0": 300, "3": 700, ... }. Values must be > 0.' }, { field: 'loads', type: 'object', required: false, notes: 'Per-channel map: { "0": 300, "3": 700, ... }. Zero values and out-of-range channels are silently skipped. Ignored if "all" is also sent.' },
], ],
response: '{ "status": "SUCCESS", "type": "telemetry.set_max_loads", "message": "All bell max loads updated" }', response: '{ "status": "SUCCESS", "type": "telemetry.set_max_loads", "message": "All bell max loads updated" }\n\n// "loads" form:\n{ "status": "SUCCESS", "type": "telemetry.set_max_loads", "message": "Bell max loads updated" }',
errors: [ errors: [
{ message: 'max_load must be > 0', condition: 'contents.all is 0' }, { message: 'max_load must be > 0', condition: 'contents.all is 0' },
{ message: 'Provide \'all\' (uint16) or \'loads\' (object)', condition: 'Neither all nor loads is present in contents' }, { message: 'Provide \'all\' (uint16) or \'loads\' (object)', condition: 'Neither all nor loads is present in contents' },
@@ -1204,7 +1205,7 @@ const NAMESPACES = [
cmd: 'telemetry.get_diagnostics', cmd: 'telemetry.get_diagnostics',
handler: 'TelemetryHandler', handler: 'TelemetryHandler',
transports: ['All'], 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.', description: 'On-demand mirror of the periodic report published to system/metrics every 5 minutes (see the Metrics Payload card on the Transports tab) — CPU temperature, WiFi reconnects, OTA state, and per-task stack high-water marks (the bell strike/load fields of system/metrics are not included). 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, 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}', 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: [], errors: [],
@@ -1217,7 +1218,7 @@ const NAMESPACES = [
transports: ['All'], 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.', 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: [ 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).' }, { 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 to [1, 200] — 0 is treated as 1, not as "no limit".' },
], ],
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}', 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: [ errors: [
@@ -1285,6 +1286,89 @@ const NAMESPACES = [
}, },
], ],
}, },
{
id: 'rf',
label: 'rf',
description: '433 MHz RF remote slots — up to 16, each binding one RF code to a playback action (or a stop). Stored in NVS namespace "rf"; blank by default. Handler: RFRemoteHandler. Only registered on HAS_RF builds — returns "Unknown command" elsewhere (e.g. bespoke-rs485).',
commands: [
{
cmd: 'rf.set_slot',
handler: 'RFRemoteHandler',
transports: ['All'],
description: 'Program one slot (fields at the top level of contents) or several ({ "slots": [ ... ] }). A play slot plays like a legacy segment_duration play: loop for duration ms, finish the loop, stop.',
contents: [
{ field: 'slot', type: 'int', required: true, notes: 'Slot index, 0–15 (0-based).' },
{ field: 'rf_code', type: 'int', required: true, notes: 'Raw 433 MHz code (uint32). Must be non-zero.' },
{ field: 'is_stop', type: 'bool', required: false, notes: 'Default false. true = this code force-stops playback; pid/speed/duration/note_assignments are then ignored.' },
{ field: 'pid', type: 'string', required: true, notes: 'Play slots only. Built-in or SD melody ID.' },
{ field: 'speed', type: 'int', required: true, notes: 'Play slots only. ms per beat, non-zero.' },
{ field: 'duration', type: 'int', required: true, notes: 'Play slots only. ms, non-zero.' },
{ field: 'note_assignments', type: 'int[16]', required: true, notes: 'Play slots only. Exactly 16 entries, at least one non-zero. Index = melody note (0-based), value = 1-based bell channel, 0 = silent.' },
{ field: 'slots', type: 'object[]', required: false, notes: 'Batch form — an array of slot objects instead of the top-level fields.' },
],
response: '{ "status": "SUCCESS", "type": "rf.set_slot", "message": "Slot 0 programmed" }\n\n// batch form:\n{ "status": "SUCCESS", "type": "rf.set_slot", "message": "3 slot(s) programmed" }',
errors: [
{ message: 'missing \'slot\'', condition: 'slot absent / not an int' },
{ message: '\'slot\' out of range (got <n>, must be 0–15)', condition: 'slot outside 0–15' },
{ message: 'slot <n>: missing or zero \'rf_code\'', condition: 'rf_code absent or 0' },
{ message: 'slot <n>: play slot missing \'pid\'', condition: 'Play slot with empty/absent pid' },
{ message: 'slot <n>: play slot missing \'speed\'', condition: 'Play slot without speed' },
{ message: 'slot <n>: \'speed\' must be non-zero', condition: 'speed is 0' },
{ message: 'slot <n>: play slot missing \'duration\'', condition: 'Play slot without duration' },
{ message: 'slot <n>: \'duration\' must be non-zero', condition: 'duration is 0' },
{ message: 'slot <n>: play slot missing \'note_assignments\'', condition: 'Play slot without note_assignments' },
{ message: 'slot <n>: \'note_assignments\' must have exactly 16 elements (got <k>)', condition: 'Wrong array length' },
{ message: 'slot <n>: \'note_assignments\' are all zero — no bells would ring', condition: 'Every entry is 0' },
{ message: 'Slot entry invalid — <reason>. No slots were saved.', condition: 'Batch form: any entry fails one of the checks above. NOTE: entries before the invalid one HAVE already been saved despite the wording.' },
],
example: '// single\n{ "v": 2, "cmd": "rf.set_slot", "contents": { "slot": 0, "rf_code": 9274392, "pid": "vesper_fst_1_4", "speed": 500, "duration": 60000, "note_assignments": [1,2,3,4,5,6,0,0,0,0,0,0,0,0,0,0] } }\n\n// batch, incl. a stop slot\n{ "v": 2, "cmd": "rf.set_slot", "contents": { "slots": [\n { "slot": 1, "rf_code": 9274388, "pid": "doxology_1_4", "speed": 500, "duration": 60000, "note_assignments": [1,2,3,0,0,0,0,0,0,0,0,0,0,0,0,0] },\n { "slot": 7, "rf_code": 9274385, "is_stop": true }\n] } }',
warning: null,
},
{
cmd: 'rf.clear_slot',
handler: 'RFRemoteHandler',
transports: ['All'],
description: 'Clear one slot, or all slots.',
contents: [
{ field: 'slot', type: 'int | string', required: true, notes: 'Slot index 0–15, or "all" (case-insensitive).' },
],
response: '{ "status": "SUCCESS", "type": "rf.clear_slot", "message": "Slot 3 cleared" }\n\n{ "status": "SUCCESS", "type": "rf.clear_slot", "message": "All slots cleared" }',
errors: [
{ message: 'Invalid value for \'slot\' — use a slot index (0–15) or "all"', condition: 'slot is a string other than "all"' },
{ message: 'Required: \'slot\' — slot index (0–15) or "all"', condition: 'slot absent / not an int or string' },
{ message: 'Invalid slot index — must be 0–15', condition: 'slot outside 0–15' },
],
example: '{ "v": 2, "cmd": "rf.clear_slot", "contents": { "slot": 3 } }\n{ "v": 2, "cmd": "rf.clear_slot", "contents": { "slot": "all" } }',
warning: null,
},
{
cmd: 'rf.get_slots',
handler: 'RFRemoteHandler',
transports: ['All'],
description: 'Return the RF enabled flag and all 16 slots. Empty slots have "empty": true, rf_code 0 and zeroed fields.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "rf.get_slots",\n "data": {\n "enabled": true,\n "slots": [\n { "slot": 0, "empty": false, "rf_code": 9274392, "pid": "vesper_fst_1_4", "speed": 500, "duration": 60000, "is_stop": false, "note_assignments": [1,2,3,4,5,6,0,0,0,0,0,0,0,0,0,0] },\n { "slot": 1, "empty": true, "rf_code": 0, "pid": "", "speed": 0, "duration": 0, "is_stop": false, "note_assignments": [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] },\n ...\n ]\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "rf.get_slots" }',
warning: null,
},
{
cmd: 'rf.enable',
handler: 'RFRemoteHandler',
transports: ['All'],
description: 'Enable or disable RF reception at runtime.',
contents: [
{ field: 'enabled', type: 'bool', required: true, notes: 'true = listen for codes, false = ignore them.' },
],
response: '{ "status": "SUCCESS", "type": "rf.enable", "message": "RF remote enabled" }',
errors: [
{ message: 'Required: \'enabled\' (bool)', condition: 'enabled absent / not a bool' },
],
example: '{ "v": 2, "cmd": "rf.enable", "contents": { "enabled": true } }',
warning: null,
},
],
},
] ]
const LEGACY_MAP = [ const LEGACY_MAP = [
@@ -1314,6 +1398,12 @@ const LEGACY_MAP = [
{ v1_cmd: 'system_info', v1_action: 'get_firmware_status', v2: 'firmware.status' }, { v1_cmd: 'system_info', v1_action: 'get_firmware_status', v2: 'firmware.status' },
{ v1_cmd: 'system_info', v1_action: 'network_info', v2: 'network.info' }, { v1_cmd: 'system_info', v1_action: 'network_info', v2: 'network.info' },
{ v1_cmd: 'system_info', v1_action: 'get_full_settings', v2: 'system.get_config' }, { v1_cmd: 'system_info', v1_action: 'get_full_settings', v2: 'system.get_config' },
{ v1_cmd: 'system_info', v1_action: 'get_all_settings', v2: 'system.get_all' },
{ v1_cmd: 'system_info', v1_action: 'get_telemetry', v2: 'system.get_telemetry' },
{ v1_cmd: 'system_info', v1_action: 'sync_time_to_lcd', v2: 'system.get_time' },
{ v1_cmd: 'bells', v1_action: 'enable', v2: 'bells.enable' },
{ v1_cmd: 'bells', v1_action: 'disable', v2: 'bells.disable' },
{ v1_cmd: 'bells', v1_action: 'get_config', v2: 'bells.get_config' },
{ v1_cmd: 'system', v1_action: 'status', v2: 'system.status' }, { v1_cmd: 'system', v1_action: 'status', v2: 'system.status' },
{ v1_cmd: 'system', v1_action: 'reset_defaults', v2: 'system.factory_reset' }, { v1_cmd: 'system', v1_action: 'reset_defaults', v2: 'system.factory_reset' },
{ v1_cmd: 'system', v1_action: 'commit_firmware', v2: 'firmware.commit' }, { v1_cmd: 'system', v1_action: 'commit_firmware', v2: 'firmware.commit' },
@@ -1329,13 +1419,6 @@ const LEGACY_MAP = [
{ v1_cmd: 'system', v1_action: 'reboot', v2: 'system.restart' }, { v1_cmd: 'system', v1_action: 'reboot', v2: 'system.restart' },
{ v1_cmd: 'system', v1_action: 'force_update', v2: 'ota.update' }, { v1_cmd: 'system', v1_action: 'force_update', v2: 'ota.update' },
{ v1_cmd: 'system', v1_action: 'custom_update', v2: 'ota.custom' }, { v1_cmd: 'system', v1_action: 'custom_update', v2: 'ota.custom' },
{ v1_cmd: 'system', v1_action: 'get_all_settings', v2: 'system.get_all' },
{ v1_cmd: 'system', v1_action: 'get_telemetry', v2: 'system.get_telemetry' },
{ v1_cmd: 'system', v1_action: 'get_full_settings', v2: 'system.get_config' },
{ v1_cmd: 'telemetry', v1_action: 'get_bell_loads', v2: 'telemetry.get_loads' },
{ v1_cmd: 'telemetry', v1_action: 'get_strike_counters', v2: 'telemetry.get_strikes' },
{ v1_cmd: 'bells', v1_action: 'set_enabled (enabled=true)', v2: 'bells.enable' },
{ v1_cmd: 'bells', v1_action: 'set_enabled (enabled=false)', v2: 'bells.disable' },
] ]
// Legacy ("Controller - Production FW", pre-rewrite) MQTT topic set, mapped to // Legacy ("Controller - Production FW", pre-rewrite) MQTT topic set, mapped to
@@ -1921,15 +2004,18 @@ function TransportsV2() {
</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 "req_id": "abc123", // only if request sent one\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} /> <MonoBlock code={'{\n "status": "SUCCESS" | "ERROR",\n "type": "<cmd echoed back>",\n "req_id": "abc123", // MQTT only, if request sent one\n "message": "...", // text responses + every error\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, 2. If omitted, MQTT/WebSocket/UART assume 1 (legacy adapter runs; unknown names pass through unchanged, so v2 names still work) and HTTP assumes 2.' },
{ 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: 'req_id', type: 'string?', notes: 'Optional correlation ID, echoed back on the reply. MQTT only — other transports ignore it.' },
{ field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' }, { field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' },
{ field: 'reply: type', type: 'string', notes: 'Normally the command name. Exceptions: ping → "pong"; playback.play/stop → "playback"; unregistered command → "unknown"; bad MQTT input → "parse_error"; WS/HTTP input without cmd → "bad_request".' },
{ field: 'reply: status', type: 'string', notes: '"SUCCESS" | "ERROR". "INFO" is never a command reply — it only appears on unsolicited WebSocket broadcasts, which keep the pre-v2 "payload" key.' },
{ field: 'reply: message / data', type: 'string / object', notes: 'message on text replies and every error; data on structured replies — never both. There is no "payload" key in v2 replies (only in v1-translated system.status replies).' },
].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)' }}>
@@ -2033,7 +2119,7 @@ function ExtrasTab() {
{ field: 'Type', notes: 'String. No length cap, character-set restriction, or format validation exists in firmware — any JSON value is accepted and stringified as-is.' }, { field: 'Type', notes: 'String. No length cap, character-set restriction, or format validation exists in firmware — any JSON value is accepted and stringified as-is.' },
{ field: 'Required', notes: 'Always optional. Omit it and the reply simply has no req_id field — existing callers and v1 clients are completely unaffected.' }, { field: 'Required', notes: 'Always optional. Omit it and the reply simply has no req_id field — existing callers and v1 clients are completely unaffected.' },
{ field: 'On parse failure', notes: 'If the whole command payload fails to parse as JSON, there\'s no req_id to recover — the error reply has no req_id key regardless of what was sent.' }, { field: 'On parse failure', notes: 'If the whole command payload fails to parse as JSON, there\'s no req_id to recover — the error reply has no req_id key regardless of what was sent.' },
{ field: 'On missing cmd', notes: 'If req_id parsed fine but cmd is missing, the "unknown command" error reply DOES still echo req_id — it\'s read before cmd is validated.' }, { field: 'On missing cmd', notes: 'If req_id parsed fine but cmd is missing, the { "type": "parse_error", "message": "Missing required field: cmd" } reply DOES still echo req_id — it\'s read before cmd is validated. An unregistered cmd ({ "type": "unknown" }) echoes it too.' },
{ field: 'Access control', notes: 'None. Any caller can set any req_id today — there\'s no ownership or uniqueness check.' }, { field: 'Access control', notes: 'None. Any caller can set any req_id today — there\'s no ownership or uniqueness check.' },
].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)' }}>