[fix](trx-frontend-http): uniform buttons, jog wheel, VFO picker, signal graph, and client count

Reorganize layout: frequency row with jog wheel on top, mode and
transmit/power side by side below. Replace VFO text box with segmented
picker. Add rolling signal history canvas. Track connected SSE clients
and display count in status hint. Unify button heights and add Enter
key support for TX limit input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Stanislaw Grams <stanislawgrams@gmail.com>
This commit is contained in:
2026-02-08 09:53:33 +01:00
parent b142d68ca2
commit 37c36d196e
5 changed files with 412 additions and 61 deletions
@@ -3,8 +3,8 @@ 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 vfoPicker = document.getElementById("vfo-picker");
const signalGraph = document.getElementById("signal-graph");
const signalBar = document.getElementById("signal-bar");
const signalValue = document.getElementById("signal-value");
const pttBtn = document.getElementById("ptt-btn");
@@ -30,11 +30,21 @@ let lastTxEn = null;
let lastRendered = null;
let rigName = "Rig";
let hintTimer = null;
const signalHistory = [];
const SIGNAL_HISTORY_MAX = 120;
let lastFreqHz = null;
let jogStep = 1000; // default 1 kHz
let jogAngle = 0;
let lastClientCount = null;
function readyText() {
return lastClientCount !== null ? `Ready \u00b7 ${lastClientCount} user${lastClientCount !== 1 ? "s" : ""}` : "Ready";
}
function showHint(msg, duration) {
powerHint.textContent = msg;
if (hintTimer) clearTimeout(hintTimer);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = "Ready"; }, duration);
if (duration) hintTimer = setTimeout(() => { powerHint.textContent = readyText(); }, duration);
}
let supportedModes = [];
let supportedBands = [];
@@ -113,7 +123,7 @@ function freqAllowed(hz) {
}
function setDisabled(disabled) {
[freqEl, modeEl, freqBtn, modeBtn, pttBtn, vfoBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
[freqEl, modeEl, freqBtn, modeBtn, pttBtn, powerBtn, txLimitInput, txLimitBtn, lockBtn].forEach((el) => {
if (el) el.disabled = disabled;
});
}
@@ -168,8 +178,11 @@ function render(update) {
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 (update.status && update.status.freq && typeof update.status.freq.hz === "number") {
lastFreqHz = update.status.freq.hz;
if (!freqDirty) {
freqEl.value = formatFreq(update.status.freq.hz);
}
}
if (!modeDirty && update.status && update.status.mode) {
const mode = normalizeMode(update.status.mode);
@@ -191,22 +204,34 @@ function render(update) {
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) => {
vfoPicker.innerHTML = "";
entries.forEach((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 ? " *" : "";
if (hz === null) return;
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;
const label = `${entry.name || String.fromCharCode(65 + idx)}: ${formatFreq(hz)}${modeText}`;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = label;
if (activeIdx === idx) btn.classList.add("active");
else btn.addEventListener("click", async () => {
btn.disabled = true;
showHint("Toggling VFO…");
try {
await postPath("/toggle_vfo");
showHint("VFO toggled", 1200);
} catch (err) {
showHint("VFO toggle failed", 2000);
console.error(err);
} finally {
btn.disabled = false;
}
});
vfoPicker.appendChild(btn);
});
} else {
vfoEl.textContent = "--";
vfoBtn.textContent = "VFO";
vfoPicker.innerHTML = "<button type=\"button\" class=\"active\">--</button>";
}
if (update.status && update.status.rx && typeof update.status.rx.sig === "number") {
const raw = Math.max(0, update.status.rx.sig);
@@ -222,15 +247,19 @@ function render(update) {
}
signalBar.style.width = `${pct}%`;
signalValue.textContent = label;
signalHistory.push(raw);
if (signalHistory.length > SIGNAL_HISTORY_MAX) signalHistory.shift();
} else {
signalBar.style.width = "0%";
signalValue.textContent = "--";
signalHistory.push(0);
if (signalHistory.length > SIGNAL_HISTORY_MAX) signalHistory.shift();
}
drawSignalGraph();
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";
@@ -246,7 +275,8 @@ function render(update) {
txLimitRow.style.display = "none";
}
powerHint.textContent = "Ready";
if (typeof update.clients === "number") lastClientCount = update.clients;
powerHint.textContent = readyText();
const locked = update.status && update.status.lock === true;
lockBtn.textContent = locked ? "Unlock" : "Lock";
@@ -271,6 +301,49 @@ function render(update) {
}
}
function drawSignalGraph() {
if (!signalGraph) return;
const ctx = signalGraph.getContext("2d");
const w = signalGraph.width;
const h = signalGraph.height;
ctx.clearRect(0, 0, w, h);
if (signalHistory.length < 2) return;
const maxVal = 12; // S9+30dB in S-units
const len = signalHistory.length;
const step = w / (SIGNAL_HISTORY_MAX - 1);
const offsetX = (SIGNAL_HISTORY_MAX - len) * step;
ctx.beginPath();
ctx.moveTo(offsetX, h);
for (let i = 0; i < len; i++) {
const val = Math.min(signalHistory[i], maxVal);
const x = offsetX + i * step;
const y = h - (val / maxVal) * h;
ctx.lineTo(x, y);
}
ctx.lineTo(offsetX + (len - 1) * step, h);
ctx.closePath();
const grad = ctx.createLinearGradient(0, h, 0, 0);
grad.addColorStop(0, "rgba(0,209,127,0.25)");
grad.addColorStop(0.6, "rgba(240,173,78,0.35)");
grad.addColorStop(1, "rgba(229,83,83,0.45)");
ctx.fillStyle = grad;
ctx.fill();
ctx.beginPath();
for (let i = 0; i < len; i++) {
const val = Math.min(signalHistory[i], maxVal);
const x = offsetX + i * step;
const y = h - (val / maxVal) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.strokeStyle = "rgba(0,209,127,0.8)";
ctx.lineWidth = 1.5;
ctx.stroke();
}
function connect() {
if (es) {
es.close();
@@ -288,7 +361,7 @@ es.onmessage = (evt) => {
render(data);
lastEventAt = Date.now();
if (data.initialized) {
powerHint.textContent = "Ready";
powerHint.textContent = readyText();
}
} catch (e) {
console.error("Bad event data", e);
@@ -332,20 +405,6 @@ powerBtn.addEventListener("click", async () => {
}
});
vfoBtn.addEventListener("click", async () => {
vfoBtn.disabled = true;
showHint("Toggling VFO…");
try {
await postPath("/toggle_vfo");
showHint("VFO toggled", 1200);
} catch (err) {
showHint("VFO toggle failed", 2000);
console.error(err);
} finally {
vfoBtn.disabled = false;
}
});
pttBtn.addEventListener("click", async () => {
pttBtn.disabled = true;
showHint("Toggling PTT…");
@@ -392,6 +451,87 @@ freqEl.addEventListener("keydown", (e) => {
}
});
// --- Jog wheel ---
const jogWheel = document.getElementById("jog-wheel");
const jogIndicator = document.getElementById("jog-indicator");
const jogDownBtn = document.getElementById("jog-down");
const jogUpBtn = document.getElementById("jog-up");
const jogStepEl = document.getElementById("jog-step");
async function jogFreq(direction) {
if (lastFreqHz === null) return;
const newHz = lastFreqHz + direction * jogStep;
if (!freqAllowed(newHz)) {
showHint("Out of supported bands", 1500);
return;
}
jogAngle = (jogAngle + direction * 15) % 360;
jogIndicator.style.transform = `translateX(-50%) rotate(${jogAngle}deg)`;
showHint("Setting frequency…");
try {
await postPath(`/set_freq?hz=${newHz}`);
showHint("Freq set", 1000);
} catch (err) {
showHint("Set freq failed", 2000);
console.error(err);
}
}
jogDownBtn.addEventListener("click", () => jogFreq(-1));
jogUpBtn.addEventListener("click", () => jogFreq(1));
jogWheel.addEventListener("wheel", (e) => {
e.preventDefault();
const direction = e.deltaY < 0 ? 1 : -1;
jogFreq(direction);
}, { passive: false });
// Touch drag on jog wheel
let jogTouchY = null;
jogWheel.addEventListener("touchstart", (e) => {
e.preventDefault();
jogTouchY = e.touches[0].clientY;
}, { passive: false });
jogWheel.addEventListener("touchmove", (e) => {
e.preventDefault();
if (jogTouchY === null) return;
const dy = jogTouchY - e.touches[0].clientY;
if (Math.abs(dy) > 12) {
jogFreq(dy > 0 ? 1 : -1);
jogTouchY = e.touches[0].clientY;
}
}, { passive: false });
jogWheel.addEventListener("touchend", () => { jogTouchY = null; });
// Mouse drag on jog wheel
let jogMouseY = null;
jogWheel.addEventListener("mousedown", (e) => {
e.preventDefault();
jogMouseY = e.clientY;
jogWheel.style.cursor = "grabbing";
});
window.addEventListener("mousemove", (e) => {
if (jogMouseY === null) return;
const dy = jogMouseY - e.clientY;
if (Math.abs(dy) > 10) {
jogFreq(dy > 0 ? 1 : -1);
jogMouseY = e.clientY;
}
});
window.addEventListener("mouseup", () => {
jogMouseY = null;
if (jogWheel) jogWheel.style.cursor = "grab";
});
// Step selector
jogStepEl.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-step]");
if (!btn) return;
jogStep = parseInt(btn.dataset.step, 10);
jogStepEl.querySelectorAll("button").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
});
modeBtn.addEventListener("click", async () => {
const mode = modeEl.value || "";
if (!mode) {
@@ -416,6 +556,13 @@ modeEl.addEventListener("input", () => {
modeDirty = true;
});
txLimitInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
txLimitBtn.click();
}
});
txLimitBtn.addEventListener("click", async () => {
const limit = txLimitInput.value;
if (limit === "" || limit === "--") {
@@ -22,34 +22,47 @@
</div>
<div id="content" style="display:none;">
<div class="status">
<div>
<div class="full-row">
<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 class="jog-container">
<button id="jog-down" type="button" class="jog-btn">&minus;</button>
<div class="jog-wheel" id="jog-wheel">
<div class="jog-indicator" id="jog-indicator"></div>
</div>
<button id="jog-up" type="button" class="jog-btn">+</button>
<div class="jog-step" id="jog-step">
<button type="button" data-step="1000000">MHz</button>
<button type="button" data-step="1000" class="active">kHz</button>
<button type="button" data-step="1">Hz</button>
</div>
</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 class="controls-row full-row">
<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 / Power</div>
<div class="btn-grid">
<button id="ptt-btn" type="button">Toggle PTT</button>
<button id="power-btn" type="button">Toggle Power</button>
<button id="lock-btn" type="button">Lock</button>
</div>
</div>
</div>
<div style="margin-bottom: 0.9rem;">
<div class="full-row" id="vfo-row">
<div class="label">VFO</div>
<div class="vfo-box" id="vfo">--</div>
<div class="vfo-picker" id="vfo-picker"></div>
</div>
<div class="full-row">
<div class="label">Signal</div>
@@ -57,6 +70,7 @@
<div class="signal-bar"><div class="signal-bar-fill" id="signal-bar"></div></div>
<div class="signal-value" id="signal-value">--</div>
</div>
<canvas id="signal-graph" width="600" height="60"></canvas>
</div>
<div class="full-row" id="tx-meters" style="display:none;">
<div class="label">TX Meters</div>
@@ -19,8 +19,117 @@ body { font-family: sans-serif; margin: 0; min-height: 100vh; display: flex; ali
.label { color: var(--text-muted); font-size: 0.9rem; margin-bottom: 6px; display: block; }
.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 var(--border-light); border-radius: 6px; background: var(--input-bg); color: var(--text); }
.vfo-box { width: 100%; min-height: 2.6rem; padding: 0.45rem 0.5rem; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); color: var(--text); white-space: pre-line; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
button { padding: 0.5rem 0.9rem; border-radius: 6px; border: 1px solid var(--btn-border); background: var(--btn-bg); color: var(--text); cursor: pointer; height: 2.4rem; }
.controls-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
align-items: start;
}
.btn-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.btn-grid button { width: 100%; height: 2.6rem; }
.jog-container {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.6rem;
}
.jog-wheel {
width: 52px;
height: 52px;
border-radius: 50%;
background: radial-gradient(circle at 40% 35%, #2a3444, #1a2230);
border: 2px solid var(--border-light);
position: relative;
cursor: grab;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.05);
user-select: none;
-webkit-user-select: none;
touch-action: none;
}
.jog-indicator {
position: absolute;
width: 4px;
height: 10px;
background: var(--accent-green);
border-radius: 2px;
top: 4px;
left: 50%;
transform-origin: 50% 22px;
transform: translateX(-50%);
pointer-events: none;
}
.jog-btn {
width: 2.2rem;
height: 2.2rem;
font-size: 1.2rem;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
}
.jog-step {
display: flex;
border: 1px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
margin-left: 0.3rem;
}
.jog-step button {
border: none;
border-right: 1px solid var(--border-light);
border-radius: 0;
height: 2rem;
padding: 0 0.55rem;
font-size: 0.78rem;
background: var(--input-bg);
color: var(--text-muted);
cursor: pointer;
}
.jog-step button:last-child { border-right: none; }
.jog-step button.active {
background: var(--btn-bg);
color: var(--accent-green);
font-weight: 600;
}
.vfo-picker {
display: flex;
border: 1px solid var(--border-light);
border-radius: 6px;
overflow: hidden;
}
.vfo-picker button {
flex: 1;
border: none;
border-right: 1px solid var(--border-light);
border-radius: 0;
height: 2.6rem;
background: var(--input-bg);
color: var(--text-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 0.85rem;
}
.vfo-picker button:last-child { border-right: none; }
.vfo-picker button.active {
background: var(--btn-bg);
color: var(--accent-green);
font-weight: 600;
}
#signal-graph {
width: 100%;
height: 60px;
margin-top: 0.4rem;
border-radius: 6px;
background: var(--input-bg);
border: 1px solid var(--border-light);
}
button { padding: 0.5rem 0.9rem; border-radius: 6px; border: 1px solid var(--btn-border); background: var(--btn-bg); color: var(--text); cursor: pointer; height: 2.6rem; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.hint { color: var(--text-muted); font-size: 0.85rem; }
.inline { display: flex; gap: 0.5rem; align-items: center; }
@@ -51,4 +160,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.card { padding: 1rem; }
button { min-height: 2.8rem; font-size: 0.95rem; }
input.status-input, select.status-input { font-size: 1.1rem; }
.controls-row { grid-template-columns: 1fr; }
.jog-container { flex-wrap: wrap; }
.vfo-picker { flex-direction: column; }
.vfo-picker button { border-right: none; border-bottom: 1px solid var(--border-light); }
.vfo-picker button:last-child { border-bottom: none; }
}
@@ -2,6 +2,9 @@
//
// SPDX-License-Identifier: BSD-2-Clause
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use actix_web::{get, post, web, HttpResponse, Responder};
use actix_web::{http::header, Error};
use bytes::Bytes;
@@ -31,27 +34,59 @@ pub async fn status_api(
Ok(HttpResponse::Ok().json(state))
}
/// Inject `"clients": N` into a JSON object string.
fn inject_clients(json: &str, count: usize) -> String {
// Fast path: insert after the opening '{'.
if let Some(pos) = json.find('{') {
let mut out = String::with_capacity(json.len() + 20);
out.push_str(&json[..=pos]);
out.push_str(&format!("\"clients\":{count},"));
out.push_str(&json[pos + 1..]);
out
} else {
json.to_string()
}
}
#[get("/events")]
pub async fn events(state: web::Data<watch::Receiver<RigState>>) -> Result<HttpResponse, Error> {
pub async fn events(
state: web::Data<watch::Receiver<RigState>>,
clients: web::Data<Arc<AtomicUsize>>,
) -> Result<HttpResponse, Error> {
let rx = state.get_ref().clone();
let initial = wait_for_view(rx.clone()).await?;
let counter = clients.get_ref().clone();
let count = counter.fetch_add(1, Ordering::Relaxed) + 1;
let initial_json =
serde_json::to_string(&initial).map_err(actix_web::error::ErrorInternalServerError)?;
let initial_json = inject_clients(&initial_json, count);
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 counter_updates = counter.clone();
let updates = WatchStream::new(rx).filter_map(move |state| {
let counter = counter_updates.clone();
async move {
state.snapshot().and_then(|v| {
serde_json::to_string(&v).ok().map(|json| {
let json = inject_clients(&json, counter.load(Ordering::Relaxed));
Ok::<Bytes, Error>(Bytes::from(format!("data: {json}\n\n")))
})
})
}
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(5)))
.map(|_| Ok::<Bytes, Error>(Bytes::from(": ping\n\n")));
// Wrap stream to decrement counter on drop.
let counter_drop = counter.clone();
let stream = initial_stream.chain(select(pings, updates));
let stream = DropStream::new(Box::pin(stream), move || {
counter_drop.fetch_sub(1, Ordering::Relaxed);
});
Ok(HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "text/event-stream"))
@@ -60,6 +95,43 @@ pub async fn events(state: web::Data<watch::Receiver<RigState>>) -> Result<HttpR
.streaming(stream))
}
/// A stream wrapper that calls a callback when dropped.
struct DropStream<I> {
inner: std::pin::Pin<Box<dyn futures_util::Stream<Item = I> + 'static>>,
on_drop: Option<Box<dyn FnOnce() + Send>>,
}
impl<I> DropStream<I> {
fn new<S, F>(inner: std::pin::Pin<Box<S>>, on_drop: F) -> Self
where
S: futures_util::Stream<Item = I> + 'static,
F: FnOnce() + Send + 'static,
{
Self {
inner,
on_drop: Some(Box::new(on_drop)),
}
}
}
impl<I> Drop for DropStream<I> {
fn drop(&mut self) {
if let Some(f) = self.on_drop.take() {
f();
}
}
}
impl<I> futures_util::Stream for DropStream<I> {
type Item = I;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.inner.as_mut().poll_next(cx)
}
}
#[post("/toggle_power")]
pub async fn toggle_power(
state: web::Data<watch::Receiver<RigState>>,
@@ -10,6 +10,8 @@ pub mod audio;
pub mod status;
use std::net::SocketAddr;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use actix_web::dev::Server;
use actix_web::{web, App, HttpServer};
@@ -67,12 +69,14 @@ fn build_server(
let state_data = web::Data::new(state_rx);
let rig_tx = web::Data::new(rig_tx);
let callsign = web::Data::new(callsign);
let clients = web::Data::new(Arc::new(AtomicUsize::new(0)));
let server = HttpServer::new(move || {
App::new()
.app_data(state_data.clone())
.app_data(rig_tx.clone())
.app_data(callsign.clone())
.app_data(clients.clone())
.configure(api::configure)
})
.shutdown_timeout(1)