From ce25751c5d3d5204443995186e4ff40efab3c06d Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 16:41:25 +0100 Subject: [PATCH] [feat](trx-rds,trx-frontend-http): add af tuning and dynamic page title Co-authored-by: Codex Signed-off-by: Stan Grams --- src/decoders/trx-rds/src/lib.rs | 45 ++++++++++++ .../trx-frontend-http/assets/web/app.js | 71 ++++++++++++++++++- .../trx-frontend-http/assets/web/index.html | 1 + .../trx-frontend-http/assets/web/style.css | 15 ++++ src/trx-core/src/rig/state.rs | 2 + 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/src/decoders/trx-rds/src/lib.rs b/src/decoders/trx-rds/src/lib.rs index 3bc58a6..4830069 100644 --- a/src/decoders/trx-rds/src/lib.rs +++ b/src/decoders/trx-rds/src/lib.rs @@ -305,6 +305,12 @@ impl Candidate { let group_type = ((block_b >> 12) & 0x0f) as u8; let version_b = ((block_b >> 11) & 0x1) != 0; if group_type == 0 { + if !version_b && block_c_kind == BlockKind::C { + let [af0, af1] = block_c.to_be_bytes(); + if self.process_af_pair(af0, af1) { + changed = true; + } + } let ta = ((block_b >> 4) & 0x1) != 0; if self.state.traffic_announcement != Some(ta) { self.state.traffic_announcement = Some(ta); @@ -421,6 +427,45 @@ impl Candidate { self.score = self.score.saturating_add(1); changed.then(|| self.state.clone()) } + + fn process_af_pair(&mut self, af0: u8, af1: u8) -> bool { + let mut changed = false; + if !is_af_count_code(af0) { + changed |= self.record_af_code(af0); + } + if !is_af_count_code(af1) { + changed |= self.record_af_code(af1); + } + changed + } + + fn record_af_code(&mut self, code: u8) -> bool { + let Some(hz) = af_code_to_hz(code) else { + return false; + }; + let afs = self + .state + .alternative_frequencies_hz + .get_or_insert_with(Vec::new); + if afs.contains(&hz) { + return false; + } + afs.push(hz); + afs.sort_unstable(); + true + } +} + +fn is_af_count_code(code: u8) -> bool { + (224..=249).contains(&code) +} + +fn af_code_to_hz(code: u8) -> Option { + if (1..=204).contains(&code) { + Some(87_500_000 + u32::from(code) * 100_000) + } else { + None + } } #[derive(Debug, Clone)] 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 73cd950..54fc1f6 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 @@ -390,6 +390,20 @@ function currentTheme() { return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark"; } +function updateDocumentTitle(rds = null) { + if (!Number.isFinite(lastFreqHz)) { + document.title = originalTitle; + return; + } + const parts = [formatFreq(lastFreqHz)]; + const ps = rds?.program_service; + if (ps && ps.length > 0) { + parts.push(ps); + } + parts.push(originalTitle); + document.title = parts.join(" - "); +} + function setTheme(theme) { const next = theme === "light" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", next); @@ -957,6 +971,7 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) { resetRdsDisplay(); } lastFreqHz = hz; + updateDocumentTitle(lastSpectrumData?.rds ?? null); refreshWavelengthDisplay(lastFreqHz); if (forceDisplay) { freqDirty = false; @@ -1136,8 +1151,10 @@ function updateFooterBuildInfo() { function updateTitle() { const titleEl = document.getElementById("rig-title"); - if (!titleEl) return; - titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; + if (titleEl) { + titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; + } + updateDocumentTitle(lastSpectrumData?.rds ?? null); } function render(update) { @@ -3281,6 +3298,42 @@ function buildRdsRawPayload(rds) { }; } +function formatRdsAfMHz(hz) { + return `${(hz / 1_000_000).toFixed(1)} MHz`; +} + +async function tuneRdsAlternativeFrequency(hz) { + if (!Number.isFinite(hz) || hz <= 0) return; + const targetHz = Math.round(hz); + try { + await postPath(`/set_freq?hz=${targetHz}`); + applyLocalTunedFrequency(targetHz); + showHint(`Tuned ${formatRdsAfMHz(targetHz)}`, 1200); + } catch (_) { + showHint("Set freq failed", 1500); + } +} + +function renderRdsAlternativeFrequencies(list) { + const afEl = document.getElementById("rds-af-list"); + if (!afEl) return; + if (!Array.isArray(list) || list.length === 0) { + afEl.textContent = "--"; + return; + } + afEl.innerHTML = ""; + for (const hz of list) { + if (!Number.isFinite(hz) || hz <= 0) continue; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "rds-af-btn"; + btn.dataset.hz = String(Math.round(hz)); + btn.textContent = formatRdsAfMHz(hz); + afEl.appendChild(btn); + } + if (!afEl.childElementCount) afEl.textContent = "--"; +} + async function copyRdsPsToClipboard() { const rds = lastSpectrumData?.rds; const ps = rds?.program_service; @@ -3328,8 +3381,19 @@ const rdsRawCopyBtn = document.getElementById("rds-raw-copy-btn"); if (rdsRawCopyBtn) { rdsRawCopyBtn.addEventListener("click", () => { copyRdsRawToClipboard(); }); } +const rdsAfListEl = document.getElementById("rds-af-list"); +if (rdsAfListEl) { + rdsAfListEl.addEventListener("click", (event) => { + const btn = event.target instanceof HTMLElement ? event.target.closest(".rds-af-btn") : null; + const hz = Number(btn?.dataset?.hz); + if (btn && Number.isFinite(hz)) { + tuneRdsAlternativeFrequency(hz); + } + }); +} function updateRdsPsOverlay(rds) { + updateDocumentTitle(rds); // Overview strip overlay if (rdsPsOverlay) { const ps = rds?.program_service; @@ -3377,6 +3441,7 @@ function updateRdsPsOverlay(rds) { const compEl = document.getElementById("rds-compressed"); const headEl = document.getElementById("rds-artificial-head"); const dynPtyEl = document.getElementById("rds-dynamic-pty"); + const afEl = document.getElementById("rds-af-list"); const rtEl = document.getElementById("rds-radio-text"); const rawEl = document.getElementById("rds-raw"); if (!statusEl) return; @@ -3401,6 +3466,7 @@ function updateRdsPsOverlay(rds) { if (compEl) compEl.textContent = "--"; if (headEl) headEl.textContent = "--"; if (dynPtyEl) dynPtyEl.textContent = "--"; + if (afEl) afEl.textContent = "--"; if (rtEl) rtEl.textContent = "--"; if (rawEl && lastSpectrumData) { const { bins: _b, ...rest } = lastSpectrumData; @@ -3427,6 +3493,7 @@ function updateRdsPsOverlay(rds) { if (compEl) compEl.textContent = formatRdsFlag(rds.compressed); if (headEl) headEl.textContent = formatRdsFlag(rds.artificial_head); if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(rds.dynamic_pty); + renderRdsAlternativeFrequencies(rds.alternative_frequencies_hz); if (rtEl) rtEl.textContent = rds.radio_text ?? "--"; rawEl.textContent = JSON.stringify(buildRdsRawPayload(rds), null, 2); } 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 8d3aef9..020a3a5 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 @@ -287,6 +287,7 @@
Compressed--
Artificial Head--
Dynamic PTY--
+
AF--
RadioText--
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 7fe7200..a18710f 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 @@ -812,6 +812,21 @@ small { color: var(--text-muted); } .rds-value { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.95rem; color: var(--text); } .rds-ps { font-size: 1.1rem; font-weight: 600; letter-spacing: 0.08em; color: var(--accent-green); } .rds-text { white-space: normal; overflow-wrap: anywhere; line-height: 1.35; } +.rds-af-list { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; } +.rds-af-btn { + height: 1.7rem; + padding: 0 0.55rem; + border: 1px solid var(--border-light); + border-radius: 999px; + background: var(--input-bg); + color: var(--text); + font: inherit; + cursor: pointer; +} +.rds-af-btn:hover { + border-color: var(--accent); + color: var(--accent); +} .rds-no-signal { color: var(--text-muted); } .rds-decoding { color: var(--accent-green); } .rds-raw-header { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.3rem; } diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index 8f92404..1082011 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -320,6 +320,8 @@ pub struct RdsData { pub compressed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dynamic_pty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alternative_frequencies_hz: Option>, } /// Read-only projection of state shared with clients.