diff --git a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx index 32aa6c3..9467952 100644 --- a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx +++ b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx @@ -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/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: '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: '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: '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.' }, ] +// 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 = [ - 'ping', 'identify', - 'playback.play', 'playback.stop', - 'relay.test_bell', 'relay.test_batch', 'relay.test_output', - 'clock.pause', 'clock.resume', 'clock.test_c1', 'clock.test_c2', - 'system.get_time', 'system.status', 'system.get_config', 'system.get_telemetry', - 'bells.enable', 'bells.disable', + 'ping', + 'playback', + 'clock.pause', 'clock.resume', + 'system.get_time', ] // Every command has: @@ -44,9 +45,10 @@ const UART_WHITELIST = [ // errors — array of { message, condition } matching every CommandResult::err() path // // Wire envelope for all replies: -// { "status": "SUCCESS"|"ERROR", "type": "", "message": "...", "data": {...} } -// "message" is present on plain-text ok(); "data" is present on ok(doc). -// Errors always have "message". +// { "status": "SUCCESS"|"ERROR", "type": "", "req_id": "...", "message": "...", "data": {...} } +// "message" is present on plain-text ok(); "data" is present on ok(doc) — never both. +// 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 = [ { @@ -62,7 +64,7 @@ const NAMESPACES = [ contents: [ { field: 'ts', type: 'int', required: false, notes: 'Caller’s own epoch-millis timestamp at send time. Omit for a plain liveness ack — the response will omit ts too.' }, ], - response: '{ "status": "SUCCESS", "type": "pong", "data": { "ts": 1752600000123, "device_uptime_ms": 184213 } }', + response: '{ "status": "SUCCESS", "type": "pong", "data": { "type": "pong", "ts": 1752600000123, "device_uptime_ms": 184213 } }', errors: [], example: '{ "v": 2, "cmd": "ping", "contents": { "ts": 1752600000123 } }', warning: null, @@ -89,44 +91,42 @@ const NAMESPACES = [ { id: '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: [ { cmd: 'playback.play', handler: 'PlaybackHandler', - transports: ['All'], - 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).', + transports: ['MQTT', 'WebSocket', 'HTTP'], + 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: [ - { 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: '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: 'name', type: 'string', required: false, notes: 'Human-readable label. Informational only — echoed in status broadcasts, never used for playback logic.' }, - { 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: '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: '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: '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: '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: '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: '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: '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: '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: '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. Prefer pid in new code.' }, + { field: 'name', type: 'string', required: false, notes: 'Display name. Informational only.' }, + { 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: 'ms per beat. Boot default 500. 0 is coerced to 300.' }, + { 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: '"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: '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: 'pause_duration', type: 'int', required: false, notes: 'ms between segments. Read for mode "interval", or when mode is absent.' }, + { 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: '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: 'Legacy — read only when mode is absent. Defaults to false on every command (not sticky).' }, ], response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }', 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 } }', - 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.', + 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 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', handler: 'PlaybackHandler', - transports: ['All'], - description: 'Stop playback immediately. Safe to call even when player is already stopped.', + transports: ['MQTT', 'WebSocket', 'HTTP'], + 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, response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }', - errors: [ - { message: 'Playback command failed', condition: 'Player rejected the stop (internal error)' }, - ], + errors: [], example: '{ "v": 2, "cmd": "playback.stop" }', warning: null, }, @@ -143,11 +143,11 @@ const NAMESPACES = [ transports: ['All'], description: 'Set strike duration (ms) for one or more bell channels. Partial updates accepted.', 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" }', 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' }, ], 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" }', 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' }, ], 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: [ { 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 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 } }', warning: null, @@ -246,7 +247,7 @@ const NAMESPACES = [ errors: [ { 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: '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}', warning: null, @@ -256,16 +257,18 @@ const NAMESPACES = [ { id: '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: [ { cmd: 'clock.set_outputs', handler: 'ClockHandler', 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: [ { 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" }', errors: [ @@ -295,20 +298,20 @@ const NAMESPACES = [ cmd: 'clock.set_alerts', handler: 'ClockHandler', 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: [ - { 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: 'alert_interval', 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: 'half_bell', 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: '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: 'alertRingInterval', type: 'int', required: false, notes: 'Ring count per trigger (SINGLE mode).' }, + { 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: 'halfBell', type: 'int', required: false, notes: 'Bell number to ring at half past (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" }', errors: [ { 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 } }', - warning: null, + example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { "alertType": "HOURS", "hourBell": 5 } }', + warning: 'camelCase only — { "alert_type": ... } is silently ignored here. Prefer clock.set_alerts_config in new code.', }, { cmd: 'clock.set_backlight', @@ -332,21 +335,17 @@ const NAMESPACES = [ cmd: 'clock.set_silence', handler: 'ClockHandler', 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: [ - { field: 'daytime_silence', type: 'bool', required: false, notes: 'Enable daytime silence window.' }, - { field: 'daytime_on', type: 'string', required: false, notes: 'Start of daytime silence "HH:MM".' }, - { 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".' }, + { field: 'daytime', type: 'object', required: false, notes: '{ "enabled": bool, "onTime": "HH:MM", "offTime": "HH:MM" } — each key optional.' }, + { field: 'nighttime', type: 'object', required: false, notes: '{ "enabled": bool, "onTime": "HH:MM", "offTime": "HH:MM" } — each key optional.' }, ], response: '{ "status": "SUCCESS", "type": "clock.set_silence", "message": "Clock silence periods updated and saved" }', errors: [ { 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" } }', - warning: null, + example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { "nighttime": { "enabled": true, "onTime": "22:00", "offTime": "07:00" } } }', + warning: 'Nested objects only — { "night_silence": true } is silently ignored here. Prefer clock.set_alerts_config in new code.', }, { cmd: 'clock.set_config', @@ -398,11 +397,11 @@ const NAMESPACES = [ cmd: 'clock.set_time', handler: 'ClockHandler', 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: [ - { field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp (UTC or local depending on whether offsets are provided)' }, - { 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: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds. Provide together with timezone_offset.' }, + { 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 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. Ignored unless timezone_offset is also sent.' }, ], response: '{ "status": "SUCCESS", "type": "clock.set_time", "message": "RTC time updated successfully" }', errors: [ @@ -464,7 +463,7 @@ const NAMESPACES = [ cmd: 'clock.sync_ntp', handler: 'ClockHandler', 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, response: '{ "status": "SUCCESS", "type": "clock.sync_ntp", "message": "NTP sync initiated" }', errors: [], @@ -572,7 +571,7 @@ const NAMESPACES = [ { id: '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: [ { cmd: 'system.status', @@ -753,13 +752,13 @@ const NAMESPACES = [ cmd: 'ota.update', handler: 'FirmwareHandler', 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: [ - { 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" }', 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" } }', warning: 'Device will reboot after flashing. Ensure playback is stopped first.', @@ -787,7 +786,7 @@ const NAMESPACES = [ cmd: 'ota.set_channel', handler: 'FirmwareHandler', 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: [ { 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: '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: [ { 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' }, @@ -881,7 +880,7 @@ const NAMESPACES = [ transports: ['All'], description: 'List all built-in melodies compiled into the firmware.', 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: [ { 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', handler: 'FileHandler', 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: [ - { 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" }', errors: [ - { message: 'Failed to download melody: ', condition: 'HTTP request failed, SD write failed, or melody not found on server' }, + { message: 'Failed to download melody: ', 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, }, { @@ -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}', errors: [ { 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" }', 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.', contents: [ { 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: [ { 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' }, @@ -1204,7 +1205,7 @@ const NAMESPACES = [ 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.', + 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, 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: [], @@ -1217,7 +1218,7 @@ const NAMESPACES = [ 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).' }, + { 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}', 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 , must be 0–15)', condition: 'slot outside 0–15' }, + { message: 'slot : missing or zero \'rf_code\'', condition: 'rf_code absent or 0' }, + { message: 'slot : play slot missing \'pid\'', condition: 'Play slot with empty/absent pid' }, + { message: 'slot : play slot missing \'speed\'', condition: 'Play slot without speed' }, + { message: 'slot : \'speed\' must be non-zero', condition: 'speed is 0' }, + { message: 'slot : play slot missing \'duration\'', condition: 'Play slot without duration' }, + { message: 'slot : \'duration\' must be non-zero', condition: 'duration is 0' }, + { message: 'slot : play slot missing \'note_assignments\'', condition: 'Play slot without note_assignments' }, + { message: 'slot : \'note_assignments\' must have exactly 16 elements (got )', condition: 'Wrong array length' }, + { message: 'slot : \'note_assignments\' are all zero — no bells would ring', condition: 'Every entry is 0' }, + { message: 'Slot entry invalid — . 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 = [ @@ -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: '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_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: 'reset_defaults', v2: 'system.factory_reset' }, { 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: 'force_update', v2: 'ota.update' }, { 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 @@ -1921,15 +2004,18 @@ function TransportsV2() {
Response
- ",\n "req_id": "abc123", // only if request sent one\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} /> + ",\n "req_id": "abc123", // MQTT only, if request sent one\n "message": "...", // text responses + every error\n "data": { ... } // structured responses\n}'} />
{[ - { 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: '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: '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 => (
@@ -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: '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 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.' }, ].map(row => (