[feat](trx-rds,trx-frontend-http): expand rds metadata display
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -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<RdsData> {
|
||||
fn process_group(
|
||||
&mut self,
|
||||
block_a: u16,
|
||||
block_b: u16,
|
||||
block_c: u16,
|
||||
block_c_kind: BlockKind,
|
||||
block_d: u16,
|
||||
) -> Option<RdsData> {
|
||||
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);
|
||||
|
||||
@@ -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 `<span class="rds-flag ${stateClass}">${label}</span>`;
|
||||
}
|
||||
|
||||
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 =
|
||||
`<span class="rds-ps-flags">` +
|
||||
`${overlayTrafficFlagHtml("TP", rds?.traffic_program)}` +
|
||||
`${overlayTrafficFlagHtml("TA", rds?.traffic_announcement)}` +
|
||||
`</span>`;
|
||||
rdsPsOverlay.innerHTML =
|
||||
`<span class="${mainClass}">${escapeMapHtml(mainText)}</span>` +
|
||||
`<span class="rds-ps-meta">${escapeMapHtml(metaText)}</span>`;
|
||||
`<span class="rds-ps-meta">` +
|
||||
`<span class="rds-ps-meta-text">${escapeMapHtml(metaText)}</span>` +
|
||||
`${trafficFlags}` +
|
||||
`</span>`;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -279,6 +279,15 @@
|
||||
<div class="rds-field"><span class="rds-label">PS</span><span id="rds-ps" class="rds-value rds-ps">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">PTY</span><span id="rds-pty" class="rds-value">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">PTY Code</span><span id="rds-pty-name" class="rds-value">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">PTYN</span><span id="rds-ptyn" class="rds-value">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">TP</span><span id="rds-tp" class="rds-value">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">TA</span><span id="rds-ta" class="rds-value">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">Audio</span><span id="rds-music" class="rds-value">--</span></div>
|
||||
<div class="rds-field"><span class="rds-label">Stereo</span><span id="rds-stereo" class="rds-value">--</span></div>
|
||||
<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">RadioText</span><span id="rds-radio-text" class="rds-value rds-text">--</span></div>
|
||||
</div>
|
||||
<div class="rds-raw-label">Raw JSON (last spectrum frame)</div>
|
||||
<pre id="rds-raw" class="rds-raw">--</pre>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -299,9 +299,27 @@ pub struct RdsData {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub program_service: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub radio_text: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub program_type_name_long: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pty: Option<u8>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pty_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub traffic_program: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub traffic_announcement: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub music: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stereo: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub artificial_head: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compressed: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dynamic_pty: Option<bool>,
|
||||
}
|
||||
|
||||
/// Read-only projection of state shared with clients.
|
||||
|
||||
Reference in New Issue
Block a user