docs(api-reference): document req_id, rework Transports/Legacy tabs, fix playback.play fields

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 20:35:20 +03:00
co-authored by Claude Sonnet 5
parent 80c0e955f0
commit c8ac0b78c5
@@ -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": "<category>", "contents": { "action": "<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 */}
<div>
<div style={LABEL_STYLE}>Contents</div>
<p style={{ margin: '0 0 var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Every command also accepts an optional <code style={{ fontFamily: 'var(--font-family-mono)' }}>req_id</code> at the envelope level (sibling of <code style={{ fontFamily: 'var(--font-family-mono)' }}>cmd</code>, not inside contents) — see the <strong>Extras</strong> tab.
</p>
{command.contents
? <ContentsTable contents={command.contents} />
: <p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>No contents required.</p>
@@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
<Tabs tabs={SUB_TABS} active={subTab} onChange={setSubTab} variant="pill" />
{subTab === 'v2' ? <TransportsV2 /> : <TransportsLegacy />}
<div style={{ paddingTop: 'var(--space-2)' }}>
<TransportsV2 />
</div>
)
}
function TransportsLegacy() {
return (
<Card title="Legacy Transports" subtitle="Pre-v2 MQTT topic shape (control / data / status/alerts / status/info)">
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
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.
</p>
</Card>
)
}
function TransportsV2() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
@@ -1692,24 +1736,6 @@ function TransportsV2() {
</p>
</Card>
<Card title="req_id Correlation" subtitle="Optional field on control/command and control/ack">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
<div>
<div style={LABEL_STYLE}>Command (control/command)</div>
<MonoBlock code={'{\n "v": 2,\n "cmd": "ping",\n "req_id": "abc123",\n "contents": {}\n}'} color="var(--color-success)" />
</div>
<div>
<div style={LABEL_STYLE}>Reply (control/ack)</div>
<MonoBlock code={'{\n "status": "SUCCESS",\n "type": "pong",\n "req_id": "abc123"\n}'} />
</div>
</div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
Always optional — a request without <code style={{ fontFamily: 'var(--font-family-mono)' }}>req_id</code> 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 <code style={{ fontFamily: 'var(--font-family-mono)' }}>req_id</code> today.
</p>
</div>
</Card>
<Card title="Heartbeat Payload" subtitle="Published every 30 s to vesper/{device_id}/status/heartbeat — retained, QoS 1. Also the LWT target.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<MonoBlock code={'{\n "device_id": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "state": "idle",\n "ok": true,\n "fw_version": "2.2.0",\n "uptime_ms": 19425000,\n "uptime_human": "5h 23m 45s",\n "ip_address": "10.0.0.3",\n "gateway": "10.0.0.1",\n "rssi": -62,\n "free_heap": 178000\n}'} />
@@ -1921,11 +1947,38 @@ function TransportsV2() {
}
// ---------------------------------------------------------------------------
// Tab: Legacy migration
// Tab: Legacy Migration
// ---------------------------------------------------------------------------
function LegacyTab() {
return (
<div style={{ paddingTop: 'var(--space-2)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
<Card title="Legacy Topic Migration" subtitle="Pre-rewrite MQTT topics (Controller - Production FW) mapped to their v2 equivalent, for historical reference only">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
{['Legacy topic', 'Direction', 'v2 equivalent', 'Notes'].map(h => (
<th key={h} style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', borderBottom: '1px solid var(--color-border)' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{LEGACY_TOPICS.map((row, i) => (
<tr key={i} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)', whiteSpace: 'nowrap' }}>{row.topic}</td>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>{row.direction}</td>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)', whiteSpace: 'nowrap' }}>{row.v2}</td>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', minWidth: '320px' }}>{row.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
<p style={{ margin: 'var(--space-4) 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
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.
</p>
</Card>
<Card title="v1 → v2 Command Migration" subtitle="Map from v1 cmd+action pairs to their v2 equivalents">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
@@ -1952,6 +2005,56 @@ function LegacyTab() {
)
}
// ---------------------------------------------------------------------------
// Tab: Extras
// ---------------------------------------------------------------------------
function ExtrasTab() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
<Card title="req_id Correlation" subtitle="Optional envelope field on every command — added 2026-09-21 in the v2 topic rebuild (F-062)">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', lineHeight: '1.6' }}>
Every command's outer envelope (sibling of <code style={{ fontFamily: 'var(--font-family-mono)' }}>v</code> / <code style={{ fontFamily: 'var(--font-family-mono)' }}>cmd</code> / <code style={{ fontFamily: 'var(--font-family-mono)' }}>contents</code> — never inside <code style={{ fontFamily: 'var(--font-family-mono)' }}>contents</code> itself) now accepts an optional <code style={{ fontFamily: 'var(--font-family-mono)' }}>req_id</code> 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.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
<div>
<div style={LABEL_STYLE}>Command (control/command)</div>
<MonoBlock code={'{\n "v": 2,\n "cmd": "ping",\n "req_id": "abc123",\n "contents": {}\n}'} color="var(--color-success)" />
</div>
<div>
<div style={LABEL_STYLE}>Reply (control/ack)</div>
<MonoBlock code={'{\n "status": "SUCCESS",\n "type": "pong",\n "req_id": "abc123"\n}'} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 'var(--space-3)' }}>
{[
{ 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 => (
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)', marginBottom: 'var(--space-1)' }}>{row.field}</div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)', padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0, marginTop: '1px' }} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
<span>
<strong>MQTT-only today.</strong> 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.
</span>
</div>
</div>
</Card>
</div>
)
}
// ---------------------------------------------------------------------------
// 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' && <TransportsTab />}
{activeTab === 'legacy' && <LegacyTab />}
{activeTab === 'extras' && <ExtrasTab />}
</div>
</>
)