Web UI¶
rigplane ships with a built-in browser UI for live control, scope/waterfall, meters, and RX/TX audio.
This page documents the current implementation (Svelte frontend + asyncio backend), public interfaces, and operational workflows.
Quick Start¶
# Default: bind all interfaces on port 8080
rigplane web
# Explicit host/port
rigplane web --host 0.0.0.0 --port 9090
# Require API/WebSocket auth token
rigplane web --auth-token "change-me"
Open http://<server-ip>:8080 (or your custom port).
What Runs Where¶
| Layer | Implementation | Notes |
|---|---|---|
| HTTP + WebSocket server | Python asyncio | Pure asyncio, no external web framework |
| WS handlers | Per-channel handlers | Control, scope, meters, and audio channels |
| Frontend app | Svelte + TypeScript | Built assets served from package by default |
The backend manages reconnect and recovery when the radio link drops; scope enable is deferred until radio_ready is true.
Public HTTP Interface¶
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Serve UI entry page (index.html) |
GET |
/api/v1/info |
Version, model, connection status, runtime capability summary |
GET |
/api/v1/state |
Current radio state snapshot (camelCase, includes revision + updatedAt) |
GET |
/api/v1/capabilities |
Capabilities, frequency ranges, supported modes/filters, scope/audio config |
GET |
/api/v1/dx/spots |
Buffered DX spots |
GET |
/api/v1/bridge |
Audio bridge status |
Advanced operational HTTP endpoints¶
These are primarily used by automation, deployment scripts, and operator tooling:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/radio/connect |
Trigger backend connect/reconnect |
POST |
/api/v1/radio/disconnect |
Trigger backend disconnect |
POST |
/api/v1/radio/power |
CI-V power control ({"state":"on" \| "off"}) |
POST |
/api/v1/commands |
Enqueue one structured control command |
POST |
/api/v1/commands/batch |
Apply an ordered stateless command batch |
POST |
/api/v1/bridge |
Start audio bridge |
DELETE |
/api/v1/bridge |
Stop audio bridge |
GET |
/api/v1/band-plan/config |
Active band-plan region |
POST |
/api/v1/band-plan/config |
Change region + reload band plans |
GET |
/api/v1/band-plan/layers |
Loaded overlay layers |
GET |
/api/v1/band-plan/segments?... |
Band-plan segments for selected range |
POST |
/api/v1/eibi/fetch |
Download/refresh EiBi DB |
GET |
/api/v1/eibi/status |
EiBi loader status |
GET |
/api/v1/eibi/stations |
EiBi station list (paged/filterable) |
GET |
/api/v1/eibi/segments?... |
EiBi overlay segments |
GET |
/api/v1/eibi/identify?... |
Broadcast station identification |
GET |
/api/v1/eibi/bands |
EiBi band list |
Auth behavior (--auth-token)¶
GET /api/*requiresAuthorization: Bearer <token>.- WebSocket endpoints accept either:
Authorization: Bearer <token>, or?token=<token>query parameter.- Static files (
/, JS, CSS, assets) are still served without token.
Audio bridge control path
Runtime bridge activation is typically done from CLI flags
(rigplane web --bridge ... / --bridge-rx-only).
WebSocket Channels¶
| Endpoint | Direction | Payload type | Purpose |
|---|---|---|---|
/api/v1/ws |
bidirectional | JSON text | Commands, responses, notifications, state_update stream |
/api/v1/scope |
server -> client | Binary | Scope/waterfall frames |
/api/v1/meters |
server -> client | Binary | Meter frames (meters_start / meters_stop control messages) |
/api/v1/audio |
bidirectional | JSON + Binary | RX stream + TX uplink |
Control Channel Workflow (/api/v1/ws)¶
Command envelope¶
Server response:
state_update payload formats¶
The backend emits state_update in two shapes:
- Full snapshot:
- Delta update (only changed fields):
Client integrations should support both formats. Assuming only full snapshots causes state drift when delta updates are enabled.
Connection control messages¶
{"type":"radio_connect","id":"..."}{"type":"radio_disconnect","id":"..."}
If backend recovery is already in progress, radio_connect returns:
Common commands¶
- Tuning/control:
set_freq,set_mode,set_filter,set_band,ptt - RF/audio levels:
set_power,set_rf_gain,set_af_level,set_squelch - DSP/features:
set_nb,set_nr,set_digisel,set_ipplus,set_comp - Receiver/routing:
select_vfo,vfo_swap,vfo_equalize,set_dual_watch - Scope control:
switch_scope_receiver,set_scope_during_tx,set_scope_center_type
These are representative command names, not the complete catalog. The HTTP and
WebSocket command surfaces share the same command names and params objects.
The full command catalog — every name, parameter shape, capability gate, and
batch-eligibility flag — is published in
HTTP / WebSocket Command Catalog.
Lower-level Python/CI-V examples are documented in CI-V Commands.
HTTP Structured Commands¶
Automation clients can send the same structured command names over HTTP:
curl -X POST http://127.0.0.1:8080/api/v1/commands \
-H 'Content-Type: application/json' \
-d '{"id":"deck-1","name":"set_freq","params":{"freq":144030000}}'
For ordered profile-like changes, send a stateless batch:
curl -X POST http://127.0.0.1:8080/api/v1/commands/batch \
-H 'Content-Type: application/json' \
-d '{
"id": "vara-fm",
"steps": [
{"name":"set_freq","params":{"freq":144030000}},
{"name":"set_mode","params":{"mode":"FM"}},
{"name":"set_data_mode","params":{"mode":1}},
{"name":"set_data1_mod_input","params":{"source":3}},
{"name":"set_usb_mod_level","params":{"level":72}},
{"name":"set_af_level","params":{"level":72}}
]
}'
Batch steps are executed in exact request order. Structured command steps go
through the radio command queue; raw CI-V transaction steps use
send_civ_transaction() and wait for the requested ACK, NAK, data response, or
timeout before the next step starts. Repeated commands in one batch are
preserved. The response includes one result per executed, timed-out, failed, or
skipped step. continue_on_error, when provided, must be a JSON boolean. Core
does not persist named profiles or stored batches; callers send the full
sequence each time.
The batch path is designed for local profile switching from tools such as Stream Deck, MQTT gateways, shell scripts, and station supervisors:
flowchart LR
Button["operator button"] --> MQTT["MQTT/profile event"]
MQTT --> Gateway["local gateway maps name to steps"]
Gateway --> Batch["POST /api/v1/commands/batch"]
Batch --> Queue["RigPlane ordered command queue"]
Queue --> Radio["radio backend and hardware"]
Use /api/v1/capabilities before sending model-specific batches; not every
radio exposes the same receivers, memory operations, audio routing controls, or
feature toggles. Prefer these structured commands over raw CI-V for routine
automation so RigPlane remains the single owner of the radio connection,
queueing, pacing, auth policy, and safety checks.
For vendor-specific CI-V commands that are not yet covered by structured
commands, use the queued send_civ escape hatch. The payload mirrors the
Python radio.send_civ(command=..., sub=..., data=...) call, but data is an
even-length hex string:
curl -X POST http://127.0.0.1:8080/api/v1/commands \
-H 'Content-Type: application/json' \
-d '{
"id": "display-type-b",
"name": "send_civ",
"params": {
"command": 26,
"sub": 5,
"data": "015301"
}
}'
send_civ is fire-and-forget in the HTTP/WS command queue. It preserves order,
including repeated raw CI-V steps in batches, but it does not return response
bytes or readback verification. Use it for model-specific gaps such as
display/menu settings; prefer structured commands for normal profile steps
where RigPlane already has a command.
When a raw CI-V operation needs a radio response, use
POST /api/v1/civ/transaction instead of send_civ. The transaction endpoint
temporarily claims the CI-V stream, sends one frame, and waits according to an
explicit expectation:
curl -X POST http://127.0.0.1:8080/api/v1/civ/transaction \
-H 'Content-Type: application/json' \
-d '{
"id": "display-type-b",
"command": 26,
"sub": 5,
"data": "015301",
"expect": "ack",
"timeout_ms": 1000
}'
expect must be none, ack, or data. none sends without waiting and
returns status: "sent"; ack waits for ACK/NAK; data waits for the
matching data response. NAK returns HTTP 200 with ok: false,
status: "nak", and error: "radio_nak". Timeouts return HTTP 504.
Inside POST /api/v1/commands/batch, use a raw_civ_transaction step when a
batch needs the same wire-level ACK, NAK, or data response before continuing.
The same transaction can be sent from a small Python tool:
import json
import urllib.request
base_url = "http://127.0.0.1:8080"
token = None # or "your-token"
payload = {
"id": "display-type-b",
"command": 26,
"sub": 5,
"data": "015301",
"expect": "ack",
"timeout_ms": 1000,
}
request = urllib.request.Request(
f"{base_url}/api/v1/civ/transaction",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
if token:
request.add_header("Authorization", f"Bearer {token}")
with urllib.request.urlopen(request, timeout=5) as response:
result = json.load(response)
if not result["ok"]:
raise SystemExit(result)
DATA mode commands use the active radio profile's numeric DATA value. For the
current IC-9700 profile, set_data_mode uses mode: 0 for OFF and mode: 1
for DATA. Its modulation input source values are 0 = MIC, 1 = ACC,
2 = MIC+ACC, 3 = USB, and 4 = MIC+USB; use
set_data1_mod_input, set_data_off_mod_input, and modulation level commands
such as set_usb_mod_level or set_acc1_mod_level to build audio-route
specific profiles.
The batch endpoint is stateless. In Core, a "profile" is simply the JSON batch the caller sends. Stored named profiles, profile builders, account sync, and profile sharing are product-layer concerns outside this open-core endpoint.
Minimal Python client:
import json
import urllib.request
batch = {
"id": "vara-fm",
"steps": [
{"name": "set_freq", "params": {"freq": 144030000}},
{"name": "set_mode", "params": {"mode": "FM"}},
{"name": "set_data_mode", "params": {"mode": 1}},
{"name": "set_data1_mod_input", "params": {"source": 3}},
],
}
request = urllib.request.Request(
"http://127.0.0.1:8080/api/v1/commands/batch",
data=json.dumps(batch).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=30) as response:
result = json.load(response)
Minimal MQTT gateway shape:
import json
import urllib.request
import paho.mqtt.client as mqtt
BATCHES = {
"vara-fm": {
"steps": [
{"name": "set_freq", "params": {"freq": 144030000}},
{"name": "set_mode", "params": {"mode": "FM"}},
{"name": "set_data_mode", "params": {"mode": 1}},
{"name": "set_data1_mod_input", "params": {"source": 3}},
],
}
}
def on_message(client, userdata, message) -> None:
batch = BATCHES.get(message.payload.decode("utf-8").strip())
if batch is None:
return
urllib.request.urlopen(
urllib.request.Request(
"http://127.0.0.1:8080/api/v1/commands/batch",
data=json.dumps(batch).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
),
timeout=30,
)
client = mqtt.Client()
client.on_message = on_message
client.connect("127.0.0.1", 1883)
client.subscribe("radio/profile")
client.loop_forever()
Band switching with set_band (bsrCode workflow)¶
set_band is intended for profile bands that expose bsrCode in
GET /api/v1/capabilities:
{
"freqRanges": [
{
"label": "HF",
"bands": [
{ "name": "20m", "default": 14200000, "bsrCode": 5 },
{ "name": "60m", "default": 5357000 }
]
}
]
}
Control command:
Backend flow (src/rigplane/web/radio_poller.py):
- Read Band Stack Register via CI-V
0x1A 0x01 <band> 0x01(register 1). - If response is valid, apply recalled frequency and mode/filter.
- If recall fails (timeout/exception/short response), fallback to profile
default_hzfor the matchingbsr_code. - If no band with that
bsr_codeexists, no retune is applied and a warning is logged.
Practical rule:
- If a band has
bsrCode, useset_band(radio recalls last freq/mode for that band). - If
bsrCodeis absent, useset_freqwith banddefault.
Audio Workflow and Constraints¶
RX/TX lifecycle¶
- Client enables RX:
{"type":"audio_start","direction":"rx"}- Client requests PTT ON on control channel (
ptt: true). - Client enables TX stream:
{"type":"audio_start","direction":"tx"}- then sends binary TX frames to
/api/v1/audio. - Client requests PTT OFF.
- Backend stops TX stream and restarts RX stream.
Important constraints¶
- Browser TX frames are ignored while PTT is OFF (frontend and backend both enforce this).
- IC-7610 LAN behavior is effectively half-duplex for web audio flow: after TX ends, RX is restarted explicitly by backend logic.
- If audio send blocks for too long, server closes stale audio WS path and client reconnect logic re-establishes the stream.
IC-7610: set MOD Input to LAN before voice TX
For network (LAN) voice TX the radio's MOD Input source for the active
mode group must be LAN (Menu → Set → Connectors → MOD Input,
DATA OFF MOD for regular SSB/AM/FM). If it is MIC — or any option that
includes MIC — the open microphone modulates the moment PTT is keyed from
the network, producing broadband noise or a rising feedback squeal instead
of your audio. See
Network Voice TX Is Noise, a Squeal, or Silent (IC-7610 MOD Input).
Frontend Runtime Workflow (Current Implementation)¶
The browser app startup path is implemented in frontend/src/App.svelte and
frontend/src/lib/transport/http-client.ts.
Boot sequence¶
- Initialize the skin selector from URL/localStorage (see "Layout and skin resolution" below).
- Register MediaSession handlers (when API is available).
- Start HTTP polling loop for
/api/v1/state(interval set to1000msin app bootstrap). - Start battery monitor (progressive enhancement) and adjust polling multiplier.
- Fetch capabilities once from
/api/v1/capabilities. - Connect control WebSocket (
/api/v1/ws) and subscribe to events.
Runtime ownership (actual code paths)¶
The frontend keeps one behavior path and splits responsibilities by module:
| Responsibility | Current implementation path | Notes |
|---|---|---|
| Runtime read/write entry point | frontend/src/lib/runtime/frontend-runtime.ts |
Exposes state, capabilities, connection snapshot, audio actions, and command send helpers. |
| UI view-model mapping | frontend/src/components-v2/wiring/state-adapter.ts |
Converts raw runtime state into panel props. |
| WS command dispatch | frontend/src/components-v2/wiring/command-bus.ts |
Maps UI callbacks to sendCommand(...) calls and optimistic state patches. |
| HTTP system actions | frontend/src/lib/runtime/system-controller.ts via runtime.system.* |
Owns radio connect/disconnect, power on/off, and EiBi identify calls. |
Current skin files in frontend/src/skins/* delegate to components-v2/layout/*;
behavior is implemented in the layout and wiring modules listed above.
Backend CI-V poll cadence (state freshness)¶
src/rigplane/web/radio_poller.py interleaves meter and state queries:
- even cycles -> meter query
- odd cycles -> one state query
Poll interval is backend-specific:
- LAN backends:
25msfast cycle (_FAST_INTERVAL) - serial backends:
100msfast cycle (_FAST_INTERVAL_SERIAL)
LAN meter polling uses a two-tier strategy:
- High tier (most cycles):
- RX path: S-meter (
0x15 0x02) - TX path: rotates RF power (
0x15 0x11), SWR (0x15 0x12), ALC (0x15 0x13) - Low tier (every 5th high cycle while RX): rotates COMP (
0x15 0x14), Vd (0x15 0x15), Id (0x15 0x16)
Serial meter polling keeps a simpler high-priority loop focused on responsiveness: S-meter, RF power, S-meter, SWR.
Practical implication: S-meter and TX safety meters update most frequently, while secondary telemetry (COMP/Vd/Id) is intentionally sampled less often.
State polling and conditional requests¶
- Polling uses
If-None-Matchwith the previousETag. 304 Not Modifiedis treated as a successful poll with no state payload.- The state
ETagincludes bothrevisionandhealthRevision. A radio-health-only transition therefore returns200with a fresh payload even when frequency/mode/meter state did not change. - On transient HTTP errors, cached ETag is cleared to force a fresh
200response. - After repeated HTTP failures, the connection store marks HTTP as disconnected until recovery.
Battery-aware polling behavior¶
frontend/src/lib/utils/battery.ts adjusts polling interval multiplier:
| Battery state | Multiplier | Effective poll interval (base 1000ms) |
|---|---|---|
| Charging or >20% | 1x |
1000ms |
| 10–20% and not charging | 2x |
2000ms |
| <=10% and not charging | 4x |
4000ms |
If the Battery Status API is unavailable, multiplier stays at 1x.
MediaSession mappings (mobile/headset controls)¶
When navigator.mediaSession is supported:
previoustrack-> tune down one step (set_freq)nexttrack-> tune up one step (set_freq)play->pttONpause->pttOFF
Implementation path: frontend/src/lib/media/media-session.ts.
Receiver routing in MediaSession tuning
MediaSession tuning currently sends set_freq with receiver: 0
(MAIN receiver).
Keyboard Shortcuts (Desktop)¶
| Key | Action |
|---|---|
F1-F11 |
Jump to preset amateur bands (160m .. 6m) |
M |
Cycle mode through supported modes |
ArrowUp / ArrowRight |
Tune up by current step |
ArrowDown / ArrowLeft |
Tune down by current step |
Space |
Toggle PTT |
Escape |
Close frequency-entry modal |
Mobile Interaction Model¶
Mobile-first interaction logic is implemented in:
frontend/src/components-v2/layout/RadioLayout.sveltefrontend/src/components-v2/layout/MobileRadioLayout.sveltefrontend/src/components-v2/controls/BottomSheet.sveltefrontend/src/components-v2/controls/CollapsiblePanel.svelte
Layout and skin resolution¶
Skin/layout is resolved in frontend/src/components-v2/layout/RadioLayout.svelte
using resolveSkinId(...) and getLayoutMode():
isMobileis true when:min(window.innerWidth, window.innerHeight) < 640, or- touch device and
min(window.innerWidth, window.innerHeight) < 500. - If
isMobileis true -> mobile skin. - Otherwise, layout preference from localStorage key
rigplane-layoutis used: lcd-> amber LCD skinstandard-> desktop v2 skinauto-> desktop v2 when any scope is available, amber LCD when no scope is available.
Status bar layout button behavior (cycleLayoutMode(...)):
- if scope is available:
auto -> lcd -> standard -> auto - if scope is not available: selecting layout forces
lcd
Bottom sheet gestures¶
Bottom sheets support swipe-to-dismiss:
- drag starts from the handle, or from content when scroll is at top
- downward dismiss triggers when either:
- drag distance is >30% of sheet height, or
- swipe velocity is >0.5 px/ms
Collapsible panel swipe gestures¶
Panel headers support vertical swipe:
- swipe down collapses an expanded panel
- swipe up expands a collapsed panel
- threshold: 30px, with vertical-dominant movement guard
Mobile PTT workflow¶
Mobile PTT button behavior:
- press-and-hold -> TX while held
- double-tap within 350ms -> latch TX lock
- tap while latched -> unlock and return to idle
- safety timeout forcibly disengages TX after 3 minutes
Operations Runbook¶
Run with DX cluster overlays¶
Run with custom UI assets¶
Quick health checks¶
Verify v2 StatusBar system actions¶
These are the HTTP calls used by runtime.system.* in StatusBar.svelte and
LcdLayout.svelte:
# Trigger backend reconnect/disconnect
curl -X POST http://127.0.0.1:8080/api/v1/radio/connect
curl -X POST http://127.0.0.1:8080/api/v1/radio/disconnect
# Remote power control
curl -X POST http://127.0.0.1:8080/api/v1/radio/power \
-H "Content-Type: application/json" \
-d '{"state":"on"}'
curl -X POST http://127.0.0.1:8080/api/v1/radio/power \
-H "Content-Type: application/json" \
-d '{"state":"off"}'
# Optional EiBi "now playing" lookup used by status bar
curl "http://127.0.0.1:8080/api/v1/eibi/identify?freq=14074000"
If these endpoints return non-2xx, runtime.system.* raises the backend text
as an error and UI actions show an alert with that message.
Dynamic UI — Radio-Aware Controls¶
The Web UI adapts to the active radio's capabilities. Capabilities are fetched once
from GET /api/v1/capabilities on startup and cached in
frontend/src/lib/stores/capabilities.svelte.ts.
VFO Labels¶
VFO button labels change based on the radio's VFO scheme:
| Radio | Scheme | Button A label | Button B label |
|---|---|---|---|
| IC-7610 | main_sub |
MAIN | SUB |
| IC-7300 | ab |
VFO A | VFO B |
The vfoLabel() function in the capabilities store drives this:
// Returns "MAIN" or "VFO A" depending on active profile
vfoLabel('A')
// Returns "SUB" or "VFO B"
vfoLabel('B')
Capability-Based UI Guards¶
Controls that depend on hardware features are automatically hidden or disabled when the active radio profile doesn't support them:
| Control | Capability flag | Visible on IC-7610 | Visible on IC-7300 |
|---|---|---|---|
| DIGI-SEL toggle | digisel |
✅ | ❌ hidden |
| IP+ toggle | ip_plus |
✅ | ❌ hidden |
| SUB receiver panel | dual_rx |
✅ | ❌ hidden |
| TX controls, PTT | tx |
✅ | ✅ |
| Audio RX/TX | audio |
✅ | ✅ |
| Scope/waterfall | scope |
✅ | ✅ |
Use hasCapability(name) to check for a capability in Svelte components:
import { hasCapability } from '$lib/stores/capabilities.svelte';
// In a Svelte component template:
// {#if hasCapability('digisel')}
// <DigiSelControl />
// {/if}
State Endpoint and Receiver Count¶
GET /api/v1/state omits the sub receiver for single-receiver radios.
Frontend code should guard against the missing sub key rather than assuming it is
always present.
Common Pitfalls for Developers¶
- Capability-gated commands: commands fail with
command_failedif active profile does not expose required capability (for example,set_rf_gainon unsupported radios). - Receiver indexing: many commands expect
receiver=0(MAIN) orreceiver=1(SUB) and validate against runtime profile receiver count. submay be absent:GET /api/v1/stateomitssubfor single-receiver radios — always guard with a null check.- VFO commands: use
select_vfo("A")/select_vfo("B")regardless of scheme; the backend translates to the correct CI-V codes for the active profile. - Authoritative state source: use
state_updatepayloads as source of truth; optimistic UI updates can be overwritten by server state. - Scope recovery behavior: scope enable/re-enable is deferred until
radio_ready=true; all-zero scope frames trigger automatic re-enable attempts. - UI version assumptions: mobile v2 interactions (sheet/panel swipe, touch-first PTT flow)
require
?ui=v2or previously stored v2 selection; default is v1. - Layout mode expectations: v2 layout preference (
rigplane-layout) is capability-aware;autoresolves to desktop only when any scope exists, otherwise LCD is selected. - System action error surfacing: connect/disconnect/power actions in v2 call
runtime.system.*and surface backend HTTP errors directly in the UI. - Battery API availability: polling slowdown on low battery is best-effort; browsers without
navigator.getBattery()remain on normal polling cadence. - MediaSession availability: headset/lock-screen controls are enabled only when
navigator.mediaSessionexists.
Related Docs¶
- CLI Reference
- Troubleshooting
- Reliability semantics — timeouts, cache TTLs, and
radio_ready/ connection state behavior.