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>
1714 lines
96 KiB
React
1714 lines
96 KiB
React
// 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>
|
||
</>
|
||
)
|
||
}
|