diff --git a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx index fdcac99..64bce2d 100644 --- a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx +++ b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx @@ -17,9 +17,9 @@ import Tabs from '@/components/ui/Tabs' const TRANSPORTS_V2 = [ { transport: 'MQTT', address: 'vesper/{device_id}/control/command', direction: 'Inbound', switch: 'command', notes: 'Read-only from the board\'s perspective — commands in. Replies go to control/ack. Optional req_id for correlation.' }, { transport: 'MQTT', address: 'vesper/{device_id}/control/ack', direction: 'Outbound, QoS 1', switch: 'command', notes: 'Strictly replies to commands (incl. pong) — nothing unsolicited is ever published here. Echoes req_id when the request sent one.' }, - { transport: 'MQTT', address: 'vesper/{device_id}/control/reports', direction: 'Outbound, QoS 1', switch: 'reports', notes: 'Critical, unsolicited, time-sensitive board-initiated events (e.g. bell_overload). Not retained.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/control/reports', direction: 'Outbound, QoS 1', switch: 'reports', notes: 'Critical, unsolicited, time-sensitive board-initiated events (bell_overload, playback_failed). Not retained. The phone app should subscribe for playback_failed.' }, { transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained, QoS 1', switch: 'heartbeat', notes: 'Every 30 s. Also carries LWT — broker publishes {"state":"offline","ok":false} on an unclean disconnect.' }, - { transport: 'MQTT', address: 'vesper/{device_id}/status/playback', direction: 'Outbound, retained, QoS 1', switch: 'playback', notes: 'Playback state transitions (playing/paused/stopping/idle) with pid, speed, duration, started_at, source, ts. Published on transition, not polled — plus on every MQTT (re)connect with the current real state, so a stale retained "playing" never outlives a reboot. Source of truth for what the board is doing.' }, + { transport: 'MQTT', address: 'vesper/{device_id}/status/playback', direction: 'Outbound, retained, QoS 1', switch: 'playback', notes: 'Playback state transitions (loading/playing/paused/stopping/idle) with pid, speed, duration, started_at, source, ts. Published on transition, not polled — plus on every MQTT (re)connect with the current real state, so a stale retained "playing" never outlives a reboot. Source of truth for what the board is doing.' }, { transport: 'MQTT', address: 'vesper/{device_id}/system/alerts', direction: 'Outbound, retained until cleared, QoS 1', switch: 'alerts', notes: 'Subsystem state changes (WARNING / CRITICAL / FAILED / CLEARED). Published on transition only.' }, { transport: 'MQTT', address: 'vesper/{device_id}/system/info', direction: 'Outbound, retained, QoS 1', switch: 'info', notes: 'Discrete system events, e.g. boot_report on every boot.' }, { transport: 'MQTT', address: 'vesper/{device_id}/system/logs', direction: 'Outbound, QoS 0', switch: 'logs', notes: 'Full log stream, mirrors serial output. Also gated by the log.set_mqtt level, independently of this switch.' }, @@ -97,7 +97,7 @@ 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 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.', + 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). Download path: ACK "Melody download started — …", status/playback shows "loading" (pid set, published just before the ACK), then "playing" on success — or "idle" plus a control/reports playback_failed { pid, reason } event on failure (the download runs after the ACK, so it cannot be an ACK error). playback.stop during loading cancels it. req_id is echoed on the ACK in both cases (it is always sent synchronously from dispatch); playback_failed has no req_id — match by pid. 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.' }, @@ -114,7 +114,8 @@ const NAMESPACES = [ ], 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 already active — send playback.stop first', condition: 'Player is not idle (playing / paused / stopping)' }, + { message: 'Playback already active — send playback.stop first', condition: 'Player is not idle (loading / playing / paused / stopping)' }, + { message: 'Playback blocked: a cancelled melody download is still finishing — try again shortly', condition: 'A download was cancelled by playback.stop and its transfer has not ended yet' }, { 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' }, @@ -137,9 +138,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). 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.', + description: 'Stop playback immediately (bells cut mid-loop, status goes straight to idle). While loading → SUCCESS "Download cancelled — playback will not start" (the transfer finishes in the background and is discarded; no playback_failed). 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 stopped" }\n\n// already idle:\n{ "status": "SUCCESS", "type": "playback", "message": "Already stopped" }', + response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback stopped" }\n\n// while loading:\n{ "status": "SUCCESS", "type": "playback", "message": "Download cancelled — playback will not start" }\n\n// already idle:\n{ "status": "SUCCESS", "type": "playback", "message": "Already stopped" }', errors: [], example: '{ "v": 2, "cmd": "playback.stop" }', warning: null, @@ -591,9 +592,9 @@ const NAMESPACES = [ cmd: 'system.status', handler: 'SystemHandler', transports: ['All'], - description: 'Get current player status, projected run time, and per-bell strike counters. Note: a caller whose original request arrived as legacy v1 (no "v" field, or "v":1 — see the Legacy Adapter table below) receives this reply translated back into the v1 shape by LegacyResponseAdapter: type "current_status", fields under "payload" (not "data"), lowercase player_status ("playing"/"paused"/"stopping"/"idle" instead of "PLAYING"/... /"STOPPED"), "time_elapsed" (not "time_elapsed_ms"), and strike_counters as an array (not an object keyed "0".."15"). This is temporary migration scaffolding — see docs/architecture/legacy-response-adapter.md in the firmware repo — and only applies to v1-originated requests; v2 callers always get the shape below unchanged.', + description: 'Get current player status, projected run time, and per-bell strike counters. Note: a caller whose original request arrived as legacy v1 (no "v" field, or "v":1 — see the Legacy Adapter table below) receives this reply translated back into the v1 shape by LegacyResponseAdapter: type "current_status", fields under "payload" (not "data"), lowercase player_status ("playing"/"paused"/"stopping"/"idle" instead of "PLAYING"/... /"STOPPED"; "LOADING" is reported as "idle"), "time_elapsed" (not "time_elapsed_ms"), and strike_counters as an array (not an object keyed "0".."15"). This is temporary migration scaffolding — see docs/architecture/legacy-response-adapter.md in the firmware repo — and only applies to v1-originated requests; v2 callers always get the shape below unchanged.', contents: null, - response: '{\n "status": "SUCCESS",\n "type": "system.status",\n "data": {\n "player_status": "STOPPED",\n "time_elapsed_ms": 0,\n "projected_run_time": 0,\n "strike_counters": { "0": 1234, "1": 567, ... }\n }\n}', + response: '{\n "status": "SUCCESS",\n "type": "system.status",\n "data": {\n "player_status": "STOPPED", // PLAYING | PAUSED | STOPPING | LOADING | STOPPED\n "time_elapsed_ms": 0,\n "projected_run_time": 0,\n "strike_counters": { "0": 1234, "1": 567, ... }\n }\n}', errors: [], example: '{ "v": 2, "cmd": "system.status" }', warning: null, @@ -1866,13 +1867,13 @@ function TransportsV2() {
- +

- Source of truth for what the board is doing — a control/ack SUCCESS only means the command was accepted. Retained, so a client can receive a stale message: real elapsed time is now − started_at (when non-zero), and ts says how old the snapshot is. WebSocket clients get the same fields on every transition as {'{ "status": "INFO", "type": "playback", "payload": { ... } }'}. + Source of truth for what the board is doing — a control/ack SUCCESS only means the command was accepted. Retained, so a client can receive a stale message: real elapsed time is now − started_at (when non-zero), and ts says how old the snapshot is. WebSocket clients get the same fields on every transition as {'{ "status": "INFO", "type": "playback", "payload": { ... } }'} — except "loading", which is MQTT-only (v1 tablets map unknown actions to "unavailable").

{[ - { field: 'action', type: 'string', notes: '"playing" | "paused" | "stopping" | "idle". paused = between interval segments (still active). stopping = duration reached, bells finishing the current melody loop (up to one loop long), then idle. playback.stop always goes straight to idle. Published on transition, not polled.' }, + { field: 'action', type: 'string', notes: '"loading" | "playing" | "paused" | "stopping" | "idle". loading = play accepted, melody downloading (→ playing, or → idle + playback_failed on control/reports, or → idle on playback.stop). paused = between interval segments (still active). stopping = duration reached, bells finishing the current melody loop (up to one loop long), then idle. playback.stop always goes straight to idle. Published on transition, not polled.' }, { field: 'time_elapsed', type: 'int', notes: 'Seconds since playback started, as of ts (0 for "idle").' }, { field: 'projected_run_time', type: 'int', notes: 'Projected total run time in ms (0 if not applicable).' }, { field: 'pid', type: 'string', notes: 'Melody being played. "" when idle.' }, @@ -1898,7 +1899,11 @@ function TransportsV2() {

- The opposite of control/command: unsolicited data the board sends because it needs to be read now. Not a reply to anything — never carries req_id. Not retained, since it's an event stream, not a status snapshot. Currently the only event type is bell_overload, published when per-bell strike load exceeds configured thresholds. + The opposite of control/command: unsolicited data the board sends because it needs to be read now. Not a reply to anything — never carries req_id. Not retained, since it's an event stream, not a status snapshot. Event types: bell_overload (per-bell strike load over threshold, shown above) and playback_failed (below). +

+ +

+ playback_failed: a playback.play was ACKed SUCCESS ("Melody download started — …") but playback never began; status/playback goes loading → idle at the same time. Not sent when the download was cancelled by playback.stop. reason is one of: "Melody download failed", "Melody failed to load: file is empty or corrupt", "Playback blocked: bell mechanism is disabled (bells.enable)", "Playback blocked: bell overload protection active — wait for the bells to cool". No req_id — match by pid (only one download can be pending). WebSocket clients get {'{ "status": "INFO", "type": "playback_failed", "payload": { "pid", "reason" } }'}.

{[