1pub const ENTER_PROGRAMMING: &[u8] = b"0M PROGRAM\r";
23
24pub const ENTER_RESPONSE: &[u8] = b"0M\r";
26
27pub const ACK: u8 = 0x06;
29
30pub const EXIT: u8 = b'E';
32
33pub const PAGE_SIZE: usize = 256;
39
40pub const TOTAL_PAGES: u16 = 1955;
42
43pub const TOTAL_SIZE: usize = 500_480;
45
46pub const FACTORY_CAL_PAGES: u16 = 2;
48
49pub const MAX_WRITABLE_PAGE: u16 = TOTAL_PAGES - FACTORY_CAL_PAGES - 1; pub const SETTINGS_START: u16 = 0x0000;
58pub const SETTINGS_END: u16 = 0x001F;
60
61pub const CHANNEL_FLAGS_START: u16 = 0x0020;
63pub const CHANNEL_FLAGS_END: u16 = 0x0032;
65
66pub const CHANNEL_DATA_START: u16 = 0x0040;
68pub const CHANNEL_DATA_END: u16 = 0x00FF;
70
71pub const CHANNEL_NAMES_START: u16 = 0x0100;
73pub const CHANNEL_NAMES_END: u16 = 0x014A;
75
76pub const GROUP_NAMES_START: u16 = 0x0148;
78pub const GROUP_NAMES_END: u16 = 0x014A;
80
81pub const APRS_STATUS_PAGE: u16 = 0x0151;
83pub const APRS_START: u16 = 0x0152;
85
86pub const DSTAR_RPT_START: u16 = 0x02A1;
88
89pub const BT_START: u16 = 0x04D1;
91
92pub const NAME_START_PAGE: u16 = CHANNEL_NAMES_START;
98
99pub const NAME_PAGE_COUNT: u16 = 63;
101
102pub const NAME_ALL_PAGE_COUNT: u16 = CHANNEL_NAMES_END - CHANNEL_NAMES_START + 1;
105
106pub const NAME_ENTRY_SIZE: usize = 16;
108
109pub const NAMES_PER_PAGE: usize = 16;
111
112pub const MAX_CHANNELS: usize = 1000;
114
115pub const TOTAL_CHANNEL_ENTRIES: usize = 1200;
117
118pub const CHANNEL_RECORD_SIZE: usize = 40;
124
125pub const CHANNELS_PER_MEMGROUP: usize = 6;
127
128pub const MEMGROUP_PADDING: usize = 16;
130
131pub const MEMGROUP_COUNT: usize = 192;
133
134pub const FLAG_RECORD_SIZE: usize = 4;
140
141pub const FLAG_EMPTY: u8 = 0xFF;
143pub const FLAG_VHF: u8 = 0x00;
145pub const FLAG_220: u8 = 0x01;
147pub const FLAG_UHF: u8 = 0x02;
149
150pub const W_RESPONSE_SIZE: usize = 261;
156
157pub const W_HEADER_SIZE: usize = 5;
159
160#[must_use]
164pub const fn build_read_command(page: u16) -> [u8; 5] {
165 let addr = page.to_be_bytes();
166 [b'R', addr[0], addr[1], 0x00, 0x00]
167}
168
169#[must_use]
175pub fn build_write_command(page: u16, data: &[u8; PAGE_SIZE]) -> Vec<u8> {
176 let addr = page.to_be_bytes();
177 let mut cmd = Vec::with_capacity(W_RESPONSE_SIZE);
178 cmd.extend_from_slice(&[b'W', addr[0], addr[1], 0x00, 0x00]);
179 cmd.extend_from_slice(data);
180 cmd
181}
182
183#[must_use]
186pub const fn is_factory_calibration_page(page: u16) -> bool {
187 page > MAX_WRITABLE_PAGE
188}
189
190pub fn parse_write_response(buf: &[u8]) -> Result<(u16, &[u8]), String> {
203 if buf.len() < W_RESPONSE_SIZE {
204 return Err(format!(
205 "W response too short: {} bytes, expected {}",
206 buf.len(),
207 W_RESPONSE_SIZE
208 ));
209 }
210 if buf[0] != b'W' {
211 return Err(format!("expected W response marker, got 0x{:02X}", buf[0]));
212 }
213 let page = u16::from_be_bytes([buf[1], buf[2]]);
215 Ok((page, &buf[5..5 + PAGE_SIZE]))
217}
218
219#[must_use]
224pub fn extract_name(entry: &[u8]) -> String {
225 let end = entry
226 .iter()
227 .position(|&b| b == 0)
228 .unwrap_or(entry.len())
229 .min(NAME_ENTRY_SIZE);
230 String::from_utf8_lossy(&entry[..end]).trim().to_string()
231}
232
233#[must_use]
241pub fn parse_channel_flag(bytes: &[u8]) -> Option<ChannelFlag> {
242 if bytes.len() < FLAG_RECORD_SIZE {
243 return None;
244 }
245 let used = bytes[0];
246 let lockout = bytes[1] & 0x01 != 0;
247 let group = bytes[2];
248 Some(ChannelFlag {
249 used,
250 lockout,
251 group,
252 })
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct ChannelFlag {
258 pub used: u8,
260 pub lockout: bool,
262 pub group: u8,
264}
265
266impl ChannelFlag {
267 #[must_use]
269 pub const fn is_empty(&self) -> bool {
270 self.used == FLAG_EMPTY
271 }
272
273 #[must_use]
275 pub const fn to_bytes(&self) -> [u8; FLAG_RECORD_SIZE] {
276 [
277 self.used,
278 if self.lockout { 0x01 } else { 0x00 },
279 self.group,
280 0xFF,
281 ]
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn build_read_command_page_256() {
291 let cmd = build_read_command(256);
292 assert_eq!(cmd, [b'R', 0x01, 0x00, 0x00, 0x00]);
293 }
294
295 #[test]
296 fn build_read_command_page_318() {
297 let cmd = build_read_command(318);
299 assert_eq!(cmd, [b'R', 0x01, 0x3E, 0x00, 0x00]);
300 }
301
302 #[test]
303 fn build_read_command_page_zero() {
304 let cmd = build_read_command(0);
305 assert_eq!(cmd, [b'R', 0x00, 0x00, 0x00, 0x00]);
306 }
307
308 #[test]
309 fn build_write_command_format() {
310 let data = [0xAA; PAGE_SIZE];
311 let cmd = build_write_command(0x0100, &data);
312 assert_eq!(cmd.len(), W_RESPONSE_SIZE);
313 assert_eq!(cmd[0], b'W');
314 assert_eq!(cmd[1], 0x01); assert_eq!(cmd[2], 0x00); assert_eq!(cmd[3], 0x00); assert_eq!(cmd[4], 0x00); assert!(cmd[5..].iter().all(|&b| b == 0xAA));
319 }
320
321 #[test]
322 fn build_write_command_page_zero() {
323 let data = [0u8; PAGE_SIZE];
324 let cmd = build_write_command(0, &data);
325 assert_eq!(cmd[1], 0x00);
326 assert_eq!(cmd[2], 0x00);
327 }
328
329 #[test]
330 fn factory_calibration_page_detection() {
331 assert!(!is_factory_calibration_page(0x07A0)); assert!(is_factory_calibration_page(0x07A1)); assert!(is_factory_calibration_page(0x07A2)); assert!(!is_factory_calibration_page(0x0000)); assert!(!is_factory_calibration_page(0x0100)); }
338
339 #[test]
340 fn parse_write_response_valid() {
341 let mut resp = vec![b'W', 0x01, 0x00, 0x00, 0x00]; resp.extend_from_slice(&[0x41; 256]); assert_eq!(resp.len(), 261);
344 let (addr, data) = parse_write_response(&resp).unwrap();
345 assert_eq!(addr, 256);
346 assert_eq!(data.len(), 256);
347 assert!(data.iter().all(|&b| b == 0x41));
348 }
349
350 #[test]
351 fn parse_write_response_full_page() {
352 let mut resp = vec![b'W', 0x01, 0x3E, 0x00, 0x00]; resp.extend_from_slice(&[0u8; 256]);
354 assert_eq!(resp.len(), 261);
355 let (addr, data) = parse_write_response(&resp).unwrap();
356 assert_eq!(addr, 318);
357 assert_eq!(data.len(), 256);
358 }
359
360 #[test]
361 fn parse_write_response_invalid_marker() {
362 let mut resp = vec![b'X', 0x01, 0x00, 0x00, 0x00];
363 resp.extend_from_slice(&[0u8; 256]);
364 assert!(parse_write_response(&resp).is_err());
365 }
366
367 #[test]
368 fn parse_write_response_empty() {
369 let resp: Vec<u8> = vec![];
370 assert!(parse_write_response(&resp).is_err());
371 }
372
373 #[test]
374 fn parse_write_response_too_short() {
375 let resp = vec![b'W', 0x01, 0x00, 0x00, 0x00, 0x41]; assert!(parse_write_response(&resp).is_err());
377 }
378
379 #[test]
380 fn extract_name_null_terminated() {
381 let mut entry = [0u8; 16];
382 entry[..4].copy_from_slice(b"RPT1");
383 assert_eq!(extract_name(&entry), "RPT1");
384 }
385
386 #[test]
387 fn extract_name_full_length() {
388 let entry = *b"ForestCityPD\x00\x00\x00\x00";
389 assert_eq!(extract_name(&entry), "ForestCityPD");
390 }
391
392 #[test]
393 fn extract_name_empty() {
394 let entry = [0u8; 16];
395 assert_eq!(extract_name(&entry), "");
396 }
397
398 #[test]
399 fn extract_name_max_16_chars() {
400 let entry = *b"1234567890ABCDEF";
401 assert_eq!(extract_name(&entry), "1234567890ABCDEF");
402 }
403
404 #[test]
405 fn extract_name_trims_whitespace() {
406 let mut entry = [0u8; 16];
407 entry[..6].copy_from_slice(b"RPT1 ");
408 assert_eq!(extract_name(&entry), "RPT1");
409 }
410
411 #[test]
412 fn name_page_calculation() {
413 fn page_for(channel: u16) -> u16 {
415 NAME_START_PAGE + channel / 16
416 }
417 assert_eq!(page_for(0), 256);
419 assert_eq!(page_for(15), 256);
421 assert_eq!(page_for(16), 257);
423 assert_eq!(page_for(999), 318);
425 }
426
427 #[test]
428 fn total_name_slots() {
429 let total = NAME_PAGE_COUNT as usize * NAMES_PER_PAGE;
430 assert_eq!(total, 1008);
431 assert!(total >= MAX_CHANNELS);
432 }
433
434 #[test]
435 fn constants_consistent() {
436 assert_eq!(ENTER_PROGRAMMING, b"0M PROGRAM\r");
437 assert_eq!(ENTER_RESPONSE, b"0M\r");
438 assert_eq!(ACK, 0x06);
439 assert_eq!(EXIT, b'E');
440 }
441
442 #[test]
443 fn memory_geometry_consistent() {
444 assert_eq!(TOTAL_SIZE, TOTAL_PAGES as usize * PAGE_SIZE);
445 #[allow(clippy::assertions_on_constants)]
448 {
449 assert!(MAX_WRITABLE_PAGE < TOTAL_PAGES);
450 }
451 assert_eq!(FACTORY_CAL_PAGES, 2);
452 }
453
454 #[test]
455 fn region_boundaries_non_overlapping() {
456 #[allow(clippy::assertions_on_constants)]
459 {
460 assert!(SETTINGS_END < CHANNEL_FLAGS_START);
462 assert!(CHANNEL_FLAGS_END < CHANNEL_DATA_START);
464 assert!(CHANNEL_DATA_END < CHANNEL_NAMES_START);
466 assert!(CHANNEL_NAMES_END < APRS_START);
468 assert!(APRS_START < DSTAR_RPT_START);
470 assert!(DSTAR_RPT_START < BT_START);
472 }
473 }
474
475 #[test]
476 fn channel_flag_parse_vhf() {
477 let bytes = [FLAG_VHF, 0x00, 0x05, 0xFF];
478 let flag = parse_channel_flag(&bytes).unwrap();
479 assert!(!flag.is_empty());
480 assert!(!flag.lockout);
481 assert_eq!(flag.group, 5);
482 assert_eq!(flag.used, FLAG_VHF);
483 }
484
485 #[test]
486 fn channel_flag_parse_empty() {
487 let bytes = [FLAG_EMPTY, 0x00, 0x00, 0xFF];
488 let flag = parse_channel_flag(&bytes).unwrap();
489 assert!(flag.is_empty());
490 }
491
492 #[test]
493 fn channel_flag_parse_locked_out() {
494 let bytes = [FLAG_UHF, 0x01, 0x0A, 0xFF];
495 let flag = parse_channel_flag(&bytes).unwrap();
496 assert!(!flag.is_empty());
497 assert!(flag.lockout);
498 assert_eq!(flag.group, 10);
499 }
500
501 #[test]
502 fn channel_flag_round_trip() {
503 let flag = ChannelFlag {
504 used: FLAG_220,
505 lockout: true,
506 group: 15,
507 };
508 let bytes = flag.to_bytes();
509 let parsed = parse_channel_flag(&bytes).unwrap();
510 assert_eq!(parsed, flag);
511 }
512
513 #[test]
514 fn channel_flag_too_short() {
515 let bytes = [0xFF, 0x00, 0x00]; assert!(parse_channel_flag(&bytes).is_none());
517 }
518}