From 93ff35a824ce3787b340088de0b3e6ac3d32c3dc Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Wed, 11 Mar 2026 22:39:02 +0100 Subject: [PATCH] Add per-channel RDS overlays for WFM vchans --- src/trx-client/src/remote_client.rs | 11 +- src/trx-client/trx-frontend/src/lib.rs | 7 +- .../trx-frontend-http-json/src/server.rs | 1 + .../trx-frontend-http/assets/web/app.js | 304 ++++++++++++------ .../assets/web/plugins/vchan.js | 20 ++ .../trx-frontend-http/assets/web/style.css | 9 +- .../trx-frontend/trx-frontend-http/src/api.rs | 7 + .../trx-frontend-rigctl/src/server.rs | 1 + src/trx-core/src/rig/mod.rs | 5 + src/trx-core/src/rig/state.rs | 21 ++ src/trx-protocol/src/codec.rs | 1 + src/trx-server/src/rig_task.rs | 2 + .../trx-backend-soapysdr/src/lib.rs | 6 +- .../trx-backend-soapysdr/src/vchan_impl.rs | 18 +- 14 files changed, 313 insertions(+), 100 deletions(-) diff --git a/src/trx-client/src/remote_client.rs b/src/trx-client/src/remote_client.rs index 202c64c..82e0ed8 100644 --- a/src/trx-client/src/remote_client.rs +++ b/src/trx-client/src/remote_client.rs @@ -146,7 +146,7 @@ async fn run_spectrum_connection( warn!("Spectrum connection dropped: {}", e); } // Mark spectrum unavailable while reconnecting. - config.spectrum.send_modify(|s| s.set(None)); + config.spectrum.send_modify(|s| s.set(None, None)); } Ok(Err(e)) => warn!("Spectrum connect failed: {}", e), Err(_) => warn!("Spectrum connect timed out"), @@ -183,18 +183,20 @@ async fn handle_spectrum_connection( } _ = interval.tick() => { if !should_poll_spectrum(config) { - config.spectrum.send_modify(|s| s.set(None)); + config.spectrum.send_modify(|s| s.set(None, None)); continue; } match send_command_no_state_update( config, &mut writer, &mut reader, ClientCommand::GetSpectrum, ).await { - Ok(snapshot) => config.spectrum.send_modify(|s| s.set(snapshot.spectrum)), + Ok(snapshot) => config + .spectrum + .send_modify(|s| s.set(snapshot.spectrum, snapshot.vchan_rds)), Err(e) => { // A spectrum timeout desynchronises the TCP framing; // return so the caller reconnects and restores sync. - config.spectrum.send_modify(|s| s.set(None)); + config.spectrum.send_modify(|s| s.set(None, None)); return Err(e); } } @@ -775,6 +777,7 @@ mod tests { cw_tone_hz: 700, filter: None, spectrum: None, + vchan_rds: None, } } diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index 295f5c5..d3ca304 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -73,15 +73,20 @@ pub struct SharedSpectrum { /// RDS JSON pre-serialised at ingestion so SSE clients don't repeat the /// work on every tick. pub rds_json: Option, + /// Virtual-channel RDS JSON pre-serialised at ingestion. + pub vchan_rds_json: Option, } impl SharedSpectrum { /// Replace the stored frame, pre-serialising RDS in one pass. - pub fn set(&mut self, frame: Option) { + pub fn set(&mut self, frame: Option, vchan_rds: Option>) { self.rds_json = frame .as_ref() .and_then(|f| f.rds.as_ref()) .and_then(|r| serde_json::to_string(r).ok()); + self.vchan_rds_json = vchan_rds + .as_ref() + .and_then(|list| serde_json::to_string(list).ok()); self.frame = frame.map(Arc::new); } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs b/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs index 80f8b06..0c4ab8b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http-json/src/server.rs @@ -413,6 +413,7 @@ mod tests { cw_tone_hz: 700, filter: None, spectrum: None, + vchan_rds: None, } } 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 aae35a6..4a6c391 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 @@ -351,6 +351,9 @@ const headerRigSwitchSelect = document.getElementById("header-rig-switch-select" const headerStylePickSelect = document.getElementById("header-style-pick-select"); const rdsPsOverlay = document.getElementById("rds-ps-overlay"); let overviewPeakHoldMs = Number(loadSetting("overviewPeakHoldMs", 2000)); +let primaryRds = null; +let vchanRdsById = new Map(); +let rdsOverlayEntries = []; function syncTopBarAccess() { const loggedOut = authEnabled && !authRole; @@ -1260,23 +1263,151 @@ function refreshFreqDisplay() { refreshWavelengthDisplay(lastFreqHz); } -function positionRdsPsOverlay() { - if (!rdsPsOverlay || !lastSpectrumData || lastFreqHz == null || !overviewCanvas) return; +function activeRdsChannelId() { + if (typeof vchanActiveId !== "undefined" && vchanActiveId) return vchanActiveId; + return null; +} + +function activeChannelRds() { + if (!activeChannelIsWfm()) return null; + const activeId = activeRdsChannelId(); + if (activeId) { + const rds = vchanRdsById.get(activeId); + if (rds) return rds; + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + if (vchanChannels[0].id === activeId) return primaryRds; + } + } + return primaryRds; +} + +function activeChannelIsWfm() { + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + const activeId = activeRdsChannelId(); + const active = vchanChannels.find((ch) => ch.id === activeId) || vchanChannels[0]; + return String(active?.mode || "").toUpperCase() === "WFM"; + } + return lastModeName === "WFM"; +} + +function activeChannelFreqHz() { + if (typeof vchanActiveChannel === "function") { + const ch = vchanActiveChannel(); + if (Number.isFinite(ch?.freq_hz)) return ch.freq_hz; + } + return lastFreqHz; +} + +function buildRdsOverlayHtml(rds) { + const ps = rds?.program_service; + const hasPs = !!(ps && ps.length > 0); + const hasPi = rds?.pi != null; + if (!hasPs && !hasPi) return ""; + const mainText = hasPs ? formatOverlayPs(ps) : formatOverlayPi(rds?.pi); + const mainClass = hasPs ? "rds-ps-main" : "rds-ps-fallback"; + const metaText = hasPs + ? `${formatOverlayPi(rds?.pi)} · ${formatOverlayPty(rds?.pty, rds?.pty_name)}` + : (rds?.pty_name ?? (rds?.pty != null ? String(rds.pty) : "")); + const trafficFlags = + `` + + `${overlayTrafficFlagHtml("TP", rds?.traffic_program)}` + + `${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}` + + ``; + return ( + `${hasPs ? formatPsHtml(ps) : escapeMapHtml(mainText)}` + + `` + + `${escapeMapHtml(metaText)}` + + `${trafficFlags}` + + `` + ); +} + +function collectRdsOverlayEntries() { + const entries = []; + if (typeof vchanChannels !== "undefined" && Array.isArray(vchanChannels) && vchanChannels.length > 0) { + for (const ch of vchanChannels) { + if (String(ch?.mode || "").toUpperCase() !== "WFM") continue; + if (!Number.isFinite(ch?.freq_hz)) continue; + const rds = vchanRdsById.get(ch.id) || (vchanChannels[0].id === ch.id ? primaryRds : null); + if (!rds) continue; + entries.push({ id: ch.id, freq_hz: ch.freq_hz, rds }); + } + } else if (lastModeName === "WFM" && primaryRds && Number.isFinite(lastFreqHz)) { + entries.push({ id: "primary", freq_hz: lastFreqHz, rds: primaryRds }); + } + return entries; +} + +function renderRdsOverlays() { + if (!rdsPsOverlay) return; + if (!lastSpectrumData || !overviewCanvas) { + rdsOverlayEntries = []; + rdsPsOverlay.style.display = "none"; + return; + } + const entries = collectRdsOverlayEntries(); + rdsOverlayEntries = []; + rdsPsOverlay.innerHTML = ""; + if (entries.length === 0) { + rdsPsOverlay.style.display = "none"; + return; + } + entries.forEach((entry, idx) => { + const html = buildRdsOverlayHtml(entry.rds); + if (!html) return; + const el = document.createElement("div"); + el.className = "rds-ps-overlay-item"; + el.dataset.freqHz = String(entry.freq_hz); + el.dataset.stackIdx = String(idx); + el.innerHTML = html; + el.addEventListener("click", (evt) => { + evt.stopPropagation(); + copyRdsPsToClipboard(entry.rds, entry.freq_hz); + }); + rdsPsOverlay.appendChild(el); + rdsOverlayEntries.push({ ...entry, el, stackIdx: idx }); + }); + if (rdsOverlayEntries.length === 0) { + rdsPsOverlay.style.display = "none"; + return; + } + rdsPsOverlay.style.display = "block"; + positionRdsOverlays(); +} + +window.renderRdsOverlays = renderRdsOverlays; + +function positionRdsOverlays() { + if (!rdsPsOverlay || !lastSpectrumData || !overviewCanvas || rdsOverlayEntries.length === 0) return; const width = overviewCanvas.clientWidth || overviewCanvas.width || 0; - if (width <= 0) { - return; - } + if (width <= 0) return; const range = spectrumVisibleRange(lastSpectrumData); - if (!Number.isFinite(range.visLoHz) || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) { - return; - } - const rel = (lastFreqHz - range.visLoHz) / range.visSpanHz; - const clamped = Math.max(0.06, Math.min(0.94, rel)); - rdsPsOverlay.style.left = `${clamped * width}px`; + if (!Number.isFinite(range.visLoHz) || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) return; + const count = rdsOverlayEntries.length; + const mid = (count - 1) / 2; + const stackStepPx = 26; + rdsOverlayEntries.forEach((entry, idx) => { + const el = entry.el; + if (!el) return; + if (!Number.isFinite(entry.freq_hz)) { + el.style.display = "none"; + return; + } + el.style.display = ""; + const rel = (entry.freq_hz - range.visLoHz) / range.visSpanHz; + const clamped = Math.max(0.06, Math.min(0.94, rel)); + const offsetPx = Math.round((idx - mid) * stackStepPx); + el.style.left = `${clamped * width}px`; + el.style.top = `calc(50% + ${offsetPx}px)`; + }); +} + +function positionRdsPsOverlay() { + positionRdsOverlays(); } function resetRdsDisplay() { - updateRdsPsOverlay(null); + updateRdsPsOverlay(primaryRds); } function resetWfmStereoIndicator() { @@ -1290,12 +1421,13 @@ function applyLocalTunedFrequency(hz, forceDisplay = false) { if (!Number.isFinite(hz)) return; const freqChanged = lastFreqHz !== hz; if (freqChanged) { + primaryRds = null; resetRdsDisplay(); resetWfmStereoIndicator(); } lastFreqHz = hz; window.lastFreqHz = lastFreqHz; - updateDocumentTitle(lastSpectrumData?.rds ?? null); + updateDocumentTitle(activeChannelRds()); refreshWavelengthDisplay(lastFreqHz); if (forceDisplay) { freqDirty = false; @@ -2243,7 +2375,7 @@ function updateTitle() { titleEl.textContent = serverVersion ? `trx-rs v${serverVersion}` : "trx-rs"; } } - updateDocumentTitle(lastSpectrumData?.rds ?? null); + updateDocumentTitle(activeChannelRds()); } function displayLabelFromUrl(url) { @@ -2434,29 +2566,30 @@ function render(update) { if (update.status && update.status.mode) { const mode = normalizeMode(update.status.mode); const modeUpper = mode ? mode.toUpperCase() : ""; + const onVirtual = typeof vchanIsOnVirtual === "function" && vchanIsOnVirtual(); // When subscribed to a virtual channel the mode picker must reflect // that channel's mode, not the primary rig mode. Skip the update here; // vchan.js will apply the correct mode via vchanSyncModeDisplay(). - if (typeof vchanIsOnVirtual !== "function" || !vchanIsOnVirtual()) { + if (!onVirtual) { modeEl.value = modeUpper; + if (modeUpper === "WFM" && lastModeName !== "WFM") { + setJogDivisor(10); + resetRdsDisplay(); + } else if (modeUpper !== "WFM" && lastModeName === "WFM") { + resetRdsDisplay(); + } + lastModeName = modeUpper; + // When filter panel is active (SDR backend), update the BW slider range + // to match the new mode — but only if the server hasn't already sent a + // filter state that overrides it. + // When SDR backend is active (spectrum visible), apply BW default for new + // mode — but only if the server hasn't already pushed a filter_state. + if (lastSpectrumData && !update.filter) { + applyBwDefaultForMode(mode, false); + } } - if (modeUpper === "WFM" && lastModeName !== "WFM") { - setJogDivisor(10); - resetRdsDisplay(); - } else if (modeUpper !== "WFM" && lastModeName === "WFM") { - resetRdsDisplay(); - } - lastModeName = modeUpper; updateWfmControls(); updateSdrSquelchControlVisibility(); - // When filter panel is active (SDR backend), update the BW slider range - // to match the new mode — but only if the server hasn't already sent a - // filter state that overrides it. - // When SDR backend is active (spectrum visible), apply BW default for new - // mode — but only if the server hasn't already pushed a filter_state. - if (lastSpectrumData && !update.filter) { - applyBwDefaultForMode(mode, false); - } } const modeUpper = update.status && update.status.mode ? normalizeMode(update.status.mode).toUpperCase() : ""; const aisStatus = document.getElementById("ais-status"); @@ -6658,6 +6791,7 @@ function startSpectrumStreaming() { const centerHz = Number(evt.data.slice(0, commaA)); const sampleRate = Number(evt.data.slice(commaA + 1, commaB)); const b64 = evt.data.slice(commaB + 1); + const hadSpectrum = !!lastSpectrumData; const raw = atob(b64); const bins = new Array(raw.length); for (let i = 0; i < raw.length; i++) bins[i] = (raw.charCodeAt(i) << 24 >> 24); @@ -6676,7 +6810,11 @@ function startSpectrumStreaming() { refreshCenterFreqDisplay(); if (window.refreshCwTonePicker) window.refreshCwTonePicker(); scheduleSpectrumDraw(); - if (lastModeName === "WFM") updateRdsPsOverlay(lastSpectrumData.rds); + if (!hadSpectrum) { + updateRdsPsOverlay(lastSpectrumData.rds); + } else { + positionRdsPsOverlay(); + } } catch (_) {} }); // Named "rds" event = RDS metadata changed (emitted only when it changes). @@ -6684,8 +6822,20 @@ function startSpectrumStreaming() { try { const rds = evt.data === "null" ? undefined : JSON.parse(evt.data); if (lastSpectrumData) lastSpectrumData.rds = rds; - if (lastModeName === "WFM") updateRdsPsOverlay(rds ?? null); - updateDocumentTitle(rds ?? null); + updateRdsPsOverlay(rds ?? null); + } catch (_) {} + }); + spectrumSource.addEventListener("rds_vchan", (evt) => { + try { + const payload = evt.data === "null" ? [] : JSON.parse(evt.data); + const next = new Map(); + if (Array.isArray(payload)) { + payload.forEach((entry) => { + if (entry && entry.id) next.set(entry.id, entry.rds ?? null); + }); + } + vchanRdsById = next; + updateRdsPsOverlay(primaryRds); } catch (_) {} }); spectrumSource.onerror = () => { @@ -6790,9 +6940,10 @@ function formatMinuteTimestamp(date = new Date()) { } function buildRdsRawPayload(rds) { + const freqHz = activeChannelFreqHz(); return { time: formatMinuteTimestamp(), - freq_hz: Number.isFinite(lastFreqHz) ? Math.round(lastFreqHz) : null, + freq_hz: Number.isFinite(freqHz) ? Math.round(freqHz) : null, ...rds, }; } @@ -6841,14 +6992,15 @@ function renderRdsAlternativeFrequencies(list) { if (!afEl.childElementCount) afEl.textContent = "--"; } -async function copyRdsPsToClipboard() { - const rds = lastSpectrumData?.rds; +async function copyRdsPsToClipboard(rdsOverride = null, freqOverrideHz = null) { + const rds = rdsOverride || activeChannelRds(); const ps = rds?.program_service; if (!rds || !ps || ps.length === 0) { showHint("No RDS PS", 1200); return; } - const freqMhz = Number.isFinite(lastFreqHz) ? (Math.round((lastFreqHz / 100_000)) / 10).toFixed(1) : "--.-"; + const freqHz = Number.isFinite(freqOverrideHz) ? freqOverrideHz : activeChannelFreqHz(); + const freqMhz = Number.isFinite(freqHz) ? (Math.round((freqHz / 100_000)) / 10).toFixed(1) : "--.-"; const piHex = rds.pi != null ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; @@ -6877,9 +7029,6 @@ async function copyRdsRawToClipboard() { } } -if (rdsPsOverlay) { - rdsPsOverlay.addEventListener("click", () => { copyRdsPsToClipboard(); }); -} const rdsPsValueEl = document.getElementById("rds-ps"); if (rdsPsValueEl) { rdsPsValueEl.addEventListener("click", () => { copyRdsPsToClipboard(); }); @@ -6900,38 +7049,10 @@ if (rdsAfListEl) { } function updateRdsPsOverlay(rds) { - updateDocumentTitle(rds); - // Overview strip overlay - if (rdsPsOverlay) { - const ps = rds?.program_service; - const hasPs = !!(ps && ps.length > 0); - const hasPi = rds?.pi != null; - if (hasPs || hasPi) { - const mainText = hasPs - ? formatOverlayPs(ps) - : formatOverlayPi(rds?.pi); - const mainClass = hasPs ? "rds-ps-main" : "rds-ps-fallback"; - const metaText = hasPs - ? `${formatOverlayPi(rds?.pi)} · ${formatOverlayPty(rds?.pty, rds?.pty_name)}` - : (rds?.pty_name ?? (rds?.pty != null ? String(rds.pty) : "")); - const trafficFlags = - `` + - `${overlayTrafficFlagHtml("TP", rds?.traffic_program)}` + - `${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}` + - ``; - rdsPsOverlay.innerHTML = - `${hasPs ? formatPsHtml(ps) : escapeMapHtml(mainText)}` + - `` + - `${escapeMapHtml(metaText)}` + - `${trafficFlags}` + - ``; - positionRdsPsOverlay(); - rdsPsOverlay.style.display = "flex"; - } else { - rdsPsOverlay.innerHTML = ""; - rdsPsOverlay.style.display = "none"; - } - } + primaryRds = rds || null; + const activeRds = activeChannelRds(); + updateDocumentTitle(activeRds); + renderRdsOverlays(); // RDS debug panel const statusEl = document.getElementById("rds-status"); @@ -6956,7 +7077,7 @@ function updateRdsPsOverlay(rds) { // Always show the current mode, frame counter, and a sanitised spectrum snapshot if (modeEl) modeEl.textContent = document.getElementById("mode")?.value || "--"; - if (!rds) { + if (!activeRds) { statusEl.textContent = "No signal"; statusEl.className = "rds-value rds-no-signal"; piEl.textContent = "--"; @@ -6975,9 +7096,10 @@ function updateRdsPsOverlay(rds) { if (rtEl) rtEl.textContent = "--"; if (rawEl && lastSpectrumData) { const { bins: _b, ...rest } = lastSpectrumData; + const freqHz = activeChannelFreqHz(); rawEl.textContent = JSON.stringify({ time: formatMinuteTimestamp(), - freq_hz: Number.isFinite(lastFreqHz) ? Math.round(lastFreqHz) : null, + freq_hz: Number.isFinite(freqHz) ? Math.round(freqHz) : null, ...rest, }, null, 2); } @@ -6986,29 +7108,31 @@ function updateRdsPsOverlay(rds) { statusEl.textContent = "Decoding"; statusEl.className = "rds-value rds-decoding"; - piEl.textContent = rds.pi != null ? `0x${rds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; + piEl.textContent = activeRds.pi != null ? `0x${activeRds.pi.toString(16).toUpperCase().padStart(4, "0")}` : "--"; if (psEl) { - if (rds.program_service) { - psEl.innerHTML = formatPsHtml(rds.program_service); + if (activeRds.program_service) { + psEl.innerHTML = formatPsHtml(activeRds.program_service); } else { psEl.textContent = "--"; } } - ptyEl.textContent = rds.pty_name ?? (rds.pty != null ? String(rds.pty) : "--"); - ptyNameEl.textContent = rds.pty != null ? String(rds.pty) : "--"; - if (ptynEl) ptynEl.textContent = rds.program_type_name_long ?? "--"; - if (tpEl) tpEl.textContent = formatRdsFlag(rds.traffic_program); - if (taEl) taEl.textContent = formatRdsFlag(rds.traffic_announcement); - if (musicEl) musicEl.textContent = formatRdsAudio(rds.music); - if (stereoEl) stereoEl.textContent = formatRdsFlag(rds.stereo); - 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); + ptyEl.textContent = activeRds.pty_name ?? (activeRds.pty != null ? String(activeRds.pty) : "--"); + ptyNameEl.textContent = activeRds.pty != null ? String(activeRds.pty) : "--"; + if (ptynEl) ptynEl.textContent = activeRds.program_type_name_long ?? "--"; + if (tpEl) tpEl.textContent = formatRdsFlag(activeRds.traffic_program); + if (taEl) taEl.textContent = formatRdsFlag(activeRds.traffic_announcement); + if (musicEl) musicEl.textContent = formatRdsAudio(activeRds.music); + if (stereoEl) stereoEl.textContent = formatRdsFlag(activeRds.stereo); + if (compEl) compEl.textContent = formatRdsFlag(activeRds.compressed); + if (headEl) headEl.textContent = formatRdsFlag(activeRds.artificial_head); + if (dynPtyEl) dynPtyEl.textContent = formatRdsFlag(activeRds.dynamic_pty); + renderRdsAlternativeFrequencies(activeRds.alternative_frequencies_hz); + if (rtEl) rtEl.textContent = activeRds.radio_text ?? "--"; + rawEl.textContent = JSON.stringify(buildRdsRawPayload(activeRds), null, 2); } +window.refreshRdsUi = () => updateRdsPsOverlay(primaryRds); + function scheduleSpectrumDraw() { if (spectrumDrawPending) return; spectrumDrawPending = true; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js index c709d8d..3e64708 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/vchan.js @@ -43,6 +43,7 @@ function vchanHandleChannels(data) { vchanReconnectAudio(); } vchanRender(); + if (typeof renderRdsOverlays === "function") renderRdsOverlays(); } catch (e) { console.warn("vchan: bad channels event", e); } @@ -234,6 +235,25 @@ function vchanSyncModeDisplay() { if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); } // When on primary channel, app.js rig-state updates handle the picker. + const modeUpper = (modeEl.value || "").toUpperCase(); + if (typeof lastModeName !== "undefined") { + if (modeUpper === "WFM" && lastModeName !== "WFM") { + if (typeof setJogDivisor === "function") setJogDivisor(10); + if (typeof resetRdsDisplay === "function") resetRdsDisplay(); + } else if (modeUpper !== "WFM" && lastModeName === "WFM") { + if (typeof resetRdsDisplay === "function") resetRdsDisplay(); + } + lastModeName = modeUpper; + } + if (typeof updateWfmControls === "function") updateWfmControls(); + if (typeof updateSdrSquelchControlVisibility === "function") { + updateSdrSquelchControlVisibility(); + } + if (typeof refreshRdsUi === "function") { + refreshRdsUi(); + } else if (typeof positionRdsPsOverlay === "function") { + positionRdsPsOverlay(); + } } // Sync the BW input to the active virtual channel's bandwidth. 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 da34c55..81c8e7f 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 @@ -620,10 +620,13 @@ small { color: var(--text-muted); } #rds-ps-overlay { display: none; position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + inset: 0; z-index: 5; + pointer-events: none; +} +.rds-ps-overlay-item { + position: absolute; + transform: translate(-50%, -50%); pointer-events: auto; cursor: pointer; color: var(--text-heading); 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 8452055..a76cbbe 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 @@ -501,6 +501,7 @@ pub async fn spectrum( // woken exactly when new spectrum data is pushed (no 40 ms polling needed). let rx = context.spectrum.subscribe(); let mut last_rds_json: Option = None; + let mut last_vchan_rds_json: Option = None; let mut last_had_frame = false; let updates = WatchStream::new(rx).filter_map(move |snapshot| { let sse_chunk: Option = if let Some(ref frame) = snapshot.frame { @@ -513,6 +514,11 @@ pub async fn spectrum( chunk.push_str(&format!("event: rds\ndata: {data}\n\n")); last_rds_json = snapshot.rds_json; } + if snapshot.vchan_rds_json != last_vchan_rds_json { + let data = snapshot.vchan_rds_json.as_deref().unwrap_or("null"); + chunk.push_str(&format!("event: rds_vchan\ndata: {data}\n\n")); + last_vchan_rds_json = snapshot.vchan_rds_json; + } Some(chunk) } else if last_had_frame { last_had_frame = false; @@ -1573,6 +1579,7 @@ async fn wait_for_view(mut rx: watch::Receiver) -> Result Option { None } + + /// Return the latest per-virtual-channel RDS data if supported. + fn get_vchan_rds(&self) -> Option> { + None + } } /// Snapshot of a rig's status that every backend can expose. diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index 47f6c64..c4d608b 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: BSD-2-Clause use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::radio::freq::Freq; use crate::rig::{RigControl, RigInfo, RigRxStatus, RigStatus, RigStatusProvider, RigTxStatus}; @@ -52,6 +53,10 @@ pub struct RigState { /// Skipped in serde (not part of persistent state); flows into RigSnapshot on demand. #[serde(skip)] pub spectrum: Option, + /// Latest virtual-channel RDS data from SDR backends. + /// Skipped in serde (not part of persistent state); flows into RigSnapshot on demand. + #[serde(skip)] + pub vchan_rds: Option>, #[serde(default, skip_serializing)] pub aprs_decode_reset_seq: u64, #[serde(default, skip_serializing)] @@ -144,6 +149,7 @@ impl RigState { cw_tone_hz: 700, filter: None, spectrum: None, + vchan_rds: None, aprs_decode_reset_seq: 0, hf_aprs_decode_reset_seq: 0, cw_decode_reset_seq: 0, @@ -207,6 +213,7 @@ impl RigState { wspr_decode_enabled: snapshot.wspr_decode_enabled, filter: snapshot.filter, spectrum: None, // spectrum flows through /api/spectrum, not persistent state + vchan_rds: None, // vchan RDS flows through /api/spectrum, not persistent state aprs_decode_reset_seq: 0, hf_aprs_decode_reset_seq: 0, cw_decode_reset_seq: 0, @@ -248,6 +255,7 @@ impl RigState { wspr_decode_enabled: self.wspr_decode_enabled, filter: self.filter.clone(), spectrum: self.spectrum.clone(), + vchan_rds: self.vchan_rds.clone(), }) } @@ -369,6 +377,16 @@ pub struct RdsData { pub alternative_frequencies_hz: Option>, } +/// RDS metadata snapshot for a virtual channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct VchanRdsEntry { + /// Virtual channel UUID. + pub id: Uuid, + /// Latest RDS data, if decoded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rds: Option, +} + /// Read-only projection of state shared with clients. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RigSnapshot { @@ -409,4 +427,7 @@ pub struct RigSnapshot { pub filter: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub spectrum: Option, + /// Per-virtual-channel RDS snapshots, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vchan_rds: Option>, } diff --git a/src/trx-protocol/src/codec.rs b/src/trx-protocol/src/codec.rs index 88fc62a..38d6f5a 100644 --- a/src/trx-protocol/src/codec.rs +++ b/src/trx-protocol/src/codec.rs @@ -432,6 +432,7 @@ mod tests { cw_tone_hz: 0, filter: None, spectrum: None, + vchan_rds: None, } } } diff --git a/src/trx-server/src/rig_task.rs b/src/trx-server/src/rig_task.rs index a2afba7..5e4677f 100644 --- a/src/trx-server/src/rig_task.rs +++ b/src/trx-server/src/rig_task.rs @@ -544,8 +544,10 @@ async fn process_command( RigCommand::GetSpectrum => { // Fetch current spectrum and embed it in a one-shot snapshot. ctx.state.spectrum = ctx.rig.get_spectrum(); + ctx.state.vchan_rds = ctx.rig.get_vchan_rds(); let result = snapshot_from(ctx.state); ctx.state.spectrum = None; // don't persist in ongoing state + ctx.state.vchan_rds = None; // don't persist in ongoing state return result; } _ => {} // fall through to normal rig handler diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs index 8af9281..5bab256 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/lib.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use trx_core::radio::freq::{Band, Freq}; use trx_core::rig::response::RigError; -use trx_core::rig::state::{RigFilterState, SpectrumData, WfmDenoiseLevel}; +use trx_core::rig::state::{RigFilterState, SpectrumData, VchanRdsEntry, WfmDenoiseLevel}; use trx_core::rig::{ AudioSource, Rig, RigAccessMethod, RigCapabilities, RigCat, RigInfo, RigStatusFuture, }; @@ -820,6 +820,10 @@ impl RigCat for SoapySdrRig { }) } + fn get_vchan_rds(&self) -> Option> { + Some(self.channel_manager.rds_snapshots()) + } + /// Override: this backend provides demodulated PCM audio. fn as_audio_source(&self) -> Option<&dyn AudioSource> { Some(self) diff --git a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs index 8f483af..99cd510 100644 --- a/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs +++ b/src/trx-server/trx-backend/trx-backend-soapysdr/src/vchan_impl.rs @@ -26,7 +26,7 @@ use std::sync::{Arc, RwLock}; use num_complex::Complex; use tokio::sync::broadcast; -use trx_core::rig::state::RigMode; +use trx_core::rig::state::{RigMode, VchanRdsEntry}; use uuid::Uuid; use crate::dsp::SdrPipeline; @@ -184,6 +184,22 @@ impl SdrVirtualChannelManager { ch.mode = mode.clone(); } } + + /// Snapshot RDS data for each WFM virtual channel (including primary). + pub fn rds_snapshots(&self) -> Vec { + let channels = self.channels.read().unwrap(); + let dsps = self.pipeline.channel_dsps.read().unwrap(); + channels + .iter() + .filter(|ch| matches!(ch.mode, RigMode::WFM)) + .map(|ch| { + let rds = dsps + .get(ch.pipeline_slot) + .and_then(|dsp| dsp.lock().ok().and_then(|d| d.rds_data())); + VchanRdsEntry { id: ch.id, rds } + }) + .collect() + } } impl VirtualChannelManager for SdrVirtualChannelManager {