docs(api-reference): list every playback.play/stop reply and error string (firmware F-067)

Replaces the generic "Playback command failed" with the firmware's specific
messages, documents "Already stopped" as SUCCESS, url no longer sticky,
and the stricter field validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:18:13 +03:00
co-authored by Claude Opus 5.5
parent 9d9b46056a
commit 8b229e7fb3
@@ -97,24 +97,38 @@ const NAMESPACES = [
cmd: 'playback.play', cmd: 'playback.play',
handler: 'PlaybackHandler', handler: 'PlaybackHandler',
transports: ['MQTT', 'WebSocket', 'HTTP'], 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.', 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 url/mode/continuous_loop keeps its value from the previous playback.play (or boot default) when omitted — including pid, speed and the tail of note_assignments — so send every field you care about on every play. A rejected play never changes the stored values (all checks run first). A needed download happens after the SUCCESS reply, so a failed download cannot be an ACK error. UART: only the v1 form { "cmd": "playback", "contents": { "action": "play", ... } } is whitelisted.',
contents: [ contents: [
{ 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: '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: '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: '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: '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). Not sticky: omitted = no download possible for this play.' },
{ field: 'speed', type: 'int', required: false, notes: 'ms per beat. Boot default 500. 0 is coerced to 300.' }, { field: 'speed', type: 'int', required: false, notes: 'ms per beat, 0–65535. 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: 'note_assignments', type: 'int[]', required: false, notes: 'Up to 16 integers, each 0–16. 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]. 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: 'mode', type: 'string', required: false, notes: '"single" | "timed" | "interval" | "infinite" — anything else is rejected. Selects the timing model (see warning). 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: 'duration', type: 'int', required: false, notes: 'ms, ≥ 0. Read only when mode is present; must be > 0 for "timed"/"interval". "timed": loop until duration elapses, finish the current loop, stop. "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: '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: '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: '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).' }, { 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 started" }\n\n// melody must be downloaded first:\n{ "status": "SUCCESS", "type": "playback", "message": "Melody download started — playback begins when it completes" }',
errors: [ errors: [
{ 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.' }, { message: 'Playback already active — send playback.stop first', condition: 'Player is not idle (playing / paused / stopping)' },
{ message: 'Missing required field: pid', condition: 'No pid/uid in this command and none stored from a previous play; or it is empty, "-", or not a string' },
{ message: 'Invalid speed: must be an integer from 0 to 65535 (ms per beat)', condition: 'speed present but not an integer in range' },
{ message: 'Invalid note_assignments: must be an array of up to 16 integers, each 0-16', condition: 'Not an array, more than 16 entries, or an entry outside 0–16' },
{ message: 'Invalid duration: must be a non-negative integer (ms)', condition: 'duration negative / non-integer / > 4294967295' },
{ message: 'Invalid segment_duration: must be a non-negative integer (ms)', condition: 'Same, for segment_duration' },
{ message: 'Invalid pause_duration: must be a non-negative integer (ms)', condition: 'Same, for pause_duration' },
{ message: 'Invalid total_duration: must be a non-negative integer (ms)', condition: 'Same, for total_duration' },
{ message: 'Invalid mode: must be single, timed, interval or infinite', condition: 'Unrecognised mode' },
{ message: 'Invalid duration: mode timed and interval require duration > 0', condition: 'mode is timed/interval and duration is missing or 0' },
{ message: 'Playback blocked: bell mechanism is disabled (bells.enable)', condition: 'Bells master switch is off (bells.disable)' },
{ message: 'Playback blocked: bell overload protection active — wait for the bells to cool', condition: 'Load guard enabled and a bell is over its max load (the guard would force-stop immediately)' },
{ message: 'Melody failed to load: file is empty or corrupt', condition: '/melodies/{pid} exists on SD but is empty or has an odd byte count' },
{ message: 'Melody not found: not built-in, not on SD card, and no url given', condition: 'Needs a download but no url was sent' },
{ message: 'Melody not found locally and this device has no SD card to download it to', condition: 'Needs a download but the build has no SD card / FileManager (agnus)' },
], ],
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 } }', 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.', 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.',
@@ -123,9 +137,9 @@ const NAMESPACES = [
cmd: 'playback.stop', cmd: 'playback.stop',
handler: 'PlaybackHandler', handler: 'PlaybackHandler',
transports: ['MQTT', 'WebSocket', 'HTTP'], 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.', description: 'Stop playback immediately (bells cut mid-loop, status goes straight to idle). Already idle → still SUCCESS, with message "Already stopped" (stop is idempotent — the goal state is reached, so it is not an error; the distinct message lets a client tell the cases apart). No error replies. 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 stopped" }\n\n// already idle:\n{ "status": "SUCCESS", "type": "playback", "message": "Already stopped" }',
errors: [], errors: [],
example: '{ "v": 2, "cmd": "playback.stop" }', example: '{ "v": 2, "cmd": "playback.stop" }',
warning: null, warning: null,