kenwood_thd75/memory/
dstar.rs

1//! Typed access to the D-STAR configuration region of the memory image.
2//!
3//! The D-STAR configuration occupies two regions:
4//!
5//! - **System settings** at byte offset `0x03F0` (~16 bytes): active
6//!   D-STAR channel information.
7//! - **Repeater/callsign list** starting at page `0x02A1` (byte offset
8//!   `0x2A100`): up to 1,500 repeater entries (108 bytes each) plus
9//!   up to 120 callsign entries (8 bytes each).
10//!
11//! # Offset confidence
12//!
13//! The D-STAR channel info at `0x03F0` is from D74 development notes.
14//! The repeater list at page `0x02A1` is confirmed from D74 development
15//! notes (1,500 + 30 DR channels). Individual field offsets within
16//! repeater records are from firmware analysis and are not yet
17//! hardware-verified on the D75.
18
19use crate::protocol::programming;
20use crate::types::dstar::{DstarCallsign, RepeaterDuplex, RepeaterEntry};
21
22/// Byte offset of the D-STAR channel info within the system settings region.
23const DSTAR_CHANNEL_INFO_OFFSET: usize = 0x03F0;
24
25/// Size of the D-STAR channel info field.
26const DSTAR_CHANNEL_INFO_SIZE: usize = 16;
27
28/// Byte offset of the D-STAR repeater list and callsign list.
29const DSTAR_RPT_OFFSET: usize = programming::DSTAR_RPT_START as usize * programming::PAGE_SIZE;
30
31/// Estimated end of the D-STAR region (before Bluetooth data).
32const DSTAR_END_OFFSET: usize = programming::BT_START as usize * programming::PAGE_SIZE;
33
34/// Size of a single D-STAR repeater list record.
35const REPEATER_RECORD_SIZE: usize = 108;
36
37/// Maximum number of repeater entries in the D75.
38const MAX_REPEATER_ENTRIES: u16 = 1500;
39
40/// Estimated byte offset of the MY callsign within the system settings.
41///
42/// The MY callsign for D-STAR is stored within the callsign data area
43/// at MCP offset `0x1300`.  The first 8 bytes are the MY callsign,
44/// followed by 4 bytes for the suffix.
45const DSTAR_MY_CALLSIGN_OFFSET: usize = 0x1300;
46
47// ---------------------------------------------------------------------------
48// Repeater record field offsets (from firmware RE at 0xC001239C)
49// ---------------------------------------------------------------------------
50
51/// Offset within a repeater record for the RPT1 callsign (16 bytes).
52const RPT_RPT1_OFFSET: usize = 0x00;
53
54/// Offset within a repeater record for the RPT2/gateway callsign (16 bytes).
55const RPT_RPT2_OFFSET: usize = 0x10;
56
57/// Offset within a repeater record for the name field (16 bytes).
58const RPT_NAME_OFFSET: usize = 0x20;
59
60/// Offset within a repeater record for the area/sub-name field (16 bytes).
61const RPT_AREA_OFFSET: usize = 0x30;
62
63/// Offset within a repeater record for the frequency (4 bytes, uint32 LE, Hz).
64const RPT_FREQ_OFFSET: usize = 0x58;
65
66// ---------------------------------------------------------------------------
67// DstarAccess (read-only)
68// ---------------------------------------------------------------------------
69
70/// Read-only access to the D-STAR configuration region.
71///
72/// Provides raw byte access and typed field accessors for D-STAR settings
73/// stored in the system settings area (channel info at `0x03F0`) and the
74/// repeater/callsign list starting at page `0x02A1`.
75///
76/// # Known sub-regions
77///
78/// | MCP Offset | Content |
79/// |-----------|---------|
80/// | `0x003F0` | D-STAR channel info (16 bytes) |
81/// | `0x01300` | MY callsign (8 bytes) + suffix (4 bytes) |
82/// | `0x2A100` | Repeater list (108-byte records) |
83/// | varies | Callsign list (8-byte entries, up to 120) |
84#[derive(Debug)]
85pub struct DstarAccess<'a> {
86    image: &'a [u8],
87}
88
89impl<'a> DstarAccess<'a> {
90    /// Create a new D-STAR accessor borrowing the raw image.
91    pub(crate) const fn new(image: &'a [u8]) -> Self {
92        Self { image }
93    }
94
95    /// Get the D-STAR channel info bytes (16 bytes at offset `0x03F0`).
96    ///
97    /// Contains the active D-STAR slot configuration.
98    #[must_use]
99    pub fn channel_info(&self) -> Option<&[u8]> {
100        let end = DSTAR_CHANNEL_INFO_OFFSET + DSTAR_CHANNEL_INFO_SIZE;
101        if end <= self.image.len() {
102            Some(&self.image[DSTAR_CHANNEL_INFO_OFFSET..end])
103        } else {
104            None
105        }
106    }
107
108    /// Get the raw repeater/callsign list region.
109    ///
110    /// This region spans from page `0x02A1` to page `0x04D0` (before
111    /// the Bluetooth data). It contains both the repeater list and the
112    /// callsign list.
113    #[must_use]
114    pub fn repeater_callsign_region(&self) -> Option<&[u8]> {
115        if DSTAR_END_OFFSET <= self.image.len() {
116            Some(&self.image[DSTAR_RPT_OFFSET..DSTAR_END_OFFSET])
117        } else {
118            None
119        }
120    }
121
122    /// Get the total size of the D-STAR repeater/callsign region in bytes.
123    #[must_use]
124    pub const fn region_size(&self) -> usize {
125        DSTAR_END_OFFSET - DSTAR_RPT_OFFSET
126    }
127
128    /// Read a repeater record by index (raw 108 bytes).
129    ///
130    /// Each record is 108 bytes. Returns `None` if the index is out of
131    /// bounds or the record extends past the region.
132    #[must_use]
133    pub fn repeater_record(&self, index: usize) -> Option<&[u8]> {
134        let offset = DSTAR_RPT_OFFSET + index * REPEATER_RECORD_SIZE;
135        let end = offset + REPEATER_RECORD_SIZE;
136        if end <= self.image.len() && end <= DSTAR_END_OFFSET {
137            Some(&self.image[offset..end])
138        } else {
139            None
140        }
141    }
142
143    /// Read an arbitrary byte range from the D-STAR region.
144    ///
145    /// The offset is an absolute MCP byte address. Returns `None` if
146    /// the range extends past the image.
147    #[must_use]
148    pub fn read_bytes(&self, offset: usize, len: usize) -> Option<&[u8]> {
149        let end = offset + len;
150        if end <= self.image.len() {
151            Some(&self.image[offset..end])
152        } else {
153            None
154        }
155    }
156
157    // -----------------------------------------------------------------------
158    // Typed D-STAR accessors
159    // -----------------------------------------------------------------------
160
161    /// Read the D-STAR MY callsign (up to 8 characters, space-padded).
162    ///
163    /// # Offset
164    ///
165    /// Located at `0x1300` (confirmed from D74 development notes as the
166    /// callsign data region). The first 8 bytes are the MY callsign.
167    ///
168    /// # Verification
169    ///
170    /// Field boundary is estimated, not hardware-verified
171    /// within the callsign data region.
172    #[must_use]
173    pub fn my_callsign(&self) -> String {
174        let offset = DSTAR_MY_CALLSIGN_OFFSET;
175        let end = offset + DstarCallsign::WIRE_LEN;
176        if end > self.image.len() {
177            return String::new();
178        }
179        let slice = &self.image[offset..end];
180        // D-STAR callsigns are space-padded; also handle null bytes.
181        let s = std::str::from_utf8(slice).unwrap_or("");
182        s.trim_end_matches([' ', '\0']).to_owned()
183    }
184
185    /// Read the D-STAR MY callsign as a typed [`DstarCallsign`].
186    ///
187    /// Returns `None` if the callsign is empty or invalid.
188    ///
189    /// # Offset
190    ///
191    /// Located at `0x1300`.
192    ///
193    /// # Verification
194    ///
195    /// Offset is estimated, not hardware-verified.
196    #[must_use]
197    pub fn my_callsign_typed(&self) -> Option<DstarCallsign> {
198        let raw = self.my_callsign();
199        if raw.is_empty() {
200            return None;
201        }
202        DstarCallsign::new(&raw)
203    }
204
205    /// Read a repeater entry by index, parsed into a [`RepeaterEntry`].
206    ///
207    /// Returns `None` if the index is out of range, the record is
208    /// all-`0xFF` (empty), or the record cannot be parsed.
209    ///
210    /// # Offset
211    ///
212    /// Repeater records start at `0x2A100` (page `0x02A1`), each 108
213    /// bytes. Record N is at offset `0x2A100 + N * 108`.
214    ///
215    /// # Verification
216    ///
217    /// Region boundary confirmed. Internal field layout from firmware RE
218    /// offset is estimated, not hardware-verified.
219    #[must_use]
220    pub fn repeater_entry(&self, index: u16) -> Option<RepeaterEntry> {
221        if index >= MAX_REPEATER_ENTRIES {
222            return None;
223        }
224        let record = self.repeater_record(index as usize)?;
225
226        // Check for empty record (all 0xFF).
227        if record.iter().all(|&b| b == 0xFF) {
228            return None;
229        }
230        // Check for all-zero record (unused).
231        if record.iter().all(|&b| b == 0x00) {
232            return None;
233        }
234
235        let rpt1 = extract_dstar_callsign(&record[RPT_RPT1_OFFSET..RPT_RPT1_OFFSET + 8]);
236        let rpt2 = extract_dstar_callsign(&record[RPT_RPT2_OFFSET..RPT_RPT2_OFFSET + 8]);
237        let name = extract_string_field(record, RPT_NAME_OFFSET, 16);
238        let sub_name = extract_string_field(record, RPT_AREA_OFFSET, 16);
239
240        let frequency = if RPT_FREQ_OFFSET + 4 <= record.len() {
241            u32::from_le_bytes([
242                record[RPT_FREQ_OFFSET],
243                record[RPT_FREQ_OFFSET + 1],
244                record[RPT_FREQ_OFFSET + 2],
245                record[RPT_FREQ_OFFSET + 3],
246            ])
247        } else {
248            0
249        };
250
251        Some(RepeaterEntry {
252            group_name: String::new(), // Group name not in the 108-byte record
253            name,
254            sub_name,
255            callsign_rpt1: rpt1,
256            gateway_rpt2: rpt2,
257            frequency,
258            duplex: RepeaterDuplex::Minus, // Typical default for D-STAR
259            offset: 0,
260            module: crate::types::dstar::DstarModule::B,
261            latitude: 0.0,
262            longitude: 0.0,
263            utc_offset: String::new(),
264            position_accuracy: crate::types::dstar::PositionAccuracy::Invalid,
265            lockout: false,
266        })
267    }
268
269    /// Count the number of non-empty repeater entries.
270    ///
271    /// Iterates through the repeater list region and counts entries that
272    /// are not all-`0xFF` or all-`0x00`.
273    #[must_use]
274    pub fn repeater_count(&self) -> u16 {
275        let mut count: u16 = 0;
276        for i in 0..MAX_REPEATER_ENTRIES {
277            if self.repeater_entry(i).is_some() {
278                count = count.saturating_add(1);
279            }
280        }
281        count
282    }
283}
284
285/// Extract a D-STAR callsign from the first 8 bytes of a slice.
286fn extract_dstar_callsign(slice: &[u8]) -> DstarCallsign {
287    if slice.len() < 8 {
288        return DstarCallsign::default();
289    }
290    let mut bytes = [0u8; 8];
291    bytes.copy_from_slice(&slice[..8]);
292    // Replace null bytes with spaces for D-STAR wire format.
293    for b in &mut bytes {
294        if *b == 0 {
295            *b = b' ';
296        }
297    }
298    DstarCallsign::from_wire_bytes(&bytes)
299}
300
301/// Extract a null-terminated string field from a record.
302fn extract_string_field(record: &[u8], offset: usize, max_len: usize) -> String {
303    let end = (offset + max_len).min(record.len());
304    if offset >= record.len() {
305        return String::new();
306    }
307    let slice = &record[offset..end];
308    let nul = slice.iter().position(|&b| b == 0).unwrap_or(slice.len());
309    String::from_utf8_lossy(&slice[..nul]).trim().to_string()
310}
311
312// ---------------------------------------------------------------------------
313// Tests
314// ---------------------------------------------------------------------------
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::protocol::programming::TOTAL_SIZE;
320
321    fn make_dstar_image() -> Vec<u8> {
322        vec![0u8; TOTAL_SIZE]
323    }
324
325    #[test]
326    fn dstar_channel_info_accessible() {
327        let mut image = make_dstar_image();
328        // Write a known pattern at the D-STAR channel info offset.
329        image[DSTAR_CHANNEL_INFO_OFFSET..DSTAR_CHANNEL_INFO_OFFSET + 4]
330            .copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
331
332        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
333        let dstar = mi.dstar();
334        let info = dstar.channel_info().unwrap();
335        assert_eq!(info.len(), DSTAR_CHANNEL_INFO_SIZE);
336        assert_eq!(&info[..4], &[0xDE, 0xAD, 0xBE, 0xEF]);
337    }
338
339    #[test]
340    fn dstar_repeater_region_accessible() {
341        let image = make_dstar_image();
342        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
343        let dstar = mi.dstar();
344        let region = dstar.repeater_callsign_region().unwrap();
345        assert!(!region.is_empty());
346        assert_eq!(region.len(), dstar.region_size());
347    }
348
349    #[test]
350    fn dstar_repeater_record() {
351        let mut image = make_dstar_image();
352        // Write a pattern at the first repeater record.
353        let offset = DSTAR_RPT_OFFSET;
354        image[offset..offset + 8].copy_from_slice(b"JR6YPR B");
355
356        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
357        let dstar = mi.dstar();
358        let record = dstar.repeater_record(0).unwrap();
359        assert_eq!(record.len(), REPEATER_RECORD_SIZE);
360        assert_eq!(&record[..8], b"JR6YPR B");
361    }
362
363    #[test]
364    fn dstar_region_size_positive() {
365        let image = make_dstar_image();
366        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
367        let dstar = mi.dstar();
368        // D-STAR region should be substantial (>100 KB).
369        assert!(dstar.region_size() > 100_000);
370    }
371
372    #[test]
373    fn dstar_my_callsign() {
374        let mut image = make_dstar_image();
375        image[DSTAR_MY_CALLSIGN_OFFSET..DSTAR_MY_CALLSIGN_OFFSET + 8].copy_from_slice(b"N0CALL  ");
376
377        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
378        let dstar = mi.dstar();
379        assert_eq!(dstar.my_callsign(), "N0CALL");
380    }
381
382    #[test]
383    fn dstar_my_callsign_typed() {
384        let mut image = make_dstar_image();
385        image[DSTAR_MY_CALLSIGN_OFFSET..DSTAR_MY_CALLSIGN_OFFSET + 8].copy_from_slice(b"W1AW    ");
386
387        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
388        let dstar = mi.dstar();
389        let typed = dstar.my_callsign_typed().unwrap();
390        assert_eq!(typed.as_str(), "W1AW");
391    }
392
393    #[test]
394    fn dstar_my_callsign_empty() {
395        let image = make_dstar_image();
396        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
397        let dstar = mi.dstar();
398        assert_eq!(dstar.my_callsign(), "");
399        assert!(dstar.my_callsign_typed().is_none());
400    }
401
402    #[test]
403    fn dstar_repeater_entry_empty_record() {
404        let image = make_dstar_image();
405        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
406        let dstar = mi.dstar();
407        // All-zero record should be None.
408        assert!(dstar.repeater_entry(0).is_none());
409    }
410
411    #[test]
412    fn dstar_repeater_entry_populated() {
413        let mut image = make_dstar_image();
414        let offset = DSTAR_RPT_OFFSET;
415
416        // Write RPT1 callsign at record offset 0x00.
417        image[offset..offset + 8].copy_from_slice(b"JR6YPR B");
418        // Write RPT2/gateway at record offset 0x10.
419        image[offset + 0x10..offset + 0x18].copy_from_slice(b"JR6YPR G");
420        // Write name at record offset 0x20.
421        let name = b"Test Rptr\0\0\0\0\0\0\0";
422        image[offset + 0x20..offset + 0x30].copy_from_slice(name);
423        // Write frequency at record offset 0x58: 439.01 MHz = 439010000 Hz.
424        let freq: u32 = 439_010_000;
425        image[offset + 0x58..offset + 0x5C].copy_from_slice(&freq.to_le_bytes());
426
427        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
428        let dstar = mi.dstar();
429        let entry = dstar.repeater_entry(0).unwrap();
430        assert_eq!(entry.callsign_rpt1.as_str(), "JR6YPR B");
431        assert_eq!(entry.gateway_rpt2.as_str(), "JR6YPR G");
432        assert_eq!(entry.name, "Test Rptr");
433        assert_eq!(entry.frequency, 439_010_000);
434    }
435
436    #[test]
437    fn dstar_repeater_entry_out_of_range() {
438        let image = make_dstar_image();
439        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
440        let dstar = mi.dstar();
441        assert!(dstar.repeater_entry(MAX_REPEATER_ENTRIES).is_none());
442    }
443
444    #[test]
445    fn dstar_repeater_count_all_empty() {
446        let image = make_dstar_image();
447        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
448        let dstar = mi.dstar();
449        assert_eq!(dstar.repeater_count(), 0);
450    }
451
452    #[test]
453    fn dstar_repeater_count_with_entries() {
454        let mut image = make_dstar_image();
455        // Populate 3 repeater entries.
456        for i in 0..3 {
457            let offset = DSTAR_RPT_OFFSET + i * REPEATER_RECORD_SIZE;
458            image[offset..offset + 8].copy_from_slice(b"TESTCALL");
459        }
460
461        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
462        let dstar = mi.dstar();
463        assert_eq!(dstar.repeater_count(), 3);
464    }
465}