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}