Skip to content

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

{"type":"cmd","id":"42","name":"set_freq","params":{"freq":14074000,"receiver":0}}

Server response:

{"type":"response","id":"42","ok":true,"result":{"freq":14074000,"receiver":0}}

state_update payload formats

The backend emits state_update in two shapes:

  1. Full snapshot:
{"type":"state_update","data":{"type":"full","data":{"main":{"freqHz":14074000}},"revision":1}}
  1. Delta update (only changed fields):
{"type":"state_update","data":{"type":"delta","changed":{"main":{"freqHz":14074100}},"revision":2}}

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:

{"type":"response","ok":false,"error":"backend_recovering"}

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:

{"type":"cmd","id":"73","name":"set_band","params":{"band":5}}

Backend flow (src/rigplane/web/radio_poller.py):

  1. Read Band Stack Register via CI-V 0x1A 0x01 <band> 0x01 (register 1).
  2. If response is valid, apply recalled frequency and mode/filter.
  3. If recall fails (timeout/exception/short response), fallback to profile default_hz for the matching bsr_code.
  4. If no band with that bsr_code exists, no retune is applied and a warning is logged.

Practical rule:

  • If a band has bsrCode, use set_band (radio recalls last freq/mode for that band).
  • If bsrCode is absent, use set_freq with band default.

Audio Workflow and Constraints

RX/TX lifecycle

  1. Client enables RX:
  2. {"type":"audio_start","direction":"rx"}
  3. Client requests PTT ON on control channel (ptt: true).
  4. Client enables TX stream:
  5. {"type":"audio_start","direction":"tx"}
  6. then sends binary TX frames to /api/v1/audio.
  7. Client requests PTT OFF.
  8. 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

  1. Initialize the workspace-backed skin selector (see "Layout and skin resolution" below).
  2. Register MediaSession handlers (when API is available).
  3. Start HTTP polling loop for /api/v1/state (interval set to 1000ms in app bootstrap).
  4. Start battery monitor (progressive enhancement) and adjust polling multiplier.
  5. Fetch capabilities once from /api/v1/capabilities.
  6. 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-Match with the previous ETag.
  • 304 Not Modified is treated as a successful poll with no state payload.
  • The state ETag includes both revision and healthRevision. A radio-health-only transition therefore returns 200 with a fresh payload even when frequency/mode/meter state did not change.
  • On transient HTTP errors, cached ETag is cleared to force a fresh 200 response.
  • 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.svelte
  • frontend/src/components-v2/layout/MobileRadioLayout.svelte
  • frontend/src/components-v2/controls/BottomSheet.svelte
  • frontend/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:

  1. isMobile is true when:
  2. min(window.innerWidth, window.innerHeight) < 640, or
  3. touch device and min(window.innerWidth, window.innerHeight) < 500.
  4. If isMobile is true -> mobile skin.
  5. Otherwise, the workspace layout preference is used:
  6. lcd-cockpit -> LCD Cockpit skin (TS-990S-style dual-cockpit)
  7. lcd-scope -> LCD Scope skin (IC-7300-style scope-dominant)
  8. standard -> desktop-v2 skin
  9. sdr-test -> SDR Screen test skin
  10. peer-split, unified-instrument, panadapter-first -> their respective LCD shell presentations
  11. dual-sdr-face -> dual-receiver SDR instrument face
  12. auto -> 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

rigplane web --dx-cluster dxc.nc7j.com:7373 --callsign YOURCALL

Run with custom UI assets

rigplane web --static-dir /opt/icom-ui/dist

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

curl http://127.0.0.1:8080/api/v1/info
curl http://127.0.0.1:8080/api/v1/state

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.

// Safe receiver access
const sub = state.sub ?? null;

Common Pitfalls for Developers

  • Capability-gated commands: commands fail with command_failed if active profile does not expose required capability (for example, set_rf_gain on unsupported radios).
  • Receiver indexing: many commands expect receiver=0 (MAIN) or receiver=1 (SUB) and validate against runtime profile receiver count.
  • sub may be absent: GET /api/v1/state omits sub for 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_update payloads 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=v1 is 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; auto resolves 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.mediaSession exists.