[feat](trx-rds,trx-frontend-http): add af tuning and dynamic page title

Co-authored-by: Codex <codex@openai.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-02-28 16:41:25 +01:00
parent a18ef33ee2
commit ce25751c5d
5 changed files with 132 additions and 2 deletions
+45
View File
@@ -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<u32> {
if (1..=204).contains(&code) {
Some(87_500_000 + u32::from(code) * 100_000)
} else {
None
}
}
#[derive(Debug, Clone)]
@@ -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,9 +1151,11 @@ function updateFooterBuildInfo() {
function updateTitle() {
const titleEl = document.getElementById("rig-title");
if (!titleEl) return;
if (titleEl) {
titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs";
}
updateDocumentTitle(lastSpectrumData?.rds ?? null);
}
function render(update) {
if (!update) return;
@@ -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);
}
@@ -287,6 +287,7 @@
<div class="rds-field"><span class="rds-label">Compressed</span><span id="rds-compressed" class="rds-value">--</span></div>
<div class="rds-field"><span class="rds-label">Artificial Head</span><span id="rds-artificial-head" class="rds-value">--</span></div>
<div class="rds-field"><span class="rds-label">Dynamic PTY</span><span id="rds-dynamic-pty" class="rds-value">--</span></div>
<div class="rds-field"><span class="rds-label">AF</span><span id="rds-af-list" class="rds-value rds-af-list">--</span></div>
<div class="rds-field"><span class="rds-label">RadioText</span><span id="rds-radio-text" class="rds-value rds-text">--</span></div>
</div>
<div class="rds-raw-header">
@@ -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; }
+2
View File
@@ -320,6 +320,8 @@ pub struct RdsData {
pub compressed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dynamic_pty: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alternative_frequencies_hz: Option<Vec<u32>>,
}
/// Read-only projection of state shared with clients.