refactor: nest trx-frontend under trx-client, trx-backend under trx-server

Move the frontend and backend crate trees to live physically under their
respective binary crate directories, grouping related code together
without merging crate boundaries. Also flatten sub-crate nesting by
moving them out of src/ subdirectories into direct children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 22:47:58 +01:00
parent 4e9f1d2be6
commit 5f91287369
27 changed files with 20 additions and 20 deletions
@@ -0,0 +1,20 @@
# SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
#
# SPDX-License-Identifier: BSD-2-Clause
[package]
name = "trx-frontend-http"
version = "0.1.0"
edition = "2021"
[dependencies]
trx-core = { path = "../../../trx-core" }
trx-frontend = { path = ".." }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tracing = { workspace = true }
actix-web = "=4.4.1"
tokio-stream = { version = "0.1", features = ["sync"] }
futures-util = "0.3"
bytes = "1"
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,303 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
use actix_web::{get, post, web, HttpResponse, Responder};
use actix_web::{http::header, Error};
use bytes::Bytes;
use futures_util::stream::{once, select, StreamExt};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::time::{self, Duration};
use tokio_stream::wrappers::{IntervalStream, WatchStream};
use trx_core::radio::freq::Freq;
use trx_core::rig::{RigAccessMethod, RigCapabilities, RigInfo};
use trx_core::{ClientResponse, RigCommand, RigMode, RigRequest, RigSnapshot, RigState};
use crate::server::status;
const FAVICON_BYTES: &[u8] = include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/assets/trx-favicon.png"
));
const LOGO_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/trx-logo.png"));
#[get("/status")]
pub async fn status_api(
state: web::Data<watch::Receiver<RigState>>,
) -> Result<impl Responder, Error> {
let state = wait_for_view(state.get_ref().clone()).await?;
Ok(HttpResponse::Ok().json(state))
}
#[get("/events")]
pub async fn events(state: web::Data<watch::Receiver<RigState>>) -> Result<HttpResponse, Error> {
let rx = state.get_ref().clone();
let initial = wait_for_view(rx.clone()).await?;
let initial_json =
serde_json::to_string(&initial).map_err(actix_web::error::ErrorInternalServerError)?;
let initial_stream =
once(async move { Ok::<Bytes, Error>(Bytes::from(format!("data: {initial_json}\n\n"))) });
let updates = WatchStream::new(rx).filter_map(|state| async move {
state
.snapshot()
.and_then(|v| serde_json::to_string(&v).ok())
.map(|json| Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n"))))
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(10)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
let stream = initial_stream.chain(select(pings, updates));
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
.insert_header((header::CACHE_CONTROL, "no-cache"))
.insert_header((header::CONNECTION, "keep-alive"))
.streaming(stream))
}
#[post("/toggle_power")]
pub async fn toggle_power(
state: web::Data<watch::Receiver<RigState>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let desired_on = !matches!(state.get_ref().borrow().control.enabled, Some(true));
let cmd = if desired_on {
RigCommand::PowerOn
} else {
RigCommand::PowerOff
};
send_command(&rig_tx, cmd).await
}
#[post("/toggle_vfo")]
pub async fn toggle_vfo(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::ToggleVfo).await
}
#[post("/lock")]
pub async fn lock_panel(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::Lock).await
}
#[post("/unlock")]
pub async fn unlock_panel(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::Unlock).await
}
#[derive(serde::Deserialize)]
pub struct FreqQuery {
pub hz: u64,
}
#[post("/set_freq")]
pub async fn set_freq(
query: web::Query<FreqQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetFreq(Freq { hz: query.hz })).await
}
#[derive(serde::Deserialize)]
pub struct ModeQuery {
pub mode: String,
}
#[post("/set_mode")]
pub async fn set_mode(
query: web::Query<ModeQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let mode = parse_mode(&query.mode);
send_command(&rig_tx, RigCommand::SetMode(mode)).await
}
#[derive(serde::Deserialize)]
pub struct PttQuery {
pub ptt: String,
}
#[post("/set_ptt")]
pub async fn set_ptt(
query: web::Query<PttQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
let ptt = match query.ptt.to_ascii_lowercase().as_str() {
"1" | "true" | "on" => Ok(true),
"0" | "false" | "off" => Ok(false),
other => Err(actix_web::error::ErrorBadRequest(format!(
"invalid ptt parameter: {other}"
))),
}?;
send_command(&rig_tx, RigCommand::SetPtt(ptt)).await
}
#[derive(serde::Deserialize)]
pub struct TxLimitQuery {
pub limit: u8,
}
#[post("/set_tx_limit")]
pub async fn set_tx_limit(
query: web::Query<TxLimitQuery>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
) -> Result<HttpResponse, Error> {
send_command(&rig_tx, RigCommand::SetTxLimit(query.limit)).await
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(index)
.service(status_api)
.service(events)
.service(toggle_power)
.service(toggle_vfo)
.service(lock_panel)
.service(unlock_panel)
.service(set_freq)
.service(set_mode)
.service(set_ptt)
.service(set_tx_limit)
.service(favicon)
.service(logo);
}
#[get("/")]
async fn index(callsign: web::Data<Option<String>>) -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8"))
.body(status::index_html(callsign.get_ref().as_deref()))
}
#[get("/favicon.ico")]
async fn favicon() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.body(FAVICON_BYTES)
}
#[get("/logo.png")]
async fn logo() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/png"))
.body(LOGO_BYTES)
}
async fn send_command(
rig_tx: &mpsc::Sender<RigRequest>,
cmd: RigCommand,
) -> Result<HttpResponse, Error> {
let (resp_tx, resp_rx) = oneshot::channel();
rig_tx
.send(RigRequest {
cmd,
respond_to: resp_tx,
})
.await
.map_err(|e| {
actix_web::error::ErrorInternalServerError(format!("failed to send to rig: {e:?}"))
})?;
let resp = tokio::time::timeout(Duration::from_secs(8), resp_rx)
.await
.map_err(|_| actix_web::error::ErrorGatewayTimeout("rig response timeout"))?;
match resp {
Ok(Ok(snapshot)) => Ok(HttpResponse::Ok().json(ClientResponse {
success: true,
state: Some(snapshot),
error: None,
})),
Ok(Err(err)) => Ok(HttpResponse::BadRequest().json(ClientResponse {
success: false,
state: None,
error: Some(err.message),
})),
Err(e) => Err(actix_web::error::ErrorInternalServerError(format!(
"rig response channel error: {e:?}"
))),
}
}
async fn wait_for_view(mut rx: watch::Receiver<RigState>) -> Result<RigSnapshot, actix_web::Error> {
if let Some(view) = rx.borrow().snapshot() {
return Ok(view);
}
while rx.changed().await.is_ok() {
if let Some(view) = rx.borrow().snapshot() {
return Ok(view);
}
}
// Fallback: build a minimal snapshot if rig info is missing.
let state = rx.borrow().clone();
Ok(RigSnapshot {
info: state
.rig_info
.clone()
.unwrap_or_else(|| RigInfoPlaceholder.into()),
status: state.status,
band: None,
enabled: state.control.enabled,
initialized: state.initialized,
})
}
struct RigInfoPlaceholder;
impl Default for RigInfoPlaceholder {
fn default() -> Self {
RigInfoPlaceholder
}
}
impl From<RigInfoPlaceholder> for RigInfo {
fn from(_: RigInfoPlaceholder) -> Self {
RigInfo {
manufacturer: "Unknown".to_string(),
model: "Rig".to_string(),
revision: "".to_string(),
capabilities: RigCapabilities {
supported_bands: vec![],
supported_modes: vec![],
num_vfos: 0,
lock: false,
lockable: false,
attenuator: false,
preamp: false,
rit: false,
rpt: false,
split: false,
},
access: RigAccessMethod::Serial {
path: "".into(),
baud: 0,
},
}
}
}
fn parse_mode(s: &str) -> RigMode {
match s.to_ascii_uppercase().as_str() {
"LSB" => RigMode::LSB,
"USB" => RigMode::USB,
"CW" => RigMode::CW,
"CWR" => RigMode::CWR,
"AM" => RigMode::AM,
"FM" => RigMode::FM,
"WFM" => RigMode::WFM,
"DIG" | "DIGI" => RigMode::DIG,
"PKT" | "PACKET" => RigMode::PKT,
other => RigMode::Other(other.to_string()),
}
}
@@ -0,0 +1,10 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
pub mod server;
pub fn register_frontend() {
use trx_frontend::FrontendSpawner;
trx_frontend::register_frontend("http", server::HttpFrontend::spawn_frontend);
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
#[path = "api.rs"]
mod api;
#[path = "status.rs"]
pub mod status;
use std::net::SocketAddr;
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
use tokio::signal;
use tokio::sync::{mpsc, watch};
use tokio::task::JoinHandle;
use tracing::{error, info};
use trx_core::RigRequest;
use trx_core::RigState;
use trx_frontend::FrontendSpawner;
/// HTTP frontend implementation.
pub struct HttpFrontend;
impl FrontendSpawner for HttpFrontend {
fn spawn_frontend(
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
listen_addr: SocketAddr,
) -> JoinHandle<()> {
tokio::spawn(async move {
if let Err(e) = serve(listen_addr, state_rx, rig_tx, callsign).await {
error!("HTTP status server error: {:?}", e);
}
})
}
}
async fn serve(
addr: SocketAddr,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
) -> Result<(), actix_web::Error> {
let server = build_server(addr, state_rx, rig_tx, callsign)?;
let handle = server.handle();
tokio::spawn(async move {
let _ = signal::ctrl_c().await;
handle.stop(false).await;
});
info!("http frontend listening on {}", addr);
info!("http frontend ready (status/control)");
server.await?;
Ok(())
}
fn build_server(
addr: SocketAddr,
state_rx: watch::Receiver<RigState>,
rig_tx: mpsc::Sender<RigRequest>,
callsign: Option<String>,
) -> Result<Server, actix_web::Error> {
let state_data = web::Data::new(state_rx);
let rig_tx = web::Data::new(rig_tx);
let callsign = web::Data::new(callsign);
let server = HttpServer::new(move || {
App::new()
.app_data(state_data.clone())
.app_data(rig_tx.clone())
.app_data(callsign.clone())
.configure(api::configure)
})
.shutdown_timeout(1)
.disable_signals()
.bind(addr)?
.run();
Ok(server)
}
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.configure(api::configure);
}
@@ -0,0 +1,588 @@
// SPDX-FileCopyrightText: 2025 Stanislaw Grams <stanislawgrams@gmail.com>
//
// SPDX-License-Identifier: BSD-2-Clause
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
pub fn index_html(callsign: Option<&str>) -> String {
INDEX_HTML_TEMPLATE
.replace("{pkg}", PKG_NAME)
.replace("{ver}", PKG_VERSION)
.replace("{callsign_opt}", callsign.unwrap_or(""))
}
const INDEX_HTML_TEMPLATE: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{pkg} v{ver} status</title>
<link rel="icon" href="/favicon.ico" />
<style>
body { font-family: sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0d1117; color: #e5e7eb; }
.card { border: 1px solid #1f2a35; border-radius: 12px; padding: 1.25rem 1.75rem; width: min(680px, 90vw); box-shadow: 0 12px 40px rgba(0,0,0,0.35); background: #161b22; }
.label { color: #9aa4b5; font-size: 0.9rem; margin-bottom: 6px; display: block; }
.value { font-size: 1.4rem; margin-bottom: 0.5rem; }
.status { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.1rem 1rem; }
input.status-input, select.status-input { width: 100%; padding: 0.45rem 0.5rem; font-size: 1rem; border: 1px solid #2d3748; border-radius: 6px; background: #0f1720; color: #e5e7eb; }
.vfo-box { width: 100%; min-height: 2.6rem; padding: 0.45rem 0.5rem; border: 1px solid #2d3748; border-radius: 6px; background: #0f1720; color: #e5e7eb; white-space: pre-line; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.controls { margin-top: 1rem; display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
button { padding: 0.5rem 0.9rem; border-radius: 6px; border: 1px solid #394455; background: #1f2937; color: #e5e7eb; cursor: pointer; height: 2.4rem; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.hint { color: #9aa4b5; font-size: 0.85rem; }
.inline { display: flex; gap: 0.5rem; align-items: center; }
.section-title { margin-top: 0.5rem; font-size: 1.05rem; font-weight: 600; color: #c5cedd; }
small { color: #9aa4b5; }
.header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 0.25rem; }
.title { font-size: 1.4rem; font-weight: 700; display: inline-flex; align-items: center; gap: 0.35rem; position: relative; z-index: 2; }
.logo-bg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; opacity: 0.2; }
.logo-bg img { max-width: 50%; max-height: 50%; filter: drop-shadow(0 4px 12px rgba(0,0,0,0.35)); }
.subtitle { color: #9aa4b5; font-size: 0.95rem; }
.band-tag { display: inline-block; padding: 2px 6px; border-radius: 6px; background: #1f2937; color: #e5e7eb; font-size: 0.82rem; border: 1px solid #2d3748; margin-left: 6px; }
.signal { display: flex; gap: 0.6rem; align-items: center; }
.signal-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: #1f2937; border: 1px solid #2d3748; overflow: hidden; }
.signal-bar-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #00d17f, #f0ad4e, #e55353); transition: width 150ms ease; }
.signal-value { font-size: 0.95rem; color: #c5cedd; min-width: 48px; text-align: right; }
.meter { display: flex; gap: 0.6rem; align-items: center; }
.meter-bar { flex: 1 1 auto; height: 12px; border-radius: 999px; background: #1f2937; border: 1px solid #2d3748; overflow: hidden; }
.meter-fill { height: 100%; width: 0%; background: linear-gradient(90deg, #00d17f, #f0ad4e, #e55353); transition: width 150ms ease; }
.meter-value { font-size: 0.95rem; color: #c5cedd; min-width: 64px; text-align: right; }
.footer { margin-top: 0.6rem; display: flex; justify-content: flex-end; }
.full-row { grid-column: 1 / -1; }
</style>
</head>
<body>
<div class="card" id="card" style="position:relative; overflow:hidden;">
<div class="logo-bg"><img id="logo" src="/logo.png?v=1" alt="trx logo" onerror="console.error('logo load failed'); this.style.display='none'" /></div>
<div class="header" style="position:relative; z-index:2;">
<div>
<div class="title"><span id="rig-title">Rig status</span></div>
<div class="subtitle">{pkg} v{ver}</div>
</div>
<div id="callsign" style="color:#9aa4b5; font-weight:600; display:none;">{callsign_opt}</div>
</div>
<div id="loading" style="text-align:center; padding:2rem 0;">
<div id="loading-title" style="margin-bottom:0.4rem; font-size:1.1rem; font-weight:600;">Initializing (rig)…</div>
<div id="loading-sub" style="color:#9aa4b5;"></div>
</div>
<div id="content" style="display:none;">
<div class="status">
<div>
<div class="label">Frequency<span class="band-tag" id="band-label">--</span></div>
<div class="inline">
<input class="status-input" id="freq" type="text" value="--" />
<button id="freq-apply" type="button">Set</button>
</div>
</div>
<div>
<div class="label">Mode</div>
<div class="inline">
<select class="status-input" id="mode">
<option value="">--</option>
</select>
<button id="mode-apply" type="button">Set</button>
</div>
</div>
<div>
<div class="label">Transmit / VFO / Power</div>
<div class="inline" style="gap: 0.6rem; flex-wrap: wrap;">
<button id="ptt-btn" type="button" style="flex: 1 1 30%;">Toggle PTT</button>
<button id="vfo-btn" type="button" style="flex: 1 1 30%;">VFO</button>
<button id="power-btn" type="button" style="flex: 1 1 30%;">Toggle Power</button>
<button id="lock-btn" type="button" style="flex: 1 1 30%;">Lock</button>
</div>
</div>
<div style="margin-bottom: 0.9rem;">
<div class="label">VFO</div>
<div class="vfo-box" id="vfo">--</div>
</div>
<div class="full-row">
<div class="label">Signal</div>
<div class="signal" style="gap: 1rem;">
<div class="signal-bar"><div class="signal-bar-fill" id="signal-bar"></div></div>
<div class="signal-value" id="signal-value">--</div>
</div>
</div>
<div class="full-row" id="tx-meters" style="display:none;">
<div class="label">TX Meters</div>
<div class="meter" style="gap: 1rem; margin-bottom: 0.4rem;">
<div class="meter-bar"><div class="meter-fill" id="pwr-bar"></div></div>
<div class="meter-value" id="pwr-value">PWR --</div>
</div>
<div class="meter" style="gap: 1rem;">
<div class="meter-bar"><div class="meter-fill" id="swr-bar"></div></div>
<div class="meter-value" id="swr-value">SWR --</div>
</div>
</div>
<div id="tx-limit-row" style="display:none;">
<div class="label">TX Limit</div>
<div class="inline">
<input class="status-input" id="tx-limit" type="number" min="0" max="255" step="1" value="" placeholder="--" />
<button id="tx-limit-btn" type="button">Set</button>
</div>
<small>Units depend on rig (percent/watts).</small>
</div>
</div>
<div class="footer">
<div class="hint" id="power-hint">Connecting…</div>
</div>
</div>
</div>
<script>
const freqEl = document.getElementById("freq");
const modeEl = document.getElementById("mode");
const bandLabel = document.getElementById("band-label");
const powerBtn = document.getElementById("power-btn");
const powerHint = document.getElementById("power-hint");
const vfoEl = document.getElementById("vfo");
const vfoBtn = document.getElementById("vfo-btn");
const signalBar = document.getElementById("signal-bar");
const signalValue = document.getElementById("signal-value");
const pttBtn = document.getElementById("ptt-btn");
const freqBtn = document.getElementById("freq-apply");
const modeBtn = document.getElementById("mode-apply");
const txLimitInput = document.getElementById("tx-limit");
const txLimitBtn = document.getElementById("tx-limit-btn");
const txLimitRow = document.getElementById("tx-limit-row");
const lockBtn = document.getElementById("lock-btn");
const txMeters = document.getElementById("tx-meters");
const pwrBar = document.getElementById("pwr-bar");
const pwrValue = document.getElementById("pwr-value");
const swrBar = document.getElementById("swr-bar");
const swrValue = document.getElementById("swr-value");
const loadingEl = document.getElementById("loading");
const contentEl = document.getElementById("content");
const callsignEl = document.getElementById("callsign");
const loadingTitle = document.getElementById("loading-title");
const loadingSub = document.getElementById("loading-sub");
let lastControl;
let lastTxEn = null;
let lastRendered = null;
let rigName = "Rig";
let supportedModes = [];
let supportedBands = [];
let freqDirty = false;
let modeDirty = false;
let initialized = false;
let lastEventAt = Date.now();
let es;
let esHeartbeat;
function formatFreq(hz) {
if (!Number.isFinite(hz)) return "--";
if (hz >= 1_000_000_000) {
return `${(hz / 1_000_000_000).toFixed(3)} GHz`;
}
if (hz >= 10_000_000) {
return `${(hz / 1_000_000).toFixed(3)} MHz`;
}
return `${(hz / 1_000).toFixed(1)} kHz`;
}
function parseFreqInput(val) {
if (!val) return null;
const trimmed = val.trim().toLowerCase();
const match = trimmed.match(/^([0-9]+(?:[.,][0-9]+)?)\s*([kmg]hz|[kmg]|hz)?$/);
if (!match) return null;
let num = parseFloat(match[1].replace(",", "."));
const unit = match[2] || "";
if (Number.isNaN(num)) return null;
if (unit.startsWith("gh") || unit === "g") {
num *= 1_000_000_000;
} else if (unit.startsWith("mh") || unit === "m") {
num *= 1_000_000;
} else if (unit.startsWith("kh") || unit === "k") {
num *= 1_000;
} else if (!unit) {
// Heuristic when no unit is provided: large numbers are kHz/Hz, small numbers are MHz.
if (num >= 1_000_000) {
// Assume already Hz.
} else if (num >= 1_000) {
num *= 1_000; // treat as kHz
} else {
num *= 1_000_000; // treat as MHz
}
}
return Math.round(num);
}
function normalizeMode(modeVal) {
if (typeof modeVal === "string") return modeVal;
if (modeVal && typeof modeVal === "object") {
const entries = Object.entries(modeVal);
if (entries.length > 0) {
const [variant, value] = entries[0];
if (variant === "Other" && typeof value === "string") return value;
return variant;
}
}
return "";
}
function updateSupportedBands(cap) {
if (cap && Array.isArray(cap.supported_bands)) {
supportedBands = cap.supported_bands
.filter((b) => typeof b.low_hz === "number" && typeof b.high_hz === "number" && b.tx_allowed === true)
.map((b) => ({ low: b.low_hz, high: b.high_hz }));
} else {
supportedBands = [];
}
}
function freqAllowed(hz) {
if (!Number.isFinite(hz)) return false;
if (supportedBands.length === 0) return true; // if unknown, don't block
return supportedBands.some((b) => hz >= b.low && hz <= b.high);
}
function setDisabled(disabled) {
[freqEl, modeEl, freqBtn, modeBtn, pttBtn, vfoBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
}
function render(update) {
if (!update) return;
if (update.info && update.info.model) {
rigName = update.info.model;
}
document.getElementById("rig-title").textContent = `${rigName} status`;
initialized = !!update.initialized;
if (!initialized) {
const manu = (update.info && update.info.manufacturer) || rigName || "Rig";
const model = (update.info && update.info.model) || rigName || "Rig";
const rev = (update.info && update.info.revision) || "";
const parts = [manu, model, rev].filter(Boolean).join(" ");
loadingTitle.textContent = `Initializing ${parts}…`;
loadingSub.textContent = "";
console.info("Rig initializing:", { manufacturer: manu, model, revision: rev });
loadingEl.style.display = "";
if (contentEl) contentEl.style.display = "none";
powerHint.textContent = "Initializing rig…";
setDisabled(true);
return;
} else {
loadingEl.style.display = "none";
if (contentEl) contentEl.style.display = "";
}
// Reveal callsign if provided and non-empty.
if (callsignEl && callsignEl.textContent.trim() !== "") {
callsignEl.style.display = "";
}
setDisabled(false);
if (update.info && update.info.capabilities && Array.isArray(update.info.capabilities.supported_modes)) {
const modes = update.info.capabilities.supported_modes.map(normalizeMode).filter(Boolean);
if (JSON.stringify(modes) !== JSON.stringify(supportedModes)) {
supportedModes = modes;
modeEl.innerHTML = "";
const empty = document.createElement("option");
empty.value = "";
empty.textContent = "--";
modeEl.appendChild(empty);
supportedModes.forEach((m) => {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m;
modeEl.appendChild(opt);
});
}
}
if (update.info && update.info.capabilities) {
updateSupportedBands(update.info.capabilities);
}
if (!freqDirty && update.status && update.status.freq && typeof update.status.freq.hz === "number") {
freqEl.value = formatFreq(update.status.freq.hz);
}
if (!modeDirty && update.status && update.status.mode) {
const mode = normalizeMode(update.status.mode);
modeEl.value = mode ? mode.toUpperCase() : "";
}
if (update.status && typeof update.status.tx_en === "boolean") {
lastTxEn = update.status.tx_en;
pttBtn.textContent = update.status.tx_en ? "PTT On" : "PTT Off";
pttBtn.style.background = update.status.tx_en ? "#ffefef" : "#f3f3f3";
pttBtn.style.borderColor = update.status.tx_en ? "#d22" : "#999";
pttBtn.style.color = update.status.tx_en ? "#a00" : "#222";
}
if (update.status && update.status.vfo && Array.isArray(update.status.vfo.entries)) {
const entries = update.status.vfo.entries;
const activeIdx = Number.isInteger(update.status.vfo.active) ? update.status.vfo.active : null;
const parts = entries.map((entry, idx) => {
const hz = entry && entry.freq && typeof entry.freq.hz === "number" ? entry.freq.hz : null;
if (hz === null) return null;
const mark = activeIdx === idx ? " *" : "";
const mode = entry.mode ? normalizeMode(entry.mode) : "";
const modeText = mode ? ` [${mode}]` : "";
return `${entry.name || `VFO ${idx + 1}`}: ${formatFreq(hz)}${modeText}${mark}`;
}).filter(Boolean);
vfoEl.textContent = parts.join("\n") || "--";
const activeLabel = activeIdx !== null
? `VFO ${activeIdx + 1}${entries[activeIdx] && entries[activeIdx].name ? ` (${entries[activeIdx].name})` : ""}`
: "VFO";
vfoBtn.textContent = activeLabel;
} else {
vfoEl.textContent = "--";
vfoBtn.textContent = "VFO";
}
if (update.status && update.status.rx && typeof update.status.rx.sig === "number") {
const raw = Math.max(0, update.status.rx.sig);
let pct;
let label;
if (raw <= 9) {
pct = Math.max(0, Math.min(100, (raw / 9) * 100));
label = `S${raw.toFixed(1)}`;
} else {
const overDb = (raw - 9) * 10;
pct = 100;
label = `S9 + ${overDb.toFixed(0)}dB`;
}
signalBar.style.width = `${pct}%`;
signalValue.textContent = label;
} else {
signalBar.style.width = "0%";
signalValue.textContent = "--";
}
bandLabel.textContent = typeof update.band === "string" ? update.band : "--";
if (typeof update.enabled === "boolean") {
powerBtn.disabled = false;
powerBtn.textContent = update.enabled ? "Power Off" : "Power On";
powerHint.textContent = "Ready";
} else {
powerBtn.disabled = true;
powerBtn.textContent = "Toggle Power";
powerHint.textContent = "State unknown";
}
lastControl = update.enabled;
if (update.status && update.status.tx && typeof update.status.tx.limit === "number") {
txLimitInput.value = update.status.tx.limit;
txLimitRow.style.display = "";
} else {
txLimitInput.value = "";
txLimitRow.style.display = "none";
}
powerHint.textContent = "Ready";
const locked = update.status && update.status.lock === true;
lockBtn.textContent = locked ? "Unlock" : "Lock";
const tx = update.status && update.status.tx ? update.status.tx : null;
txMeters.style.display = "";
if (tx && typeof tx.power === "number") {
const pct = Math.max(0, Math.min(100, tx.power));
pwrBar.style.width = `${pct}%`;
pwrValue.textContent = `PWR ${tx.power.toFixed(0)}%`;
} else {
pwrBar.style.width = "0%";
pwrValue.textContent = "PWR --";
}
if (tx && typeof tx.swr === "number") {
const swr = Math.max(1, tx.swr);
const pct = Math.max(0, Math.min(100, ((swr - 1) / 2) * 100));
swrBar.style.width = `${pct}%`;
swrValue.textContent = `SWR ${tx.swr.toFixed(2)}`;
} else {
swrBar.style.width = "0%";
swrValue.textContent = "SWR --";
}
}
function connect() {
if (es) {
es.close();
}
if (esHeartbeat) {
clearInterval(esHeartbeat);
}
es = new EventSource("/events");
lastEventAt = Date.now();
es.onmessage = (evt) => {
try {
if (evt.data === lastRendered) return;
const data = JSON.parse(evt.data);
lastRendered = evt.data;
render(data);
lastEventAt = Date.now();
if (data.initialized) {
powerHint.textContent = "Ready";
}
} catch (e) {
console.error("Bad event data", e);
}
};
es.onerror = () => {
powerHint.textContent = "Disconnected, retrying…";
es.close();
setTimeout(connect, 1000);
};
esHeartbeat = setInterval(() => {
const now = Date.now();
if (now - lastEventAt > 8000) {
es.close();
connect();
}
}, 4000);
}
async function postPath(path) {
const resp = await fetch(path, { method: "POST" });
if (!resp.ok) {
const text = await resp.text();
throw new Error(text || resp.statusText);
}
return resp;
}
powerBtn.addEventListener("click", async () => {
powerBtn.disabled = true;
powerHint.textContent = "Sending...";
try {
await postPath("/toggle_power");
powerHint.textContent = "Toggled, waiting for update…";
} catch (err) {
powerHint.textContent = "Toggle failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
powerBtn.disabled = false;
}
});
vfoBtn.addEventListener("click", async () => {
vfoBtn.disabled = true;
powerHint.textContent = "Toggling VFO…";
try {
await postPath("/toggle_vfo");
powerHint.textContent = "VFO toggled, waiting for update…";
setTimeout(() => {
if (powerHint.textContent.includes("VFO toggled")) {
powerHint.textContent = "Ready";
}
}, 1200);
} catch (err) {
powerHint.textContent = "VFO toggle failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
vfoBtn.disabled = false;
}
});
pttBtn.addEventListener("click", async () => {
pttBtn.disabled = true;
powerHint.textContent = "Toggling PTT…";
try {
const desired = lastTxEn ? "false" : "true";
await postPath(`/set_ptt?ptt=${desired}`);
powerHint.textContent = "PTT command sent";
} catch (err) {
powerHint.textContent = "PTT toggle failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
pttBtn.disabled = false;
}
});
freqBtn.addEventListener("click", async () => {
const parsed = parseFreqInput(freqEl.value);
if (parsed === null) {
powerHint.textContent = "Freq missing";
return;
}
if (!freqAllowed(parsed)) {
powerHint.textContent = "Out of supported bands";
setTimeout(() => powerHint.textContent = "Ready", 1500);
return;
}
freqDirty = false;
freqBtn.disabled = true;
powerHint.textContent = "Setting frequency…";
try {
await postPath(`/set_freq?hz=${parsed}`);
powerHint.textContent = "Freq set";
} catch (err) {
powerHint.textContent = "Set freq failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
freqBtn.disabled = false;
}
});
freqEl.addEventListener("keydown", (e) => {
freqDirty = true;
if (e.key === "Enter") {
e.preventDefault();
freqBtn.click();
}
});
modeBtn.addEventListener("click", async () => {
const mode = modeEl.value || "";
if (!mode) {
powerHint.textContent = "Mode missing";
return;
}
modeDirty = false;
modeBtn.disabled = true;
powerHint.textContent = "Setting mode…";
try {
await postPath(`/set_mode?mode=${encodeURIComponent(mode)}`);
powerHint.textContent = "Mode set";
} catch (err) {
powerHint.textContent = "Set mode failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
modeBtn.disabled = false;
}
});
modeEl.addEventListener("input", () => {
modeDirty = true;
});
txLimitBtn.addEventListener("click", async () => {
const limit = txLimitInput.value;
if (limit === "" || limit === "--") {
powerHint.textContent = "Limit missing";
return;
}
txLimitBtn.disabled = true;
powerHint.textContent = "Setting TX limit…";
try {
await postPath(`/set_tx_limit?limit=${encodeURIComponent(limit)}`);
powerHint.textContent = "TX limit set";
} catch (err) {
powerHint.textContent = "TX limit failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
txLimitBtn.disabled = false;
}
});
lockBtn.addEventListener("click", async () => {
lockBtn.disabled = true;
powerHint.textContent = "Toggling lock…";
try {
const nextLock = lockBtn.textContent === "Lock";
await postPath(nextLock ? "/lock" : "/unlock");
powerHint.textContent = "Lock toggled";
} catch (err) {
powerHint.textContent = "Lock toggle failed";
console.error(err);
setTimeout(() => powerHint.textContent = "Ready", 2000);
} finally {
lockBtn.disabled = false;
}
});
connect();
</script>
</body>
</html>
"##;