kenwood_thd75/sdcard/
repeater_list.rs

1//! Parser for D-STAR repeater list `.tsv` files.
2//!
3//! The repeater list is a UTF-16LE encoded, tab-separated file with a
4//! BOM (`FF FE`). It contains the D-STAR repeater directory used for
5//! DR (D-STAR Repeater) mode operation.
6//!
7//! # Location
8//!
9//! `/KENWOOD/TH-D75/SETTINGS/RPT_LIST/*.tsv`
10//!
11//! # Capacity
12//!
13//! Up to 1500 repeater entries.
14
15use super::SdCardError;
16
17/// Number of expected columns in the repeater list TSV.
18const EXPECTED_COLUMNS: usize = 8;
19
20/// A single D-STAR repeater directory entry.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct RepeaterEntry {
23    /// Repeater group or region name (e.g., `"Japan"`).
24    pub group_name: String,
25    /// Repeater name or description.
26    pub name: String,
27    /// Sub-name or area description.
28    pub sub_name: String,
29    /// RPT1 callsign (D-STAR 8-char, space-padded, e.g., `"JR6YPR A"`).
30    pub callsign_rpt1: String,
31    /// RPT2/gateway callsign (D-STAR 8-char, space-padded, e.g., `"JR6YPR G"`).
32    pub callsign_rpt2: String,
33    /// Operating frequency in Hz.
34    pub frequency: u32,
35    /// Duplex direction (`"+"`, `"-"`, or empty for simplex).
36    pub duplex: String,
37    /// TX offset frequency in Hz.
38    pub offset: u32,
39}
40
41/// Parses a repeater list TSV file from raw bytes.
42///
43/// Expects UTF-16LE encoding with a BOM prefix (`FF FE`). The first
44/// line is treated as a column header and is skipped.
45///
46/// # Errors
47///
48/// Returns an [`SdCardError`] if the encoding is invalid or any data
49/// row has an unexpected column count.
50pub fn parse_repeater_list(data: &[u8]) -> Result<Vec<RepeaterEntry>, SdCardError> {
51    let text = decode_utf16le_bom(data)?;
52    let mut entries = Vec::new();
53
54    for (line_idx, line) in text.lines().enumerate() {
55        // Skip header row and blank lines.
56        if line_idx == 0 || line.trim().is_empty() {
57            continue;
58        }
59
60        let line_num = line_idx + 1;
61        let cols: Vec<&str> = line.split('\t').collect();
62        if cols.len() < EXPECTED_COLUMNS {
63            return Err(SdCardError::ColumnCount {
64                line: line_num,
65                expected: EXPECTED_COLUMNS,
66                actual: cols.len(),
67            });
68        }
69
70        let frequency = parse_frequency_mhz(cols[5], line_num, "Frequency")?;
71        let offset = parse_frequency_mhz(cols[7], line_num, "Offset")?;
72
73        entries.push(RepeaterEntry {
74            group_name: cols[0].to_owned(),
75            name: cols[1].to_owned(),
76            sub_name: cols[2].to_owned(),
77            callsign_rpt1: cols[3].to_owned(),
78            callsign_rpt2: cols[4].to_owned(),
79            frequency,
80            duplex: cols[6].to_owned(),
81            offset,
82        });
83    }
84
85    Ok(entries)
86}
87
88/// Generates a repeater list TSV file as UTF-16LE bytes with BOM.
89///
90/// The output includes a header row followed by one row per entry.
91#[must_use]
92pub fn write_repeater_list(entries: &[RepeaterEntry]) -> Vec<u8> {
93    let mut text = String::new();
94
95    // Header row
96    text.push_str(
97        "Group Name\tName\tSub Name\tRepeater Call Sign\t\
98         Gateway Call Sign\tFrequency\tDup\tOffset\r\n",
99    );
100
101    // Data rows
102    for entry in entries {
103        text.push_str(&entry.group_name);
104        text.push('\t');
105        text.push_str(&entry.name);
106        text.push('\t');
107        text.push_str(&entry.sub_name);
108        text.push('\t');
109        text.push_str(&entry.callsign_rpt1);
110        text.push('\t');
111        text.push_str(&entry.callsign_rpt2);
112        text.push('\t');
113        text.push_str(&format_frequency_mhz(entry.frequency));
114        text.push('\t');
115        text.push_str(&entry.duplex);
116        text.push('\t');
117        text.push_str(&format_frequency_mhz(entry.offset));
118        text.push_str("\r\n");
119    }
120
121    encode_utf16le_bom(&text)
122}
123
124/// Parses a MHz frequency string (e.g., `"145.000000"`) into Hz.
125fn parse_frequency_mhz(s: &str, line: usize, column: &str) -> Result<u32, SdCardError> {
126    let trimmed = s.trim();
127    if trimmed.is_empty() {
128        return Ok(0);
129    }
130    let mhz: f64 = trimmed.parse().map_err(|_| SdCardError::InvalidField {
131        line,
132        column: column.to_owned(),
133        detail: format!("invalid frequency: {trimmed:?}"),
134    })?;
135    // Convert MHz to Hz, rounding to nearest integer.
136    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
137    let hz = (mhz * 1_000_000.0).round() as u32;
138    Ok(hz)
139}
140
141/// Formats a frequency in Hz as a MHz string with 6 decimal places.
142fn format_frequency_mhz(hz: u32) -> String {
143    let mhz = f64::from(hz) / 1_000_000.0;
144    format!("{mhz:.6}")
145}
146
147/// Decodes a UTF-16LE byte sequence with a leading BOM into a `String`.
148fn decode_utf16le_bom(data: &[u8]) -> Result<String, SdCardError> {
149    if data.len() < 2 {
150        return Err(SdCardError::MissingBom);
151    }
152    if data[0] != 0xFF || data[1] != 0xFE {
153        return Err(SdCardError::MissingBom);
154    }
155
156    let payload = &data[2..];
157    if !payload.len().is_multiple_of(2) {
158        return Err(SdCardError::InvalidUtf16Length { len: payload.len() });
159    }
160
161    let code_units: Vec<u16> = payload
162        .chunks_exact(2)
163        .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
164        .collect();
165
166    String::from_utf16(&code_units).map_err(|e| SdCardError::Utf16Decode {
167        detail: e.to_string(),
168    })
169}
170
171/// Encodes a string as UTF-16LE bytes with a leading BOM.
172fn encode_utf16le_bom(text: &str) -> Vec<u8> {
173    let mut out = Vec::with_capacity(2 + text.len() * 2);
174    // BOM
175    out.push(0xFF);
176    out.push(0xFE);
177    // UTF-16LE payload
178    for unit in text.encode_utf16() {
179        let bytes = unit.to_le_bytes();
180        out.push(bytes[0]);
181        out.push(bytes[1]);
182    }
183    out
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn decode_utf16le_bom_basic() {
192        let text = "hello";
193        let encoded = encode_utf16le_bom(text);
194        let decoded = decode_utf16le_bom(&encoded).unwrap();
195        assert_eq!(decoded, "hello");
196    }
197
198    #[test]
199    fn decode_utf16le_missing_bom() {
200        let err = decode_utf16le_bom(&[0x00, 0x00]).unwrap_err();
201        assert!(matches!(err, SdCardError::MissingBom));
202    }
203
204    #[test]
205    fn decode_utf16le_odd_length() {
206        let err = decode_utf16le_bom(&[0xFF, 0xFE, 0x41]).unwrap_err();
207        assert!(matches!(err, SdCardError::InvalidUtf16Length { .. }));
208    }
209
210    #[test]
211    fn format_frequency_round_trip() {
212        let hz = 145_000_000u32;
213        let s = format_frequency_mhz(hz);
214        assert_eq!(s, "145.000000");
215        let back = parse_frequency_mhz(&s, 1, "test").unwrap();
216        assert_eq!(back, hz);
217    }
218
219    #[test]
220    fn parse_frequency_empty() {
221        let hz = parse_frequency_mhz("", 1, "test").unwrap();
222        assert_eq!(hz, 0);
223    }
224}