kenwood_thd75/memory/
mod.rs

1//! Typed access to the TH-D75 memory image.
2//!
3//! Parses raw memory bytes (from MCP programming or `.d75` files) into
4//! structured Rust types for every radio subsystem. The memory image is
5//! 500,480 bytes (1,955 pages of 256 bytes) and is identical whether
6//! read via the MCP binary protocol or extracted from a `.d75` SD card
7//! config file (after stripping the 256-byte file header).
8//!
9//! # Design
10//!
11//! [`MemoryImage`] owns the raw byte buffer and hands out lightweight
12//! accessor structs ([`ChannelAccess`], [`SettingsAccess`], etc.) that
13//! borrow into it. No data is copied on access — parsing happens
14//! on-demand when you call methods on the accessors.
15//!
16//! Mutation works the same way: call a mutable accessor, modify a
17//! field, and the change is written directly into the backing buffer.
18//! When you are done, call [`MemoryImage::into_raw`] to get the bytes
19//! back for writing to the radio or saving to a `.d75` file.
20
21pub mod aprs;
22pub mod channels;
23pub mod dstar;
24pub mod gps;
25pub mod settings;
26
27use std::fmt;
28
29use crate::protocol::programming;
30use crate::sdcard::config::{self as d75, ConfigHeader};
31
32// ---------------------------------------------------------------------------
33// Error type
34// ---------------------------------------------------------------------------
35
36/// Errors that can occur when working with a memory image.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum MemoryError {
39    /// The raw data is not the expected size.
40    InvalidSize {
41        /// The actual size in bytes.
42        actual: usize,
43        /// The expected size in bytes.
44        expected: usize,
45    },
46    /// A channel number is out of range.
47    ChannelOutOfRange {
48        /// The requested channel number.
49        channel: u16,
50        /// The maximum valid channel number (inclusive).
51        max: u16,
52    },
53    /// A region could not be parsed.
54    ParseError {
55        /// The region name (e.g. "channel 42 data").
56        region: String,
57        /// Human-readable detail.
58        detail: String,
59    },
60    /// The `.d75` file is invalid.
61    D75Error {
62        /// Human-readable detail.
63        detail: String,
64    },
65}
66
67impl fmt::Display for MemoryError {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            Self::InvalidSize { actual, expected } => {
71                write!(
72                    f,
73                    "invalid memory image size: {actual} bytes (expected {expected})"
74                )
75            }
76            Self::ChannelOutOfRange { channel, max } => {
77                write!(f, "channel {channel} out of range (max {max})")
78            }
79            Self::ParseError { region, detail } => {
80                write!(f, "failed to parse {region}: {detail}")
81            }
82            Self::D75Error { detail } => {
83                write!(f, "invalid .d75 file: {detail}")
84            }
85        }
86    }
87}
88
89impl std::error::Error for MemoryError {}
90
91// ---------------------------------------------------------------------------
92// Re-exports for convenience
93// ---------------------------------------------------------------------------
94
95pub use aprs::AprsAccess;
96pub use channels::{ChannelAccess, ChannelWriter};
97pub use dstar::DstarAccess;
98pub use gps::GpsAccess;
99pub use settings::{SettingsAccess, SettingsWriter};
100
101// ---------------------------------------------------------------------------
102// MemoryImage
103// ---------------------------------------------------------------------------
104
105/// A parsed TH-D75 memory image providing typed access to all settings.
106///
107/// The image is exactly [`programming::TOTAL_SIZE`] bytes (500,480).
108/// Create one from a raw MCP dump, or from a `.d75` file via
109/// [`from_d75_file`](Self::from_d75_file).
110///
111/// # Examples
112///
113/// ```rust,no_run
114/// use kenwood_thd75::memory::MemoryImage;
115///
116/// # fn example(raw: Vec<u8>) -> Result<(), kenwood_thd75::memory::MemoryError> {
117/// let image = MemoryImage::from_raw(raw)?;
118///
119/// // Read channel 0.
120/// let channels = image.channels();
121/// if channels.is_used(0) {
122///     if let Some(entry) = channels.get(0) {
123///         println!("Ch 0: {} — {} Hz", entry.name, entry.flash.rx_frequency.as_hz());
124///     }
125/// }
126///
127/// // Get the raw bytes back for writing.
128/// let bytes = image.into_raw();
129/// # Ok(())
130/// # }
131/// ```
132#[derive(Debug, Clone)]
133pub struct MemoryImage {
134    raw: Vec<u8>,
135}
136
137impl MemoryImage {
138    /// Create from a raw memory dump (from `read_memory_image` or `.d75`
139    /// file body).
140    ///
141    /// # Errors
142    ///
143    /// Returns [`MemoryError::InvalidSize`] if the data is not exactly
144    /// 500,480 bytes.
145    pub fn from_raw(data: Vec<u8>) -> Result<Self, MemoryError> {
146        if data.len() != programming::TOTAL_SIZE {
147            return Err(MemoryError::InvalidSize {
148                actual: data.len(),
149                expected: programming::TOTAL_SIZE,
150            });
151        }
152        Ok(Self { raw: data })
153    }
154
155    /// Get the raw bytes (for `write_memory_image`).
156    #[must_use]
157    pub fn into_raw(self) -> Vec<u8> {
158        self.raw
159    }
160
161    /// Borrow the raw bytes.
162    #[must_use]
163    pub fn as_raw(&self) -> &[u8] {
164        &self.raw
165    }
166
167    /// Mutably borrow the raw bytes.
168    #[must_use]
169    pub fn as_raw_mut(&mut self) -> &mut [u8] {
170        &mut self.raw
171    }
172
173    /// Access channel data (read-only).
174    #[must_use]
175    pub fn channels(&self) -> ChannelAccess<'_> {
176        ChannelAccess::new(&self.raw)
177    }
178
179    /// Access channel data (mutable, for writing channels).
180    #[must_use]
181    pub fn channels_mut(&mut self) -> ChannelWriter<'_> {
182        ChannelWriter::new(&mut self.raw)
183    }
184
185    /// Access system settings (read-only raw bytes for unmapped regions).
186    #[must_use]
187    pub fn settings(&self) -> SettingsAccess<'_> {
188        SettingsAccess::new(&self.raw)
189    }
190
191    /// Access system settings (mutable, for writing verified settings).
192    #[must_use]
193    pub fn settings_mut(&mut self) -> SettingsWriter<'_> {
194        SettingsWriter::new(&mut self.raw)
195    }
196
197    /// Apply a settings mutation and return the changed byte's MCP offset
198    /// and new value.
199    ///
200    /// The closure receives a `SettingsWriter` to modify exactly one setting.
201    /// This method snapshots the settings page before the closure, runs it,
202    /// then diffs to find the single changed byte. Returns `Some((offset, value))`
203    /// if a byte changed, or `None` if nothing changed.
204    ///
205    /// # Panics
206    ///
207    /// Panics if more than one byte changed (the closure should modify
208    /// exactly one setting).
209    pub fn modify_setting<F>(&mut self, f: F) -> Option<(u16, u8)>
210    where
211        F: FnOnce(&mut SettingsWriter<'_>),
212    {
213        // Settings live at offsets 0x0000..0x2000 in the raw image
214        // (MCP addresses 0x1000..0x10FF map to image[0x1000..0x10FF])
215        const SETTINGS_START: usize = 0x1000;
216        const SETTINGS_END: usize = 0x1100;
217
218        // Snapshot the settings region
219        let mut snapshot = [0u8; SETTINGS_END - SETTINGS_START];
220        snapshot.copy_from_slice(&self.raw[SETTINGS_START..SETTINGS_END]);
221
222        // Apply the mutation
223        f(&mut SettingsWriter::new(&mut self.raw));
224
225        // Diff to find the changed byte
226        let mut changed: Option<(u16, u8)> = None;
227        for (i, &snap_byte) in snapshot.iter().enumerate() {
228            let current = self.raw[SETTINGS_START + i];
229            if current != snap_byte {
230                assert!(
231                    changed.is_none(),
232                    "modify_setting: more than one byte changed"
233                );
234                #[allow(clippy::cast_possible_truncation)]
235                let offset = (SETTINGS_START + i) as u16;
236                changed = Some((offset, current));
237            }
238        }
239        changed
240    }
241
242    /// Access the APRS configuration region (raw bytes).
243    #[must_use]
244    pub fn aprs(&self) -> AprsAccess<'_> {
245        AprsAccess::new(&self.raw)
246    }
247
248    /// Access the D-STAR configuration region (raw bytes).
249    #[must_use]
250    pub fn dstar(&self) -> DstarAccess<'_> {
251        DstarAccess::new(&self.raw)
252    }
253
254    /// Access the GPS configuration region (raw bytes).
255    #[must_use]
256    pub fn gps(&self) -> GpsAccess<'_> {
257        GpsAccess::new(&self.raw)
258    }
259
260    // -----------------------------------------------------------------------
261    // .d75 file integration
262    // -----------------------------------------------------------------------
263
264    /// Create from a `.d75` config file (strips the 256-byte header).
265    ///
266    /// The `.d75` file format is a 256-byte file header followed by the
267    /// raw MCP memory image. This constructor validates the header and
268    /// extracts the image body.
269    ///
270    /// # Errors
271    ///
272    /// Returns [`MemoryError::D75Error`] if the file is too short or
273    /// the header model string is not recognised.
274    /// Returns [`MemoryError::InvalidSize`] if the body is not the
275    /// expected size.
276    pub fn from_d75_file(data: &[u8]) -> Result<Self, MemoryError> {
277        let min_size = d75::HEADER_SIZE + programming::TOTAL_SIZE;
278        if data.len() < min_size {
279            return Err(MemoryError::D75Error {
280                detail: format!(
281                    "file too small: {} bytes (expected at least {})",
282                    data.len(),
283                    min_size
284                ),
285            });
286        }
287
288        // Validate the header by attempting to parse it.
289        let header_result = d75::parse_config(data);
290        if let Err(e) = header_result {
291            return Err(MemoryError::D75Error {
292                detail: e.to_string(),
293            });
294        }
295
296        let body = data[d75::HEADER_SIZE..d75::HEADER_SIZE + programming::TOTAL_SIZE].to_vec();
297        Self::from_raw(body)
298    }
299
300    /// Export as a `.d75` config file (prepends header).
301    ///
302    /// Uses the provided [`ConfigHeader`] to build the file. The header
303    /// is preserved as-is (including model string and version bytes) for
304    /// round-trip fidelity.
305    #[must_use]
306    pub fn to_d75_file(&self, header: &ConfigHeader) -> Vec<u8> {
307        let mut out = Vec::with_capacity(d75::HEADER_SIZE + self.raw.len());
308        out.extend_from_slice(&header.raw);
309        out.extend_from_slice(&self.raw);
310        out
311    }
312
313    /// Export this image as a `.d75` file ready to write to the SD card.
314    ///
315    /// Uses a default TH-D75A header with the standard version bytes.
316    /// For a specific model or custom header, use [`to_d75_file`](Self::to_d75_file).
317    ///
318    /// # Panics
319    ///
320    /// Panics if the built-in model string is rejected, which should never
321    /// happen since the model is a known constant.
322    #[must_use]
323    pub fn to_d75_bytes(&self) -> Vec<u8> {
324        // Use a standard D75A header. make_header is infallible for known models.
325        let header =
326            d75::make_header("Data For TH-D75A", [0x95, 0xC4, 0x8F, 0x42]).expect("known model");
327        self.to_d75_file(&header)
328    }
329
330    /// Read a byte range from the image.
331    ///
332    /// Returns `None` if the range is out of bounds.
333    #[must_use]
334    pub fn read_region(&self, offset: usize, len: usize) -> Option<&[u8]> {
335        self.raw.get(offset..offset + len)
336    }
337
338    /// Write bytes into the image at the given offset.
339    ///
340    /// # Errors
341    ///
342    /// Returns [`MemoryError::InvalidSize`] if the write extends past
343    /// the end of the image.
344    pub fn write_region(&mut self, offset: usize, data: &[u8]) -> Result<(), MemoryError> {
345        let end = offset + data.len();
346        if end > self.raw.len() {
347            return Err(MemoryError::InvalidSize {
348                actual: end,
349                expected: self.raw.len(),
350            });
351        }
352        self.raw[offset..end].copy_from_slice(data);
353        Ok(())
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::protocol::programming;
361
362    #[test]
363    fn to_d75_bytes_round_trip() {
364        let raw = vec![0u8; programming::TOTAL_SIZE];
365        let image = MemoryImage::from_raw(raw.clone()).unwrap();
366        let d75_bytes = image.to_d75_bytes();
367
368        // Should be header + raw image.
369        assert_eq!(d75_bytes.len(), d75::HEADER_SIZE + programming::TOTAL_SIZE);
370
371        // The body portion should match the original raw data.
372        assert_eq!(&d75_bytes[d75::HEADER_SIZE..], &raw[..]);
373
374        // The header should be parseable and identify as D75A.
375        let reparsed = MemoryImage::from_d75_file(&d75_bytes).unwrap();
376        assert_eq!(reparsed.as_raw(), &raw[..]);
377    }
378
379    #[test]
380    fn to_d75_file_with_custom_header() {
381        let raw = vec![0u8; programming::TOTAL_SIZE];
382        let image = MemoryImage::from_raw(raw).unwrap();
383        let header = d75::make_header("Data For TH-D75E", [0x01, 0x02, 0x03, 0x04]).unwrap();
384        let d75_bytes = image.to_d75_file(&header);
385
386        // Verify header model.
387        let reparsed_config = d75::parse_config(&d75_bytes).unwrap();
388        assert_eq!(reparsed_config.header.model, "Data For TH-D75E");
389        assert_eq!(
390            reparsed_config.header.version_bytes,
391            [0x01, 0x02, 0x03, 0x04]
392        );
393    }
394
395    #[test]
396    fn from_raw_wrong_size() {
397        let err = MemoryImage::from_raw(vec![0u8; 100]).unwrap_err();
398        assert!(matches!(err, MemoryError::InvalidSize { .. }));
399    }
400
401    // -----------------------------------------------------------------------
402    // modify_setting tests
403    // -----------------------------------------------------------------------
404
405    #[test]
406    fn modify_setting_returns_changed_byte() {
407        let mut image = MemoryImage::from_raw(vec![0u8; programming::TOTAL_SIZE]).unwrap();
408        // key_beep lives at offset 0x1071; set it from 0 to 1
409        let result = image.modify_setting(|w| {
410            w.set_key_beep(true);
411        });
412        assert_eq!(result, Some((0x1071, 1)));
413    }
414
415    #[test]
416    fn modify_setting_no_change_returns_none() {
417        let mut image = MemoryImage::from_raw(vec![0u8; programming::TOTAL_SIZE]).unwrap();
418        // beep is already 0 (false); setting it to false again changes nothing
419        let result = image.modify_setting(|w| {
420            w.set_key_beep(false);
421        });
422        assert_eq!(result, None);
423    }
424
425    #[test]
426    #[should_panic(expected = "more than one byte changed")]
427    fn modify_setting_panics_on_multi_byte() {
428        let mut image = MemoryImage::from_raw(vec![0u8; programming::TOTAL_SIZE]).unwrap();
429        let _ = image.modify_setting(|w| {
430            // Change two distinct single-byte settings
431            w.set_key_beep(true); // 0x1071
432            w.set_beep_volume(5); // 0x1072
433        });
434    }
435
436    // -----------------------------------------------------------------------
437    // write_region error path
438    // -----------------------------------------------------------------------
439
440    #[test]
441    fn write_region_out_of_bounds() {
442        let mut image = MemoryImage::from_raw(vec![0u8; programming::TOTAL_SIZE]).unwrap();
443        assert!(
444            image
445                .write_region(programming::TOTAL_SIZE - 10, &[0u8; 20])
446                .is_err()
447        );
448    }
449
450    // -----------------------------------------------------------------------
451    // from_d75_file too-small error
452    // -----------------------------------------------------------------------
453
454    #[test]
455    fn from_d75_file_too_small() {
456        assert!(MemoryImage::from_d75_file(&[0u8; 100]).is_err());
457    }
458
459    // -----------------------------------------------------------------------
460    // MemoryError variant coverage
461    // -----------------------------------------------------------------------
462
463    #[test]
464    fn error_channel_out_of_range_display() {
465        let err = MemoryError::ChannelOutOfRange {
466            channel: 2000,
467            max: 1199,
468        };
469        let msg = err.to_string();
470        assert!(msg.contains("2000"));
471        assert!(msg.contains("1199"));
472    }
473
474    #[test]
475    fn error_parse_error_display() {
476        let err = MemoryError::ParseError {
477            region: "channel 42 data".into(),
478            detail: "bad mode byte".into(),
479        };
480        let msg = err.to_string();
481        assert!(msg.contains("channel 42 data"));
482        assert!(msg.contains("bad mode byte"));
483    }
484}