From cf8d0743ce622b243b49c57bb46698652365801f Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sat, 28 Feb 2026 15:27:26 +0100 Subject: [PATCH] [feat](trx-rds,trx-frontend-http): expand rds metadata display Co-authored-by: Codex Signed-off-by: Stan Grams --- src/decoders/trx-rds/src/lib.rs | 133 +++++++++++++++++- .../trx-frontend-http/assets/web/app.js | 52 ++++++- .../trx-frontend-http/assets/web/index.html | 9 ++ .../trx-frontend-http/assets/web/style.css | 36 ++++- src/trx-core/src/rig/state.rs | 18 +++ 5 files changed, 244 insertions(+), 4 deletions(-) diff --git a/src/decoders/trx-rds/src/lib.rs b/src/decoders/trx-rds/src/lib.rs index 2c4ca81..3bc58a6 100644 --- a/src/decoders/trx-rds/src/lib.rs +++ b/src/decoders/trx-rds/src/lib.rs @@ -80,10 +80,17 @@ struct Candidate { block_bits: u8, block_a: u16, block_b: u16, + block_c: u16, + block_c_kind: BlockKind, score: u32, state: RdsData, ps_bytes: [u8; 8], ps_seen: [bool; 4], + rt_bytes: [u8; 64], + rt_seen: [bool; 16], + rt_ab_flag: bool, + ptyn_bytes: [u8; 8], + ptyn_seen: [bool; 2], } impl Candidate { @@ -107,10 +114,17 @@ impl Candidate { block_bits: 0, block_a: 0, block_b: 0, + block_c: 0, + block_c_kind: BlockKind::C, score: 0, state: RdsData::default(), ps_bytes: [b' '; 8], ps_seen: [false; 4], + rt_bytes: [b' '; 64], + rt_seen: [false; 16], + rt_ab_flag: false, + ptyn_bytes: [b' '; 8], + ptyn_seen: [false; 2], } } @@ -216,6 +230,8 @@ impl Candidate { None } (ExpectBlock::C, BlockKind::C | BlockKind::CPrime) => { + self.block_c = data; + self.block_c_kind = kind; self.expect = ExpectBlock::D; None } @@ -223,7 +239,7 @@ impl Candidate { self.locked = false; self.search_bits = 0; self.search_reg = 0; - self.process_group(self.block_a, self.block_b, data) + self.process_group(self.block_a, self.block_b, self.block_c, self.block_c_kind, data) } (_, BlockKind::A) => { self.locked = true; @@ -259,13 +275,26 @@ impl Candidate { } } - fn process_group(&mut self, block_a: u16, block_b: u16, block_d: u16) -> Option { + fn process_group( + &mut self, + block_a: u16, + block_b: u16, + block_c: u16, + block_c_kind: BlockKind, + block_d: u16, + ) -> Option { let mut changed = false; if self.state.pi != Some(block_a) { self.state.pi = Some(block_a); changed = true; } + let tp = ((block_b >> 10) & 0x1) != 0; + if self.state.traffic_program != Some(tp) { + self.state.traffic_program = Some(tp); + changed = true; + } + let pty = ((block_b >> 5) & 0x1f) as u8; if self.state.pty != Some(pty) { self.state.pty = Some(pty); @@ -274,8 +303,47 @@ impl Candidate { } let group_type = ((block_b >> 12) & 0x0f) as u8; + let version_b = ((block_b >> 11) & 0x1) != 0; if group_type == 0 { + let ta = ((block_b >> 4) & 0x1) != 0; + if self.state.traffic_announcement != Some(ta) { + self.state.traffic_announcement = Some(ta); + changed = true; + } + let music = ((block_b >> 3) & 0x1) != 0; + if self.state.music != Some(music) { + self.state.music = Some(music); + changed = true; + } let segment = usize::from((block_b & 0x0003) as u8); + let di = ((block_b >> 2) & 0x1) != 0; + match segment { + 0 => { + if self.state.dynamic_pty != Some(di) { + self.state.dynamic_pty = Some(di); + changed = true; + } + } + 1 => { + if self.state.compressed != Some(di) { + self.state.compressed = Some(di); + changed = true; + } + } + 2 => { + if self.state.artificial_head != Some(di) { + self.state.artificial_head = Some(di); + changed = true; + } + } + 3 => { + if self.state.stereo != Some(di) { + self.state.stereo = Some(di); + changed = true; + } + } + _ => {} + } let [b0, b1] = block_d.to_be_bytes(); self.ps_bytes[segment * 2] = sanitize_text_byte(b0); self.ps_bytes[segment * 2 + 1] = sanitize_text_byte(b1); @@ -287,6 +355,67 @@ impl Candidate { changed = true; } } + } else if group_type == 2 { + let text_ab = ((block_b >> 4) & 0x1) != 0; + if text_ab != self.rt_ab_flag { + self.rt_ab_flag = text_ab; + self.rt_bytes = [b' '; 64]; + self.rt_seen = [false; 16]; + } + let segment = usize::from((block_b & 0x000f) as u8); + if version_b { + let [b0, b1] = block_d.to_be_bytes(); + let base = segment.saturating_mul(2); + if base + 1 < self.rt_bytes.len() { + self.rt_bytes[base] = sanitize_text_byte(b0); + self.rt_bytes[base + 1] = sanitize_text_byte(b1); + self.rt_seen[segment] = true; + } + } else if block_c_kind == BlockKind::C { + let [c0, c1] = block_c.to_be_bytes(); + let [d0, d1] = block_d.to_be_bytes(); + let base = segment.saturating_mul(4); + if base + 3 < self.rt_bytes.len() { + self.rt_bytes[base] = sanitize_text_byte(c0); + self.rt_bytes[base + 1] = sanitize_text_byte(c1); + self.rt_bytes[base + 2] = sanitize_text_byte(d0); + self.rt_bytes[base + 3] = sanitize_text_byte(d1); + self.rt_seen[segment] = true; + } + } + if let Some(last_seen) = self.rt_seen.iter().rposition(|seen| *seen) { + let rt_len = if version_b { + (last_seen + 1) * 2 + } else { + (last_seen + 1) * 4 + }; + let rt = String::from_utf8_lossy(&self.rt_bytes[..rt_len]).trim_end().to_string(); + if !rt.is_empty() && self.state.radio_text.as_deref() != Some(rt.as_str()) { + self.state.radio_text = Some(rt); + changed = true; + } + } + } else if group_type == 10 && !version_b && block_c_kind == BlockKind::C { + let segment = usize::from((block_b & 0x0001) as u8); + let [c0, c1] = block_c.to_be_bytes(); + let [d0, d1] = block_d.to_be_bytes(); + let base = segment.saturating_mul(4); + if base + 3 < self.ptyn_bytes.len() { + self.ptyn_bytes[base] = sanitize_text_byte(c0); + self.ptyn_bytes[base + 1] = sanitize_text_byte(c1); + self.ptyn_bytes[base + 2] = sanitize_text_byte(d0); + self.ptyn_bytes[base + 3] = sanitize_text_byte(d1); + self.ptyn_seen[segment] = true; + } + if self.ptyn_seen.iter().all(|seen| *seen) { + let ptyn = String::from_utf8_lossy(&self.ptyn_bytes).trim_end().to_string(); + if !ptyn.is_empty() + && self.state.program_type_name_long.as_deref() != Some(ptyn.as_str()) + { + self.state.program_type_name_long = Some(ptyn); + changed = true; + } + } } self.score = self.score.saturating_add(1); 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 e0272fd..78569c0 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 @@ -3227,6 +3227,21 @@ function formatOverlayPty(pty, ptyName) { return pty != null ? String(pty) : "--"; } +function overlayTrafficFlagHtml(label, active) { + const stateClass = active === true ? "rds-flag-active" : "rds-flag-inactive"; + return `${label}`; +} + +function formatRdsFlag(value, yes = "Yes", no = "No") { + if (value == null) return "--"; + return value ? yes : no; +} + +function formatRdsAudio(value) { + if (value == null) return "--"; + return value ? "Music" : "Speech"; +} + async function copyRdsPsToClipboard() { const rds = lastSpectrumData?.rds; const ps = rds?.program_service; @@ -3276,9 +3291,17 @@ function updateRdsPsOverlay(rds) { 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 = `${escapeMapHtml(mainText)}` + - `${escapeMapHtml(metaText)}`; + `` + + `${escapeMapHtml(metaText)}` + + `${trafficFlags}` + + ``; positionRdsPsOverlay(); rdsPsOverlay.style.display = "flex"; } else { @@ -3294,6 +3317,15 @@ function updateRdsPsOverlay(rds) { const psEl = document.getElementById("rds-ps"); const ptyEl = document.getElementById("rds-pty"); const ptyNameEl = document.getElementById("rds-pty-name"); + const ptynEl = document.getElementById("rds-ptyn"); + const tpEl = document.getElementById("rds-tp"); + const taEl = document.getElementById("rds-ta"); + const musicEl = document.getElementById("rds-music"); + const stereoEl = document.getElementById("rds-stereo"); + const compEl = document.getElementById("rds-compressed"); + const headEl = document.getElementById("rds-artificial-head"); + const dynPtyEl = document.getElementById("rds-dynamic-pty"); + const rtEl = document.getElementById("rds-radio-text"); const rawEl = document.getElementById("rds-raw"); if (!statusEl) return; @@ -3309,6 +3341,15 @@ function updateRdsPsOverlay(rds) { psEl.textContent = "--"; ptyEl.textContent = "--"; ptyNameEl.textContent = "--"; + if (ptynEl) ptynEl.textContent = "--"; + if (tpEl) tpEl.textContent = "--"; + if (taEl) taEl.textContent = "--"; + if (musicEl) musicEl.textContent = "--"; + if (stereoEl) stereoEl.textContent = "--"; + if (compEl) compEl.textContent = "--"; + if (headEl) headEl.textContent = "--"; + if (dynPtyEl) dynPtyEl.textContent = "--"; + if (rtEl) rtEl.textContent = "--"; if (rawEl && lastSpectrumData) { const { bins: _b, ...rest } = lastSpectrumData; rawEl.textContent = JSON.stringify(rest, null, 2); @@ -3322,6 +3363,15 @@ function updateRdsPsOverlay(rds) { psEl.textContent = rds.program_service ?? "--"; 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); + if (rtEl) rtEl.textContent = rds.radio_text ?? "--"; rawEl.textContent = JSON.stringify(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 32e12b6..468e142 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 @@ -279,6 +279,15 @@
PS--
PTY--
PTY Code--
+
PTYN--
+
TP--
+
TA--
+
Audio--
+
Stereo--
+
Compressed--
+
Artificial Head--
+
Dynamic PTY--
+
RadioText--
Raw JSON (last spectrum frame)
--
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 514a4db..0f44a40 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 @@ -496,13 +496,46 @@ small { color: var(--text-muted); } white-space: nowrap; } .rds-ps-meta { - display: block; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: clamp(0.58rem, 1.1vw, 0.78rem); letter-spacing: 0.04em; color: var(--text-muted); white-space: nowrap; } +.rds-ps-meta-text { + display: inline-block; +} +.rds-ps-flags { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.rds-flag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.9em; + padding: 0.05rem 0.35rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--border-light) 72%, transparent); + font-size: 0.92em; + font-weight: 700; + letter-spacing: 0.03em; +} +.rds-flag-active { + color: #ffd7d7; + background: color-mix(in srgb, #b31217 68%, transparent); + border-color: color-mix(in srgb, #ff7b7b 46%, transparent); + box-shadow: 0 0 10px color-mix(in srgb, #b31217 28%, transparent); +} +.rds-flag-inactive { + color: var(--text-muted); + background: color-mix(in srgb, var(--card-bg) 62%, transparent); +} .overview-toolbar { display: flex; align-items: center; @@ -778,6 +811,7 @@ small { color: var(--text-muted); } .rds-label { color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap; } .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-no-signal { color: var(--text-muted); } .rds-decoding { color: var(--accent-green); } .rds-raw-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 0.3rem; } diff --git a/src/trx-core/src/rig/state.rs b/src/trx-core/src/rig/state.rs index 290a3e0..8f92404 100644 --- a/src/trx-core/src/rig/state.rs +++ b/src/trx-core/src/rig/state.rs @@ -299,9 +299,27 @@ pub struct RdsData { #[serde(default, skip_serializing_if = "Option::is_none")] pub program_service: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub radio_text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program_type_name_long: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub pty: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub pty_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traffic_program: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub traffic_announcement: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub music: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stereo: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artificial_head: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compressed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic_pty: Option, } /// Read-only projection of state shared with clients.