Web UI¶
icom-lan 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
icom-lan web
# Explicit host/port
icom-lan web --host 0.0.0.0 --port 9090
# Require API/WebSocket auth token
icom-lan 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/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
(icom-lan 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
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/icom_lan/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.
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 UI version selector (
?ui=v1|v2takes priority over localStorage). - 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.
v2 runtime ownership (actual code paths)¶
v2 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/* are migration wrappers that delegate
to components-v2/layout/*; behavior is still implemented in the v2 layout and
wiring modules listed above.
State polling and conditional requests¶
- Polling uses
If-None-Matchwith the previousETag. 304 Not Modifiedis treated as a successful poll with no state payload.- 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 v2 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
Enabling v2 UI¶
v2 can be selected with ?ui=v2 (or stored in localStorage by the app).
Without selection, UI version defaults to v1.
Layout and skin resolution in v2¶
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
icom-lan-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 (
icom-lan-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.