From c8ac0b78c597cc0702857b8d93edc2f5c3c28803 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 21 Sep 2026 20:35:20 +0300 Subject: [PATCH] docs(api-reference): document req_id, rework Transports/Legacy tabs, fix playback.play fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit req_id (added 2026-09-21 in the v2 topic rebuild, F-062): - New Extras tab documents it fully: envelope-level (sibling of cmd, not inside contents), always optional, exact echo/reply behavior, parse-failure edge case, and an MQTT-only gap — WebSocket and HTTP transports don't actually read or echo it despite CommandBus supporting it generically at the bus level. - Every command's Contents section now carries a persistent note pointing to Extras, instead of duplicating the explanation 40+ times or only mentioning it in one Transports card. - Removed the old "req_id Correlation" card from the Transports tab — superseded by the Extras tab. Transports tab: dropped the V2/Legacy sub-tab switcher. It only ever showed a "not documented yet" placeholder for Legacy, and the legacy topic set belongs with the rest of the migration reference, not alongside the current transport list. Legacy Migration tab (renamed from "v1 → v2 Migration"): added a "Legacy Topic Migration" table ahead of the command migration table, mapping every MQTT topic from the old pre-rewrite firmware ("Controller - Production FW") to its v2 equivalent — including three v2 topics (system/alerts, system/info, system/metrics) that have no legacy predecessor at all. Reconstructed from that firmware's source; it has been fully replaced, so this is historical reference only. playback.play: filled in the full contents field set per the current Player.cpp implementation — segment_duration, pause_duration, total_duration, and continuous_loop are the legacy (no "mode") path, while duration is the v2 path read only when mode is present. Added a warning callout since sending mode alongside the legacy duration fields doesn't merge behavior — only one path is read, based solely on whether mode is present. This is intentional: mode is how a v2 caller opts in, and its absence is how a v1 caller's request still works. Co-Authored-By: Claude Sonnet 5 --- .../developer/ApiReferencePage.jsx | 217 +++++++++++++----- 1 file changed, 161 insertions(+), 56 deletions(-) diff --git a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx index 3a7da93..32aa6c3 100644 --- a/frontend/src/pages/engineering/developer/ApiReferencePage.jsx +++ b/frontend/src/pages/engineering/developer/ApiReferencePage.jsx @@ -95,25 +95,27 @@ const NAMESPACES = [ cmd: 'playback.play', handler: 'PlaybackHandler', transports: ['All'], - description: 'Load and play a melody. All attributes except pid are optional — omitted values carry over from the previous command. Rejected if playback is already active (send playback.stop first).', + 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).', contents: [ - { field: 'pid', type: 'string', required: true, notes: 'Melody UID — matched against built-in library first, then /melodies/ on SD card.' }, - { field: 'uid', type: 'string', required: false, notes: 'Alias for pid. Use pid in new code.' }, - { field: 'name', type: 'string', required: false, notes: 'Human-readable label, used in status broadcasts.' }, - { field: 'url', type: 'string', required: false, notes: 'Download URL. Required only if the melody is not yet on the SD card and is not a built-in.' }, - { field: 'speed', type: 'int', required: false, notes: 'Inter-note delay in ms. Default 300.' }, - { field: 'note_assignments', type: 'int[]', required: false, notes: 'Array of up to 16 relay output indices, mapping melody note 1–16 → physical output.' }, - { field: 'mode', type: 'string', required: false, notes: '"single" (default) | "timed" | "interval" | "infinite"' }, - { field: 'duration', type: 'int', required: false, notes: 'Segment duration in ms. Required for mode "timed" or "interval".' }, - { field: 'pause_duration', type: 'int', required: false, notes: 'Pause between segments in ms. Used only with mode "interval".' }, - { field: 'total_duration', type: 'int', required: false, notes: 'Total playback time in ms. Used only with mode "interval".' }, + { 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.' }, ], 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, melody not found)' }, + { message: 'Playback command failed', condition: 'Player rejected the command (e.g. already playing, missing pid/uid, melody not found and no url given)' }, ], - example: '// Single play\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "westminster", "speed": 400, "mode": "single" } }\n\n// Timed loop for 2 minutes\n{ "v": 2, "cmd": "playback.play", "contents": { "pid": "ABC123", "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 } }', - warning: null, + 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.', }, { cmd: 'playback.stop', @@ -1336,6 +1338,62 @@ const LEGACY_MAP = [ { v1_cmd: 'bells', v1_action: 'set_enabled (enabled=false)', v2: 'bells.disable' }, ] +// Legacy ("Controller - Production FW", pre-rewrite) MQTT topic set, mapped to +// their v2 equivalents where one exists. Reconstructed from the old firmware's +// source (MQTTAsyncClient.cpp/.hpp, CommunicationRouter.cpp, Logging.cpp) — +// this predecessor codebase has since been fully replaced by the current +// Vesper firmware, so this table is historical reference only. +const LEGACY_TOPICS = [ + { + topic: 'vesper/{device_id}/control', + direction: 'Subscribed to (inbound)', + v2: 'vesper/{device_id}/control/command', + notes: 'Single inbound command channel for everything — { "cmd": "", "contents": { "action": "", ... } }, 8 categories (ping, identify, playback, file_manager, relay_setup, clock_setup, system_info, system) with the sub-verb in contents.action. v2 splits this into per-namespace dot commands (e.g. playback.play) with no separate action field, and adds optional req_id correlation.', + }, + { + topic: 'vesper/{device_id}/data', + direction: 'Published (outbound)', + v2: 'vesper/{device_id}/control/ack + control/reports', + notes: 'One catch-all outbound topic for command replies AND unsolicited events (bell_overload, player status pushes) — { "status": "SUCCESS"|"ERROR"|"INFO", "type": "...", "payload": ... }. v2 splits this in two: control/ack strictly replies to commands, control/reports carries only unsolicited board-initiated events. Note: the old firmware\'s publish() treats the literal string "data" as shorthand for this topic — a quirk of the old client code, not a documented API.', + }, + { + topic: 'vesper/{device_id}/status/heartbeat', + direction: 'Published (outbound), retained, every 30s', + v2: 'vesper/{device_id}/status/heartbeat', + notes: 'Same topic name and cadence survive into v2, but the payload shape changed: legacy carried a human-readable uptime string as "timestamp" and had no state/ok health fields; v2 adds state, ok, rssi, free_heap and drops the ambiguous "timestamp" key. Legacy also had no LWT — offline detection relied entirely on heartbeat staleness. v2 adds a real LWT publishing {"state":"offline","ok":false} on unclean disconnect.', + }, + { + topic: 'vesper/{device_id}/logs', + direction: 'Published (outbound), gated by MQTT log level', + v2: 'vesper/{device_id}/system/logs', + notes: 'Same purpose (log stream mirroring Serial, off by default), moved under the system/ namespace and still gated by log.set_mqtt (was setMqttLogLevel).', + }, + { + topic: '(none — no equivalent existed)', + direction: '—', + v2: 'vesper/{device_id}/system/alerts', + notes: 'New in v2. The legacy firmware had no dedicated subsystem-health-transition topic — a WARNING/CRITICAL/FAILED/CLEARED state change was, at most, folded into an ad-hoc payload on the shared data topic (e.g. bell_overload). v2 gives subsystem alerts their own retained-until-cleared topic.', + }, + { + topic: '(none — no equivalent existed)', + direction: '—', + v2: 'vesper/{device_id}/system/info', + notes: 'New in v2. Boot reports (boot_count, reset_reason, crash detail) were not published proactively by the legacy firmware — that information, if available at all, had to be pulled via a system/system_info command. v2 publishes a boot_report here automatically on every boot.', + }, + { + topic: '(none — no equivalent existed)', + direction: '—', + v2: 'vesper/{device_id}/system/metrics', + notes: 'New in v2. CPU temp, WiFi reconnect stats, OTA state, and bell strike/heat telemetry did not exist as a published stream in the legacy firmware.', + }, + { + topic: '(none — no equivalent existed)', + direction: '—', + v2: 'vesper/{device_id}/status/playback', + notes: 'New in v2 as its own topic. The legacy firmware did push playback status, but as an unsolicited payload on the shared data topic rather than a dedicated retained topic.', + }, +] + // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- @@ -1562,6 +1620,9 @@ function CommandCard({ command }) { {/* Contents */}
Contents
+

+ Every command also accepts an optional req_id at the envelope level (sibling of cmd, not inside contents) — see the Extras tab. +

{command.contents ? :

No contents required.

@@ -1633,34 +1694,17 @@ function NamespaceSection({ ns, searchQuery }) { } // --------------------------------------------------------------------------- -// Tab: Transports (split into Legacy / V2 sub-tabs) +// Tab: Transports (current v2 transports only — legacy topics live on the +// Legacy Migration tab instead) // --------------------------------------------------------------------------- function TransportsTab() { - const [subTab, setSubTab] = useState('v2') - const SUB_TABS = [ - { key: 'v2', label: 'V2' }, - { key: 'legacy', label: 'Legacy' }, - ] - return ( -
- - {subTab === 'v2' ? : } +
+
) } -function TransportsLegacy() { - return ( - -

- Not documented yet. The v2 topic rebuild (see the V2 tab) fully replaced the legacy topic set — there was no dual-publish - migration period. This tab is reserved for historical reference on the pre-v2 shape and will be filled in later. -

-
- ) -} - function TransportsV2() { return (
@@ -1692,24 +1736,6 @@ function TransportsV2() {

- -
-
-
-
Command (control/command)
- -
-
-
Reply (control/ack)
- -
-
-

- Always optional — a request without req_id gets a reply without one, so v1 clients and any existing caller are unaffected. Implemented bus-wide (not MQTT-only) so a client fanning requests out over one shared channel — multiple devices on one broker — can match replies to requests. No access control yet: any caller can set any req_id today. -

-
-
-
@@ -1921,11 +1947,38 @@ function TransportsV2() { } // --------------------------------------------------------------------------- -// Tab: Legacy migration +// Tab: Legacy Migration // --------------------------------------------------------------------------- function LegacyTab() { return ( -
+
+ +
+ + + + {['Legacy topic', 'Direction', 'v2 equivalent', 'Notes'].map(h => ( + + ))} + + + + {LEGACY_TOPICS.map((row, i) => ( + 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}> + + + + + + ))} + +
{h}
{row.topic}{row.direction}{row.v2}{row.notes}
+
+

+ Reconstructed from the old "Controller - Production FW" codebase's source (MQTTAsyncClient, CommunicationRouter, Logging) — that firmware has been fully replaced, there was no dual-publish migration period, and this table is documentation only. +

+
+
@@ -1952,6 +2005,56 @@ function LegacyTab() { ) } +// --------------------------------------------------------------------------- +// Tab: Extras +// --------------------------------------------------------------------------- +function ExtrasTab() { + return ( +
+ +
+

+ Every command's outer envelope (sibling of v / cmd / contents — never inside contents itself) now accepts an optional req_id string. The device echoes it back unchanged on the reply, so a caller sending multiple commands over one shared channel — e.g. this console fanning requests out to several devices on one broker — can match each reply to the request that triggered it, instead of assuming replies arrive in the same order requests were sent. +

+ +
+
+
Command (control/command)
+ +
+
+
Reply (control/ack)
+ +
+
+ +
+ {[ + { 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: 'Access control', notes: 'None. Any caller can set any req_id today — there\'s no ownership or uniqueness check.' }, + ].map(row => ( +
+
{row.field}
+

{row.notes}

+
+ ))} +
+ +
+ + + MQTT-only today. req_id is implemented at the CommandBus level, so it's generic across every transport in principle — but only the MQTT transport actually reads req_id off the incoming request and writes it into the reply. Sending req_id over WebSocket or HTTP is silently ignored: the reply just comes back without a req_id field, exactly as if none had been sent. If a console flow ever moves off MQTT for device commands, correlation will need to be re-checked. + +
+
+
+
+ ) +} + // --------------------------------------------------------------------------- // Tab: Commands // --------------------------------------------------------------------------- @@ -2023,7 +2126,8 @@ export default function ApiReferencePage() { const TABS = [ { key: 'commands', label: 'Commands' }, { key: 'transports', label: 'Transports' }, - { key: 'legacy', label: 'v1 → v2 Migration' }, + { key: 'legacy', label: 'Legacy Migration' }, + { key: 'extras', label: 'Extras' }, ] const totalCmds = NAMESPACES.reduce((a, n) => a + n.commands.length, 0) @@ -2068,6 +2172,7 @@ export default function ApiReferencePage() { )} {activeTab === 'transports' && } {activeTab === 'legacy' && } + {activeTab === 'extras' && } )