kenwood_thd75/sdcard/
config.rs

1//! Parser for `.d75` configuration files.
2//!
3//! These files contain the complete radio configuration and can be
4//! saved (Menu No. 800) and loaded (Menu No. 810) from the microSD card.
5//! The data format is the same as the MCP-D75 PC application uses.
6//!
7//! Per Operating Tips ยง5.14.3: it is recommended to export and save the
8//! configuration before performing a firmware upgrade, as the upgrade
9//! process may reset settings.
10//!
11//! The file format is a 256-byte header followed by a raw memory image
12//! identical to what the MCP programming protocol reads.
13//!
14//! # File Layout
15//!
16//! | Offset | Size | Content |
17//! |--------|------|---------|
18//! | 0x000 | 0x100 | File header (model ID, metadata) |
19//! | 0x100 | ... | MCP memory image (settings, channels, names, etc.) |
20//!
21//! Channel data lives at `.d75 offset 0x100 + MCP offset`. The exact
22//! section layout is inferred from D74 development notes and adapted
23//! for the D75's expanded feature set.
24
25use super::SdCardError;
26use crate::types::channel::FlashChannel;
27
28/// Size of the `.d75` file header in bytes.
29pub const HEADER_SIZE: usize = 0x100;
30
31/// Maximum number of memory channels on the TH-D75.
32pub const MAX_CHANNELS: usize = 1000;
33
34/// Size of each channel memory entry in bytes.
35const CHANNEL_ENTRY_SIZE: usize = FlashChannel::BYTE_SIZE; // 40
36
37/// Size of each channel name entry in bytes.
38const CHANNEL_NAME_SIZE: usize = 16;
39
40/// `.d75` file offset to the channel flags table.
41///
42/// Each channel has a 4-byte flags entry. This precedes the channel
43/// memory data in the file layout.
44///
45/// File offset = `HEADER_SIZE + 0x2000 = 0x2100`.
46const CHANNEL_FLAGS_OFFSET: usize = HEADER_SIZE + 0x2000;
47
48/// `.d75` file offset to the channel memory data section.
49///
50/// Each channel is a 40-byte structure.
51///
52/// File offset = `HEADER_SIZE + 0x4000 = 0x4100`.
53const CHANNEL_DATA_OFFSET: usize = HEADER_SIZE + 0x4000;
54
55/// `.d75` file offset to the channel name table.
56///
57/// Channel names are 16-byte null-padded strings.
58///
59/// File offset = `HEADER_SIZE + 0x10000 = 0x10100`.
60const CHANNEL_NAME_OFFSET: usize = HEADER_SIZE + 0x10000;
61
62/// Size of each channel flags entry in bytes.
63const CHANNEL_FLAGS_SIZE: usize = 4;
64
65/// Known model identification strings found at offset 0 of the header.
66const KNOWN_MODELS: &[&str] = &["Data For TH-D75A", "Data For TH-D75E", "Data For TH-D75"];
67
68/// Parsed `.d75` configuration file header (256 bytes).
69///
70/// The header contains the model identification string and metadata
71/// fields. The radio rejects files with unrecognised model strings.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ConfigHeader {
74    /// Model identification string (e.g., `"Data For TH-D75A"`).
75    ///
76    /// Null-terminated, stored at offset 0x00 (up to 16 bytes).
77    pub model: String,
78
79    /// Version/checksum bytes at offset 0x14 (4 bytes).
80    ///
81    /// Observed as `0x95C48F42` for the TH-D75A; exact semantics unknown.
82    pub version_bytes: [u8; 4],
83
84    /// Raw header bytes preserved for round-trip fidelity.
85    ///
86    /// Always exactly 256 bytes. Fields above are parsed views into
87    /// this buffer.
88    pub raw: [u8; HEADER_SIZE],
89}
90
91/// Complete radio configuration from a `.d75` file.
92///
93/// This is the top-level structure returned by [`parse_config`].
94#[derive(Debug, Clone)]
95pub struct RadioConfig {
96    /// The 256-byte file header.
97    pub header: ConfigHeader,
98
99    /// Parsed memory channels (up to 1000).
100    ///
101    /// Each entry pairs the channel data with its display name and
102    /// flags. Unused channels (all-`0xFF` frequency) are still
103    /// present; check [`ChannelEntry::used`] to filter.
104    pub channels: Vec<ChannelEntry>,
105
106    /// Raw settings bytes (everything outside the channel regions).
107    ///
108    /// This preserves all data between the header and the channel
109    /// sections, and after the channel name table, enabling
110    /// round-trip write-back of settings we do not yet parse.
111    pub raw_image: Vec<u8>,
112}
113
114/// A single memory channel combining frequency data, display name, and flags.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct ChannelEntry {
117    /// Channel number (0--999).
118    pub number: u16,
119
120    /// User-assigned display name (up to 16 bytes, ASCII).
121    pub name: String,
122
123    /// The 40-byte flash channel data (frequency, mode, tone, offset, etc.).
124    ///
125    /// Uses the flash memory encoding ([`FlashChannel`]) which differs from
126    /// the CAT wire format ([`crate::types::ChannelMemory`]). Key differences
127    /// include the mode field (8 modes vs 4) and structured tone/duplex bit
128    /// packing.
129    pub flash: FlashChannel,
130
131    /// Whether this channel slot contains valid data.
132    ///
133    /// A channel is considered unused when its RX frequency is
134    /// `0x00000000` or `0xFFFFFFFF`.
135    pub used: bool,
136
137    /// Channel lockout state from the flags table.
138    pub lockout: bool,
139}
140
141/// Parses a `.d75` configuration file from raw bytes.
142///
143/// # Errors
144///
145/// Returns [`SdCardError::FileTooSmall`] if the data is shorter than
146/// the minimum required size, or [`SdCardError::InvalidModelString`]
147/// if the header model is not recognised.
148pub fn parse_config(data: &[u8]) -> Result<RadioConfig, SdCardError> {
149    // Minimum size: header + channel names region must be reachable.
150    let min_size = CHANNEL_NAME_OFFSET + (MAX_CHANNELS * CHANNEL_NAME_SIZE);
151    if data.len() < min_size {
152        return Err(SdCardError::FileTooSmall {
153            expected: min_size,
154            actual: data.len(),
155        });
156    }
157
158    // --- Parse header ---
159    let mut raw_header = [0u8; HEADER_SIZE];
160    raw_header.copy_from_slice(&data[..HEADER_SIZE]);
161
162    let model = extract_null_terminated(&raw_header[..16]);
163    if !KNOWN_MODELS.contains(&model.as_str()) {
164        return Err(SdCardError::InvalidModelString { found: model });
165    }
166
167    let mut version_bytes = [0u8; 4];
168    version_bytes.copy_from_slice(&raw_header[0x14..0x18]);
169
170    let header = ConfigHeader {
171        model,
172        version_bytes,
173        raw: raw_header,
174    };
175
176    // --- Parse channels ---
177    let mut channels = Vec::with_capacity(MAX_CHANNELS);
178
179    for i in 0..MAX_CHANNELS {
180        let ch_offset = CHANNEL_DATA_OFFSET + (i * CHANNEL_ENTRY_SIZE);
181        let name_offset = CHANNEL_NAME_OFFSET + (i * CHANNEL_NAME_SIZE);
182        let flags_offset = CHANNEL_FLAGS_OFFSET + (i * CHANNEL_FLAGS_SIZE);
183
184        // Channel data: if the file is too short for this channel,
185        // treat it as unused rather than erroring (the file may have
186        // been truncated after the documented sections).
187        let ch_end = ch_offset + CHANNEL_ENTRY_SIZE;
188        #[allow(clippy::cast_possible_truncation)]
189        let ch_index = i as u16; // MAX_CHANNELS = 1000, always fits in u16
190
191        let (used, flash) = if ch_end <= data.len() {
192            let ch_bytes = &data[ch_offset..ch_end];
193            let rx_freq = u32::from_le_bytes([ch_bytes[0], ch_bytes[1], ch_bytes[2], ch_bytes[3]]);
194            let is_used = rx_freq != 0 && rx_freq != 0xFFFF_FFFF;
195            let ch = FlashChannel::from_bytes(ch_bytes).map_err(|e| SdCardError::ChannelParse {
196                index: ch_index,
197                detail: e.to_string(),
198            })?;
199            (is_used, ch)
200        } else {
201            (false, FlashChannel::default())
202        };
203
204        // Channel name
205        let name = if name_offset + CHANNEL_NAME_SIZE <= data.len() {
206            extract_null_terminated(&data[name_offset..name_offset + CHANNEL_NAME_SIZE])
207        } else {
208            String::new()
209        };
210
211        // Channel flags: bit 0 of byte 0 = lockout
212        let lockout = if flags_offset + CHANNEL_FLAGS_SIZE <= data.len() {
213            data[flags_offset] & 0x01 != 0
214        } else {
215            false
216        };
217
218        channels.push(ChannelEntry {
219            number: ch_index,
220            name,
221            flash,
222            used,
223            lockout,
224        });
225    }
226
227    // Preserve the entire memory image (minus header) for round-trip.
228    let raw_image = data[HEADER_SIZE..].to_vec();
229
230    Ok(RadioConfig {
231        header,
232        channels,
233        raw_image,
234    })
235}
236
237/// Generates a `.d75` file from a [`RadioConfig`].
238///
239/// The output is the header concatenated with the raw memory image,
240/// with channel data, names, and flags patched in from the
241/// [`RadioConfig::channels`] entries.
242#[must_use]
243pub fn write_config(config: &RadioConfig) -> Vec<u8> {
244    let image_size = config.raw_image.len();
245    let total_size = HEADER_SIZE + image_size;
246    let mut out = vec![0u8; total_size];
247
248    // Write header
249    out[..HEADER_SIZE].copy_from_slice(&config.header.raw);
250
251    // Write raw image as the base (preserves all settings)
252    out[HEADER_SIZE..].copy_from_slice(&config.raw_image);
253
254    // Patch channel data, names, and flags
255    for entry in &config.channels {
256        let i = entry.number as usize;
257        if i >= MAX_CHANNELS {
258            continue;
259        }
260
261        // Channel memory (40 bytes)
262        let ch_offset = CHANNEL_DATA_OFFSET + (i * CHANNEL_ENTRY_SIZE);
263        let ch_end = ch_offset + CHANNEL_ENTRY_SIZE;
264        if ch_end <= out.len() {
265            let bytes = entry.flash.to_bytes();
266            out[ch_offset..ch_end].copy_from_slice(&bytes);
267        }
268
269        // Channel name (16 bytes, null-padded)
270        let name_offset = CHANNEL_NAME_OFFSET + (i * CHANNEL_NAME_SIZE);
271        let name_end = name_offset + CHANNEL_NAME_SIZE;
272        if name_end <= out.len() {
273            let mut name_buf = [0u8; CHANNEL_NAME_SIZE];
274            let src = entry.name.as_bytes();
275            let copy_len = src.len().min(CHANNEL_NAME_SIZE);
276            name_buf[..copy_len].copy_from_slice(&src[..copy_len]);
277            out[name_offset..name_end].copy_from_slice(&name_buf);
278        }
279
280        // Channel flags (4 bytes, bit 0 = lockout)
281        let flags_offset = CHANNEL_FLAGS_OFFSET + (i * CHANNEL_FLAGS_SIZE);
282        if flags_offset + CHANNEL_FLAGS_SIZE <= out.len() {
283            // Preserve existing flag bits; only toggle lockout bit 0.
284            if entry.lockout {
285                out[flags_offset] |= 0x01;
286            } else {
287                out[flags_offset] &= !0x01;
288            }
289        }
290    }
291
292    out
293}
294
295/// Extracts a null-terminated ASCII string from a byte slice.
296fn extract_null_terminated(bytes: &[u8]) -> String {
297    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
298    String::from_utf8_lossy(&bytes[..end]).into_owned()
299}
300
301/// Creates a minimal valid `.d75` header for the given model string.
302///
303/// Useful for generating new configuration files from scratch.
304///
305/// # Errors
306///
307/// Returns [`SdCardError::InvalidModelString`] if the model string
308/// is not one of the known variants.
309pub fn make_header(model: &str, version_bytes: [u8; 4]) -> Result<ConfigHeader, SdCardError> {
310    if !KNOWN_MODELS.contains(&model) {
311        return Err(SdCardError::InvalidModelString {
312            found: model.to_owned(),
313        });
314    }
315
316    let mut raw = [0u8; HEADER_SIZE];
317    let model_bytes = model.as_bytes();
318    let copy_len = model_bytes.len().min(16);
319    raw[..copy_len].copy_from_slice(&model_bytes[..copy_len]);
320    raw[0x14..0x18].copy_from_slice(&version_bytes);
321
322    Ok(ConfigHeader {
323        model: model.to_owned(),
324        version_bytes,
325        raw,
326    })
327}
328
329/// Creates an empty [`ChannelEntry`] for the given channel number.
330#[must_use]
331pub fn empty_channel(number: u16) -> ChannelEntry {
332    ChannelEntry {
333        number,
334        name: String::new(),
335        flash: FlashChannel::default(),
336        used: false,
337        lockout: false,
338    }
339}
340
341/// Creates a [`ChannelEntry`] with the given flash channel data.
342///
343/// The channel is automatically marked as `used = true` if the RX
344/// frequency is nonzero.
345#[must_use]
346pub fn make_channel(number: u16, name: &str, flash: FlashChannel) -> ChannelEntry {
347    let used = flash.rx_frequency.as_hz() != 0;
348    ChannelEntry {
349        number,
350        name: name.to_owned(),
351        flash,
352        used,
353        lockout: false,
354    }
355}
356
357/// Write a `.d75` configuration file from a raw memory image and header.
358///
359/// The `.d75` file format is: 256-byte header + raw MCP memory image.
360/// This produces files identical to those exported by Menu No. 800
361/// or the MCP-D75 application.
362///
363/// # Errors
364///
365/// Returns [`SdCardError::InvalidModelString`] if the header model string
366/// is not recognised. Returns [`SdCardError::FileTooSmall`] if the image
367/// is smaller than the minimum expected size for channel parsing.
368pub fn write_d75(
369    image: &crate::memory::MemoryImage,
370    header: &ConfigHeader,
371) -> Result<Vec<u8>, SdCardError> {
372    // Validate the header model string.
373    if !KNOWN_MODELS.contains(&header.model.as_str()) {
374        return Err(SdCardError::InvalidModelString {
375            found: header.model.clone(),
376        });
377    }
378
379    let raw = image.as_raw();
380
381    // Validate that the image is at least large enough for channel data
382    // (this ensures round-trip parse_config will succeed).
383    let min_body = CHANNEL_NAME_OFFSET - HEADER_SIZE + (MAX_CHANNELS * CHANNEL_NAME_SIZE);
384    if raw.len() < min_body {
385        return Err(SdCardError::FileTooSmall {
386            expected: min_body,
387            actual: raw.len(),
388        });
389    }
390
391    let mut out = Vec::with_capacity(HEADER_SIZE + raw.len());
392    out.extend_from_slice(&header.raw);
393    out.extend_from_slice(raw);
394    Ok(out)
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use crate::types::frequency::Frequency;
401
402    #[test]
403    fn extract_null_terminated_basic() {
404        let mut buf = [0u8; 16];
405        buf[..5].copy_from_slice(b"hello");
406        assert_eq!(extract_null_terminated(&buf), "hello");
407    }
408
409    #[test]
410    fn extract_null_terminated_full() {
411        let buf = *b"abcdefghijklmnop";
412        assert_eq!(extract_null_terminated(&buf), "abcdefghijklmnop");
413    }
414
415    #[test]
416    fn make_header_valid() {
417        let hdr = make_header("Data For TH-D75A", [0x95, 0xC4, 0x8F, 0x42]).unwrap();
418        assert_eq!(hdr.model, "Data For TH-D75A");
419        assert_eq!(hdr.version_bytes, [0x95, 0xC4, 0x8F, 0x42]);
420        assert_eq!(hdr.raw.len(), HEADER_SIZE);
421    }
422
423    #[test]
424    fn make_header_invalid_model() {
425        let err = make_header("Data For TH-D74A", [0; 4]).unwrap_err();
426        assert!(matches!(err, SdCardError::InvalidModelString { .. }));
427    }
428
429    #[test]
430    fn empty_channel_defaults() {
431        let ch = empty_channel(42);
432        assert_eq!(ch.number, 42);
433        assert!(!ch.used);
434        assert!(!ch.lockout);
435        assert_eq!(ch.name, "");
436    }
437
438    #[test]
439    fn make_channel_marks_used() {
440        let flash = FlashChannel {
441            rx_frequency: Frequency::new(145_000_000),
442            ..FlashChannel::default()
443        };
444        let ch = make_channel(0, "2M RPT", flash);
445        assert!(ch.used);
446        assert_eq!(ch.name, "2M RPT");
447    }
448
449    #[test]
450    fn make_channel_zero_freq_unused() {
451        let ch = make_channel(0, "empty", FlashChannel::default());
452        assert!(!ch.used);
453    }
454
455    #[test]
456    fn write_d75_round_trip() {
457        use crate::memory::MemoryImage;
458        use crate::protocol::programming;
459
460        let header = make_header("Data For TH-D75A", [0x95, 0xC4, 0x8F, 0x42]).unwrap();
461        let raw = vec![0u8; programming::TOTAL_SIZE];
462        let image = MemoryImage::from_raw(raw).unwrap();
463
464        // Write the .d75 file.
465        let d75_bytes = write_d75(&image, &header).unwrap();
466
467        // The output should be header + image.
468        assert_eq!(d75_bytes.len(), HEADER_SIZE + programming::TOTAL_SIZE);
469        assert_eq!(&d75_bytes[..HEADER_SIZE], &header.raw);
470        assert_eq!(&d75_bytes[HEADER_SIZE..], image.as_raw());
471
472        // Round-trip: parse it back and verify.
473        let parsed = parse_config(&d75_bytes).unwrap();
474        assert_eq!(parsed.header.model, "Data For TH-D75A");
475        assert_eq!(parsed.header.version_bytes, [0x95, 0xC4, 0x8F, 0x42]);
476        assert_eq!(parsed.raw_image.len(), d75_bytes.len() - HEADER_SIZE);
477    }
478
479    #[test]
480    fn write_d75_invalid_model_rejected() {
481        use crate::memory::MemoryImage;
482        use crate::protocol::programming;
483
484        let mut raw_header = [0u8; HEADER_SIZE];
485        raw_header[..17].copy_from_slice(b"Data For TH-D74A\0");
486        let header = ConfigHeader {
487            model: "Data For TH-D74A".to_owned(),
488            version_bytes: [0; 4],
489            raw: raw_header,
490        };
491        let raw = vec![0u8; programming::TOTAL_SIZE];
492        let image = MemoryImage::from_raw(raw).unwrap();
493
494        let err = write_d75(&image, &header).unwrap_err();
495        assert!(matches!(err, SdCardError::InvalidModelString { .. }));
496    }
497
498    #[test]
499    fn write_d75_preserves_channel_data() {
500        use crate::memory::MemoryImage;
501        use crate::protocol::programming;
502
503        let header = make_header("Data For TH-D75A", [0x95, 0xC4, 0x8F, 0x42]).unwrap();
504
505        // Build a raw image with some nonzero data in the channel region.
506        let mut raw = vec![0u8; programming::TOTAL_SIZE];
507        // Put a marker byte at offset 0x4000 (channel data section in the body).
508        if raw.len() > 0x4000 {
509            raw[0x4000] = 0xAB;
510        }
511        let image = MemoryImage::from_raw(raw).unwrap();
512
513        let d75_bytes = write_d75(&image, &header).unwrap();
514
515        // The marker should be at file offset HEADER_SIZE + 0x4000.
516        assert_eq!(d75_bytes[HEADER_SIZE + 0x4000], 0xAB);
517    }
518
519    #[test]
520    fn parse_config_channel_parse_error() {
521        use crate::protocol::programming;
522
523        let header = make_header("Data For TH-D75A", [0x95, 0xC4, 0x8F, 0x42]).unwrap();
524
525        // Build a valid .d75 file, then corrupt channel 0's step_size byte.
526        let mut d75_data = vec![0u8; HEADER_SIZE + programming::TOTAL_SIZE];
527        d75_data[..HEADER_SIZE].copy_from_slice(&header.raw);
528
529        // Channel 0 data starts at file offset CHANNEL_DATA_OFFSET.
530        // Give it a nonzero RX frequency so it's "used" and parsed.
531        let ch0_offset = CHANNEL_DATA_OFFSET;
532        d75_data[ch0_offset..ch0_offset + 4].copy_from_slice(&[0x01, 0x00, 0x00, 0x00]);
533        // Byte 0x08 of the channel record: high nibble = step_size.
534        // Value 0xF0 => step_size = 15 which is out of range.
535        d75_data[ch0_offset + 0x08] = 0xF0;
536
537        let err = parse_config(&d75_data).unwrap_err();
538        assert!(
539            matches!(err, SdCardError::ChannelParse { index: 0, .. }),
540            "expected ChannelParse for index 0, got {err:?}"
541        );
542    }
543}