From 8b229e7fb3f879c909ff2c654d35a5e12c7b8ed6 Mon Sep 17 00:00:00 2001 From: bonamin Date: Tue, 29 Sep 2026 20:18:13 +0300 Subject: [PATCH] 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 --- .../developer/ApiReferencePage.jsx | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx index 213854b..fdcac99 100644 --- a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx +++ b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx @@ -97,24 +97,38 @@ const NAMESPACES = [ cmd: 'playback.play', handler: 'PlaybackHandler', 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: [ { 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: '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, 0–65535. Boot default 500. 0 is coerced to 300.' }, + { 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" — 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, ≥ 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: '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" }', + 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: [ - { 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 } }', 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', handler: 'PlaybackHandler', 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, - 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: [], example: '{ "v": 2, "cmd": "playback.stop" }', warning: null,