diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index fb79408..798856d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -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 = "";
}
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 === "--") {
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 56f8959..e0d6db1 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -22,34 +22,47 @@
-
+
-
-
Mode
-
-
-
+
+
+
+
+
+
+
+
+
-
-
Transmit / VFO / Power
-
-
-
-
-
+
+
+
Mode
+
+
+
+
+
+
+
Transmit / Power
+
+
+
+
+
-
+
Signal
@@ -57,6 +70,7 @@
--
+
TX Meters
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 565f4d2..808af61 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -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; }
}
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
index 3bd74c9..e55da40 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs
@@ -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
>) -> Result {
+pub async fn events(
+ state: web::Data>,
+ clients: web::Data>,
+) -> Result {
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::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::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::from(format!("data: {json}\n\n")))
+ })
+ })
+ }
});
let pings = IntervalStream::new(time::interval(Duration::from_secs(5)))
.map(|_| Ok::(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>) -> Result {
+ inner: std::pin::Pin + 'static>>,
+ on_drop: Option>,
+}
+
+impl DropStream {
+ fn new(inner: std::pin::Pin>, on_drop: F) -> Self
+ where
+ S: futures_util::Stream- + 'static,
+ F: FnOnce() + Send + 'static,
+ {
+ Self {
+ inner,
+ on_drop: Some(Box::new(on_drop)),
+ }
+ }
+}
+
+impl Drop for DropStream {
+ fn drop(&mut self) {
+ if let Some(f) = self.on_drop.take() {
+ f();
+ }
+ }
+}
+
+impl futures_util::Stream for DropStream {
+ type Item = I;
+ fn poll_next(
+ mut self: std::pin::Pin<&mut Self>,
+ cx: &mut std::task::Context<'_>,
+ ) -> std::task::Poll