Files
bellsystems-cp/frontend/src/pages/engineering/developer/ApiReferencePage.jsx
T
bonaminandClaude Sonnet 4.6 bff90965bf feat(ApiReference): add clock.set_config, clock.set_alerts_config, relay.set_config entries
Document three new batch commands with full response/errors/example fields:
- relay.set_config: combined bell durations + outputs
- clock.set_config: clock hardware settings (enabled, c1, c2, timings)
- clock.set_alerts_config: alert type, bell assignments, silence windows

Also updated output field notes throughout clock and relay commands to reflect
the enforced 1-based output numbering convention (0 = disabled, 255 = unconfigured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:45:12 +03:00

1714 lines
96 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// frontend/src/pages/developer/ApiReferencePage.jsx
// Vesper Firmware API Reference v2 — Command explorer
import { useState, useMemo } from 'react'
import PageHeader from '@/components/ui/PageHeader'
import Card from '@/components/ui/Card'
import SearchBar from '@/components/ui/SearchBar'
import Tabs from '@/components/ui/Tabs'
// ---------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------
const TRANSPORTS = [
{ transport: 'MQTT', address: 'vesper/{device_id}/control', direction: 'Inbound', notes: 'Commands in. Responses go to vesper/{device_id}/data' },
{ transport: 'MQTT', address: 'vesper/{device_id}/data', direction: 'Outbound', notes: 'Responses + events' },
{ transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained', notes: 'Heartbeat every 30 s' },
{ transport: 'MQTT', address: 'vesper/{device_id}/status/alerts', direction: 'Outbound, QoS 1', notes: 'Subsystem state changes (WARNING / CRITICAL / FAILED / CLEARED). Published on transition only.' },
{ transport: 'MQTT', address: 'vesper/{device_id}/status/info', direction: 'Outbound, QoS 0', notes: 'Significant device events (playback start/stop, etc.).' },
{ transport: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', notes: 'Requires identify on connect' },
{ transport: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', notes: 'Web console endpoints' },
{ transport: 'UART', address: 'Hardware serial', direction: 'Bidirectional', notes: 'Restricted — whitelist only' },
{ transport: 'UDP', address: 'Port 32101', direction: 'Discovery', notes: 'Passive — device announces itself' },
]
const UART_WHITELIST = [
'ping', 'identify',
'playback.play', 'playback.stop',
'relay.test_bell', 'relay.test_batch', 'relay.test_output',
'clock.pause', 'clock.resume', 'clock.test_c1', 'clock.test_c2',
'system.get_time', 'system.status', 'system.get_config', 'system.get_telemetry',
'bells.enable', 'bells.disable',
]
// Every command has:
// response — exact wire-format success JSON (what the data topic carries on SUCCESS)
// errors — array of { message, condition } matching every CommandResult::err() path
//
// Wire envelope for all replies:
// { "status": "SUCCESS"|"ERROR", "type": "<cmd>", "message": "...", "data": {...} }
// "message" is present on plain-text ok(); "data" is present on ok(doc).
// Errors always have "message".
const NAMESPACES = [
{
id: 'root',
label: 'Root',
description: 'Top-level commands — no namespace prefix.',
commands: [
{
cmd: 'ping',
handler: 'SystemHandler',
transports: ['All'],
description: 'Connectivity check. No contents required.',
contents: null,
response: '{ "status": "SUCCESS", "type": "pong" }',
errors: [],
example: '{ "v": 2, "cmd": "ping" }',
warning: null,
},
{
cmd: 'identify',
handler: 'SystemHandler',
transports: ['WebSocket'],
description: 'Register client type with the device. Must be sent immediately after connecting on WebSocket.',
contents: [
{ field: 'device_type', type: 'string', required: true, notes: '"master" (app/console) or "secondary" (slave board)' },
],
response: '{ "status": "SUCCESS", "type": "identify", "message": "Device identified as master" }',
errors: [
{ message: 'identify is only available via WebSocket', condition: 'Command sent over non-WebSocket transport' },
{ message: 'Missing required field: device_type', condition: 'contents.device_type is absent' },
{ message: 'Invalid device_type. Use \'master\' or \'secondary\'', condition: 'device_type is any value other than "master" or "secondary"' },
],
example: '{ "v": 2, "cmd": "identify", "contents": { "device_type": "master" } }',
warning: null,
},
],
},
{
id: 'playback',
label: 'playback',
description: 'Melody playback control. Handler: PlaybackHandler.',
commands: [
{
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).',
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".' },
],
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)' },
],
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,
},
{
cmd: 'playback.stop',
handler: 'PlaybackHandler',
transports: ['All'],
description: 'Stop playback immediately. Safe to call even when player is already stopped.',
contents: null,
response: '{ "status": "SUCCESS", "type": "playback", "message": "Playback command executed" }',
errors: [
{ message: 'Playback command failed', condition: 'Player rejected the stop (internal error)' },
],
example: '{ "v": 2, "cmd": "playback.stop" }',
warning: null,
},
],
},
{
id: 'relay',
label: 'relay',
description: 'Bell relay channel configuration and testing. Handler: RelayHandler.',
commands: [
{
cmd: 'relay.set_durations',
handler: 'RelayHandler',
transports: ['All'],
description: 'Set strike duration (ms) for one or more bell channels. Partial updates accepted.',
contents: [
{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → duration ms. e.g. { "0": 95, "2": 110 }' },
],
response: '{ "status": "SUCCESS", "type": "relay.set_durations", "message": "Bell durations updated and saved" }',
errors: [
{ message: 'Missing or invalid \'bells\' object', condition: 'contents.bells is absent or not an object' },
{ message: 'Durations updated but failed to save to SD', condition: 'SD card write failed — values are applied in RAM but will not survive reboot' },
],
example: '{\n "v": 2,\n "cmd": "relay.set_durations",\n "contents": { "bells": { "0": 95, "1": 100, "2": 110 } }\n}',
warning: null,
},
{
cmd: 'relay.set_outputs',
handler: 'RelayHandler',
transports: ['All'],
description: 'Map bell channels to physical relay outputs. Partial updates accepted.',
contents: [
{ field: 'bells', type: 'object', required: true, notes: 'Map of bell index (0-based) → relay output number (1-based, 1 = first relay). 0 = disabled. e.g. { "0": 1, "1": 2 }' },
],
response: '{ "status": "SUCCESS", "type": "relay.set_outputs", "message": "Bell outputs updated and saved" }',
errors: [
{ message: 'Missing or invalid \'bells\' object', condition: 'contents.bells is absent or not an object' },
{ message: 'Outputs updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{\n "v": 2,\n "cmd": "relay.set_outputs",\n "contents": { "bells": { "0": 2, "1": 3, "2": 4 } }\n}',
warning: null,
},
{
cmd: 'relay.set_config',
handler: 'RelayHandler',
transports: ['All'],
description: 'Set durations and/or output assignments in a single command. Both fields are optional — omit whichever you do not need to change. Partial updates are accepted.',
contents: [
{ field: 'durations', type: 'object', required: false, notes: 'Map of bell index (0-based) → duration ms. e.g. { "0": 95, "2": 110 }' },
{ field: 'outputs', type: 'object', required: false, notes: 'Map of bell index (0-based) → relay output number (1-based, 1 = first relay). 0 = disabled. e.g. { "0": 1, "1": 2 }' },
],
response: '{ "status": "SUCCESS", "type": "relay.set_config", "message": "Bell configuration updated and saved" }',
errors: [
{ message: 'Provide \'durations\' and/or \'outputs\' objects', condition: 'Neither durations nor outputs is present in contents' },
{ message: 'Durations applied but failed to save to SD', condition: 'SD write failed after applying durations' },
{ message: 'Outputs applied but failed to save to SD', condition: 'SD write failed after applying outputs' },
],
example: '// Set both at once\n{\n "v": 2,\n "cmd": "relay.set_config",\n "contents": {\n "durations": { "0": 95, "1": 100, "2": 110 },\n "outputs": { "0": 2, "1": 3, "2": 4 }\n }\n}\n\n// Update only durations\n{ "v": 2, "cmd": "relay.set_config", "contents": { "durations": { "0": 95 } } }\n\n// Update only outputs\n{ "v": 2, "cmd": "relay.set_config", "contents": { "outputs": { "0": 2 } } }',
warning: null,
},
{
cmd: 'relay.get_config',
handler: 'RelayHandler',
transports: ['All'],
description: 'Read current bell durations and relay output assignments for all 16 channels.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "relay.get_config",\n "data": {\n "durations": { "0": 95, "1": 100, ... },\n "outputs": { "0": 1, "1": 2, ... } // 1-based: 1=first relay, 0=disabled\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "relay.get_config" }',
warning: null,
},
{
cmd: 'relay.test_output',
handler: 'RelayHandler',
transports: ['All'],
description: 'Fire a raw relay output for a caller-specified duration. Bypasses bell assignments. Player must be stopped.',
contents: [
{ field: 'output', type: 'int', required: true, notes: 'Raw chip pin index (0-based, 0–31). This is the only command that uses 0-based indexing — all other commands use 1-based output numbers.' },
{ field: 'duration_ms', type: 'int', required: true, notes: 'Duration in ms (1–5000)' },
],
response: '{ "status": "SUCCESS", "type": "relay.test_output", "message": "Output 2 fired for 95 ms" }',
errors: [
{ message: 'Missing required field: output', condition: 'contents.output is absent' },
{ message: 'Missing required field: duration_ms', condition: 'contents.duration_ms is absent' },
{ message: 'Player must be stopped before testing outputs', condition: 'Player is currently PLAYING, PAUSED, or STOPPING' },
{ message: 'output must be 0-31', condition: 'output is outside 0–31 range' },
{ message: 'duration_ms must be 1-5000', condition: 'duration_ms is 0 or greater than 5000' },
],
example: '{\n "v": 2,\n "cmd": "relay.test_output",\n "contents": { "output": 2, "duration_ms": 95 }\n}',
warning: null,
},
{
cmd: 'relay.test_bell',
handler: 'RelayHandler',
transports: ['All'],
description: 'Fire bell channel N using its configured output and duration. Non-blocking. Can be sent in rapid succession like piano keys. Bypasses bellsEnabled guard.',
contents: [
{ field: 'bell', type: 'int', required: true, notes: 'Bell channel index (0–15, 0-based)' },
],
response: '{\n "status": "SUCCESS",\n "type": "relay.test_bell",\n "data": { "bell": 0, "output": 1, "duration_ms": 95 }\n} // output is 1-based',
errors: [
{ message: 'Missing required field: bell (0-based bell index)', condition: 'contents.bell is absent' },
{ message: 'bell must be 0-15', condition: 'bell is outside 0–15 range' },
],
example: '// Fire bell 0\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 0 } }\n\n// Rapid succession\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 0 } }\n{ "v": 2, "cmd": "relay.test_bell", "contents": { "bell": 1 } }',
warning: null,
},
{
cmd: 'relay.test_batch',
handler: 'RelayHandler',
transports: ['All'],
description: 'Fire a list of bell channels simultaneously, like a single melody step. Each bell fires for its own configured duration. Non-blocking. Bypasses bellsEnabled guard.',
contents: [
{ field: 'bells', type: 'int[]', required: true, notes: 'Array of bell channel indices (0–15). All fire simultaneously.' },
],
response: '{\n "status": "SUCCESS",\n "type": "relay.test_batch",\n "data": { "fired": 3 }\n}',
errors: [
{ message: 'Missing required field: bells (array of 0-based bell indices)', condition: 'contents.bells is absent or not an array' },
{ message: 'bells array must not be empty', condition: 'contents.bells is an empty array' },
{ message: 'No valid bell indices in array', condition: 'All entries in the array are out of range (0–15) or non-integer' },
],
example: '{\n "v": 2,\n "cmd": "relay.test_batch",\n "contents": { "bells": [0, 2, 4] }\n}',
warning: null,
},
],
},
{
id: 'clock',
label: 'clock',
description: 'Clock strike configuration, RTC time, face position, timezone, and silence periods. Handler: ClockHandler.',
commands: [
{
cmd: 'clock.set_outputs',
handler: 'ClockHandler',
transports: ['All'],
description: 'Set which relay outputs the clock strikes use.',
contents: [
{ field: 'c1', type: 'int', required: false, notes: 'Relay output for C1 (ODD strikes)' },
{ field: 'c2', type: 'int', required: false, notes: 'Relay output for C2 (EVEN strikes). At least one of c1/c2 required.' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_outputs", "message": "Clock outputs updated and saved" }',
errors: [
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_outputs", "contents": { "c1": 4, "c2": 5 } }',
warning: null,
},
{
cmd: 'clock.set_timings',
handler: 'ClockHandler',
transports: ['All'],
description: 'Set clock pulse and pause durations. At least one field required.',
contents: [
{ field: 'pulse_duration', type: 'int', required: false, notes: 'Duration of each clock output pulse in ms. Typical: 80–150 ms.' },
{ field: 'pause_duration', type: 'int', required: false, notes: 'Pause between consecutive pulses (e.g. hour chimes) in ms.' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_timings", "message": "Clock timings updated and saved" }',
errors: [
{ message: 'No timing fields provided (pulse_duration, pause_duration)', condition: 'Neither pulse_duration nor pause_duration present in contents' },
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_timings", "contents": { "pulse_duration": 100, "pause_duration": 500 } }',
warning: null,
},
{
cmd: 'clock.set_alerts',
handler: 'ClockHandler',
transports: ['All'],
description: 'Configure hourly/quarter alert behavior. Partial updates accepted.',
contents: [
{ field: 'alert_type', type: 'string', required: false, notes: '"OFF" | "SINGLE" | "HOURS" — OFF disables alerts; SINGLE fires once per trigger; HOURS fires N times equal to the current hour count.' },
{ field: 'alert_interval', type: 'int', required: false, notes: 'Ring count per trigger (SINGLE mode).' },
{ field: 'hour_bell', type: 'int', required: false, notes: 'Bell number to ring on the hour (1-based). The bell fires on whichever output it is assigned to in relay config. 255 = disabled.' },
{ field: 'half_bell', type: 'int', required: false, notes: 'Bell number to ring at half past (1-based). 255 = disabled.' },
{ field: 'quarter_bell', type: 'int', required: false, notes: 'Bell number to ring at quarter past/to (1-based). 255 = disabled.' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_alerts", "message": "Clock alerts updated and saved" }',
errors: [
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { "alert_type": "HOURS", "hour_bell": 5 } }',
warning: null,
},
{
cmd: 'clock.set_backlight',
handler: 'ClockHandler',
transports: ['All'],
description: 'Configure LCD backlight behavior. Partial updates accepted.',
contents: [
{ field: 'backlight', type: 'bool', required: false, notes: 'Enable automatic backlight control.' },
{ field: 'backlight_output', type: 'int', required: false, notes: 'Relay output index for backlight.' },
{ field: 'backlight_on', type: 'string', required: false, notes: 'Time to turn on, format "HH:MM".' },
{ field: 'backlight_off', type: 'string', required: false, notes: 'Time to turn off, format "HH:MM".' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_backlight", "message": "Clock backlight updated and saved" }',
errors: [
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_backlight", "contents": { "backlight": true, "backlight_output": 6, "backlight_on": "07:00", "backlight_off": "22:00" } }',
warning: null,
},
{
cmd: 'clock.set_silence',
handler: 'ClockHandler',
transports: ['All'],
description: 'Configure silence periods (quiet hours). Daytime and nighttime windows are independent. Partial updates accepted.',
contents: [
{ field: 'daytime_silence', type: 'bool', required: false, notes: 'Enable daytime silence window.' },
{ field: 'daytime_on', type: 'string', required: false, notes: 'Start of daytime silence "HH:MM".' },
{ field: 'daytime_off', type: 'string', required: false, notes: 'End of daytime silence "HH:MM".' },
{ field: 'night_silence', type: 'bool', required: false, notes: 'Enable nighttime silence window.' },
{ field: 'night_on', type: 'string', required: false, notes: 'Start of nighttime silence "HH:MM".' },
{ field: 'night_off', type: 'string', required: false, notes: 'End of nighttime silence "HH:MM".' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_silence", "message": "Clock silence periods updated and saved" }',
errors: [
{ message: 'Updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { "night_silence": true, "night_on": "22:00", "night_off": "07:00" } }',
warning: null,
},
{
cmd: 'clock.set_config',
handler: 'ClockHandler',
transports: ['All'],
description: 'Combined command to set clock hardware settings in one round trip. Replaces sending clock.enable/disable + clock.set_outputs + clock.set_timings separately. All fields optional — at least one required.',
contents: [
{ field: 'enabled', type: 'bool', required: false, notes: 'Enable or disable the physical clock motor.' },
{ field: 'c1', type: 'int', required: false, notes: 'Relay output for clock pulse 1 (1-based). 0 = disabled.' },
{ field: 'c2', type: 'int', required: false, notes: 'Relay output for clock pulse 2 (1-based). 0 = disabled.' },
{ field: 'pulse_duration', type: 'int', required: false, notes: 'Duration of each clock pulse in milliseconds.' },
{ field: 'pause_duration', type: 'int', required: false, notes: 'Pause between C1 and C2 pulses in milliseconds.' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_config", "message": "Clock configuration updated and saved" }',
errors: [
{ message: 'Provide at least one field: enabled, c1, c2, pulse_duration, pause_duration', condition: 'No known fields present in contents' },
{ message: 'Settings applied but failed to save to SD', condition: 'SD card write failed after applying values' },
],
example: '{ "v": 2, "cmd": "clock.set_config", "contents": { "enabled": true, "c1": 5, "c2": 6, "pulse_duration": 100, "pause_duration": 500 } }',
warning: null,
},
{
cmd: 'clock.set_alerts_config',
handler: 'ClockHandler',
transports: ['All'],
description: 'Combined command to set alert behavior and silence periods in one round trip. Replaces sending clock.set_alerts + clock.set_silence separately. All fields optional — at least one required.',
contents: [
{ field: 'alert_type', type: 'string', required: false, notes: '"OFF" | "SINGLE" | "HOURS" — OFF disables alerts; SINGLE fires once per trigger; HOURS fires N times equal to the current hour count.' },
{ field: 'alert_interval', type: 'int', required: false, notes: 'Ring count per trigger (SINGLE mode).' },
{ field: 'hour_bell', type: 'int', required: false, notes: 'Bell number to ring on the hour (1-based). The bell fires on whichever output it is assigned to in relay config. 255 = disabled.' },
{ field: 'half_bell', type: 'int', required: false, notes: 'Bell number to ring at half past (1-based). 255 = disabled.' },
{ field: 'quarter_bell', type: 'int', required: false, notes: 'Bell number to ring at quarter past/to (1-based). 255 = disabled.' },
{ field: 'daytime_silence', type: 'bool', required: false, notes: 'Enable daytime silence window.' },
{ field: 'daytime_on', type: 'string', required: false, notes: 'Start of daytime silence "HH:MM".' },
{ field: 'daytime_off', type: 'string', required: false, notes: 'End of daytime silence "HH:MM".' },
{ field: 'night_silence', type: 'bool', required: false, notes: 'Enable nighttime silence window.' },
{ field: 'night_on', type: 'string', required: false, notes: 'Start of nighttime silence "HH:MM".' },
{ field: 'night_off', type: 'string', required: false, notes: 'End of nighttime silence "HH:MM".' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_alerts_config", "message": "Clock alerts and silence configuration updated and saved" }',
errors: [
{ message: 'Provide at least one of: alert_type, alert_interval, hour_bell, half_bell, quarter_bell, daytime_silence, daytime_on, daytime_off, night_silence, night_on, night_off', condition: 'No known fields present in contents' },
{ message: 'Settings applied but failed to save to SD', condition: 'SD card write failed after applying values' },
],
example: '{\n "v": 2,\n "cmd": "clock.set_alerts_config",\n "contents": {\n "alert_type": "HOURS",\n "hour_bell": 3,\n "half_bell": 255,\n "quarter_bell": 255,\n "night_silence": true,\n "night_on": "22:00",\n "night_off": "07:00"\n }\n}',
warning: null,
},
{
cmd: 'clock.set_time',
handler: 'ClockHandler',
transports: ['All'],
description: 'Set the RTC time directly.',
contents: [
{ field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp (UTC or local depending on whether offsets are provided)' },
{ field: 'timezone_offset', type: 'int', required: false, notes: 'Total UTC offset in seconds (e.g. 7200 for UTC+2). Provide with dst_offset to use the combined-offset path.' },
{ field: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds. Provide together with timezone_offset.' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_time", "message": "RTC time updated successfully" }',
errors: [
{ message: 'Missing required field: timestamp', condition: 'contents.timestamp is absent' },
{ message: 'Invalid timestamp value', condition: 'timestamp is 0' },
{ message: 'RTC time set but verification failed', condition: 'RTC read-back returned 0 after the write — hardware issue' },
],
example: '{\n "v": 2,\n "cmd": "clock.set_time",\n "contents": { "timestamp": 1740000000, "timezone_offset": 7200, "dst_offset": 3600 }\n}',
warning: null,
},
{
cmd: 'clock.set_face',
handler: 'ClockHandler',
transports: ['All'],
description: 'Set the physical analog clock face position (for clocktower synchronization).',
contents: [
{ field: 'hour', type: 'int', required: true, notes: 'Hour hand (0–23). Firmware converts to 12-h internally.' },
{ field: 'minute', type: 'int', required: true, notes: 'Minute hand (0–59).' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_face", "message": "Physical clock face updated and saved" }',
errors: [
{ message: 'Missing required fields: hour, minute', condition: 'Either hour or minute is absent' },
{ message: 'hour must be 0-23', condition: 'hour is outside 0–23' },
{ message: 'minute must be 0-59', condition: 'minute is outside 0–59' },
{ message: 'Face set but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_face", "contents": { "hour": 10, "minute": 10 } }',
warning: null,
},
{
cmd: 'clock.set_timezone',
handler: 'ClockHandler',
transports: ['All'],
description: 'Set timezone independently of RTC time. Does not alter the stored time value — only changes how it is displayed/offset.',
contents: [
{ field: 'gmt_offset_sec', type: 'int', required: true, notes: 'GMT offset in seconds (e.g. 7200 for UTC+2)' },
{ field: 'dst_offset_sec', type: 'int', required: false, notes: 'DST offset in seconds (e.g. 3600)' },
],
response: '{ "status": "SUCCESS", "type": "clock.set_timezone", "message": "Timezone updated and saved" }',
errors: [
{ message: 'Missing required field: gmt_offset_sec', condition: 'contents.gmt_offset_sec is absent' },
{ message: 'Timezone updated but failed to save to SD', condition: 'SD card write failed' },
],
example: '{ "v": 2, "cmd": "clock.set_timezone", "contents": { "gmt_offset_sec": 7200, "dst_offset_sec": 3600 } }',
warning: null,
},
{
cmd: 'clock.get_timezone',
handler: 'ClockHandler',
transports: ['All'],
description: 'Read current timezone settings.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "clock.get_timezone",\n "data": {\n "gmt_offset_sec": 7200,\n "dst_offset_sec": 3600,\n "ntp_server": "pool.ntp.org"\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "clock.get_timezone" }',
warning: null,
},
{
cmd: 'clock.sync_ntp',
handler: 'ClockHandler',
transports: ['All'],
description: 'Force an immediate NTP time sync.',
contents: null,
response: '{ "status": "SUCCESS", "type": "clock.sync_ntp", "message": "NTP sync initiated" }',
errors: [],
example: '{ "v": 2, "cmd": "clock.sync_ntp" }',
warning: null,
},
{
cmd: 'clock.enable',
handler: 'ClockHandler',
transports: ['All'],
description: 'Enable clock strike output. Covers both physical C1/C2 outputs and alert chimes.',
contents: null,
response: '{ "status": "SUCCESS", "type": "clock.enable", "message": "Clock enabled and saved" }',
errors: [
{ message: 'Enabled but failed to save to SD', condition: 'SD card write failed — state applied in RAM only' },
],
example: '{ "v": 2, "cmd": "clock.enable" }',
warning: null,
},
{
cmd: 'clock.disable',
handler: 'ClockHandler',
transports: ['All'],
description: 'Disable clock strike output. Covers both physical C1/C2 outputs and alert chimes.',
contents: null,
response: '{ "status": "SUCCESS", "type": "clock.disable", "message": "Clock disabled and saved" }',
errors: [
{ message: 'Disabled but failed to save to SD', condition: 'SD card write failed — state applied in RAM only' },
],
example: '{ "v": 2, "cmd": "clock.disable" }',
warning: null,
},
{
cmd: 'clock.pause',
handler: 'ClockHandler',
transports: ['All'],
description: 'Pause automatic clock updates (C1/C2 advance stops). Does not disable alerts.',
contents: null,
response: '{ "status": "SUCCESS", "type": "clock.pause", "message": "Clock updates paused" }',
errors: [],
example: '{ "v": 2, "cmd": "clock.pause" }',
warning: null,
},
{
cmd: 'clock.resume',
handler: 'ClockHandler',
transports: ['All'],
description: 'Resume automatic clock updates.',
contents: null,
response: '{ "status": "SUCCESS", "type": "clock.resume", "message": "Clock updates resumed" }',
errors: [],
example: '{ "v": 2, "cmd": "clock.resume" }',
warning: null,
},
{
cmd: 'clock.get_face',
handler: 'ClockHandler',
transports: ['All'],
description: 'Read current analog clock face position and sync state.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "clock.get_face",\n "data": {\n "clock_hour": 10,\n "clock_minute": 30,\n "last_sync_time": 1740000000,\n "next_output_is_c1": true\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "clock.get_face" }',
warning: null,
},
{
cmd: 'clock.get_config',
handler: 'ClockHandler',
transports: ['All'],
description: 'Read full clock configuration — outputs, timings, alerts, backlight, silence, enabled state.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "clock.get_config",\n "data": {\n "enabled": true,\n "c1output": 4, "c2output": 5,\n "pulse_duration": 100, "pause_duration": 500,\n "physical_hour": 10, "physical_minute": 30,\n "next_is_c1": true, "last_sync_time": 1740000000,\n "alert_type": "HOURS", "alert_interval": 1,\n "hour_bell": 5, "half_bell": 255, "quarter_bell": 255,\n "backlight": true, "backlight_output": 6,\n "backlight_on": "07:00", "backlight_off": "22:00",\n "daytime_silence": false, "daytime_on": "00:00", "daytime_off": "00:00",\n "night_silence": true, "night_on": "22:00", "night_off": "07:00"\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "clock.get_config" }',
warning: null,
},
{
cmd: 'clock.test_c1',
handler: 'ClockHandler',
transports: ['All'],
description: 'Fire the C1 (ODD) clock output immediately using the configured pulse_duration. Non-blocking.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "clock.test_c1",\n "data": { "output": "C1", "duration_ms": 100 }\n}',
errors: [
{ message: 'OutputManager not available', condition: 'OutputManager reference was not injected (should not happen in normal operation)' },
],
example: '{ "v": 2, "cmd": "clock.test_c1" }',
warning: null,
},
{
cmd: 'clock.test_c2',
handler: 'ClockHandler',
transports: ['All'],
description: 'Fire the C2 (EVEN) clock output immediately using the configured pulse_duration. Non-blocking.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "clock.test_c2",\n "data": { "output": "C2", "duration_ms": 100 }\n}',
errors: [
{ message: 'OutputManager not available', condition: 'OutputManager reference was not injected' },
],
example: '{ "v": 2, "cmd": "clock.test_c2" }',
warning: null,
},
],
},
{
id: 'system',
label: 'system',
description: 'Device status, settings, health, and control. Handler: SystemHandler.',
commands: [
{
cmd: 'system.status',
handler: 'SystemHandler',
transports: ['All'],
description: 'Get current player status, projected run time, and per-bell strike counters.',
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}',
errors: [],
example: '{ "v": 2, "cmd": "system.status" }',
warning: null,
},
{
cmd: 'system.get_time',
handler: 'SystemHandler',
transports: ['All'],
description: 'Get current RTC time in local and UTC, plus individual date/time fields.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "system.get_time",\n "data": {\n "local_timestamp": 1740000000,\n "utc_timestamp": 1739993000,\n "rtc_available": true,\n "year": 2025, "month": 2, "day": 20,\n "hour": 10, "minute": 30, "second": 0\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "system.get_time" }',
warning: null,
},
{
cmd: 'system.get_config',
handler: 'SystemHandler',
transports: ['All'],
description: 'Full ConfigManager dump — all saved settings grouped by subsystem. Alias: system.get_settings (backward compatible).',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "system.get_config",\n "data": {\n "general": {\n "bells_enabled": true, "mqtt_enabled": true,\n "ota_channel": "stable",\n "serial_log": 3, "sd_log": 2, "mqtt_log": 1\n },\n "bell": { "durations": {...}, "outputs": {...} },\n "clock": { ... },\n "time": { "gmtOffsetSec": 7200, "daylightOffsetSec": 3600, ... },\n "network": { "hostname": "...", "useStaticIP": false, ... },\n "mqtt": { "host": "...", "port": 1883, ... }\n }\n}',
errors: [
{ message: 'Failed to serialize settings', condition: 'Internal JSON serialization error — rare' },
],
example: '{ "v": 2, "cmd": "system.get_config" }',
warning: null,
},
{
cmd: 'system.get_all',
handler: 'SystemHandler',
transports: ['All'],
description: 'Single-shot fetch of everything: device identity + full config + live telemetry + player status. Use on initial console load to avoid multiple round trips.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "system.get_all",\n "data": {\n "device": {\n "serial": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "hw_family": "VS", "hw_revision": "01",\n "fw_version": "143",\n "uptime_ms": 123456, "free_heap": 180000\n },\n "config": { ... },\n "telemetry": {\n "strikes": {...}, "loads": {...}, "max_loads": {...},\n "cooling_active": false, "guard_enabled": true,\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200\n },\n "player": { "status": "STOPPED" }\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "system.get_all" }',
warning: null,
},
{
cmd: 'system.get_telemetry',
handler: 'SystemHandler',
transports: ['All'],
description: 'Live telemetry snapshot: strike counters, bell loads, guard state, runtime metrics, uptime, free heap. Read-only — no config included.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "system.get_telemetry",\n "data": {\n "strikes": { "0": 1234, ... },\n "loads": { "0": 42, ... },\n "max_loads":{ "0": 500, ... },\n "cooling_active": false,\n "guard_enabled": true,\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "uptime_ms": 12345678,\n "free_heap": 178000\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "system.get_telemetry" }',
warning: null,
},
{
cmd: 'system.get_device_info',
handler: 'SystemHandler',
transports: ['All'],
description: 'Get device identity and runtime stats.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "system.get_device_info",\n "data": {\n "uid": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "hw_type": "VS", "hw_version": "01",\n "fw_version": "143",\n "uptime_ms": 12345678,\n "free_heap": 178000,\n "min_free_heap": 150000\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "system.get_device_info" }',
warning: null,
},
{
cmd: 'system.health',
handler: 'SystemHandler',
transports: ['All'],
description: 'Full HealthMonitor report — all subsystem states, warnings, and critical failures.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "system.health",\n "data": {\n "critical_count": 0,\n "warning_count": 1,\n "firmware_stable": true,\n "subsystems": [\n { "name": "BellEngine", "status": "HEALTHY" },\n { "name": "OutputManager", "status": "WARNING", "error": "..." },\n ...\n ]\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "system.health" }',
warning: null,
},
{
cmd: 'system.factory_reset',
handler: 'SystemHandler',
transports: ['All'],
description: 'Reset all settings to factory defaults. Device must be restarted to apply.',
contents: null,
response: '{ "status": "SUCCESS", "type": "system.factory_reset", "message": "Factory reset complete. Restart device to apply." }',
errors: [
{ message: 'Factory reset partially applied — some settings may not have saved', condition: 'One or more ConfigManager save calls failed during reset' },
],
example: '{ "v": 2, "cmd": "system.factory_reset" }',
warning: 'Non-reversible. All saved configuration is permanently wiped.',
},
{
cmd: 'system.restart',
handler: 'SystemHandler',
transports: ['All'],
description: 'Reboot the device. Response is sent before reboot (2 s delay).',
contents: null,
response: '{ "status": "SUCCESS", "type": "system.restart", "message": "Device will restart in 2 seconds" }',
errors: [],
example: '{ "v": 2, "cmd": "system.restart" }',
warning: null,
},
],
},
{
id: 'firmware',
label: 'firmware',
description: 'OTA slot management — validate, commit, and roll back firmware. Handler: FirmwareHandler.',
commands: [
{
cmd: 'firmware.status',
handler: 'FirmwareHandler',
transports: ['All'],
description: 'Get firmware validation state, version, boot count, and commit/rollback eligibility.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "firmware.status",\n "data": {\n "validation_state": "VALIDATED",\n "current_version": "143",\n "is_testing": false,\n "is_valid": true,\n "boot_count": 12,\n "build_date": "2026-07-10",\n "ota_channel": "stable",\n "can_commit": false,\n "can_rollback": false\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "firmware.status" }',
warning: null,
},
{
cmd: 'firmware.commit',
handler: 'FirmwareHandler',
transports: ['All'],
description: 'Commit the current firmware as permanent (marks OTA slot as valid). Only valid during a testing window.',
contents: null,
response: '{ "status": "SUCCESS", "type": "firmware.commit", "message": "Firmware committed successfully" }',
errors: [
{ message: 'No firmware validation in progress', condition: 'Device is not in testing mode — firmware already committed or no OTA was performed' },
{ message: 'Failed to commit firmware', condition: 'esp_ota_mark_app_valid_cancel_rollback() threw an exception' },
],
example: '{ "v": 2, "cmd": "firmware.commit" }',
warning: null,
},
{
cmd: 'firmware.rollback',
handler: 'FirmwareHandler',
transports: ['All'],
description: 'Roll back to the previous OTA slot. Device reboots immediately.',
contents: null,
response: '{ "status": "SUCCESS", "type": "firmware.rollback", "message": "Firmware rollback initiated — device will reboot" }',
errors: [
{ message: 'Failed to initiate firmware rollback', condition: 'esp_ota_mark_app_invalid_rollback_and_reboot() threw an exception' },
],
example: '{ "v": 2, "cmd": "firmware.rollback" }',
warning: 'Device will reboot immediately after this command.',
},
],
},
{
id: 'ota',
label: 'ota',
description: 'Over-the-air update management. Handler: FirmwareHandler.',
commands: [
{
cmd: 'ota.update',
handler: 'FirmwareHandler',
transports: ['All'],
description: 'Trigger OTA update from the VPS update server using the configured channel. Device reboots after flashing.',
contents: [
{ field: 'channel', type: 'string', required: false, notes: '"stable", "beta", or "development". If omitted, uses the channel saved in config.' },
],
response: '{ "status": "SUCCESS", "type": "ota.update", "message": "OTA update started from channel: stable" }',
errors: [
{ message: 'Cannot update while playback is active', condition: 'Player is currently PLAYING' },
],
example: '{ "v": 2, "cmd": "ota.update" }\n{ "v": 2, "cmd": "ota.update", "contents": { "channel": "stable" } }',
warning: 'Device will reboot after flashing. Ensure playback is stopped first.',
},
{
cmd: 'ota.custom',
handler: 'FirmwareHandler',
transports: ['All'],
description: 'Flash firmware from a custom HTTP URL. Use for staging builds or manual pushes.',
contents: [
{ field: 'firmware_url', type: 'string', required: true, notes: 'Direct HTTP download URL for the .bin file. HTTPS not supported.' },
{ field: 'checksum', type: 'string', required: false, notes: 'Optional integrity checksum.' },
{ field: 'file_size', type: 'int', required: false, notes: 'Optional file size in bytes.' },
{ field: 'version', type: 'string', required: false, notes: 'Version label for display/logging.' },
],
response: '{ "status": "SUCCESS", "type": "ota.custom", "message": "Custom OTA update started" }',
errors: [
{ message: 'Missing required field: firmware_url', condition: 'contents.firmware_url is absent' },
{ message: 'Cannot update while playback is active', condition: 'Player is currently PLAYING' },
],
example: '{\n "v": 2,\n "cmd": "ota.custom",\n "contents": {\n "firmware_url": "http://example.com/firmware.bin",\n "version": "143"\n }\n}',
warning: 'Device will reboot after flashing.',
},
{
cmd: 'ota.set_channel',
handler: 'FirmwareHandler',
transports: ['All'],
description: 'Persist the OTA update channel. Subsequent ota.update calls without a channel argument will use this value.',
contents: [
{ field: 'channel', type: 'string', required: true, notes: '"stable", "beta", or "development"' },
],
response: '{\n "status": "SUCCESS",\n "type": "ota.set_channel",\n "data": { "channel": "beta" }\n}',
errors: [
{ message: 'Missing required field: channel', condition: 'contents.channel is absent' },
{ message: 'Invalid channel. Valid values: stable, beta, development', condition: 'channel is not one of the three allowed values' },
],
example: '{ "v": 2, "cmd": "ota.set_channel", "contents": { "channel": "beta" } }',
warning: null,
},
],
},
{
id: 'network',
label: 'network',
description: 'Network configuration and connection status. Handler: NetworkHandler.',
commands: [
{
cmd: 'network.info',
handler: 'NetworkHandler',
transports: ['All'],
description: 'Get IP address, gateway, and DNS.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "network.info",\n "data": {\n "ip": "10.0.0.3",\n "gateway": "10.0.0.1",\n "dns": "8.8.8.8"\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "network.info" }',
warning: null,
},
{
cmd: 'network.status',
handler: 'NetworkHandler',
transports: ['All'],
description: 'Get full connection state including SSID, MAC, hostname, RSSI, and AP mode flag.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "network.status",\n "data": {\n "connected": true,\n "state": "CONNECTED_WIFI",\n "type": "WIFI",\n "ip": "10.0.0.3",\n "rssi": -62,\n "ap_mode": false,\n "ssid": "BellSystemsInfra",\n "mac": "AA:BB:CC:DD:EE:FF",\n "hostname": "vesper-lab"\n }\n}\n\n// Possible state values: DISCONNECTED | CONNECTING | CONNECTED_WIFI | CONNECTED_ETHERNET | RECONNECTING | AP_MODE\n// Possible type values: WIFI | ETHERNET | AP | NONE',
errors: [],
example: '{ "v": 2, "cmd": "network.status" }',
warning: null,
},
{
cmd: 'network.set_config',
handler: 'NetworkHandler',
transports: ['All'],
description: 'Update network configuration. Partial updates accepted. Restart required to apply changes.',
contents: [
{ field: 'hostname', type: 'string', required: false, notes: 'Device hostname (1–32 chars)' },
{ field: 'useStaticIP', type: 'bool', required: false, notes: 'Enable static IP mode' },
{ field: 'ip', type: 'string', required: false, notes: 'Static IP. Required with gateway + subnet when useStaticIP is true.' },
{ field: 'gateway', type: 'string', required: false, notes: 'Gateway address' },
{ field: 'subnet', type: 'string', required: false, notes: 'Subnet mask' },
{ field: 'dns1', type: 'string', required: false, notes: 'Primary DNS' },
{ field: 'dns2', type: 'string', required: false, notes: 'Secondary DNS' },
{ field: 'permanent_ap_mode', type: 'bool', required: false, notes: 'Enable/disable permanent AP mode. If present, all other fields are ignored.' },
],
response: '{ "status": "SUCCESS", "type": "network.set_config", "message": "Network configuration saved. Restart device to apply changes." }',
errors: [
{ message: 'No network parameters provided', condition: 'Contents contains none of: hostname, useStaticIP, permanent_ap_mode' },
{ message: 'Hostname must be 1-32 characters', condition: 'hostname is empty or longer than 32 chars' },
{ message: 'Static IP requires: ip, gateway, subnet', condition: 'useStaticIP is true but ip, gateway, or subnet is missing' },
{ message: 'Invalid IP address format', condition: 'ip string cannot be parsed as an IPv4 address' },
{ message: 'Invalid gateway format', condition: 'gateway string cannot be parsed as an IPv4 address' },
{ message: 'Invalid subnet format', condition: 'subnet string cannot be parsed as an IPv4 address' },
],
example: '{\n "v": 2,\n "cmd": "network.set_config",\n "contents": { "hostname": "vesper-lab", "useStaticIP": false }\n}',
warning: null,
},
],
},
{
id: 'files',
label: 'files',
description: 'Melody file management on the SD card and built-in firmware storage. Handler: FileHandler.',
commands: [
{
cmd: 'files.list',
handler: 'FileHandler',
transports: ['All'],
description: 'List melody files on the SD card.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "files.list",\n "data": {\n "files": [\n { "name": "westminster.bsm", "size": 1024 },\n ...\n ]\n }\n}',
errors: [
{ message: 'Failed to read melody list', condition: 'SD card unavailable or /melodies directory cannot be read' },
],
example: '{ "v": 2, "cmd": "files.list" }',
warning: null,
},
{
cmd: 'files.list_builtin',
handler: 'FileHandler',
transports: ['All'],
description: 'List all built-in melodies compiled into the firmware.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "files.list_builtin",\n "data": [\n { "name": "Westminster", "uid": "westminster" },\n ...\n ]\n}',
errors: [
{ message: 'Failed to read built-in melody list', condition: 'Internal JSON parse error (should never happen)' },
],
example: '{ "v": 2, "cmd": "files.list_builtin" }',
warning: null,
},
{
cmd: 'files.download',
handler: 'FileHandler',
transports: ['All'],
description: 'Download a melody from the server to the SD card.',
contents: [
{ field: 'melodys_uid', type: 'string', required: true, notes: 'UID of the melody to download' },
],
response: '{ "status": "SUCCESS", "type": "files.download", "message": "Melody downloaded: ABC123" }',
errors: [
{ message: 'Failed to download melody: <uid>', condition: 'HTTP request failed, SD write failed, or melody not found on server' },
],
example: '{ "v": 2, "cmd": "files.download", "contents": { "melodys_uid": "ABC123" } }',
warning: null,
},
{
cmd: 'files.delete',
handler: 'FileHandler',
transports: ['All'],
description: 'Delete a melody file from the SD card.',
contents: [
{ field: 'name', type: 'string', required: true, notes: 'Filename including extension, e.g. "westminster.bsm"' },
],
response: '{ "status": "SUCCESS", "type": "files.delete", "message": "Melody deleted: westminster.bsm" }',
errors: [
{ message: 'Missing required field: name', condition: 'contents.name is absent' },
{ message: 'Failed to delete melody: <name>', condition: 'File does not exist on SD or SD remove call failed' },
],
example: '{ "v": 2, "cmd": "files.delete", "contents": { "name": "westminster.bsm" } }',
warning: null,
},
],
},
{
id: 'log',
label: 'log',
description: 'Log verbosity control per output channel. Level 0 = off, 5 = verbose. Handler: LoggingHandler.',
commands: [
{
cmd: 'log.set_serial',
handler: 'LoggingHandler',
transports: ['All'],
description: 'Set log verbosity for the UART serial output.',
contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }],
response: '{ "status": "SUCCESS", "type": "log.set_serial", "message": "serial log level set to 3 and saved" }',
errors: [
{ message: 'Missing or invalid \'level\' (0-5 required)', condition: 'contents.level is absent or not a uint8' },
{ message: 'Invalid log level (must be 0-5)', condition: 'level is greater than 5' },
{ message: 'Level applied but failed to save to SD', condition: 'Level takes effect immediately but SD config write failed' },
],
example: '{ "v": 2, "cmd": "log.set_serial", "contents": { "level": 3 } }',
warning: null,
},
{
cmd: 'log.set_sd',
handler: 'LoggingHandler',
transports: ['All'],
description: 'Set log verbosity for SD card logging.',
contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }],
response: '{ "status": "SUCCESS", "type": "log.set_sd", "message": "sd log level set to 2 and saved" }',
errors: [
{ message: 'Missing or invalid \'level\' (0-5 required)', condition: 'contents.level is absent or not a uint8' },
{ message: 'Invalid log level (must be 0-5)', condition: 'level is greater than 5' },
{ message: 'Level applied but failed to save to SD', condition: 'Level takes effect immediately but SD config write failed' },
],
example: '{ "v": 2, "cmd": "log.set_sd", "contents": { "level": 2 } }',
warning: null,
},
{
cmd: 'log.set_mqtt',
handler: 'LoggingHandler',
transports: ['All'],
description: 'Set log verbosity for MQTT log publishing.',
contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }],
response: '{ "status": "SUCCESS", "type": "log.set_mqtt", "message": "mqtt log level set to 1 and saved" }',
errors: [
{ message: 'Missing or invalid \'level\' (0-5 required)', condition: 'contents.level is absent or not a uint8' },
{ message: 'Invalid log level (must be 0-5)', condition: 'level is greater than 5' },
{ message: 'Level applied but failed to save to SD', condition: 'Level takes effect immediately but SD config write failed' },
],
example: '{ "v": 2, "cmd": "log.set_mqtt", "contents": { "level": 1 } }',
warning: null,
},
{
cmd: 'log.get_config',
handler: 'LoggingHandler',
transports: ['All'],
description: 'Read current log levels for all three output channels.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "log.get_config",\n "data": { "serial_level": 3, "sd_level": 2, "mqtt_level": 1 }\n}',
errors: [],
example: '{ "v": 2, "cmd": "log.get_config" }',
warning: null,
},
],
},
{
id: 'mqtt',
label: 'mqtt',
description: 'MQTT connectivity control. Handler: MQTTHandler.',
commands: [
{
cmd: 'mqtt.enable',
handler: 'MQTTHandler',
transports: ['All'],
description: 'Enable MQTT connectivity. Persists to SD and triggers an immediate connect attempt.',
contents: null,
response: '{ "status": "SUCCESS", "type": "mqtt.enable", "message": "MQTT enabled and saved" }',
errors: [
{ message: 'MQTT state changed but failed to save to SD', condition: 'SD config write failed — state applied in RAM only' },
],
example: '{ "v": 2, "cmd": "mqtt.enable" }',
warning: null,
},
{
cmd: 'mqtt.disable',
handler: 'MQTTHandler',
transports: ['All'],
description: 'Disable MQTT connectivity. Persists to SD and disconnects immediately.',
contents: null,
response: '{ "status": "SUCCESS", "type": "mqtt.disable", "message": "MQTT disabled and saved" }',
errors: [
{ message: 'MQTT state changed but failed to save to SD', condition: 'SD config write failed — state applied in RAM only' },
],
example: '{ "v": 2, "cmd": "mqtt.disable" }',
warning: null,
},
{
cmd: 'mqtt.get_config',
handler: 'MQTTHandler',
transports: ['All'],
description: 'Read MQTT connection configuration. Password is never included in the response.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "mqtt.get_config",\n "data": {\n "enabled": true,\n "host": "72.61.191.197",\n "port": 1883,\n "user": "BSVSPR-26E11J-STD10R-M4SC2Z",\n "use_ssl": false\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "mqtt.get_config" }',
warning: null,
},
],
},
{
id: 'telemetry',
label: 'telemetry',
description: 'Bell strike counters, thermal load monitoring, and runtime metrics. Handler: TelemetryHandler.',
commands: [
{
cmd: 'telemetry.get_strikes',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Get lifetime strike counts per bell channel (persisted to SD).',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_strikes",\n "data": {\n "strikes": { "0": 1234, "1": 567, "2": 890, ... }\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "telemetry.get_strikes" }',
warning: null,
},
{
cmd: 'telemetry.reset_strikes',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Reset all strike counters to zero. Saved to SD immediately. (Per-bell reset is not yet implemented — all channels are always reset.)',
contents: [
{ field: 'bell', type: 'int', required: false, notes: 'Currently ignored — all channels are reset regardless. Reserved for a future per-bell path.' },
],
response: '{ "status": "SUCCESS", "type": "telemetry.reset_strikes", "message": "All strike counts reset" }',
errors: [],
example: '{ "v": 2, "cmd": "telemetry.reset_strikes" }',
warning: null,
},
{
cmd: 'telemetry.get_loads',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Get current heat load per bell channel, configured max loads, cooling status, guard state, and overloaded channel list.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_loads",\n "data": {\n "loads": { "0": 42, "1": 0, ... },\n "max_loads":{ "0": 500, "1": 500, ... },\n "cooling_active": false,\n "guard_enabled": true,\n "overloaded": []\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "telemetry.get_loads" }',
warning: null,
},
{
cmd: 'telemetry.set_max_loads',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Set overload threshold. Use "all" to set all channels at once, or "loads" for per-channel values. At least one is required.',
contents: [
{ field: 'all', type: 'int', required: false, notes: 'Set all 16 channels to this value. Must be > 0.' },
{ field: 'loads', type: 'object', required: false, notes: 'Per-channel map: { "0": 300, "3": 700, ... }. Values must be > 0.' },
],
response: '{ "status": "SUCCESS", "type": "telemetry.set_max_loads", "message": "All bell max loads updated" }',
errors: [
{ message: 'max_load must be > 0', condition: 'contents.all is 0' },
{ message: 'Provide \'all\' (uint16) or \'loads\' (object)', condition: 'Neither all nor loads is present in contents' },
],
example: '// Set all channels to 500\n{ "v": 2, "cmd": "telemetry.set_max_loads", "contents": { "all": 500 } }\n\n// Set specific channels\n{ "v": 2, "cmd": "telemetry.set_max_loads", "contents": { "loads": { "0": 300, "3": 700 } } }',
warning: null,
},
{
cmd: 'telemetry.set_guard_enabled',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Enable or disable the bell load guard. When disabled, the guard never triggers an emergency stop — load still decays but no action is taken.',
contents: [
{ field: 'enabled', type: 'bool', required: true, notes: 'true = guard active, false = guard bypassed' },
],
response: '{ "status": "SUCCESS", "type": "telemetry.set_guard_enabled", "message": "Bell guard enabled" }',
errors: [
{ message: 'Missing required field: enabled (bool)', condition: 'contents.enabled is absent or not a boolean' },
],
example: '{ "v": 2, "cmd": "telemetry.set_guard_enabled", "contents": { "enabled": false } }',
warning: null,
},
{
cmd: 'telemetry.get_metrics',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Read runtime metrics: lifetime seconds, current session seconds, total playback count, and guard state.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "telemetry.get_metrics",\n "data": {\n "lifetime_runtime_seconds": 1234567,\n "session_runtime_seconds": 3600,\n "total_playbacks": 4200,\n "guard_enabled": true\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "telemetry.get_metrics" }',
warning: null,
},
{
cmd: 'telemetry.reset_metrics',
handler: 'TelemetryHandler',
transports: ['All'],
description: 'Reset lifetime runtime and playback count to zero. Strike counters are NOT affected — use telemetry.reset_strikes for that.',
contents: null,
response: '{ "status": "SUCCESS", "type": "telemetry.reset_metrics", "message": "Lifetime runtime and playback count reset to zero" }',
errors: [],
example: '{ "v": 2, "cmd": "telemetry.reset_metrics" }',
warning: 'Clears all historical runtime data. Cannot be undone.',
},
],
},
{
id: 'bells',
label: 'bells',
description: 'Bell mechanism master enable/disable and config read. Handler: BellsHandler.',
commands: [
{
cmd: 'bells.enable',
handler: 'BellsHandler',
transports: ['All'],
description: 'Enable the bell mechanism. Allows BellEngine playback and clock alerts to fire. Manual relay tests (relay.test_*) bypass this flag regardless.',
contents: null,
response: '{ "status": "SUCCESS", "type": "bells.enable", "message": "Bell mechanism enabled and saved" }',
errors: [
{ message: 'Bell mechanism enabled but failed to save to SD', condition: 'SD config write failed — state applied in RAM only' },
],
example: '{ "v": 2, "cmd": "bells.enable" }',
warning: null,
},
{
cmd: 'bells.disable',
handler: 'BellsHandler',
transports: ['All'],
description: 'Disable the bell mechanism. Blocks BellEngine playback and clock alerts. Manual relay tests still work. Persisted to SD.',
contents: null,
response: '{ "status": "SUCCESS", "type": "bells.disable", "message": "Bell mechanism disabled and saved" }',
errors: [
{ message: 'Bell mechanism disabled but failed to save to SD', condition: 'SD config write failed — state applied in RAM only' },
],
example: '{ "v": 2, "cmd": "bells.disable" }',
warning: null,
},
{
cmd: 'bells.get_config',
handler: 'BellsHandler',
transports: ['All'],
description: 'Read bell mechanism enabled state plus all 16 channel durations and output assignments.',
contents: null,
response: '{\n "status": "SUCCESS",\n "type": "bells.get_config",\n "data": {\n "bells_enabled": true,\n "durations": { "0": 95, "1": 100, ... },\n "outputs": { "0": 1, "1": 2, ... } // 1-based: 1=first relay, 0=disabled\n }\n}',
errors: [],
example: '{ "v": 2, "cmd": "bells.get_config" }',
warning: null,
},
],
},
]
const LEGACY_MAP = [
{ v1_cmd: 'ping', v1_action: '—', v2: 'ping' },
{ v1_cmd: 'identify', v1_action: '—', v2: 'identify' },
{ v1_cmd: 'playback', v1_action: 'play', v2: 'playback.play' },
{ v1_cmd: 'playback', v1_action: 'stop', v2: 'playback.stop' },
{ v1_cmd: 'file_manager', v1_action: 'list_melodies', v2: 'files.list' },
{ v1_cmd: 'file_manager', v1_action: 'download_melody', v2: 'files.download' },
{ v1_cmd: 'file_manager', v1_action: 'delete_melody', v2: 'files.delete' },
{ v1_cmd: 'relay_setup', v1_action: 'set_timings', v2: 'relay.set_durations' },
{ v1_cmd: 'relay_setup', v1_action: 'set_outputs', v2: 'relay.set_outputs' },
{ v1_cmd: 'clock_setup', v1_action: 'set_outputs', v2: 'clock.set_outputs' },
{ v1_cmd: 'clock_setup', v1_action: 'set_timings', v2: 'clock.set_timings' },
{ v1_cmd: 'clock_setup', v1_action: 'set_alerts', v2: 'clock.set_alerts' },
{ v1_cmd: 'clock_setup', v1_action: 'set_backlight', v2: 'clock.set_backlight' },
{ v1_cmd: 'clock_setup', v1_action: 'set_silence', v2: 'clock.set_silence' },
{ v1_cmd: 'clock_setup', v1_action: 'set_rtc_time', v2: 'clock.set_time' },
{ v1_cmd: 'clock_setup', v1_action: 'set_physical_clock_time', v2: 'clock.set_face' },
{ v1_cmd: 'clock_setup', v1_action: 'pause_clock_updates', v2: 'clock.pause' },
{ v1_cmd: 'clock_setup', v1_action: 'resume_clock_updates', v2: 'clock.resume' },
{ v1_cmd: 'clock_setup', v1_action: 'set_enabled (enabled=true)', v2: 'clock.enable' },
{ v1_cmd: 'clock_setup', v1_action: 'set_enabled (enabled=false)', v2: 'clock.disable' },
{ v1_cmd: 'system_info', v1_action: 'report_status', v2: 'system.status' },
{ v1_cmd: 'system_info', v1_action: 'get_device_time', v2: 'system.get_time' },
{ v1_cmd: 'system_info', v1_action: 'get_clock_time', v2: 'clock.get_face' },
{ v1_cmd: 'system_info', v1_action: 'get_firmware_status', v2: 'firmware.status' },
{ v1_cmd: 'system_info', v1_action: 'network_info', v2: 'network.info' },
{ v1_cmd: 'system_info', v1_action: 'get_full_settings', v2: 'system.get_config' },
{ v1_cmd: 'system', v1_action: 'status', v2: 'system.status' },
{ v1_cmd: 'system', v1_action: 'reset_defaults', v2: 'system.factory_reset' },
{ v1_cmd: 'system', v1_action: 'commit_firmware', v2: 'firmware.commit' },
{ v1_cmd: 'system', v1_action: 'rollback_firmware', v2: 'firmware.rollback' },
{ v1_cmd: 'system', v1_action: 'get_firmware_status', v2: 'firmware.status' },
{ v1_cmd: 'system', v1_action: 'set_network_config', v2: 'network.set_config' },
{ v1_cmd: 'system', v1_action: 'set_serial_log_level', v2: 'log.set_serial' },
{ v1_cmd: 'system', v1_action: 'set_sd_log_level', v2: 'log.set_sd' },
{ v1_cmd: 'system', v1_action: 'set_mqtt_log_level', v2: 'log.set_mqtt' },
{ v1_cmd: 'system', v1_action: 'set_mqtt_enabled (enabled=true)', v2: 'mqtt.enable' },
{ v1_cmd: 'system', v1_action: 'set_mqtt_enabled (enabled=false)', v2: 'mqtt.disable' },
{ v1_cmd: 'system', v1_action: 'restart', v2: 'system.restart' },
{ v1_cmd: 'system', v1_action: 'reboot', v2: 'system.restart' },
{ v1_cmd: 'system', v1_action: 'force_update', v2: 'ota.update' },
{ v1_cmd: 'system', v1_action: 'custom_update', v2: 'ota.custom' },
{ v1_cmd: 'system', v1_action: 'get_all_settings', v2: 'system.get_all' },
{ v1_cmd: 'system', v1_action: 'get_telemetry', v2: 'system.get_telemetry' },
{ v1_cmd: 'system', v1_action: 'get_full_settings', v2: 'system.get_config' },
{ v1_cmd: 'telemetry', v1_action: 'get_bell_loads', v2: 'telemetry.get_loads' },
{ v1_cmd: 'telemetry', v1_action: 'get_strike_counters', v2: 'telemetry.get_strikes' },
{ v1_cmd: 'bells', v1_action: 'set_enabled (enabled=true)', v2: 'bells.enable' },
{ v1_cmd: 'bells', v1_action: 'set_enabled (enabled=false)', v2: 'bells.disable' },
]
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const TRANSPORT_META = {
MQTT: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
WebSocket: { color: 'var(--color-primary)', bg: 'var(--color-primary-subtle)' },
HTTP: { color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
UART: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
UDP: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
All: { color: 'var(--color-text-muted)', bg: 'var(--color-bg-island)' },
}
const LABEL_STYLE = {
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
marginBottom: 'var(--space-2)',
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TransportPill({ name }) {
const meta = TRANSPORT_META[name] || TRANSPORT_META.All
return (
<span style={{
display: 'inline-flex', alignItems: 'center',
padding: 'var(--space-1) var(--space-2)',
borderRadius: 'var(--radius-full)',
fontSize: 'var(--font-size-xs)',
fontFamily: 'var(--font-family-mono)',
fontWeight: 'var(--font-weight-medium)',
color: meta.color, backgroundColor: meta.bg, whiteSpace: 'nowrap',
}}>
{name}
</span>
)
}
function MonoBlock({ code, color = 'var(--color-info)' }) {
return (
<pre style={{
margin: 0, padding: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-bg-abyss)',
border: '1px solid var(--color-border)',
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-sm)',
color,
overflowX: 'auto', lineHeight: '1.6',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{code}
</pre>
)
}
function CodeBlock({ code }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1800)
})
}
return (
<div style={{ position: 'relative' }} className="api-code-wrap">
<MonoBlock code={code} color="var(--color-success)" />
<button
type="button" onClick={handleCopy} className="api-copy-btn" aria-label="Copy code"
style={{
position: 'absolute', top: 'var(--space-2)', right: 'var(--space-2)',
padding: '2px var(--space-2)', borderRadius: 'var(--radius-sm)',
fontSize: 'var(--font-size-xs)', cursor: 'pointer',
border: '1px solid var(--color-border-strong)',
backgroundColor: copied ? 'var(--color-success-bg)' : 'var(--color-bg-elevated)',
color: copied ? 'var(--color-success)' : 'var(--color-text-muted)',
transition: 'all 0.15s ease', opacity: 0, fontFamily: 'var(--font-family-base)',
}}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
)
}
function ContentsTable({ contents }) {
return (
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
{['Field', 'Type', 'Req', 'Notes'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', 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>
{contents.map((field, i) => (
<tr key={field.field} 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-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{field.field}</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-info)' }}>{field.type}</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-xs)' }}>
{field.required
? <span style={{ color: 'var(--color-danger)', fontWeight: 'var(--font-weight-semibold)' }}>yes</span>
: <span style={{ color: 'var(--color-text-muted)' }}>no</span>}
</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{field.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function ErrorsTable({ errors }) {
return (
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
{['Error message', 'Condition'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', 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>
{errors.map((e, i) => (
<tr key={i} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(255,100,100,0.03)' : 'transparent' }}>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)', whiteSpace: 'nowrap' }}>{e.message}</td>
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{e.condition}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function CommandCard({ command }) {
const [open, setOpen] = useState(false)
const isUartAllowed = UART_WHITELIST.includes(command.cmd)
const hasErrors = command.errors && command.errors.length > 0
return (
<div
className={`api-cmd-card${open ? ' api-cmd-card--open' : ''}`}
style={{ borderRadius: 'var(--radius-lg)', border: '1px solid var(--color-border)', overflow: 'hidden', backgroundColor: 'var(--color-bg-surface)', transition: 'border-color 0.15s ease' }}
>
<button
type="button" onClick={() => setOpen(v => !v)}
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-3) var(--space-4)', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left', gap: 'var(--space-3)' }}
aria-expanded={open}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', minWidth: 0, flex: 1 }}>
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)', flexShrink: 0 }}>
{command.cmd}
</span>
{command.warning && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '2px var(--space-2)', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-bg)', flexShrink: 0 }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" 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>
caution
</span>
)}
{hasErrors && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '2px var(--space-2)', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-danger)', backgroundColor: 'rgba(255,80,80,0.08)', flexShrink: 0 }}>
{command.errors.length} {command.errors.length === 1 ? 'error' : 'errors'}
</span>
)}
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{command.description}
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
{command.transports.map(t => <TransportPill key={t} name={t} />)}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
style={{ color: 'var(--color-text-muted)', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s ease', flexShrink: 0, marginLeft: 'var(--space-1)' }}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
{open && (
<div style={{ borderTop: '1px solid var(--color-border)', padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', backgroundColor: 'var(--color-bg-elevated)' }}>
<p style={{ margin: 0, fontSize: 'var(--font-size-base)', color: 'var(--color-text-secondary)', lineHeight: '1.6' }}>
{command.description}
</p>
{command.warning && (
<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>{command.warning}</span>
</div>
)}
<div className="api-detail-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
{/* Left column: meta + contents + response + errors */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{/* Meta */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-4)' }}>
<div>
<div style={LABEL_STYLE}>Handler</div>
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-info)' }}>{command.handler}</span>
</div>
{isUartAllowed && (
<div>
<div style={LABEL_STYLE}>UART</div>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '2px var(--space-2)', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', backgroundColor: 'var(--color-success-bg)' }}>
<svg width="6" height="6" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><circle cx="6" cy="6" r="6" /></svg>
whitelisted
</span>
</div>
)}
</div>
{/* Contents */}
<div>
<div style={LABEL_STYLE}>Contents</div>
{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>
}
</div>
{/* Success response */}
<div>
<div style={LABEL_STYLE}>Success response</div>
<MonoBlock code={command.response} />
</div>
{/* Errors */}
<div>
<div style={LABEL_STYLE}>Error responses</div>
{hasErrors
? <ErrorsTable errors={command.errors} />
: <p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>This command does not produce errors.</p>
}
{hasErrors && (
<p style={{ margin: 'var(--space-2) 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
All error responses share the envelope: <code style={{ fontFamily: 'var(--font-family-mono)' }}>{`{ "status": "ERROR", "type": "<cmd>", "message": "<message above>" }`}</code>
</p>
)}
</div>
</div>
{/* Right column: example */}
<div>
<div style={LABEL_STYLE}>Example</div>
<CodeBlock code={command.example} />
</div>
</div>
</div>
)}
</div>
)
}
function NamespaceSection({ ns, searchQuery }) {
const filteredCommands = useMemo(() => {
if (!searchQuery) return ns.commands
const q = searchQuery.toLowerCase()
return ns.commands.filter(c =>
c.cmd.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q) ||
c.handler.toLowerCase().includes(q)
)
}, [ns.commands, searchQuery])
if (searchQuery && filteredCommands.length === 0) return null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-3)', paddingBottom: 'var(--space-3)', borderBottom: '1px solid var(--color-border)' }}>
<h2 style={{ margin: 0, fontFamily: 'var(--font-family-display)', fontSize: 'var(--font-size-lg)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', letterSpacing: 'var(--tracking-tight)' }}>
{ns.label === 'Root' ? 'root' : ns.label}
</h2>
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: '20px', height: '20px', padding: '0 6px', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)' }}>
{filteredCommands.length}
</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{ns.description}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{filteredCommands.map(cmd => <CommandCard key={cmd.cmd} command={cmd} />)}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Tab: Transports
// ---------------------------------------------------------------------------
function TransportsTab() {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
<Card title="Transport Channels" subtitle="All available communication interfaces">
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
{['Transport', 'Address / Topic', 'Direction', '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)', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{TRANSPORTS.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)' }}><TransportPill name={row.transport} /></td>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{row.address}</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)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card title="UART Whitelist" subtitle="Commands permitted on the hardware serial interface">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{UART_WHITELIST.map(cmd => (
<span key={cmd} style={{ display: 'inline-flex', alignItems: 'center', padding: 'var(--space-1) var(--space-3)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)', border: '1px solid var(--color-border)' }}>
{cmd}
</span>
))}
</div>
</Card>
<Card title="Message Format" subtitle="Standard envelope for all v2 commands">
<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}>Request</div>
<MonoBlock code={'{\n "v": 2,\n "cmd": "<namespace>.<command>",\n "contents": { ... } // optional\n}'} color="var(--color-success)" />
</div>
<div>
<div style={LABEL_STYLE}>Response</div>
<MonoBlock code={'{\n "status": "SUCCESS" | "ERROR",\n "type": "<cmd echoed back>",\n "message": "...", // text responses\n "data": { ... } // structured responses\n}'} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
{[
{ field: 'v', type: 'int', notes: 'Protocol version. Always 2 for v2 firmware.' },
{ field: 'cmd', type: 'string', notes: 'Command name, e.g. "relay.set_durations"' },
{ field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' },
].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={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
</div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
</div>
))}
</div>
</div>
</Card>
</div>
)
}
// ---------------------------------------------------------------------------
// Tab: Legacy migration
// ---------------------------------------------------------------------------
function LegacyTab() {
return (
<div style={{ paddingTop: 'var(--space-2)' }}>
<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' }}>
<thead>
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
{['v1 cmd', 'v1 action', 'v2 command'].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_MAP.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)' }}>{row.v1_cmd}</td>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{row.v1_action}</td>
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.v2}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
</div>
)
}
// ---------------------------------------------------------------------------
// Tab: Commands
// ---------------------------------------------------------------------------
function CommandsTab({ activeNamespace, setActiveNamespace, searchQuery, setSearchQuery }) {
const totalCmds = NAMESPACES.reduce((acc, ns) => acc + ns.commands.length, 0)
const nsTabs = [{ key: 'all', label: 'All', count: totalCmds }, ...NAMESPACES.map(ns => ({ key: ns.id, label: ns.label, count: ns.commands.length }))]
const visibleNamespaces = useMemo(() => {
if (activeNamespace === 'all') return NAMESPACES
return NAMESPACES.filter(ns => ns.id === activeNamespace)
}, [activeNamespace])
const noResults = searchQuery && visibleNamespaces.every(ns => {
const q = searchQuery.toLowerCase()
return ns.commands.filter(c => c.cmd.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || c.handler.toLowerCase().includes(q)).length === 0
})
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)', paddingTop: 'var(--space-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 200px', maxWidth: '340px' }}>
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search commands…" />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{nsTabs.map(tab => (
<button
key={tab.key} type="button" onClick={() => setActiveNamespace(tab.key)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)',
padding: 'var(--space-1) var(--space-3)', borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-mono)',
fontWeight: activeNamespace === tab.key ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
cursor: 'pointer', border: '1px solid',
borderColor: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-border-strong)',
backgroundColor: activeNamespace === tab.key ? 'var(--color-primary-subtle)' : 'transparent',
color: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-text-muted)',
transition: 'all 0.15s ease',
}}
>
{tab.label}
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: '16px', height: '16px', padding: '0 4px', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', backgroundColor: activeNamespace === tab.key ? 'var(--color-primary-subtle)' : 'var(--color-bg-island)', color: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
{tab.count}
</span>
</button>
))}
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
{visibleNamespaces.map(ns => <NamespaceSection key={ns.id} ns={ns} searchQuery={searchQuery} />)}
{noResults && (
<div style={{ textAlign: 'center', padding: 'var(--space-12)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-base)' }}>
No commands match "{searchQuery}"
</div>
)}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function ApiReferencePage() {
const [activeTab, setActiveTab] = useState('commands')
const [activeNamespace, setActiveNamespace] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
const TABS = [
{ key: 'commands', label: 'Commands' },
{ key: 'transports', label: 'Transports' },
{ key: 'legacy', label: 'v1 → v2 Migration' },
]
const totalCmds = NAMESPACES.reduce((a, n) => a + n.commands.length, 0)
return (
<>
<style>{`
.api-code-wrap:hover .api-copy-btn { opacity: 1 !important; }
.api-cmd-card:hover { border-color: var(--color-border-strong) !important; }
.api-cmd-card--open { border-color: rgba(192,193,255,0.25) !important; }
@media (max-width: 768px) {
.api-detail-grid { grid-template-columns: 1fr !important; }
}
`}</style>
<div className="page-wrapper">
<PageHeader title="API Reference" subtitle="Vesper Firmware Command Protocol v2" />
<div style={{ display: 'flex', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
{[
{ label: 'Namespaces', value: NAMESPACES.length },
{ label: 'Commands', value: totalCmds },
{ label: 'Transports', value: 5 },
{ label: 'Protocol', value: 'v2' },
].map(stat => (
<div key={stat.label} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-lg)', backgroundColor: 'var(--color-bg-surface)', border: '1px solid var(--color-border)', boxShadow: 'var(--shadow-card)' }}>
<span style={{ fontFamily: 'var(--font-family-display)', fontSize: 'var(--font-size-xl)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-primary)', lineHeight: 1 }}>{stat.value}</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{stat.label}</span>
</div>
))}
</div>
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} variant="line" />
{activeTab === 'commands' && (
<CommandsTab
activeNamespace={activeNamespace}
setActiveNamespace={setActiveNamespace}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
)}
{activeTab === 'transports' && <TransportsTab />}
{activeTab === 'legacy' && <LegacyTab />}
</div>
</>
)
}