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
# Managed local runtime on loopback
rigplane station --port 0
Open http://<server-ip>:8080 (or your custom port).
Browser microphone requirement¶
Browser voice TX uses getUserMedia() to capture the microphone. Browsers expose
that API only in a secure context, such as browser-trusted HTTPS; a private-LAN
http://<server-ip> URL is not the same as localhost or a loopback origin.
See the W3C Secure Contexts specification
and the MDN getUserMedia reference.
For a remote browser, start the Web UI with Core TLS support using
--tls-cert and --tls-key together. Use a certificate valid and trusted for
the server hostname. The --tls option alone generates self-signed TLS and
does not establish browser certificate trust. Install the tls extra when needed, and open the resulting
https:// URL. A browser connected through an SSH loopback tunnel may instead
use http://localhost:<forwarded-port> because loopback is a browser-trusted
origin; the LAN HTTP URL itself remains insecure. This requirement affects
browser microphone capture and voice TX; it does not imply that every control
or receive operation fails over HTTP.
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 |
Application credentials¶
Clients that can reach Core's web listener need no application credential for
HTTP or WebSocket access. The browser does not prompt for, read, persist or send
an application token (frontend/src/lib/auth.ts: getAuthToken,
frontend/src/lib/transport/http-client.ts: fetchInfo). Old browser
token entries may remain inert; preference migration leaves them untouched
(frontend/src/lib/migrate-legacy-storage.ts: migrateLegacyStorage).
Remove --auth-token and --auth-token-file from old launch commands; these
flags now fail before radio startup. RIGPLANE_AUTH_TOKEN is ignored. See the
CLI migration note and API migration contract
for the empty-only WebConfig.auth_token compatibility field and diagnostic
CSRF, Origin and consent behavior. Radio credentials, bind selection and TLS
options retain their existing roles.
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. Most non-TX
commands share names and params objects across HTTP and WebSocket. Momentary
ptt uses the owned WebSocket flow; the legacy HTTP ptt, ptt_on and
ptt_off command payloads are rejected. Latched TRANSMIT and unconditional OFF
use the managed-transmit API described below.
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 structured non-TX commands 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, 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"
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",
)
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).
Managed TX and PTT ownership¶
The browser's App-owned managed TX controller sends intents to the backend's
canonical managed-transmit authority. Presentation components render its
snapshot and request operations; they do not create another TX owner or
infer radio state from a button press. The composition entry is
frontend/src/lib/runtime/tx-controller/managed-app-host.ts:
provideManagedAppTxHost; WebServer._managed_tx_authority in
src/rigplane/web/server.py supplies the server authority.
Momentary PTT uses the control WebSocket and keeps press/release on the same
connection owner. Latched TRANSMIT is a separate operation through
POST /api/v1/managed-transmit/command; its force_off operation requests
unconditional OFF. Releasing an owner's PTT intent is not unconditional OFF.
See the TX migration examples
for request shapes and the intentionally rejected legacy HTTP commands.
Admission is not completion or observed RF. Read the managed document at
GET /api/v1/managed-transmit and preserve unavailable, stale and unknown
states. A lost response must not trigger an automatic positive-TX retry.
An unavailable managed authority is not permission to fall back to a legacy
direct-key path. Time-out configuration and remaining time come from the
managed document; do not infer a universal timer or ownership guarantee from
a backend name. Direct hardware or external keying is not an owned browser
PTT intent. Final operator/hardware acceptance is separate from these API
contracts.
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 workspace-backed skin selector (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/lib/runtime/adapters/ (radio-view-model-adapter.ts, panel-adapters.ts) |
|
| WS command dispatch | frontend/src/lib/runtime/commands/panel-commands.ts |
|
| HTTP system actions | frontend/src/lib/runtime/system-controller.ts via runtime.system.* |
Owns radio connect/disconnect, power on/off, and EiBi identify calls. |
| Presentation composition and resource handover | frontend/src/App.svelte: requestPresentation, acquireSwapBridge; frontend/src/lib/runtime/resource-demand.ts |
The bridge preserves already-demanded resources during a layout change; it does not create demand for an otherwise unused service. |
| Browser TX facade and session cleanup | frontend/src/lib/runtime/tx-controller/managed-app-host.ts: provideManagedAppTxHost |
App lifetime, outside replaceable skins; renders server-managed state and submits intents. |
Skin entry points are registered in frontend/src/skins/registry.ts and may
compose semantic surfaces or shared layout components. Layout replacement
does not recreate runtime bootstrap or TX ownership. The accepted dependency
direction is documented in the v3 architecture decision.
Backend CI-V poll cadence (state freshness)¶
_FAST_INTERVAL (LAN) and _FAST_INTERVAL_SERIAL (serial) in
web/radio_poller.py: RadioPoller are how often the poller loop wakes, not
how often any field is read: a pass dispatches whatever the acquisition
scheduler already has queued. What puts a cadence request there is the
field's own cadence_seconds in rigs/*.toml
([state_acquisition.field_policies], falling back to that profile's
default_cadence_seconds), emitted by
core/acquisition_scheduler.py: StateFreshnessService.tick
(AcquisitionScheduler.ensure_fresh and .prime_unobserved also enqueue
requests, outside this cadence).
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)
MediaSession does not control PTT. play/pause have no handler
registered; only the volume-key tuning actions above are wired up.
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)¶
Bindings are capability-driven: the backend serializes a per-radio
keyboard config into GET /api/v1/capabilities, built from a shared
default (rigs/_keyboard-default.toml) plus optional per-rig overrides.
Press ? in the desktop skin to open the in-app shortcut list for the
connected radio's actual bindings.
Representative bindings from the shared default profile:
| Key | Action |
|---|---|
ArrowUp / ArrowDown |
Increase / decrease the tuning step |
ArrowLeft / ArrowRight |
Tune down / up by the current step |
PageUp / PageDown |
Jump the active receiver +/- 1 kHz |
1-9 |
Select band (160m .. 6m) |
F1-F10 |
Select mode (LSB, USB, CW, CW-R, AM, FM, RTTY, RTTY-R, PSK, PSK-R) |
m / Shift+M / Shift+S |
Toggle active receiver / activate MAIN / activate SUB |
Escape |
Clear RIT/XIT offset |
Editable value controls, the active-receiver toggle, and the spectrum
splitter (Up/Down only) own their unmodified arrow keys: while one is
focused, those arrows act on that control instead of tuning (Shift steps a
value control finely; a control that is not editable owns nothing).
Modified arrows — the Ctrl+ArrowUp/ArrowDown AF level and
Ctrl+Shift+ArrowUp/ArrowDown RF gain bindings — and focus anywhere
else, including plain buttons, keep the global bindings.
There is no global keyboard shortcut bound to PTT. Focused PTT controls
support Space/Enter through their own press/release handlers; this is
distinct from a window-level radio shortcut. Global-binding implementation:
frontend/src/components-v2/layout/keyboard-map.ts (event matching) and
frontend/src/lib/runtime/commands/panel-commands.ts (makeKeyboardHandlers,
action dispatch).
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/App.svelte via resolveSkinId(...)
and lazy-loaded through skins/registry.ts:
isMobileis true when:min(window.innerWidth, window.innerHeight) < 640, or- touch device and
min(window.innerWidth, window.innerHeight) < 500. - If
isMobileis true ->mobileskin. - Otherwise, the workspace layout preference is used:
lcd-cockpit-> LCD Cockpit skin (TS-990S-style dual-cockpit)lcd-scope-> LCD Scope skin (IC-7300-style scope-dominant)standard-> desktop-v2 skinsdr-test-> SDR Screen test skinpeer-split,unified-instrument,panadapter-first-> their respective LCD shell presentationsdual-sdr-face-> dual-receiver SDR instrument faceauto-> desktop-v2 unconditionally (MOR-1097 cutover); scope availability does not affect this resolution.
normalizeLayoutMode() (frontend/src/presentation/layout-mode.ts, since
MOR-2059; re-exported from lib/stores/layout.svelte.ts for existing
callers) normalizes legacy persisted values so old localStorage entries
keep resolving: amber-lcd/lcd -> lcd-cockpit, spectrum/desktop-v2
-> standard.
The status bar layout control (StatusBar.svelte) is a <select> dropdown.
The persistent ID vocabulary is CANONICAL_LAYOUT_MODES in
frontend/src/presentation/layout-mode.ts; it is distinct from skin manifest
IDs (standard maps to desktop-v2). The QA-only dual-receiver-cockpit
query override is not a persistable workspace choice.
Workspace settings, import and export¶
In the standard layout, open Settings and expand WORKSPACE. Select layout,
design language and theme there. Language IDs are studioline,
fieldline and segmentline; a language activates only on compatible
layouts. Density is a supported workspace JSON field, clamped to the active
language's supported values; WORKSPACE currently has no density picker.
The schema is WorkspaceV1 in
frontend/src/presentation/workspace/contract.ts. It stores stable IDs and
per-zone surface visibility/order in rigplane:workspace. A workspace can
hide or reorder surfaces already declared within a zone. It cannot force-show
unsupported controls, move surfaces across zones, remove required semantic
surfaces, or persist capabilities, radio state, credentials, transport sessions
or TX safety settings. Pinned command names are validated schema data with
no user-interface consumer yet.
Export saves rigplane-workspace.json. Import accepts a JSON file or pasted
JSON and validates the whole document before replacing the workspace. Any
rejected field prevents the import; the UI reports field/reason details.
Export includes preserved unknown fields. Reset restores the frozen defaults
and offers an in-session undo for supported presentation settings. Undo does
not restore pinned command names or preserved unknown fields. These operations
use importWorkspace, exportWorkspace and resetWorkspace in
frontend/src/presentation/workspace/store.svelte.ts.
Schema versions 1–3 are readable by the current v1 reader. A lossy newer document is held read-only for the session until explicit whole-document reset or a valid import; normal edits do not silently overwrite it. Legacy pre-workspace keys are retained. An older build reads those retained old preferences, not edits made only to the new workspace. The workspace verification runbook records a historical storage probe, not final installed-release acceptance.
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 pointer timing is implemented by
components-v2/wiring/managed-tx-gesture.ts: createManagedTxGesture, wired
into MobileRadioLayout.svelte through the App-owned managed TX controller:
- a press requests momentary PTT; release arms a 300ms window before requesting its release
- a second press inside that window requests canonical latched TRANSMIT when available, or releases PTT otherwise
- a press while latched requests ForceOff
- changing orientation destroys the old gesture generation and releases its in-progress or pending momentary PTT intent; it does not clear canonical latched TRANSMIT
These are requests, not confirmation of radio transitions. Use the managed state/observation and explicit OFF control; local gesture cleanup does not prove RF OFF. There is no mobile-local safety timer. See Managed TX and PTT ownership for the authority and observation boundary.
Operations Runbook¶
Run with DX cluster overlays¶
Run with custom UI assets¶
Package cutover and rollback¶
Before replacing a deployed package, export the workspace and preserve the browser origin/storage used by the deployment. End the active operating session and confirm the required release/RX state before stopping or replacing it; an accepted OFF request alone is insufficient. Restore a known complete package/frontend artifact together, then verify its version, UI and preferences. Do not mix legacy static files into a running current session.
The unused pre-v2 static.old source copy was removed in #2400; it is not the
rollback UI. Retained workspace keys preserve the documented legacy-read
window, but do not by themselves prove package or active-session rollback.
Final candidate install/browser/rollback evidence remains separate from the
source contracts here. Core beta scope and the later Pro release are described
in the migration guide.
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 |
✅ | ✅ |
| 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. - No v1 UI:
?ui=v1is no longer supported (since v0.20+) — the app only ships the current skins-based UI. Mobile interactions (sheet/panel swipe, touch-first PTT flow) are always active on a mobile-sized viewport; no query param or stored selection is required. - Layout mode expectations: workspace layout preference resolution is not
capability-aware;
autoresolves to desktop-v2 unconditionally (MOR-1097 cutover), and scope availability plays no part in it. - 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.