docs(api-reference): loading playback state and playback_failed report (firmware F-068)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,9 @@ import Tabs from '@/components/ui/Tabs'
|
|||||||
const TRANSPORTS_V2 = [
|
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/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/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/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/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/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.' },
|
||||||
@@ -97,7 +97,7 @@ 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 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: [
|
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.' },
|
||||||
@@ -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" }',
|
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 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: '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 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 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',
|
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). 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,
|
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: [],
|
errors: [],
|
||||||
example: '{ "v": 2, "cmd": "playback.stop" }',
|
example: '{ "v": 2, "cmd": "playback.stop" }',
|
||||||
warning: null,
|
warning: null,
|
||||||
@@ -591,9 +592,9 @@ const NAMESPACES = [
|
|||||||
cmd: 'system.status',
|
cmd: 'system.status',
|
||||||
handler: 'SystemHandler',
|
handler: 'SystemHandler',
|
||||||
transports: ['All'],
|
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,
|
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: [],
|
errors: [],
|
||||||
example: '{ "v": 2, "cmd": "system.status" }',
|
example: '{ "v": 2, "cmd": "system.status" }',
|
||||||
warning: null,
|
warning: null,
|
||||||
@@ -1866,13 +1867,13 @@ function TransportsV2() {
|
|||||||
|
|
||||||
<Card title="Playback Payload" subtitle="Published on every playback transition, and on every MQTT (re)connect, to vesper/{device_id}/status/playback — retained, QoS 1">
|
<Card title="Playback Payload" subtitle="Published on every playback transition, and on every MQTT (re)connect, to vesper/{device_id}/status/playback — retained, QoS 1">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
<MonoBlock code={'{\n "action": "playing",\n "time_elapsed": 12,\n "projected_run_time": 45000,\n "pid": "westminster",\n "speed": 400,\n "duration": 120000,\n "started_at": 1790000000,\n "source": "app",\n "ts": 1790000012\n}\n\n// idle\n{ "action": "idle", "time_elapsed": 0, "projected_run_time": 0, "pid": "", "speed": 0, "duration": 0, "started_at": 0, "source": "", "ts": 1790000130 }'} />
|
<MonoBlock code={'{\n "action": "playing",\n "time_elapsed": 12,\n "projected_run_time": 45000,\n "pid": "westminster",\n "speed": 400,\n "duration": 120000,\n "started_at": 1790000000,\n "source": "app",\n "ts": 1790000012\n}\n\n// loading (play accepted, melody downloading)\n{ "action": "loading", "time_elapsed": 0, "projected_run_time": 0, "pid": "ABC123", "speed": 400, "duration": 120000, "started_at": 0, "source": "app", "ts": 1790000100 }\n\n// idle\n{ "action": "idle", "time_elapsed": 0, "projected_run_time": 0, "pid": "", "speed": 0, "duration": 0, "started_at": 0, "source": "", "ts": 1790000130 }'} />
|
||||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
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").
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
{[
|
{[
|
||||||
{ 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: '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: '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.' },
|
{ field: 'pid', type: 'string', notes: 'Melody being played. "" when idle.' },
|
||||||
@@ -1898,7 +1899,11 @@ function TransportsV2() {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
<MonoBlock code={'{\n "type": "bell_overload",\n "payload": {\n "bells": [2, 5],\n "loads": [54, 48],\n "severity": "WARNING"\n }\n}'} />
|
<MonoBlock code={'{\n "type": "bell_overload",\n "payload": {\n "bells": [2, 5],\n "loads": [54, 48],\n "severity": "WARNING"\n }\n}'} />
|
||||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
The opposite of control/command: unsolicited data the board sends because it needs to be read <em>now</em>. 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 <code style={{ fontFamily: 'var(--font-family-mono)' }}>bell_overload</code>, 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 <em>now</em>. Not a reply to anything — never carries req_id. Not retained, since it's an event stream, not a status snapshot. Event types: <code style={{ fontFamily: 'var(--font-family-mono)' }}>bell_overload</code> (per-bell strike load over threshold, shown above) and <code style={{ fontFamily: 'var(--font-family-mono)' }}>playback_failed</code> (below).
|
||||||
|
</p>
|
||||||
|
<MonoBlock code={'{\n "type": "playback_failed",\n "payload": {\n "pid": "ABC123",\n "reason": "Melody download failed"\n }\n}'} />
|
||||||
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||||
|
<code style={{ fontFamily: 'var(--font-family-mono)' }}>playback_failed</code>: 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" } }'}.
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)' }}>
|
||||||
{[
|
{[
|
||||||
|
|||||||
Reference in New Issue
Block a user