Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
// 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', 'playback', 'clock.pause', 'clock.resume', 'system.get_time']
|
||||
|
||||
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, returns: '{ "status": "SUCCESS", "type": "pong" }', 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 or targeted responses will not be received.', contents: [{ field: 'device_type', type: 'string', required: true, notes: '"master" (app/console) or "secondary" (slave board)' }], returns: null, example: '{ "v": 2, "cmd": "identify", "contents": { "device_type": "master" } }', warning: null },
|
||||
{ cmd: 'playback', handler: 'PlaybackHandler', transports: ['All'], description: 'Control melody playback. The action field is always required.', contents: [{ field: 'action', type: 'string', required: true, notes: '"play" | "stop" | "pause" | "unpause"' }, { field: 'melody_uid', type: 'string', required: false, notes: 'Required when action is play' }], returns: null, example: '{ "v": 2, "cmd": "playback", "contents": { "action": "play", "melody_uid": "ABC123" } }\n{ "v": 2, "cmd": "playback", "contents": { "action": "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 — omitted channels are unchanged.', contents: [{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → duration ms. e.g. { "0": 95, "2": 110 }. At least one entry required.' }], returns: null, 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 channel index → relay output index. e.g. { "0": 2, "1": 3 }' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.set_outputs",\n "contents": { "bells": { "0": 2, "1": 3, "2": 4 } }\n}', warning: null },
|
||||
{ cmd: 'relay.get_config', handler: 'RelayHandler', transports: ['All'], description: 'Read current bell durations and relay output assignments.', contents: null, returns: '{ "durations": { "0": 95, ... }, "outputs": { "0": 2, ... } }', example: '{ "v": 2, "cmd": "relay.get_config" }', warning: null },
|
||||
{ cmd: 'relay.test_output', handler: 'RelayHandler', transports: ['All'], description: 'Fire a specific relay for a caller-specified duration. Safety-gated: Player must be stopped.', contents: [{ field: 'output', type: 'int', required: true, notes: 'Physical relay output index' }, { field: 'duration_ms', type: 'int', required: true, notes: 'Duration in ms. No defaults. Typical range: 85–140 ms' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.test_output",\n "contents": { "output": 2, "duration_ms": 95 }\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. Partial updates accepted.', contents: [{ field: 'c1', type: 'int', required: false, notes: 'Relay output for the first clock channel' }, { field: 'c2', type: 'int', required: false, notes: 'Relay output for the second clock channel. At least one of c1/c2 required.' }], returns: null, 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 strike timing configuration. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_timings", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_alerts', handler: 'ClockHandler', transports: ['All'], description: 'Configure hourly/quarter alert behavior. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_backlight', handler: 'ClockHandler', transports: ['All'], description: 'Configure LCD backlight behavior. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_backlight", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_silence', handler: 'ClockHandler', transports: ['All'], description: 'Configure silence period (quiet hours). Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_time', handler: 'ClockHandler', transports: ['All'], description: 'Set the RTC time.', contents: [{ field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp' }, { field: 'timezone_offset', type: 'int', required: false, notes: 'Offset in seconds (e.g. 7200 for UTC+2)' }, { field: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds' }], returns: null, example: '{\n "v": 2,\n "cmd": "clock.set_time",\n "contents": { "timestamp": 1740000000, "timezone_offset": 7200 }\n}', warning: null },
|
||||
{ cmd: 'clock.set_face', handler: 'ClockHandler', transports: ['All'], description: 'Set the physical analog clock face position.', contents: [{ field: 'hour', type: 'int', required: true, notes: 'Hour hand position (0–11)' }, { field: 'minute', type: 'int', required: true, notes: 'Minute hand position (0–59)' }], returns: null, 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.', 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' }, { field: 'timezone_name', type: 'string', required: false, notes: 'IANA timezone name, e.g. "Europe/Athens"' }], returns: null, example: '{\n "v": 2,\n "cmd": "clock.set_timezone",\n "contents": { "gmt_offset_sec": 7200, "dst_offset_sec": 3600, "timezone_name": "Europe/Athens" }\n}', warning: null },
|
||||
{ cmd: 'clock.get_timezone', handler: 'ClockHandler', transports: ['All'], description: 'Read current timezone settings.', contents: null, returns: '{ "gmt_offset_sec": 7200, "dst_offset_sec": 3600, "timezone_name": "..." }', 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, returns: null, example: '{ "v": 2, "cmd": "clock.sync_ntp" }', warning: null },
|
||||
{ cmd: 'clock.enable', handler: 'ClockHandler', transports: ['All'], description: 'Enable clock strike output.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.enable" }', warning: null },
|
||||
{ cmd: 'clock.disable', handler: 'ClockHandler', transports: ['All'], description: 'Disable clock strike output.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.disable" }', warning: null },
|
||||
{ cmd: 'clock.pause', handler: 'ClockHandler', transports: ['All'], description: 'Pause clock updates.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.pause" }', warning: null },
|
||||
{ cmd: 'clock.resume', handler: 'ClockHandler', transports: ['All'], description: 'Resume clock updates.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.resume" }', warning: null },
|
||||
{ cmd: 'clock.get_face', handler: 'ClockHandler', transports: ['All'], description: 'Read current analog clock face position.', contents: null, returns: '{ "clock_hour", "clock_minute", "last_sync_time", "next_output_is_c1" }', 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, returns: null, example: '{ "v": 2, "cmd": "clock.get_config" }', 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 device status: player state, strike counters, projected run time.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.status" }', warning: null },
|
||||
{ cmd: 'system.get_time', handler: 'SystemHandler', transports: ['All'], description: 'Get current RTC time.', contents: null, returns: '{ "local_timestamp", "utc_timestamp", "year", "month", "day", "hour", "minute", "second", "rtc_available" }', example: '{ "v": 2, "cmd": "system.get_time" }', warning: null },
|
||||
{ cmd: 'system.get_settings', handler: 'SystemHandler', transports: ['All'], description: 'Get full ConfigManager JSON dump (all saved settings).', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.get_settings" }', warning: null },
|
||||
{ cmd: 'system.get_device_info', handler: 'SystemHandler', transports: ['All'], description: 'Get device identity and runtime stats.', contents: null, returns: '{ "uid", "hw_type", "hw_version", "fw_version", "uptime_ms", "free_heap", "min_free_heap" }', example: '{ "v": 2, "cmd": "system.get_device_info" }', warning: null },
|
||||
{ cmd: 'system.health', handler: 'SystemHandler', transports: ['All'], description: 'Get full HealthMonitor report — all subsystem states, warnings, and critical failures.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.health" }', warning: null },
|
||||
{ cmd: 'system.factory_reset', handler: 'SystemHandler', transports: ['All'], description: 'Reset all settings to factory defaults.', contents: null, returns: null, 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, returns: null, 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.', contents: null, returns: '{ "validation_state", "version", "boot_count", "can_commit", "can_rollback" }', 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).', contents: null, returns: null, example: '{ "v": 2, "cmd": "firmware.commit" }', warning: null },
|
||||
{ cmd: 'firmware.rollback', handler: 'FirmwareHandler', transports: ['All'], description: 'Roll back to the previous firmware. Device reboots.', contents: null, returns: null, 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.status', handler: 'FirmwareHandler', transports: ['All'], description: 'Get OTA update status.', contents: null, returns: '{ "current_version", "available_version", "channel", "update_available", "last_check", "progress" }', example: '{ "v": 2, "cmd": "ota.status" }', warning: null },
|
||||
{ cmd: 'ota.check', handler: 'FirmwareHandler', transports: ['All'], description: 'Check for available updates.', contents: [{ field: 'channel', type: 'string', required: false, notes: 'Update channel. Default: "stable"' }], returns: null, example: '{ "v": 2, "cmd": "ota.check", "contents": { "channel": "beta" } }', warning: null },
|
||||
{ cmd: 'ota.update', handler: 'FirmwareHandler', transports: ['All'], description: 'Trigger OTA update from VPS. Device may reboot after flashing.', contents: [{ field: 'channel', type: 'string', required: false, notes: 'Update channel. Default: "stable"' }], returns: null, example: '{ "v": 2, "cmd": "ota.update", "contents": { "channel": "stable" } }', warning: 'Device may reboot after the update is applied.' },
|
||||
{ cmd: 'ota.custom', handler: 'FirmwareHandler', transports: ['All'], description: 'Flash firmware from a custom URL.', contents: [{ field: 'firmware_url', type: 'string', required: true, notes: 'Direct download URL for the firmware binary' }, { 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 purposes' }], returns: null, example: '{\n "v": 2,\n "cmd": "ota.custom",\n "contents": {\n "firmware_url": "http://example.com/firmware.bin",\n "checksum": "abc123",\n "version": "142"\n }\n}', warning: 'Device may reboot after the update is applied.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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, returns: '{ "ip", "gateway", "dns" }', example: '{ "v": 2, "cmd": "network.info" }', warning: null },
|
||||
{ cmd: 'network.status', handler: 'NetworkHandler', transports: ['All'], description: 'Get full connection state.', contents: null, returns: '{ "connected", "state", "type", "ip", "rssi", "ap_mode" }', example: '{ "v": 2, "cmd": "network.status" }', warning: null },
|
||||
{ cmd: 'network.set_config', handler: 'NetworkHandler', transports: ['All'], description: 'Update network configuration. Partial updates accepted. Returns restart_required flag.', contents: [{ field: 'hostname', type: 'string', required: false, notes: 'Device hostname on the network' }, { field: 'useStaticIP', type: 'bool', required: false, notes: 'Enable static IP mode' }, { field: 'ip', type: 'string', required: false, notes: 'Static IP address. Required together with gateway + subnet when using static IP.' }, { 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' }], returns: '{ "restart_required": true/false }', 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, returns: 'Array of SD card melody file objects', 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, returns: '[{ "name": "...", "uid": "..." }, ...]', 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' }], returns: null, 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.mel"' }], returns: null, example: '{ "v": 2, "cmd": "files.delete", "contents": { "name": "westminster.mel" } }', 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' }], returns: null, 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' }], returns: null, 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' }], returns: null, example: '{ "v": 2, "cmd": "log.set_mqtt", "contents": { "level": 1 } }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
label: 'mqtt',
|
||||
description: 'MQTT connectivity control. Handler: MQTTHandler.',
|
||||
commands: [
|
||||
{ cmd: 'mqtt.enable', handler: 'MQTTHandler', transports: ['All'], description: 'Enable MQTT connectivity.', contents: null, returns: null, example: '{ "v": 2, "cmd": "mqtt.enable" }', warning: null },
|
||||
{ cmd: 'mqtt.disable', handler: 'MQTTHandler', transports: ['All'], description: 'Disable MQTT connectivity.', contents: null, returns: null, example: '{ "v": 2, "cmd": "mqtt.disable" }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'telemetry',
|
||||
label: 'telemetry',
|
||||
description: 'Bell strike counters and thermal load monitoring. Handler: TelemetryHandler.',
|
||||
commands: [
|
||||
{ cmd: 'telemetry.get_strikes', handler: 'TelemetryHandler', transports: ['All'], description: 'Get strike counts per bell channel.', contents: null, returns: '{ "strikes": { "0": 1234, "1": 567, ... } }', example: '{ "v": 2, "cmd": "telemetry.get_strikes" }', warning: null },
|
||||
{ cmd: 'telemetry.reset_strikes', handler: 'TelemetryHandler', transports: ['All'], description: 'Reset strike counters. Omit bell to reset all channels; include it to reset a single channel.', contents: [{ field: 'bell', type: 'int', required: false, notes: 'Channel index. If omitted, all channels are reset.' }], returns: null, example: '{ "v": 2, "cmd": "telemetry.reset_strikes" }\n{ "v": 2, "cmd": "telemetry.reset_strikes", "contents": { "bell": 2 } }', warning: null },
|
||||
{ cmd: 'telemetry.get_loads', handler: 'TelemetryHandler', transports: ['All'], description: 'Get heat load per bell channel, cooling status, and overload flags.', contents: null, returns: '{ "loads": { "0": 42.5, ... }, "cooling_active": false, "overloaded": [2, 5] }', example: '{ "v": 2, "cmd": "telemetry.get_loads" }', warning: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const LEGACY_MAP = [
|
||||
{ v1_cmd: 'ping', v1_action: '—', v2: 'ping' },
|
||||
{ v1_cmd: 'identify', v1_action: '—', v2: 'identify' },
|
||||
{ v1_cmd: 'playback', v1_action: '—', v2: 'playback' },
|
||||
{ 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_settings' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
// Transport badge 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)' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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">
|
||||
<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: 'var(--color-success)',
|
||||
overflowX: 'auto', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{code}
|
||||
</pre>
|
||||
<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 ReturnBlock({ code }) {
|
||||
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: 'var(--color-info)',
|
||||
overflowX: 'auto', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{code}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandCard({ command }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isUartAllowed = UART_WHITELIST.includes(command.cmd)
|
||||
|
||||
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}
|
||||
>
|
||||
{/* Left */}
|
||||
<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>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{command.description}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right */}
|
||||
<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: meta + contents + returns */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
{/* Meta */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-4)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>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={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>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 */}
|
||||
{command.contents ? (
|
||||
<div>
|
||||
<div 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)' }}>
|
||||
Contents
|
||||
</div>
|
||||
<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>
|
||||
{command.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>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>No contents required.</p>
|
||||
)}
|
||||
|
||||
{/* Returns */}
|
||||
{command.returns && (
|
||||
<div>
|
||||
<div 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)' }}>Returns</div>
|
||||
<ReturnBlock code={command.returns} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: example */}
|
||||
<div>
|
||||
<div 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)' }}>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)' }}>
|
||||
<CodeBlock code={'{\n "v": 2,\n "cmd": "<namespace>.<command>",\n "contents": { ... } // optional\n}'} />
|
||||
<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)' }}>
|
||||
{/* Filters row */}
|
||||
<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>
|
||||
|
||||
{/* Command list */}
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* Stats strip */}
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user