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}