kenwood_thd75/sdcard/
qso_log.rs

1//! Parser for QSO log `.tsv` files.
2//!
3//! The QSO log records communication history, primarily for D-STAR
4//! contacts. Each entry contains the direction (TX/RX), timestamp,
5//! frequency, mode, GPS position, signal report, and D-STAR routing
6//! information.
7//!
8//! # Location
9//!
10//! `/KENWOOD/TH-D75/QSO_LOG/*.tsv`
11//!
12//! # Format
13//!
14//! Tab-separated values, plain text (ASCII/UTF-8). The first line is
15//! a header row with 24 column names.
16
17use super::SdCardError;
18
19/// Number of expected columns in the QSO log TSV.
20const EXPECTED_COLUMNS: usize = 24;
21
22/// A single QSO log entry.
23///
24/// All fields are stored as strings to preserve the exact firmware
25/// output format. Callers can parse individual fields as needed.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct QsoEntry {
28    /// Direction: `"TX"` or `"RX"`.
29    pub tx_rx: String,
30    /// Date and time of the contact (e.g., `"2026/03/28 14:30"`).
31    pub date: String,
32    /// Operating frequency (e.g., `"145.000.000"` or Hz string).
33    pub frequency: String,
34    /// Operating mode: `"FM"`, `"DV"`, `"NFM"`, or `"AM"`.
35    pub mode: String,
36    /// Own GPS latitude at time of QSO.
37    pub my_latitude: String,
38    /// Own GPS longitude at time of QSO.
39    pub my_longitude: String,
40    /// Own GPS altitude at time of QSO.
41    pub my_altitude: String,
42    /// Transmit power level.
43    pub rf_power: String,
44    /// Signal strength reading.
45    pub s_meter: String,
46    /// Source callsign (own callsign for TX, remote for RX).
47    pub caller: String,
48    /// User memo/notes field.
49    pub memo: String,
50    /// Destination callsign (URCALL).
51    pub called: String,
52    /// D-STAR RPT1 (link source repeater).
53    pub rpt1: String,
54    /// D-STAR RPT2 (link destination repeater).
55    pub rpt2: String,
56    /// D-STAR slow-data message.
57    pub message: String,
58    /// Repeater control flags.
59    pub repeater_control: String,
60    /// Break (BK) flag.
61    pub bk: String,
62    /// Emergency (EMR) flag.
63    pub emr: String,
64    /// D-STAR fast data flag.
65    pub fast_data: String,
66    /// Remote station latitude (from D-STAR GPS data).
67    pub latitude: String,
68    /// Remote station longitude (from D-STAR GPS data).
69    pub longitude: String,
70    /// Remote station altitude.
71    pub altitude: String,
72    /// Remote station course/heading.
73    pub course: String,
74    /// Remote station speed.
75    pub speed: String,
76}
77
78/// Parses a QSO log TSV file from raw bytes.
79///
80/// Expects plain ASCII/UTF-8 text with tab-separated columns. The
81/// first line is treated as a header row and is skipped. Each data
82/// row must have exactly 24 columns.
83///
84/// # Errors
85///
86/// Returns an [`SdCardError`] if a data row has an unexpected column
87/// count.
88pub fn parse_qso_log(data: &[u8]) -> Result<Vec<QsoEntry>, SdCardError> {
89    let text = String::from_utf8_lossy(data);
90    let mut entries = Vec::new();
91
92    for (line_idx, line) in text.lines().enumerate() {
93        // Skip header row and blank lines.
94        if line_idx == 0 || line.trim().is_empty() {
95            continue;
96        }
97
98        let line_num = line_idx + 1;
99        let cols: Vec<&str> = line.split('\t').collect();
100        if cols.len() < EXPECTED_COLUMNS {
101            return Err(SdCardError::ColumnCount {
102                line: line_num,
103                expected: EXPECTED_COLUMNS,
104                actual: cols.len(),
105            });
106        }
107
108        entries.push(QsoEntry {
109            tx_rx: cols[0].to_owned(),
110            date: cols[1].to_owned(),
111            frequency: cols[2].to_owned(),
112            mode: cols[3].to_owned(),
113            my_latitude: cols[4].to_owned(),
114            my_longitude: cols[5].to_owned(),
115            my_altitude: cols[6].to_owned(),
116            rf_power: cols[7].to_owned(),
117            s_meter: cols[8].to_owned(),
118            caller: cols[9].to_owned(),
119            memo: cols[10].to_owned(),
120            called: cols[11].to_owned(),
121            rpt1: cols[12].to_owned(),
122            rpt2: cols[13].to_owned(),
123            message: cols[14].to_owned(),
124            repeater_control: cols[15].to_owned(),
125            bk: cols[16].to_owned(),
126            emr: cols[17].to_owned(),
127            fast_data: cols[18].to_owned(),
128            latitude: cols[19].to_owned(),
129            longitude: cols[20].to_owned(),
130            altitude: cols[21].to_owned(),
131            course: cols[22].to_owned(),
132            speed: cols[23].to_owned(),
133        });
134    }
135
136    Ok(entries)
137}
138
139/// Generates a QSO log TSV file as UTF-8 bytes.
140///
141/// The output includes the 24-column header row followed by one row
142/// per entry.
143#[must_use]
144pub fn write_qso_log(entries: &[QsoEntry]) -> Vec<u8> {
145    let mut text = String::new();
146
147    // Header row (matching firmware column names)
148    text.push_str(
149        "TX/RX\tDate\tFrequency\tMode\t\
150         My Latitude\tMy Longitude\tMy Altitude\t\
151         RF Power\tS Meter\tCaller\tMemo\tCalled\t\
152         RPT1\tRPT2\tMessage\tRepeater Control\t\
153         BK\tEMR\tFast Data\t\
154         Latitude\tLongitude\tAltitude\tCourse\tSpeed\r\n",
155    );
156
157    // Data rows
158    for e in entries {
159        text.push_str(&e.tx_rx);
160        text.push('\t');
161        text.push_str(&e.date);
162        text.push('\t');
163        text.push_str(&e.frequency);
164        text.push('\t');
165        text.push_str(&e.mode);
166        text.push('\t');
167        text.push_str(&e.my_latitude);
168        text.push('\t');
169        text.push_str(&e.my_longitude);
170        text.push('\t');
171        text.push_str(&e.my_altitude);
172        text.push('\t');
173        text.push_str(&e.rf_power);
174        text.push('\t');
175        text.push_str(&e.s_meter);
176        text.push('\t');
177        text.push_str(&e.caller);
178        text.push('\t');
179        text.push_str(&e.memo);
180        text.push('\t');
181        text.push_str(&e.called);
182        text.push('\t');
183        text.push_str(&e.rpt1);
184        text.push('\t');
185        text.push_str(&e.rpt2);
186        text.push('\t');
187        text.push_str(&e.message);
188        text.push('\t');
189        text.push_str(&e.repeater_control);
190        text.push('\t');
191        text.push_str(&e.bk);
192        text.push('\t');
193        text.push_str(&e.emr);
194        text.push('\t');
195        text.push_str(&e.fast_data);
196        text.push('\t');
197        text.push_str(&e.latitude);
198        text.push('\t');
199        text.push_str(&e.longitude);
200        text.push('\t');
201        text.push_str(&e.altitude);
202        text.push('\t');
203        text.push_str(&e.course);
204        text.push('\t');
205        text.push_str(&e.speed);
206        text.push_str("\r\n");
207    }
208
209    text.into_bytes()
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn parse_empty_log() {
218        let data = b"TX/RX\tDate\tFrequency\tMode\t\
219            My Latitude\tMy Longitude\tMy Altitude\t\
220            RF Power\tS Meter\tCaller\tMemo\tCalled\t\
221            RPT1\tRPT2\tMessage\tRepeater Control\t\
222            BK\tEMR\tFast Data\t\
223            Latitude\tLongitude\tAltitude\tCourse\tSpeed\r\n";
224        let entries = parse_qso_log(data).unwrap();
225        assert!(entries.is_empty());
226    }
227
228    #[test]
229    fn parse_too_few_columns() {
230        let data = b"TX/RX\tDate\tFrequency\n\
231            TX\t2026/03/28\n";
232        let err = parse_qso_log(data).unwrap_err();
233        assert!(matches!(err, SdCardError::ColumnCount { line: 2, .. }));
234    }
235}