kenwood_thd75/sdcard/
mod.rs

1//! SD card file format parsers for the TH-D75.
2//!
3//! The TH-D75 stores configuration data, logs, recordings, and captures
4//! on a microSD/microSDHC card (up to 32 GB, per Operating Tips §5.14).
5//! These parsers allow reading and writing radio data without entering
6//! MCP programming mode -- just mount the SD card via USB Mass Storage
7//! mode (Menu No. 980) or remove it physically.
8//!
9//! Per User Manual Chapter 19:
10//!
11//! - Supported cards: microSD (2 GB) and microSDHC (4-32 GB).
12//!   microSDXC is NOT supported.
13//! - File system: FAT32. Maximum 255 files per folder.
14//! - Format via Menu No. 830 (erases all data).
15//! - Unmount before removal via Menu No. 820.
16//! - Export config: Menu No. 800-803. Import: Menu No. 810-813.
17//! - Mass Storage mode (Menu No. 980 set to `Mass Storage`): the radio
18//!   appears as a removable disk on the PC. RX/TX and recording are
19//!   disabled in this mode.
20//!
21//! Per User Manual Chapter 20 (Recording):
22//!
23//! - Recording format: WAV, 16-bit, 16 kHz, mono.
24//! - Up to 2 GB per file (approximately 18 hours). Continues in a new
25//!   file if exceeded.
26//! - Recording band selectable: A or B (Menu No. 302).
27//! - Recording starts/stops via Menu No. 301.
28//!
29//! Per User Manual Chapter 19 (QSO Log):
30//!
31//! - Menu No. 180 enables QSO history logging.
32//! - Format: TSV (tab-separated values).
33//! - Records: TX/RX, date, frequency, mode, position, power, S-meter,
34//!   callsigns, messages, repeater control flags, and more.
35//!
36//! # File Types
37//!
38//! | Path | Format | Type | Parsed? |
39//! |------|--------|------|---------|
40//! | `KENWOOD/TH-D75/SETTINGS/DATA/*.d75` | Binary | Full radio configuration | Yes |
41//! | `KENWOOD/TH-D75/SETTINGS/RPT_LIST/*.tsv` | UTF-16LE TSV | D-STAR repeater list | Yes |
42//! | `KENWOOD/TH-D75/SETTINGS/CALLSIGN_LIST/*.tsv` | UTF-16LE TSV | D-STAR callsign list | Yes |
43//! | `KENWOOD/TH-D75/QSO_LOG/*.tsv` | TSV | QSO contact history | Yes |
44//! | `KENWOOD/TH-D75/GPS_LOG/*.nme` | NMEA 0183 | GPS track logs | Yes |
45//! | `KENWOOD/TH-D75/AUDIO_REC/*.wav` | WAV 16kHz/16-bit/mono | TX/RX audio recordings | Yes |
46//! | `KENWOOD/TH-D75/CAPTURE/*.bmp` | BMP 240x180/24-bit | Screen captures | Yes |
47//!
48//! # Encoding
49//!
50//! All parsers accept `&[u8]` input — the caller decides how to read the
51//! file (e.g., `std::fs::read`, memory-mapped, etc.).
52//!
53//! The repeater list and callsign list use UTF-16LE encoding with a BOM.
54//! The QSO log and GPS log use plain ASCII/UTF-8 text.
55
56pub mod audio;
57pub mod callsign_list;
58pub mod capture;
59pub mod config;
60pub mod gps_log;
61pub mod qso_log;
62pub mod repeater_list;
63
64pub use audio::AudioRecording;
65pub use capture::ScreenCapture;
66
67use std::fmt;
68
69/// Errors that can occur when parsing SD card files.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum SdCardError {
72    /// The file is too small to contain the expected data.
73    FileTooSmall {
74        /// Minimum expected size in bytes.
75        expected: usize,
76        /// Actual size in bytes.
77        actual: usize,
78    },
79
80    /// The .d75 file header contains an unrecognised model string.
81    InvalidModelString {
82        /// The model string found in the header.
83        found: String,
84    },
85
86    /// A UTF-16LE encoded file is missing the byte order mark (BOM).
87    MissingBom,
88
89    /// A UTF-16LE file contains an odd number of bytes (invalid encoding).
90    InvalidUtf16Length {
91        /// The byte count, which must be even for UTF-16.
92        len: usize,
93    },
94
95    /// A UTF-16 code unit sequence could not be decoded.
96    Utf16Decode {
97        /// Human-readable detail about the decode failure.
98        detail: String,
99    },
100
101    /// A TSV row has an unexpected number of columns.
102    ColumnCount {
103        /// The 1-based line number in the file.
104        line: usize,
105        /// The expected number of columns.
106        expected: usize,
107        /// The actual number of columns.
108        actual: usize,
109    },
110
111    /// A required field in a TSV row is empty or invalid.
112    InvalidField {
113        /// The 1-based line number in the file.
114        line: usize,
115        /// The column name or index.
116        column: String,
117        /// Human-readable detail about the problem.
118        detail: String,
119    },
120
121    /// A channel entry in the .d75 binary could not be parsed.
122    ChannelParse {
123        /// The 0-based channel index.
124        index: u16,
125        /// Human-readable detail about the parse failure.
126        detail: String,
127    },
128
129    /// A WAV file header is invalid or corrupt.
130    InvalidWavHeader {
131        /// Human-readable detail about the problem.
132        detail: String,
133    },
134
135    /// A WAV file has a valid header but unexpected audio format
136    /// (not matching TH-D75 spec: 16 kHz, 16-bit, mono).
137    UnexpectedAudioFormat {
138        /// The sample rate found in the file.
139        sample_rate: u32,
140        /// The bits per sample found in the file.
141        bits_per_sample: u16,
142        /// The channel count found in the file.
143        channels: u16,
144    },
145
146    /// A BMP file header is invalid or corrupt.
147    InvalidBmpHeader {
148        /// Human-readable detail about the problem.
149        detail: String,
150    },
151
152    /// A BMP file has a valid header but unexpected image format
153    /// (not matching TH-D75 spec: 240x180, 24-bit).
154    UnexpectedImageFormat {
155        /// The image width found in the file.
156        width: u32,
157        /// The image height found in the file.
158        height: u32,
159        /// The bits per pixel found in the file.
160        bits_per_pixel: u16,
161    },
162}
163
164impl fmt::Display for SdCardError {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        match self {
167            Self::FileTooSmall { expected, actual } => {
168                write!(
169                    f,
170                    "file too small: expected at least {expected} bytes, got {actual}"
171                )
172            }
173            Self::InvalidModelString { found } => {
174                write!(f, "invalid model string in .d75 header: {found:?}")
175            }
176            Self::MissingBom => write!(f, "UTF-16LE file missing byte order mark (BOM)"),
177            Self::InvalidUtf16Length { len } => {
178                write!(f, "UTF-16LE file has odd byte count ({len}), expected even")
179            }
180            Self::Utf16Decode { detail } => {
181                write!(f, "UTF-16 decode error: {detail}")
182            }
183            Self::ColumnCount {
184                line,
185                expected,
186                actual,
187            } => {
188                write!(f, "line {line}: expected {expected} columns, got {actual}")
189            }
190            Self::InvalidField {
191                line,
192                column,
193                detail,
194            } => {
195                write!(f, "line {line}, column {column}: {detail}")
196            }
197            Self::ChannelParse { index, detail } => {
198                write!(f, "channel {index}: {detail}")
199            }
200            Self::InvalidWavHeader { detail } => {
201                write!(f, "invalid WAV header: {detail}")
202            }
203            Self::UnexpectedAudioFormat {
204                sample_rate,
205                bits_per_sample,
206                channels,
207            } => {
208                write!(
209                    f,
210                    "unexpected WAV format: {sample_rate} Hz, {bits_per_sample}-bit, \
211                     {channels} ch (expected 16000 Hz, 16-bit, 1 ch)"
212                )
213            }
214            Self::InvalidBmpHeader { detail } => {
215                write!(f, "invalid BMP header: {detail}")
216            }
217            Self::UnexpectedImageFormat {
218                width,
219                height,
220                bits_per_pixel,
221            } => {
222                write!(
223                    f,
224                    "unexpected BMP format: {width}x{height} @ {bits_per_pixel} bpp \
225                     (expected 240x180 @ 24 bpp)"
226                )
227            }
228        }
229    }
230}
231
232impl std::error::Error for SdCardError {}
233
234/// Read a little-endian `u16` from a byte slice at the given offset.
235pub(crate) fn read_u16_le(data: &[u8], offset: usize) -> u16 {
236    u16::from_le_bytes([data[offset], data[offset + 1]])
237}
238
239/// Read a little-endian `u32` from a byte slice at the given offset.
240pub(crate) fn read_u32_le(data: &[u8], offset: usize) -> u32 {
241    u32::from_le_bytes([
242        data[offset],
243        data[offset + 1],
244        data[offset + 2],
245        data[offset + 3],
246    ])
247}