use serde::Serialize;
use std::fmt;
use tracing::{debug, trace};
#[allow(dead_code)]
const RDS_POLY: u32 = 0x5B9; #[allow(dead_code)]
const RDS_POLY_DEGREE: usize = 10;
#[allow(dead_code)]
const OFFSET_WORD_A: u16 = 0b0011111100; #[allow(dead_code)]
const OFFSET_WORD_B: u16 = 0b0110011000; #[allow(dead_code)]
const OFFSET_WORD_C: u16 = 0b0101101000; #[allow(dead_code)]
const OFFSET_WORD_C_PRIME: u16 = 0b1101010000; #[allow(dead_code)]
const OFFSET_WORD_D: u16 = 0b0110110100;
const SYNDROME_A: u16 = 0b1111011000; const SYNDROME_B: u16 = 0b1111010100; const SYNDROME_C: u16 = 0b1001011100; const SYNDROME_C_PRIME: u16 = 0b1111001100; const SYNDROME_D: u16 = 0b1001011000;
const MAX_TOLERABLE_BLER: usize = 85;
const MAX_ERRORS_OVER_50: usize = MAX_TOLERABLE_BLER / 2;
const OFFSET_WORDS: [u32; 5] = [
0b0011111100, 0b0110011000, 0b0101101000, 0b1101010000, 0b0110110100, ];
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum OffsetType {
A = 0,
B = 1,
C = 2,
Cprime = 3,
D = 4,
}
impl OffsetType {
fn from_char(c: char) -> Option<Self> {
match c {
'A' => Some(OffsetType::A),
'B' => Some(OffsetType::B),
'C' => Some(OffsetType::C),
'c' => Some(OffsetType::Cprime),
'D' => Some(OffsetType::D),
_ => None,
}
}
}
struct RunningSum<const N: usize> {
buffer: [bool; N],
pos: usize,
sum: usize,
filled: bool,
}
impl<const N: usize> RunningSum<N> {
fn new() -> Self {
Self {
buffer: [false; N],
pos: 0,
sum: 0,
filled: false,
}
}
fn push(&mut self, value: bool) {
if self.filled && self.buffer[self.pos] {
self.sum = self.sum.saturating_sub(1);
}
self.buffer[self.pos] = value;
if value {
self.sum += 1;
}
self.pos = (self.pos + 1) % N;
if self.pos == 0 {
self.filled = true;
}
}
fn get_sum(&self) -> usize {
self.sum
}
fn clear(&mut self) {
self.buffer = [false; N];
self.pos = 0;
self.sum = 0;
self.filled = false;
}
}
struct FecTable {
tables: [Vec<(u16, u32)>; 5],
}
impl FecTable {
fn new() -> Self {
let mut tables: [Vec<(u16, u32)>; 5] = Default::default();
for (offset_idx, &offset_word) in OFFSET_WORDS.iter().enumerate() {
for &error_bits in &[0b1u32, 0b11u32] {
for shift in 0..26u32 {
let error_vector = (error_bits << shift) & 0x03FF_FFFF; let syndrome = rds_syndrome(error_vector ^ offset_word);
tables[offset_idx].push((syndrome, error_vector));
}
}
}
Self { tables }
}
fn try_correct(&self, raw_block: u32, expected_offset: OffsetType) -> Option<u32> {
let syndrome = rds_syndrome(raw_block);
let table = &self.tables[expected_offset as usize];
for &(synd, error_vector) in table {
if synd == syndrome {
return Some(raw_block ^ error_vector);
}
}
None
}
}
fn get_fec_table() -> &'static FecTable {
use std::sync::OnceLock;
static FEC_TABLE: OnceLock<FecTable> = OnceLock::new();
FEC_TABLE.get_or_init(FecTable::new)
}
const BLOCK_LENGTH: u32 = 26;
#[derive(Clone, Copy, Debug, Default)]
struct SyncPulse {
offset: char,
bit_position: u32,
}
impl SyncPulse {
fn could_follow(&self, other: &SyncPulse) -> bool {
if self.offset == '\0' || other.offset == '\0' {
return false;
}
let sync_distance = self.bit_position.wrapping_sub(other.bit_position);
if !sync_distance.is_multiple_of(BLOCK_LENGTH) {
return false;
}
let blocks_apart = sync_distance / BLOCK_LENGTH;
if blocks_apart > 6 || blocks_apart == 0 {
return false;
}
let block_num = |off: char| -> u32 {
match off {
'A' => 0,
'B' => 1,
'C' | 'c' => 2,
'D' => 3,
_ => 0,
}
};
let other_block = block_num(other.offset);
let this_block = block_num(self.offset);
let expected_block = (other_block + blocks_apart) % 4;
expected_block == this_block
}
}
struct SyncPulseBuffer {
pulses: [SyncPulse; 4],
}
impl SyncPulseBuffer {
fn new() -> Self {
Self {
pulses: [SyncPulse::default(); 4],
}
}
fn push(&mut self, offset: char, bit_position: u32) {
for i in 0..3 {
self.pulses[i] = self.pulses[i + 1];
}
self.pulses[3] = SyncPulse {
offset,
bit_position,
};
}
fn is_sequence_found(&self) -> bool {
let third = &self.pulses[3];
for i_first in 0..2 {
for i_second in (i_first + 1)..3 {
if third.could_follow(&self.pulses[i_second])
&& self.pulses[i_second].could_follow(&self.pulses[i_first])
{
return true;
}
}
}
false
}
fn clear(&mut self) {
self.pulses = [SyncPulse::default(); 4];
}
}
const PTY_NAMES: &[&str] = &[
"None",
"News",
"Information",
"Sports",
"Talk",
"Rock",
"Classic Rock",
"Adult Hits",
"MOR",
"Easy Listening",
"Lite Classics",
"Classical",
"Rhythm & Blues",
"Soft Rhythm & Blues",
"Language",
"Religious Music",
"Religious Talk",
"Personality",
"Public Affairs",
"Drama",
"Easy Listening (alt)",
"Reggae",
"Heritage",
"Children",
"Social Affairs",
"Documentary",
"Unknown (25)",
"Unknown (26)",
"Unknown (27)",
"Unknown (28)",
"Unknown (29)",
"Unknown (30)",
];
const LANGUAGE_NAMES: &[&str] = &[
"Unknown",
"Albanian",
"Breton",
"Catalan",
"Croatian",
"Welsh",
"Czech",
"Danish",
"German",
"English",
"Spanish",
"Esperanto",
"Estonian",
"Basque",
"Faroese",
"French",
"Frisian",
"Irish",
"Gaelic",
"Galician",
"Icelandic",
"Italian",
"Lappish",
"Latin",
"Latvian",
"Luxembourgian",
"Lithuanian",
"Hungarian",
"Maltese",
"Dutch",
"Norwegian",
"Occitan",
"Polish",
"Portuguese",
"Romanian",
"Romansh",
"Serbian",
"Slovak",
"Slovene",
"Finnish",
"Swedish",
"Turkish",
"Flemish",
"Walloon",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"",
"Background",
"",
"",
"",
"",
"Zulu",
"Vietnamese",
"Uzbek",
"Urdu",
"Ukrainian",
"Thai",
"Telugu",
"Tatar",
"Tamil",
"Tadzhik",
"Swahili",
"SrananTongo",
"Somali",
"Sinhalese",
"Shona",
"Serbo-Croat",
"Ruthenian",
"Russian",
"Quechua",
"Pushtu",
"Punjabi",
"Persian",
"Papamiento",
"Oriya",
"Nepali",
"Ndebele",
"Marathi",
"Moldovian",
"Malaysian",
"Malagasay",
"Macedonian",
"Laotian",
"Korean",
"Khmer",
"Kazakh",
"Kannada",
"Japanese",
"Indonesian",
"Hindi",
"Hebrew",
"Hausa",
"Gurani",
"Gujurati",
"Greek",
"Georgian",
"Fulani",
"Dari",
"Churash",
"Chinese",
"Burmese",
"Bulgarian",
"Bengali",
"Belorussian",
"Bambora",
"Azerbaijan",
"Assamese",
"Armenian",
"Arabic",
"Amharic",
];
const SLC_VARIANTS: &[&str] = &[
"ECC and PI", "TMC ID", "Pager", "Language", "Reserved 4", "Reserved 5", "Broadcaster Bits", "EWS", ];
const ODA_APPS: &[(u16, &str)] = &[
(0x0000, "None"),
(0x0093, "Cross referencing DAB within RDS"),
(0x0BCB, "Leisure & Practical Info for Drivers"),
(0x0C24, "ELECTRABEL-DSM 7"),
(0x0CC1, "Wireless Playground broadcast control signal"),
(0x0D45, "RDS-TMC: ALERT-C / EN ISO 14819-1"),
(0x0D8B, "ELECTRABEL-DSM 18"),
(0x0E2C, "ELECTRABEL-DSM 3"),
(0x0E31, "ELECTRABEL-DSM 13"),
(0x0F87, "ELECTRABEL-DSM 2"),
(0x125F, "I-FM-RDS for fixed and mobile devices"),
(0x1BDA, "ELECTRABEL-DSM 1"),
(0x1C5E, "ELECTRABEL-DSM 20"),
(0x1C68, "ITIS In-vehicle data base"),
(0x1CB1, "ELECTRABEL-DSM 10"),
(0x1D47, "ELECTRABEL-DSM 4"),
(0x1DC2, "CITIBUS 4"),
(0x1DC5, "Encrypted TTI using ALERT-Plus"),
(0x1E8F, "ELECTRABEL-DSM 17"),
(0x4400, "RDS-Light"),
(0x4AA1, "RASANT"),
(0x4AB7, "ELECTRABEL-DSM 9"),
(0x4BA2, "ELECTRABEL-DSM 5"),
(0x4BD7, "RadioText+ (RT+)"),
(0x4BD8, "RadioText Plus / RT+ for eRT"),
(0x4C59, "CITIBUS 2"),
(0x4D87, "Radio Commerce System (RCS)"),
(0x4D95, "ELECTRABEL-DSM 16"),
(0x4D9A, "ELECTRABEL-DSM 11"),
(0x50DD, "To warn people in case of disasters or emergency"),
(0x5757, "Personal weather station"),
(0x6363, "Hybradio RDS-Net (for testing use, only)"),
(0x6365, "RDS2 – 9 bit AF lists ODA"),
(0x6552, "Enhanced RadioText (eRT)"),
(0x6A7A, "Warning receiver"),
(0x7373, "Enhanced early warning system"),
(0xA112, "NL Alert system"),
(0xA911, "Data FM Selective Multipoint Messaging"),
(0xABCF, "RF Power Monitoring"),
(0xC350, "NRSC Song Title and Artist"),
(0xC3A1, "Personal Radio Service"),
(0xC3B0, "iTunes Tagging"),
(0xC3C3, "NAVTEQ Traffic Plus"),
(0xC4D4, "eEAS"),
(0xC549, "Smart Grid Broadcast Channel"),
(0xC563, "ID Logic"),
(0xC6A7, "Veil Enabled Interactive Device"),
(0xC737, "Utility Message Channel (UMC)"),
(0xCB73, "CITIBUS 1"),
(0xCB97, "ELECTRABEL-DSM 14"),
(0xCC21, "CITIBUS 3"),
(0xCD46, "RDS-TMC: ALERT-C"),
(0xCD47, "RDS-TMC: ALERT-C"),
(0xCD9E, "ELECTRABEL-DSM 8"),
(0xCE6B, "Encrypted TTI using ALERT-Plus"),
(0xE123, "APS Gateway"),
(0xE1C1, "Action code"),
(0xE319, "ELECTRABEL-DSM 12"),
(0xE411, "Beacon downlink"),
(0xE440, "ELECTRABEL-DSM 15"),
(0xE4A6, "ELECTRABEL-DSM 19"),
(0xE5D7, "ELECTRABEL-DSM 6"),
(0xE911, "EAS open protocol"),
(0xFF7F, "RFT: Station logo"),
(0xFF80, "RFT+ (work title)"),
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DIFlags {
pub dynamic_pty: bool,
pub compressed: bool,
pub artificial_head: bool,
pub stereo: bool,
}
impl DIFlags {
pub fn flag_name(segment: usize) -> &'static str {
match segment {
0 => "dynamic_pty",
1 => "compressed",
2 => "artificial_head",
3 => "stereo",
_ => "unknown",
}
}
pub fn set_flag(&mut self, segment: usize, value: bool) {
match segment {
0 => self.dynamic_pty = value,
1 => self.compressed = value,
2 => self.artificial_head = value,
3 => self.stereo = value,
_ => {}
}
}
pub fn get_flag(&self, segment: usize) -> bool {
match segment {
0 => self.dynamic_pty,
1 => self.compressed,
2 => self.artificial_head,
3 => self.stereo,
_ => false,
}
}
pub fn as_string(&self) -> String {
format!(
"dynamic_pty={} compressed={} artificial_head={} stereo={}",
self.dynamic_pty, self.compressed, self.artificial_head, self.stereo
)
}
}
#[derive(Debug, Clone, Default)]
pub struct ProgramItemInfo {
pub number: u16,
pub day: u8,
pub hour: u8,
pub minute: u8,
}
#[derive(Debug, Clone, Default)]
pub struct ODAInfo {
pub target_group_type: u8,
pub app_id: u16,
pub message: u16,
}
#[derive(Debug, Clone, Default)]
pub struct ClockTimeInfo {
pub year: u16,
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub local_offset: f32,
}
#[derive(Debug, Clone, Default)]
pub struct EONInfo {
pub pi: u16,
pub variant: u8,
pub program_type: Option<u8>,
pub has_linkage: Option<bool>,
pub linkage_set: Option<u16>,
pub alt_frequency: Option<u8>,
}
const PARITY_CHECK_MATRIX: [u16; 26] = [
0b1000000000, 0b0100000000,
0b0010000000,
0b0001000000,
0b0000100000,
0b0000010000,
0b0000001000,
0b0000000100,
0b0000000010,
0b0000000001, 0b1011011100, 0b0101101110,
0b0010110111,
0b1010000111,
0b1110011111,
0b1100010011,
0b1101010101,
0b1101110110,
0b0110111011,
0b1000000001, 0b1111011100, 0b0111101110,
0b0011110111,
0b1010100111,
0b1110001111,
0b1100011011, ];
fn rds_syndrome(word26: u32) -> u16 {
let mut result: u16 = 0;
for k in 0..26 {
let bit = (word26 >> k) & 1;
if bit != 0 {
result ^= PARITY_CHECK_MATRIX[25 - k];
}
}
result
}
fn rds_offset_for_syndrome(s: u16) -> Option<char> {
if s == SYNDROME_A {
Some('A')
} else if s == SYNDROME_B {
Some('B')
} else if s == SYNDROME_C {
Some('C')
} else if s == SYNDROME_C_PRIME {
Some('c')
}
else if s == SYNDROME_D {
Some('D')
} else {
None
}
}
fn rds_data_from_word(word26: u32) -> u16 {
((word26 >> 10) & 0xFFFF) as u16
}
#[derive(Debug, Clone, Serialize)]
pub struct RdsGroupJson {
pub pi: String,
pub group: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tp: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pty_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ps: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ta: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub music: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub af: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ct: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub partial: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
}
impl RdsGroupJson {
pub fn new(pi: u16, group_type: &str) -> Self {
Self {
pi: format!("0x{:04X}", pi),
group: group_type.to_string(),
tp: None,
pty_name: None,
ps: None,
rt: None,
ta: None,
music: None,
af: None,
ct: None,
partial: None,
raw: None,
}
}
pub fn to_json(&self) -> String {
serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
}
}
pub struct RdsParser {
shift: u32,
shift_len: usize,
ps: [u8; 8],
ps_received_mask: u8, ps_sequential_len: usize,
ps_prev_pos: Option<usize>,
ps_last_complete: Option<String>,
rt: Vec<u8>,
rt_received_mask: Vec<bool>,
verbose: bool,
station_info: StationInfo,
groups_decoded: u32,
blocks_received: u32,
bits_pushed: u64,
is_synced: bool,
expected_offset: char,
bits_until_next_block: u8,
current_group: [(bool, u16); 4], block_error_sum: RunningSum<50>,
use_fec: bool,
sync_buffer: SyncPulseBuffer,
bitcount: u32,
json_output_queue: Vec<RdsGroupJson>,
json_mode: bool,
}
#[derive(Debug, Clone, Default)]
pub struct StationInfo {
pub is_traffic_program: bool,
pub is_traffic_announcement: bool,
pub program_type: u8,
pub di_flags: DIFlags,
pub is_music: bool,
pub af_list: Vec<u16>,
pub program_item: Option<ProgramItemInfo>,
pub extended_country_code: Option<u8>,
pub language_code: Option<u8>,
pub tmc_id: Option<u16>,
pub has_linkage: bool,
pub oda_info: Option<ODAInfo>,
pub clock_time: Option<ClockTimeInfo>,
pub eon_info: Option<EONInfo>,
}
impl fmt::Debug for RdsParser {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let ps = String::from_utf8_lossy(&self.ps);
write!(f, "RdsParser {{ PS='{}' }}", ps)
}
}
impl RdsParser {
pub fn new() -> Self {
Self {
shift: 0,
shift_len: 0,
ps: [b' '; 8],
ps_received_mask: 0,
ps_sequential_len: 0,
ps_prev_pos: None,
ps_last_complete: None,
rt: vec![b' '; 64],
rt_received_mask: vec![false; 16], verbose: false,
station_info: StationInfo::default(),
groups_decoded: 0,
blocks_received: 0,
bits_pushed: 0,
is_synced: false,
expected_offset: 'A',
bits_until_next_block: 1, current_group: [(false, 0); 4],
block_error_sum: RunningSum::new(),
use_fec: true, sync_buffer: SyncPulseBuffer::new(),
bitcount: 0,
json_output_queue: Vec::new(),
json_mode: false,
}
}
pub fn stats(&self) -> (u64, u32, u32) {
(self.bits_pushed, self.blocks_received, self.groups_decoded)
}
pub fn set_json_mode(&mut self, enabled: bool) {
self.json_mode = enabled;
if enabled {
self.verbose = false; }
}
pub fn take_json_outputs(&mut self) -> Vec<RdsGroupJson> {
std::mem::take(&mut self.json_output_queue)
}
pub fn has_data(&self) -> bool {
self.groups_decoded > 0
}
pub fn set_verbose(&mut self, verbose: bool) {
self.verbose = verbose;
}
pub fn station_name(&self) -> Option<String> {
self.ps_last_complete.clone()
}
pub fn radio_text(&self) -> Option<String> {
let have = self.rt_received_mask.iter().filter(|&&x| x).count();
if have >= 2 {
let rt_text = String::from_utf8_lossy(&self.rt).trim_end().to_string();
if !rt_text.is_empty() {
Some(rt_text)
} else {
None
}
} else {
None
}
}
pub fn station_info(&self) -> &StationInfo {
&self.station_info
}
pub fn program_type_name(&self) -> &str {
PTY_NAMES[self.station_info.program_type as usize]
}
pub fn language_name(&self) -> Option<&'static str> {
self.station_info.language_code.and_then(|code| {
if (code as usize) < LANGUAGE_NAMES.len() {
let name = LANGUAGE_NAMES[code as usize];
if !name.is_empty() { Some(name) } else { None }
} else {
None
}
})
}
pub fn slc_variant_name(variant: u8) -> &'static str {
if (variant as usize) < SLC_VARIANTS.len() {
SLC_VARIANTS[variant as usize]
} else {
"Unknown"
}
}
pub fn oda_app_name(app_id: u16) -> &'static str {
for &(id, name) in ODA_APPS {
if id == app_id {
return name;
}
}
"(Unknown ODA)"
}
pub fn push_bits(&mut self, bits: &[u8]) {
self.bits_pushed += bits.len() as u64;
for &b in bits {
self.shift = ((self.shift << 1) & ((1u32 << 26) - 1)) | (b as u32 & 1);
if self.shift_len < 26 {
self.shift_len += 1;
}
self.bitcount = self.bitcount.wrapping_add(1);
if self.shift_len < 26 {
continue;
}
self.bits_until_next_block = self.bits_until_next_block.saturating_sub(1);
if self.is_synced {
if self.bits_until_next_block == 0 {
self.process_block_at_boundary();
self.bits_until_next_block = 26;
}
} else {
self.try_acquire_sync();
}
}
}
fn try_acquire_sync(&mut self) {
let synd = rds_syndrome(self.shift);
if let Some(offset) = rds_offset_for_syndrome(synd) {
self.sync_buffer.push(offset, self.bitcount);
self.blocks_received += 1;
debug!(
offset = %offset,
bitcount = self.bitcount,
"RDS block detected (acquiring sync)"
);
if self.sync_buffer.is_sequence_found() {
self.is_synced = true;
self.bits_until_next_block = 26;
self.block_error_sum.clear();
self.expected_offset = match offset {
'A' => 'B',
'B' => 'C',
'C' | 'c' => 'D',
'D' => 'A',
_ => 'A',
};
let block_idx = match offset {
'A' => 0,
'B' => 1,
'C' | 'c' => 2,
'D' => 3,
_ => 0,
};
let data = rds_data_from_word(self.shift);
self.current_group[block_idx] = (true, data);
debug!(
offset = %offset,
next_expected = %self.expected_offset,
bitcount = self.bitcount,
"RDS sync acquired (SyncPulseBuffer)"
);
if offset == 'D' {
self.process_current_group();
}
}
}
}
fn process_block_at_boundary(&mut self) {
let synd = rds_syndrome(self.shift);
let detected_offset = rds_offset_for_syndrome(synd);
let matches_expected = match detected_offset {
Some(off) => off == self.expected_offset || (self.expected_offset == 'C' && off == 'c'),
None => false,
};
debug!(
expected = %self.expected_offset,
detected = ?detected_offset,
matches = matches_expected,
bitcount = self.bitcount,
shift = format!("0x{:06X}", self.shift),
"RDS block at boundary"
);
let had_errors = !matches_expected;
self.block_error_sum.push(had_errors);
if self.block_error_sum.get_sum() > MAX_ERRORS_OVER_50 {
self.is_synced = false;
self.block_error_sum.clear();
self.sync_buffer.clear(); debug!(
errors = self.block_error_sum.get_sum(),
threshold = MAX_ERRORS_OVER_50,
"RDS sync lost"
);
return;
}
if self.expected_offset == 'C' && detected_offset == Some('c') {
}
let mut block_data: Option<u16> = None;
let mut block_is_valid = false;
if matches_expected {
block_data = Some(rds_data_from_word(self.shift));
block_is_valid = true;
self.blocks_received += 1;
} else if self.use_fec {
if let Some(expected_type) = OffsetType::from_char(self.expected_offset)
&& let Some(corrected) = get_fec_table().try_correct(self.shift, expected_type)
{
let corrected_synd = rds_syndrome(corrected);
let corrected_offset = rds_offset_for_syndrome(corrected_synd);
if corrected_offset == Some(self.expected_offset)
|| (self.expected_offset == 'C' && corrected_offset == Some('c'))
{
block_data = Some(rds_data_from_word(corrected));
block_is_valid = true;
self.blocks_received += 1;
trace!(
block = %self.expected_offset,
original = format!("0x{:06X}", self.shift),
corrected = format!("0x{:06X}", corrected),
"RDS FEC corrected block"
);
}
}
}
let block_idx = match self.expected_offset {
'A' => 0,
'B' => 1,
'C' => 2,
'D' => 3,
_ => 0,
};
if block_is_valid {
self.current_group[block_idx] = (true, block_data.unwrap());
} else {
self.current_group[block_idx] = (false, 0);
trace!(
expected = %self.expected_offset,
syndrome = format!("0x{:03X}", synd),
detected = ?detected_offset,
"RDS bad block"
);
}
let next_offset = match self.expected_offset {
'A' => 'B',
'B' => 'C',
'C' => 'D',
'D' => {
self.process_current_group();
'A'
}
_ => 'A',
};
self.expected_offset = next_offset;
}
fn process_current_group(&mut self) {
let received_count = self.current_group.iter().filter(|(r, _)| *r).count();
debug!(
blocks_valid = received_count,
a_valid = self.current_group[0].0,
b_valid = self.current_group[1].0,
c_valid = self.current_group[2].0,
d_valid = self.current_group[3].0,
"RDS group boundary"
);
if self.current_group[0].0 && self.current_group[1].0 {
let datas: [u16; 4] = [
self.current_group[0].1,
self.current_group[1].1,
if self.current_group[2].0 {
self.current_group[2].1
} else {
0
},
if self.current_group[3].0 {
self.current_group[3].1
} else {
0
},
];
let block_valid = [
self.current_group[0].0,
self.current_group[1].0,
self.current_group[2].0,
self.current_group[3].0,
];
if received_count < 4 {
trace!(blocks = received_count, "RDS partial group");
}
self.handle_group(datas, block_valid);
}
self.current_group = [(false, 0); 4];
}
#[inline]
#[allow(dead_code)]
fn is_valid_rds_char(b: u8) -> bool {
b >= 0x20 || b == 0x0D
}
pub fn update_ps(&mut self, seg: usize, ch1: u8, ch2: u8) {
if seg > 3 {
return;
}
let pos = seg * 2;
self.ps[pos] = ch1;
self.ps[pos + 1] = ch2;
self.ps_received_mask |= 1 << seg;
if pos == 0
|| (self.ps_prev_pos.is_some_and(|p| pos == p + 1) && self.ps_sequential_len == pos)
{
self.ps_sequential_len = pos + 1;
}
self.ps_prev_pos = Some(pos);
let pos2 = pos + 1;
if pos2 == 0
|| (self.ps_prev_pos.is_some_and(|p| pos2 == p + 1) && self.ps_sequential_len == pos2)
{
self.ps_sequential_len = pos2 + 1;
}
self.ps_prev_pos = Some(pos2);
if self.ps_sequential_len >= 8 {
let ps_filtered: Vec<u8> = self
.ps
.iter()
.map(|&b| {
if b >= 0x20 || b == 0x0D { b } else { b' ' }
})
.collect();
let ps_str = String::from_utf8_lossy(&ps_filtered).to_string();
let trimmed = ps_str.trim();
if !trimmed.is_empty() {
self.ps_last_complete = Some(trimmed.to_string());
}
}
if self.verbose {
debug!(
" [PS] Segment {}/4: 0x{:02X} 0x{:02X} = '{}{}' (mask: {:04b}, seq_len: {})",
seg,
ch1,
ch2,
if ch1.is_ascii_graphic() || ch1 == b' ' {
ch1 as char
} else {
'?'
},
if ch2.is_ascii_graphic() || ch2 == b' ' {
ch2 as char
} else {
'?'
},
self.ps_received_mask,
self.ps_sequential_len
);
}
}
fn handle_group(&mut self, datas: [u16; 4], block_valid: [bool; 4]) {
self.groups_decoded += 1;
let pi = datas[0];
let block2 = datas[1];
let block3 = datas[2];
let block4 = datas[3];
let has_block3 = block_valid[2];
let has_block4 = block_valid[3];
let group_type = ((block2 >> 12) & 0x0F) as u8;
let version = ((block2 >> 11) & 0x01) as u8;
let c_bits = (block2 & 0x03) as u8;
let rt_seg_addr = (block2 & 0x0F) as usize;
self.station_info.is_traffic_program = (block2 & 0x0400) != 0; self.station_info.program_type = ((block2 >> 5) & 0x1F) as u8; self.station_info.is_traffic_announcement = (block2 & 0x0010) != 0; self.station_info.is_music = (block2 & 0x0008) != 0;
let group_type_str = format!("{}{}", group_type, if version == 0 { 'A' } else { 'B' });
let mut json_out = RdsGroupJson::new(pi, &group_type_str);
json_out.tp = Some(self.station_info.is_traffic_program);
json_out.pty_name = Some(self.program_type_name().to_string());
json_out.ta = Some(self.station_info.is_traffic_announcement);
json_out.music = Some(self.station_info.is_music);
if !block_valid.iter().all(|&v| v) {
json_out.partial = Some(true);
}
debug!(
group = %group_type_str,
pi = format!("0x{:04X}", pi),
tp = self.station_info.is_traffic_program,
pty = self.station_info.program_type,
"RDS group received"
);
if self.verbose {
debug!(
tp = self.station_info.is_traffic_program,
ta = self.station_info.is_traffic_announcement,
pty = self.station_info.program_type,
pty_name = %self.program_type_name(),
music = self.station_info.is_music,
di = %self.station_info.di_flags.as_string(),
"RDS flags"
);
}
match (group_type, version) {
(0, 0) => {
let seg = c_bits as usize;
let di_flag = (block2 & 0x0004) != 0; self.station_info.di_flags.set_flag(seg, di_flag);
if self.verbose {
debug!(
" [DI] Segment {}: {} = {}",
seg,
DIFlags::flag_name(seg),
di_flag
);
}
let af1 = ((block3 >> 8) & 0xFF) as u8;
let af2 = (block3 & 0xFF) as u8;
if self.verbose {
debug!(
" [AF] Raw codes: AF1=0x{:02X} ({}), AF2=0x{:02X} ({})",
af1, af1, af2, af2
);
}
if af1 > 0 && af1 < 205 {
let freq_10khz = 8750u16 + (af1 as u16) * 10;
if !self.station_info.af_list.contains(&freq_10khz) {
self.station_info.af_list.push(freq_10khz);
if self.verbose {
debug!(
" [AF] Added AF1: {:.1} MHz (code {})",
(freq_10khz as f32) / 100.0,
af1
);
}
}
}
if af2 > 0 && af2 < 205 {
let freq_10khz = 8750u16 + (af2 as u16) * 10;
if !self.station_info.af_list.contains(&freq_10khz) {
self.station_info.af_list.push(freq_10khz);
if self.verbose {
debug!(
" [AF] Added AF2: {:.1} MHz (code {})",
(freq_10khz as f32) / 100.0,
af2
);
}
}
}
if has_block4 {
let ch1 = ((block4 >> 8) & 0xFF) as u8; let ch2 = (block4 & 0xFF) as u8;
self.update_ps(seg, ch1, ch2);
}
}
(0, 1) => {
if has_block4 {
let ch1 = ((block4 >> 8) & 0xFF) as u8;
let ch2 = (block4 & 0xFF) as u8;
let seg = c_bits as usize;
self.update_ps(seg, ch1, ch2);
}
}
(2, 0) => {
if has_block3 && has_block4 {
let seg = rt_seg_addr & 0x0F;
let a = ((block3 >> 8) & 0xFF) as u8; let b = (block3 & 0xFF) as u8; let c = ((block4 >> 8) & 0xFF) as u8; let d = (block4 & 0xFF) as u8; let pos = seg * 4;
if pos + 3 < self.rt.len() {
self.rt[pos] = a;
self.rt[pos + 1] = b;
self.rt[pos + 2] = c;
self.rt[pos + 3] = d;
self.rt_received_mask[seg] = true;
if self.verbose {
let received_count =
self.rt_received_mask.iter().filter(|&&x| x).count();
debug!(
" [RT] Segment {}/16: 0x{:02X} 0x{:02X} 0x{:02X} 0x{:02X} = '{}{}{}{}' ({}/16 segments)",
seg,
a,
b,
c,
d,
if a.is_ascii_graphic() || a == b' ' {
a as char
} else {
'?'
},
if b.is_ascii_graphic() || b == b' ' {
b as char
} else {
'?'
},
if c.is_ascii_graphic() || c == b' ' {
c as char
} else {
'?'
},
if d.is_ascii_graphic() || d == b' ' {
d as char
} else {
'?'
},
received_count
);
}
}
}
}
(2, 1) => {
let seg = rt_seg_addr & 0x0F;
let c = ((block4 >> 8) & 0xFF) as u8; let d = (block4 & 0xFF) as u8; let pos = seg * 2; if pos + 1 < self.rt.len() {
self.rt[pos] = c;
self.rt[pos + 1] = d;
self.rt_received_mask[seg] = true;
if self.verbose {
let received_count = self.rt_received_mask.iter().filter(|&&x| x).count();
debug!(
" [RT] Segment {}/16: 0x{:02X} 0x{:02X} = '{}{}' ({}/16 segments)",
seg,
c,
d,
if c.is_ascii_graphic() || c == b' ' {
c as char
} else {
'?'
},
if d.is_ascii_graphic() || d == b' ' {
d as char
} else {
'?'
},
received_count
);
}
}
}
(1, 0) => {
let linkage_la = (block3 >> 15) & 0x01;
self.station_info.has_linkage = linkage_la != 0;
let slc_variant = ((block3 >> 12) & 0x07) as u8;
let day = ((block4 >> 11) & 0x1F) as u8;
let hour = ((block4 >> 6) & 0x1F) as u8;
let minute = (block4 & 0x3F) as u8;
if (1..=31).contains(&day) && hour <= 24 && minute <= 59 {
self.station_info.program_item = Some(ProgramItemInfo {
number: block4,
day,
hour,
minute,
});
if self.verbose {
debug!(
" [PIN] Day {} {:02}:{:02} (0x{:04X})",
day, hour, minute, block4
);
}
}
match slc_variant {
0 => {
let ecc = (block3 & 0xFF) as u8;
let cc = (pi >> 12) & 0x0F;
self.station_info.extended_country_code = Some(ecc);
if self.verbose {
debug!(
" [SLC] {} - ECC=0x{:02X}, CC={} (country code from PI)",
Self::slc_variant_name(slc_variant),
ecc,
cc
);
}
}
1 => {
let tmc_id = block3 & 0xFFF;
self.station_info.tmc_id = Some(tmc_id);
if self.verbose {
debug!(
" [SLC] {} - TMC ID=0x{:03X}",
Self::slc_variant_name(slc_variant),
tmc_id
);
}
}
2 => {
if self.verbose {
debug!(
" [SLC] {} - (Pager, deprecated)",
Self::slc_variant_name(slc_variant)
);
}
}
3 => {
let lang_code = (block3 & 0xFF) as u8;
self.station_info.language_code = Some(lang_code);
if self.verbose {
let lang_name = if lang_code < LANGUAGE_NAMES.len() as u8 {
LANGUAGE_NAMES[lang_code as usize]
} else {
"Unknown"
};
debug!(
" [SLC] {} - Language=0x{:02X} ({})",
Self::slc_variant_name(slc_variant),
lang_code,
lang_name
);
}
}
6 => {
let bits = block3 & 0x7FF;
if self.verbose {
debug!(
" [SLC] {} - Broadcaster bits=0x{:03X}",
Self::slc_variant_name(slc_variant),
bits
);
}
}
7 => {
let ews = block3 & 0xFFF;
if self.verbose {
debug!(
" [SLC] {} - EWS=0x{:03X}",
Self::slc_variant_name(slc_variant),
ews
);
}
}
_ => {
if self.verbose {
debug!(
" [SLC] {} (variant {})",
Self::slc_variant_name(slc_variant),
slc_variant
);
}
}
}
}
(3, 0) => {
let target_group_type = (block2 & 0x1F) as u8; let oda_app_id = block4;
let oda_message = block3;
if self.verbose {
debug!(
" [ODA] Target Group: {}A, App ID: 0x{:04X} ({})",
target_group_type,
oda_app_id,
Self::oda_app_name(oda_app_id)
);
}
self.station_info.oda_info = Some(ODAInfo {
target_group_type,
app_id: oda_app_id,
message: oda_message,
});
match oda_app_id {
0x4BD7 => {
if self.verbose {
let cb = (oda_message >> 12) & 0x01 != 0;
let scb = (oda_message >> 8) & 0x0F;
let template_num = (oda_message & 0xFF) as u8;
debug!(
" [RT+] CB={}, SCB=0x{:X}, Template={}",
cb, scb, template_num
);
}
}
0x6552 => {
if self.verbose {
let encoding = if (oda_message & 0x01) != 0 {
"UTF-8"
} else {
"UCS2"
};
let direction = if (oda_message & 0x02) != 0 {
"RTL"
} else {
"LTR"
};
debug!(" [eRT] Encoding={}, Direction={}", encoding, direction);
}
}
0xCD46 | 0xCD47 => {
if self.verbose {
debug!(" [TMC] ALERT-C message: 0x{:04X}", oda_message);
}
}
_ => {}
}
}
(4, 0) => {
let concat = block3 as u32 | ((block2 as u32) << 16);
let mjd = (concat >> 1) & ((1u32 << 17) - 1);
if self.verbose {
debug!(
" [Time] Block2: 0x{:04X}, Block3: 0x{:04X}",
block2, block3
);
debug!(" [Time] MJD extracted: {} (0x{:05X})", mjd, mjd);
}
let mjd_f = mjd as f64;
if mjd_f < 15079.0 {
if self.verbose {
debug!(" [Time] Invalid MJD: {}", mjd);
}
return;
}
let mut year_utc = ((mjd_f - 15078.2) / 365.25) as i32;
let mut month_utc =
((mjd_f - 14956.1 - (year_utc as f64 * 365.25).trunc()) / 30.6001) as i32;
let day_utc = (mjd_f
- 14956.0
- (year_utc as f64 * 365.25).trunc()
- (month_utc as f64 * 30.6001).trunc()) as u8;
if self.verbose {
debug!(
" [Time] Conversion: year_pre={}, month_pre={}, day={}",
year_utc, month_utc, day_utc
);
}
if month_utc == 14 || month_utc == 15 {
year_utc += 1;
month_utc -= 12;
}
year_utc += 1900;
month_utc -= 1;
if self.verbose {
debug!(
" [Time] After adjustment: year_post={}, month_post={}",
year_utc, month_utc
);
}
let hour_utc = ((block3 & 0x0001) << 4) | ((block4 >> 12) & 0x0F);
let hour_utc = hour_utc as u8;
let minute_utc = ((block4 >> 6) & 0x3F) as u8;
let offset_sign = ((block4 >> 5) & 0x01) != 0; let offset_half_hours = (block4 & 0x1F) as u8;
let local_offset = (offset_half_hours as f32) * 0.5;
let local_offset = if offset_sign {
-local_offset
} else {
local_offset
};
if (1..=31).contains(&day_utc)
&& hour_utc <= 23
&& minute_utc <= 59
&& year_utc >= 1900
{
self.station_info.clock_time = Some(ClockTimeInfo {
year: year_utc as u16,
month: month_utc as u8,
day: day_utc,
hour: hour_utc,
minute: minute_utc,
local_offset,
});
if self.verbose {
debug!(
" [Time] {}-{:02}-{:02} {:02}:{:02} UTC, Offset: {:+.1}h",
year_utc, month_utc, day_utc, hour_utc, minute_utc, local_offset
);
}
}
}
(10, 0) => {
let seg = ((block2 >> 4) & 0x03) as usize;
let block3_inv = block3 ^ 0xFFFF;
let block4_inv = block4 ^ 0xFFFF;
let c1 = (block3_inv & 0xFF) as u8;
let c2 = ((block3_inv >> 8) & 0xFF) as u8;
let c3 = (block4_inv & 0xFF) as u8;
let c4 = ((block4_inv >> 8) & 0xFF) as u8;
if self.verbose {
debug!(
" [PTYN] Segment {}/4: chars at positions {} {}: '{}{}{}{}' (0x{:02X} 0x{:02X} 0x{:02X} 0x{:02X})",
seg,
seg * 4,
seg * 4 + 3,
if c1.is_ascii_graphic() || c1 == b' ' {
c1 as char
} else {
'?'
},
if c2.is_ascii_graphic() || c2 == b' ' {
c2 as char
} else {
'?'
},
if c3.is_ascii_graphic() || c3 == b' ' {
c3 as char
} else {
'?'
},
if c4.is_ascii_graphic() || c4 == b' ' {
c4 as char
} else {
'?'
},
c1,
c2,
c3,
c4
);
}
}
(14, 0) => {
let on_pi = block4;
let eon_variant = (block2 & 0x0F) as u8;
let mut eon_data = EONInfo {
pi: on_pi,
variant: eon_variant,
program_type: None,
has_linkage: None,
linkage_set: None,
alt_frequency: None,
};
if self.verbose {
debug!(
" [EON] PI=0x{:04X}, Variant {} (Other Network)",
on_pi, eon_variant
);
}
if eon_variant <= 3 {
let b3_h = ((block3 >> 8) & 0xFF) as u8;
let b3_l = (block3 & 0xFF) as u8;
if self.verbose {
debug!(
" [EON-PS] Segment {}: chars at {} {}: '{}{}' (0x{:02X} 0x{:02X})",
eon_variant,
eon_variant * 2,
eon_variant * 2 + 1,
if b3_h.is_ascii_graphic() || b3_h == b' ' {
b3_h as char
} else {
'?'
},
if b3_l.is_ascii_graphic() || b3_l == b' ' {
b3_l as char
} else {
'?'
},
b3_h,
b3_l
);
}
}
else if eon_variant == 4 {
let af1 = ((block3 >> 8) & 0xFF) as u8;
let af2 = (block3 & 0xFF) as u8;
eon_data.alt_frequency = Some(af1);
if self.verbose {
debug!(" [EON-AF] AF1=0x{:02X}, AF2=0x{:02X}", af1, af2);
}
}
else if (5..=9).contains(&eon_variant) {
let freq_code = (block3 & 0xFF) as u8;
if freq_code > 0 && freq_code < 205 {
let freq_mhz = 87.5 + (freq_code as f32) * 0.1;
if self.verbose {
debug!(
" [EON-Freq] Variant {} = {:.1} MHz",
eon_variant, freq_mhz
);
}
}
}
else if eon_variant == 12 {
let has_linkage = (block3 >> 15) & 0x01 != 0;
let lsn = block3 & 0x0FFF;
eon_data.has_linkage = Some(has_linkage);
if has_linkage && lsn != 0 {
eon_data.linkage_set = Some(lsn);
}
if self.verbose {
debug!(
" [EON-Link] Has linkage: {}, LSN=0x{:03X}",
has_linkage, lsn
);
}
}
else if eon_variant == 13 {
let pty = ((block3 >> 11) & 0x1F) as u8;
let ta = (block3 & 0x0001) != 0;
eon_data.program_type = Some(pty);
if self.verbose {
debug!(
" [EON-PTY] PTY={} ({}), TA={}",
pty,
if (pty as usize) < PTY_NAMES.len() {
PTY_NAMES[pty as usize]
} else {
"Unknown"
},
ta
);
}
}
else if eon_variant == 14 {
if self.verbose {
let day = ((block3 >> 11) & 0x1F) as u8;
let hour = ((block3 >> 6) & 0x1F) as u8;
let minute = (block3 & 0x3F) as u8;
debug!(" [EON-PIN] Day {}, {:02}:{:02}", day, hour, minute);
}
}
else if eon_variant == 15 && self.verbose {
debug!(" [EON-Data] Broadcaster data: 0x{:04X}", block3);
}
self.station_info.eon_info = Some(eon_data);
}
_ => {
trace!(group = %group_type_str, "Unhandled RDS group type");
}
}
if let Some(ps) = self.station_name() {
json_out.ps = Some(ps);
}
if let Some(rt) = self.radio_text() {
json_out.rt = Some(rt);
}
if !self.station_info.af_list.is_empty() {
let af_strs: Vec<String> = self
.station_info
.af_list
.iter()
.map(|&f| format!("{:.1}", f as f64 / 100.0))
.collect();
json_out.af = Some(af_strs);
}
if let Some(clock) = &self.station_info.clock_time {
json_out.ct = Some(format!(
"{}-{:02}-{:02} {:02}:{:02} UTC ({:+.1}h)",
clock.year, clock.month, clock.day, clock.hour, clock.minute, clock.local_offset
));
}
if self.json_mode {
self.json_output_queue.push(json_out);
}
}
}
impl Default for RdsParser {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "rds_tests.rs"]
mod tests;