kenwood_thd75/memory/
aprs.rs

1//! Typed access to the APRS configuration region of the memory image.
2//!
3//! The APRS configuration occupies pages `0x0151`+ in the MCP address
4//! space. This includes the APRS message status header (256 bytes at
5//! page `0x0151`), followed by APRS messages, settings, and extended
6//! configuration data.
7//!
8//! # Offset confidence
9//!
10//! The APRS region boundaries (page `0x0151` for the status header,
11//! page `0x0152` for the data region) are confirmed from D74 development
12//! notes. Individual field offsets within the data region are estimated
13//! and marked with `# Verification` in the doc comments.
14
15use crate::protocol::programming;
16use crate::types::aprs::AprsCallsign;
17
18/// Byte offset of the APRS message status header (`0x15100`).
19pub const APRS_STATUS_OFFSET: usize =
20    programming::APRS_STATUS_PAGE as usize * programming::PAGE_SIZE;
21
22/// Byte offset of the APRS messages and settings region (`0x15200`).
23pub const APRS_DATA_OFFSET: usize = programming::APRS_START as usize * programming::PAGE_SIZE;
24
25/// Estimated end of the APRS region (before D-STAR repeater list).
26pub const APRS_END_OFFSET: usize = programming::DSTAR_RPT_START as usize * programming::PAGE_SIZE;
27
28// ---------------------------------------------------------------------------
29// Estimated field offsets within the APRS data region
30//
31// The APRS data region starts at 0x15200.  Field offsets below are
32// relative to that base and are estimated from D74 layout conventions.
33// None of these offsets have been hardware-verified on a D75 yet.
34// ---------------------------------------------------------------------------
35
36/// Estimated offset of the APRS MY callsign (10 bytes, null-terminated
37/// ASCII including SSID, e.g. "N0CALL-9\0").
38///
39/// Relative to the start of the APRS data region (`0x15200`).
40const APRS_MY_CALLSIGN_REL: usize = 0x0000;
41
42/// Maximum callsign field length including null terminator.
43const APRS_CALLSIGN_FIELD_LEN: usize = 10;
44
45/// Estimated offset of the beacon interval (2 bytes, little-endian,
46/// value in seconds).
47///
48/// Relative to the start of the APRS data region (`0x15200`).
49const APRS_BEACON_INTERVAL_REL: usize = 0x000A;
50
51/// Estimated offset of the packet path selection (1 byte, enum index).
52///
53/// Relative to the start of the APRS data region (`0x15200`).
54const APRS_PACKET_PATH_REL: usize = 0x000C;
55
56// ---------------------------------------------------------------------------
57// APRS/GPS position data region
58//
59// The APRS/GPS position data occupies 0x4B00 bytes (19,200 bytes) starting
60// at byte offset 0x25100 in the MCP memory image.
61// ---------------------------------------------------------------------------
62
63/// Byte offset of the APRS/GPS position data region (`0x25100`).
64///
65/// 0x4B00 bytes of APRS/GPS position data starting at offset 0x25100.
66pub const APRS_POSITION_DATA_OFFSET: usize = 0x2_5100;
67
68/// Size of the APRS/GPS position data region in bytes.
69pub const APRS_POSITION_DATA_SIZE: usize = 0x4B00;
70
71// ---------------------------------------------------------------------------
72// AprsAccess (read-only)
73// ---------------------------------------------------------------------------
74
75/// Read-only access to the APRS configuration region.
76///
77/// Provides raw byte access and typed field accessors for the APRS
78/// settings region at pages `0x0151`+. The region boundaries are
79/// confirmed from D74 development notes; individual field offsets within
80/// the data region are estimated.
81///
82/// # Known sub-regions
83///
84/// | MCP Offset | Content |
85/// |-----------|---------|
86/// | `0x15100` | APRS message status header (256 bytes) |
87/// | `0x15200` | APRS messages and settings (~16 KB) |
88/// | ~`0x19000` | APRS extended config / GPS settings |
89#[derive(Debug)]
90pub struct AprsAccess<'a> {
91    image: &'a [u8],
92}
93
94impl<'a> AprsAccess<'a> {
95    /// Create a new APRS accessor borrowing the raw image.
96    pub(crate) const fn new(image: &'a [u8]) -> Self {
97        Self { image }
98    }
99
100    /// Get the raw APRS message status header (256 bytes at page `0x0151`).
101    ///
102    /// Contains metadata for APRS messages: count, read/unread flags,
103    /// index pointers.
104    #[must_use]
105    pub fn status_header(&self) -> Option<&[u8]> {
106        let end = APRS_STATUS_OFFSET + programming::PAGE_SIZE;
107        if end <= self.image.len() {
108            Some(&self.image[APRS_STATUS_OFFSET..end])
109        } else {
110            None
111        }
112    }
113
114    /// Get the raw APRS data region (pages `0x0152` through the start of
115    /// the D-STAR region).
116    ///
117    /// Contains APRS messages, callsign, status texts, packet path,
118    /// `SmartBeaconing` parameters, digipeater config, and more.
119    #[must_use]
120    pub fn data_region(&self) -> Option<&[u8]> {
121        if APRS_END_OFFSET <= self.image.len() {
122            Some(&self.image[APRS_DATA_OFFSET..APRS_END_OFFSET])
123        } else {
124            None
125        }
126    }
127
128    /// Read an arbitrary byte range from the APRS region.
129    ///
130    /// The offset is an absolute MCP byte address. Returns `None` if
131    /// the range extends past the image.
132    #[must_use]
133    pub fn read_bytes(&self, offset: usize, len: usize) -> Option<&[u8]> {
134        let end = offset + len;
135        if end <= self.image.len() {
136            Some(&self.image[offset..end])
137        } else {
138            None
139        }
140    }
141
142    /// Get the total size of the APRS region in bytes.
143    #[must_use]
144    pub const fn region_size(&self) -> usize {
145        APRS_END_OFFSET - APRS_STATUS_OFFSET
146    }
147
148    // -----------------------------------------------------------------------
149    // Typed APRS accessors (estimated offsets)
150    // -----------------------------------------------------------------------
151
152    /// Read the APRS MY callsign (station callsign with optional SSID).
153    ///
154    /// Returns the callsign as a string (up to 9 characters, e.g.
155    /// "N0CALL-9"). Returns an empty string if unreadable.
156    ///
157    /// # Offset
158    ///
159    /// Estimated at `0x15200` (first bytes of the APRS data region)
160    /// based on D74 layout analysis.
161    ///
162    /// # Verification
163    ///
164    /// Offset is estimated, not hardware-verified.
165    #[must_use]
166    pub fn my_callsign(&self) -> String {
167        let offset = APRS_DATA_OFFSET + APRS_MY_CALLSIGN_REL;
168        let end = offset + APRS_CALLSIGN_FIELD_LEN;
169        if end > self.image.len() {
170            return String::new();
171        }
172        let slice = &self.image[offset..end];
173        let nul = slice
174            .iter()
175            .position(|&b| b == 0)
176            .unwrap_or(APRS_CALLSIGN_FIELD_LEN);
177        let s = std::str::from_utf8(&slice[..nul]).unwrap_or("").trim();
178        s.to_owned()
179    }
180
181    /// Read the APRS MY callsign as a typed [`AprsCallsign`].
182    ///
183    /// Returns `None` if the callsign is empty or too long.
184    ///
185    /// # Offset
186    ///
187    /// Estimated at `0x15200` (first bytes of the APRS data region).
188    ///
189    /// # Verification
190    ///
191    /// Offset is estimated, not hardware-verified.
192    #[must_use]
193    pub fn my_callsign_typed(&self) -> Option<AprsCallsign> {
194        let raw = self.my_callsign();
195        if raw.is_empty() {
196            return None;
197        }
198        AprsCallsign::new(&raw)
199    }
200
201    /// Read the beacon interval in seconds.
202    ///
203    /// Returns the interval as a 16-bit value (range 30-9999 in normal
204    /// operation). Returns 0 if unreadable.
205    ///
206    /// # Offset
207    ///
208    /// Estimated at `0x1520A` (APRS data region + 0x0A) based on D74 layout analysis.
209    ///
210    /// # Verification
211    ///
212    /// Offset is estimated, not hardware-verified.
213    #[must_use]
214    pub fn beacon_interval(&self) -> u16 {
215        let offset = APRS_DATA_OFFSET + APRS_BEACON_INTERVAL_REL;
216        if offset + 2 > self.image.len() {
217            return 0;
218        }
219        u16::from_le_bytes([self.image[offset], self.image[offset + 1]])
220    }
221
222    /// Read the packet path selection index.
223    ///
224    /// Returns a raw index value (0 = Off, 1 = WIDE1-1, 2 = WIDE1-1
225    /// WIDE2-1, etc.). Returns 0 if unreadable.
226    ///
227    /// # Offset
228    ///
229    /// Estimated at `0x1520C` (APRS data region + 0x0C) based on D74 layout analysis.
230    ///
231    /// # Verification
232    ///
233    /// Offset is estimated, not hardware-verified.
234    #[must_use]
235    pub fn packet_path_index(&self) -> u8 {
236        let offset = APRS_DATA_OFFSET + APRS_PACKET_PATH_REL;
237        self.image.get(offset).copied().unwrap_or(0)
238    }
239
240    /// Read the packet path as a display string.
241    ///
242    /// Translates the raw index into a human-readable path string.
243    ///
244    /// # Offset
245    ///
246    /// Estimated at `0x1520C` (APRS data region + 0x0C).
247    ///
248    /// # Verification
249    ///
250    /// Offset is estimated, not hardware-verified.
251    #[must_use]
252    pub fn packet_path(&self) -> String {
253        match self.packet_path_index() {
254            0 => "Off".to_owned(),
255            1 => "WIDE1-1".to_owned(),
256            2 => "WIDE1-1,WIDE2-1".to_owned(),
257            3 => "WIDE1-1,WIDE2-2".to_owned(),
258            4 => "User 1".to_owned(),
259            5 => "User 2".to_owned(),
260            6 => "User 3".to_owned(),
261            _ => "Unknown".to_owned(),
262        }
263    }
264
265    // -----------------------------------------------------------------------
266    // APRS/GPS position data region (confirmed address)
267    // -----------------------------------------------------------------------
268
269    /// Get the raw APRS/GPS position data region (0x4B00 bytes at `0x25100`).
270    ///
271    /// This region contains APRS position data, stored object data, and
272    /// GPS-related configuration.
273    ///
274    /// Returns `None` if the region extends past the image.
275    #[must_use]
276    pub fn position_data_region(&self) -> Option<&[u8]> {
277        let end = APRS_POSITION_DATA_OFFSET + APRS_POSITION_DATA_SIZE;
278        if end <= self.image.len() {
279            Some(&self.image[APRS_POSITION_DATA_OFFSET..end])
280        } else {
281            None
282        }
283    }
284
285    /// Get the total size of the APRS/GPS position data region in bytes.
286    ///
287    /// Always returns 0x4B00 (19,200 bytes).
288    #[must_use]
289    pub const fn position_data_size(&self) -> usize {
290        APRS_POSITION_DATA_SIZE
291    }
292
293    /// Read a byte range from the APRS/GPS position data region.
294    ///
295    /// The `rel_offset` is relative to the start of the position data
296    /// region (`0x25100`). Returns `None` if the range extends past the
297    /// region or the image.
298    #[must_use]
299    pub fn position_data_bytes(&self, rel_offset: usize, len: usize) -> Option<&[u8]> {
300        if rel_offset + len > APRS_POSITION_DATA_SIZE {
301            return None;
302        }
303        let abs_offset = APRS_POSITION_DATA_OFFSET + rel_offset;
304        let end = abs_offset + len;
305        if end <= self.image.len() {
306            Some(&self.image[abs_offset..end])
307        } else {
308            None
309        }
310    }
311
312    /// Check if the APRS/GPS position data region contains any non-zero data.
313    ///
314    /// Returns `true` if any byte in the region is non-zero, indicating
315    /// that position data has been stored.
316    #[must_use]
317    pub fn has_position_data(&self) -> bool {
318        self.position_data_region()
319            .is_some_and(|data| data.iter().any(|&b| b != 0x00 && b != 0xFF))
320    }
321}
322
323// ---------------------------------------------------------------------------
324// Tests
325// ---------------------------------------------------------------------------
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::protocol::programming::TOTAL_SIZE;
331
332    fn make_aprs_image() -> Vec<u8> {
333        vec![0u8; TOTAL_SIZE]
334    }
335
336    #[test]
337    fn aprs_status_header_accessible() {
338        let image = vec![0xAA_u8; TOTAL_SIZE];
339        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
340        let aprs = mi.aprs();
341        let header = aprs.status_header().unwrap();
342        assert_eq!(header.len(), programming::PAGE_SIZE);
343        assert!(header.iter().all(|&b| b == 0xAA));
344    }
345
346    #[test]
347    fn aprs_data_region_accessible() {
348        let image = vec![0u8; TOTAL_SIZE];
349        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
350        let aprs = mi.aprs();
351        let data = aprs.data_region().unwrap();
352        assert!(!data.is_empty());
353        // Region should span from APRS_DATA_OFFSET to APRS_END_OFFSET.
354        let expected_size = APRS_END_OFFSET - APRS_DATA_OFFSET;
355        assert_eq!(data.len(), expected_size);
356    }
357
358    #[test]
359    fn aprs_region_size() {
360        let image = vec![0u8; TOTAL_SIZE];
361        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
362        let aprs = mi.aprs();
363        // Region should be non-trivial (several KB).
364        assert!(aprs.region_size() > 1000);
365    }
366
367    #[test]
368    fn aprs_my_callsign() {
369        let mut image = make_aprs_image();
370        let offset = APRS_DATA_OFFSET + APRS_MY_CALLSIGN_REL;
371        let cs = b"N0CALL-9\0\0";
372        image[offset..offset + 10].copy_from_slice(cs);
373
374        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
375        let aprs = mi.aprs();
376        assert_eq!(aprs.my_callsign(), "N0CALL-9");
377    }
378
379    #[test]
380    fn aprs_my_callsign_typed() {
381        let mut image = make_aprs_image();
382        let offset = APRS_DATA_OFFSET + APRS_MY_CALLSIGN_REL;
383        let cs = b"W1AW-7\0\0\0\0";
384        image[offset..offset + 10].copy_from_slice(cs);
385
386        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
387        let aprs = mi.aprs();
388        let typed = aprs.my_callsign_typed().unwrap();
389        assert_eq!(typed.as_str(), "W1AW-7");
390    }
391
392    #[test]
393    fn aprs_my_callsign_empty() {
394        let image = make_aprs_image();
395        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
396        let aprs = mi.aprs();
397        assert_eq!(aprs.my_callsign(), "");
398        assert!(aprs.my_callsign_typed().is_none());
399    }
400
401    #[test]
402    fn aprs_beacon_interval() {
403        let mut image = make_aprs_image();
404        let offset = APRS_DATA_OFFSET + APRS_BEACON_INTERVAL_REL;
405        // 180 seconds = 0x00B4 little-endian
406        image[offset] = 0xB4;
407        image[offset + 1] = 0x00;
408
409        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
410        let aprs = mi.aprs();
411        assert_eq!(aprs.beacon_interval(), 180);
412    }
413
414    #[test]
415    fn aprs_beacon_interval_zero() {
416        let image = make_aprs_image();
417        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
418        assert_eq!(mi.aprs().beacon_interval(), 0);
419    }
420
421    #[test]
422    fn aprs_packet_path() {
423        let mut image = make_aprs_image();
424        let offset = APRS_DATA_OFFSET + APRS_PACKET_PATH_REL;
425        image[offset] = 2; // WIDE1-1,WIDE2-1
426
427        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
428        let aprs = mi.aprs();
429        assert_eq!(aprs.packet_path_index(), 2);
430        assert_eq!(aprs.packet_path(), "WIDE1-1,WIDE2-1");
431    }
432
433    #[test]
434    fn aprs_packet_path_off() {
435        let image = make_aprs_image();
436        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
437        assert_eq!(mi.aprs().packet_path(), "Off");
438    }
439
440    #[test]
441    fn aprs_packet_path_unknown() {
442        let mut image = make_aprs_image();
443        let offset = APRS_DATA_OFFSET + APRS_PACKET_PATH_REL;
444        image[offset] = 0xFF;
445
446        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
447        assert_eq!(mi.aprs().packet_path(), "Unknown");
448    }
449
450    // -----------------------------------------------------------------------
451    // APRS/GPS position data region tests (confirmed address)
452    // -----------------------------------------------------------------------
453
454    #[test]
455    fn aprs_position_data_region_accessible() {
456        let image = vec![0u8; TOTAL_SIZE];
457        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
458        let aprs = mi.aprs();
459        let region = aprs.position_data_region().unwrap();
460        assert_eq!(region.len(), APRS_POSITION_DATA_SIZE);
461    }
462
463    #[test]
464    fn aprs_position_data_size() {
465        let image = vec![0u8; TOTAL_SIZE];
466        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
467        assert_eq!(mi.aprs().position_data_size(), 0x4B00);
468    }
469
470    #[test]
471    fn aprs_position_data_bytes() {
472        let mut image = vec![0u8; TOTAL_SIZE];
473        // Write known data at the start of the position data region.
474        image[APRS_POSITION_DATA_OFFSET..APRS_POSITION_DATA_OFFSET + 4]
475            .copy_from_slice(&[0x01, 0x02, 0x03, 0x04]);
476
477        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
478        let aprs = mi.aprs();
479        let bytes = aprs.position_data_bytes(0, 4).unwrap();
480        assert_eq!(bytes, &[0x01, 0x02, 0x03, 0x04]);
481    }
482
483    #[test]
484    fn aprs_position_data_bytes_past_region() {
485        let image = vec![0u8; TOTAL_SIZE];
486        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
487        // Try to read past the end of the position data region.
488        assert!(
489            mi.aprs()
490                .position_data_bytes(APRS_POSITION_DATA_SIZE, 1)
491                .is_none()
492        );
493    }
494
495    #[test]
496    fn aprs_has_position_data_empty() {
497        let image = vec![0u8; TOTAL_SIZE];
498        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
499        assert!(!mi.aprs().has_position_data());
500    }
501
502    #[test]
503    fn aprs_has_position_data_populated() {
504        let mut image = vec![0u8; TOTAL_SIZE];
505        // Write non-zero data in the position data region.
506        image[APRS_POSITION_DATA_OFFSET + 100] = 0x42;
507        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
508        assert!(mi.aprs().has_position_data());
509    }
510
511    #[test]
512    fn aprs_has_position_data_all_ff() {
513        let mut image = vec![0u8; TOTAL_SIZE];
514        // Fill with 0xFF (common empty marker) -- should not count.
515        let end = APRS_POSITION_DATA_OFFSET + APRS_POSITION_DATA_SIZE;
516        image[APRS_POSITION_DATA_OFFSET..end].fill(0xFF);
517        let mi = crate::memory::MemoryImage::from_raw(image).unwrap();
518        assert!(!mi.aprs().has_position_data());
519    }
520}