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}