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' && } )