1use core::fmt;
8
9use ax25_codec::Callsign;
10
11use crate::error::AprsError;
12
13#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
23pub struct Latitude(f64);
24
25impl Latitude {
26 pub const MIN: f64 = -90.0;
28 pub const MAX: f64 = 90.0;
30
31 pub fn new(degrees: f64) -> Result<Self, AprsError> {
38 if !degrees.is_finite() || !(Self::MIN..=Self::MAX).contains(°rees) {
39 return Err(AprsError::InvalidLatitude(
40 "must be finite and in [-90.0, 90.0]",
41 ));
42 }
43 Ok(Self(degrees))
44 }
45
46 #[must_use]
49 #[allow(clippy::missing_const_for_fn)] pub fn new_clamped(degrees: f64) -> Self {
51 if degrees.is_nan() {
52 return Self(0.0);
53 }
54 Self(degrees.clamp(Self::MIN, Self::MAX))
55 }
56
57 #[must_use]
59 pub const fn as_degrees(self) -> f64 {
60 self.0
61 }
62
63 #[must_use]
66 pub fn as_aprs_uncompressed(self) -> String {
67 let hemisphere = if self.0 >= 0.0 { 'N' } else { 'S' };
68 let lat_abs = self.0.abs();
69 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
70 let degrees = lat_abs as u32;
71 let minutes = (lat_abs - f64::from(degrees)) * 60.0;
72 format!("{degrees:02}{minutes:05.2}{hemisphere}")
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
80pub struct Longitude(f64);
81
82impl Longitude {
83 pub const MIN: f64 = -180.0;
85 pub const MAX: f64 = 180.0;
87
88 pub fn new(degrees: f64) -> Result<Self, AprsError> {
95 if !degrees.is_finite() || !(Self::MIN..=Self::MAX).contains(°rees) {
96 return Err(AprsError::InvalidLongitude(
97 "must be finite and in [-180.0, 180.0]",
98 ));
99 }
100 Ok(Self(degrees))
101 }
102
103 #[must_use]
105 #[allow(clippy::missing_const_for_fn)] pub fn new_clamped(degrees: f64) -> Self {
107 if degrees.is_nan() {
108 return Self(0.0);
109 }
110 Self(degrees.clamp(Self::MIN, Self::MAX))
111 }
112
113 #[must_use]
115 pub const fn as_degrees(self) -> f64 {
116 self.0
117 }
118
119 #[must_use]
122 pub fn as_aprs_uncompressed(self) -> String {
123 let hemisphere = if self.0 >= 0.0 { 'E' } else { 'W' };
124 let lon_abs = self.0.abs();
125 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
126 let degrees = lon_abs as u32;
127 let minutes = (lon_abs - f64::from(degrees)) * 60.0;
128 format!("{degrees:03}{minutes:05.2}{hemisphere}")
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
146pub enum Speed {
147 Knots(u16),
149 Kmh(f64),
151 Mph(u16),
153}
154
155impl Speed {
156 pub const KNOTS_TO_KMH: f64 = 1.852;
158 pub const MPH_TO_KMH: f64 = 1.609_344;
160
161 #[must_use]
163 pub fn as_kmh(self) -> f64 {
164 match self {
165 Self::Knots(k) => f64::from(k) * Self::KNOTS_TO_KMH,
166 Self::Kmh(k) => k,
167 Self::Mph(m) => f64::from(m) * Self::MPH_TO_KMH,
168 }
169 }
170
171 #[must_use]
173 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
174 pub fn as_knots(self) -> u16 {
175 match self {
176 Self::Knots(k) => k,
177 Self::Kmh(k) => (k / Self::KNOTS_TO_KMH).round() as u16,
178 Self::Mph(m) => (f64::from(m) * Self::MPH_TO_KMH / Self::KNOTS_TO_KMH).round() as u16,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
193pub struct Course(u16);
194
195impl Course {
196 pub const MAX: u16 = 360;
198
199 pub const fn new(degrees: u16) -> Result<Self, AprsError> {
205 if degrees <= Self::MAX {
206 Ok(Self(degrees))
207 } else {
208 Err(AprsError::InvalidCourse("must be 0-360 degrees"))
209 }
210 }
211
212 #[must_use]
214 pub const fn as_degrees(self) -> u16 {
215 self.0
216 }
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Hash)]
229pub struct MessageId(String);
230
231impl MessageId {
232 pub const MAX_LEN: usize = 5;
234
235 pub fn new(s: &str) -> Result<Self, AprsError> {
242 if s.is_empty() || s.len() > Self::MAX_LEN {
243 return Err(AprsError::InvalidMessageId("must be 1-5 characters"));
244 }
245 if !s.bytes().all(|b| b.is_ascii_alphanumeric()) {
246 return Err(AprsError::InvalidMessageId("must be ASCII alphanumeric"));
247 }
248 Ok(Self(s.to_owned()))
249 }
250
251 #[must_use]
253 pub fn as_str(&self) -> &str {
254 &self.0
255 }
256}
257
258impl fmt::Display for MessageId {
259 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
260 f.write_str(&self.0)
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
277pub enum SymbolTable {
278 Primary,
280 Alternate,
282 Overlay(u8),
285}
286
287impl SymbolTable {
288 pub const fn from_byte(b: u8) -> Result<Self, AprsError> {
295 match b {
296 b'/' => Ok(Self::Primary),
297 b'\\' => Ok(Self::Alternate),
298 b'0'..=b'9' | b'A'..=b'Z' => Ok(Self::Overlay(b)),
299 _ => Err(AprsError::InvalidSymbolTable(
300 "must be '/', '\\\\', 0-9, or A-Z",
301 )),
302 }
303 }
304
305 #[must_use]
307 pub const fn as_byte(self) -> u8 {
308 match self {
309 Self::Primary => b'/',
310 Self::Alternate => b'\\',
311 Self::Overlay(b) => b,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
321pub struct AprsSymbol {
322 pub table: SymbolTable,
324 pub code: u8,
326}
327
328impl AprsSymbol {
329 pub const CAR: Self = Self {
331 table: SymbolTable::Primary,
332 code: b'>',
333 };
334 pub const HOUSE: Self = Self {
336 table: SymbolTable::Primary,
337 code: b'-',
338 };
339 pub const WEATHER: Self = Self {
341 table: SymbolTable::Primary,
342 code: b'_',
343 };
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
356pub struct Fahrenheit(i16);
357
358impl Fahrenheit {
359 pub const MIN: i16 = -99;
361 pub const MAX: i16 = 999;
363
364 pub const fn new(f: i16) -> Result<Self, AprsError> {
371 if f < Self::MIN || f > Self::MAX {
372 return Err(AprsError::InvalidTemperature("must be -99..=999"));
373 }
374 Ok(Self(f))
375 }
376
377 #[must_use]
379 pub const fn get(self) -> i16 {
380 self.0
381 }
382}
383
384#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
397pub struct Tocall(String);
398
399impl Tocall {
400 pub const MAX_LEN: usize = 6;
402
403 pub const TH_D75: &'static str = "APK005";
406
407 pub fn new(s: &str) -> Result<Self, AprsError> {
414 let _validated = Callsign::new(s)
419 .map_err(|_| AprsError::InvalidTocall("must be 1-6 uppercase A-Z or 0-9"))?;
420 Ok(Self(s.to_owned()))
421 }
422
423 #[must_use]
425 pub fn th_d75() -> Self {
426 Self(Self::TH_D75.to_owned())
427 }
428
429 #[must_use]
431 pub fn as_str(&self) -> &str {
432 &self.0
433 }
434}
435
436impl fmt::Display for Tocall {
437 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438 f.write_str(&self.0)
439 }
440}
441
442#[cfg(test)]
447mod tests {
448 use super::*;
449
450 type TestResult = Result<(), Box<dyn std::error::Error>>;
451
452 #[test]
453 fn latitude_accepts_valid_range() -> TestResult {
454 let _lat = Latitude::new(0.0)?;
455 let _lat = Latitude::new(90.0)?;
456 let _lat = Latitude::new(-90.0)?;
457 let _lat = Latitude::new(35.25)?;
458 Ok(())
459 }
460
461 #[test]
462 fn latitude_rejects_out_of_range() {
463 assert!(matches!(
464 Latitude::new(90.01),
465 Err(AprsError::InvalidLatitude(_))
466 ));
467 assert!(matches!(
468 Latitude::new(-90.01),
469 Err(AprsError::InvalidLatitude(_))
470 ));
471 assert!(matches!(
472 Latitude::new(f64::NAN),
473 Err(AprsError::InvalidLatitude(_))
474 ));
475 assert!(matches!(
476 Latitude::new(f64::INFINITY),
477 Err(AprsError::InvalidLatitude(_))
478 ));
479 }
480
481 #[test]
482 fn latitude_clamped() {
483 assert!((Latitude::new_clamped(200.0).as_degrees() - 90.0).abs() < f64::EPSILON);
484 assert!((Latitude::new_clamped(-200.0).as_degrees() - (-90.0)).abs() < f64::EPSILON);
485 assert!((Latitude::new_clamped(f64::NAN).as_degrees() - 0.0).abs() < f64::EPSILON);
486 }
487
488 #[test]
489 fn longitude_accepts_valid_range() -> TestResult {
490 let _lon = Longitude::new(180.0)?;
491 let _lon = Longitude::new(-180.0)?;
492 let _lon = Longitude::new(0.0)?;
493 Ok(())
494 }
495
496 #[test]
497 fn longitude_rejects_out_of_range() {
498 assert!(matches!(
499 Longitude::new(180.01),
500 Err(AprsError::InvalidLongitude(_))
501 ));
502 assert!(matches!(
503 Longitude::new(-180.01),
504 Err(AprsError::InvalidLongitude(_))
505 ));
506 }
507
508 #[test]
509 fn speed_conversions() {
510 let s = Speed::Knots(10);
511 assert!((s.as_kmh() - 18.52).abs() < 1e-6);
512 let s = Speed::Kmh(100.0);
513 assert_eq!(s.as_knots(), 54); let s = Speed::Mph(60);
515 assert!((s.as_kmh() - 96.5606).abs() < 1e-3);
516 }
517
518 #[test]
519 fn course_valid_range() -> TestResult {
520 assert_eq!(Course::new(0)?.as_degrees(), 0);
521 assert_eq!(Course::new(360)?.as_degrees(), 360);
522 assert_eq!(Course::new(180)?.as_degrees(), 180);
523 Ok(())
524 }
525
526 #[test]
527 fn course_rejects_too_large() {
528 assert!(matches!(Course::new(361), Err(AprsError::InvalidCourse(_))));
529 }
530
531 #[test]
532 fn message_id_valid() -> TestResult {
533 assert_eq!(MessageId::new("1")?.as_str(), "1");
534 assert_eq!(MessageId::new("12345")?.as_str(), "12345");
535 assert_eq!(MessageId::new("ABC")?.as_str(), "ABC");
536 Ok(())
537 }
538
539 #[test]
540 fn message_id_rejects_empty_or_long() {
541 assert!(matches!(
542 MessageId::new(""),
543 Err(AprsError::InvalidMessageId(_))
544 ));
545 assert!(matches!(
546 MessageId::new("123456"),
547 Err(AprsError::InvalidMessageId(_))
548 ));
549 }
550
551 #[test]
552 fn message_id_rejects_non_alnum() {
553 assert!(matches!(
554 MessageId::new("12-3"),
555 Err(AprsError::InvalidMessageId(_))
556 ));
557 assert!(matches!(
558 MessageId::new("ab c"),
559 Err(AprsError::InvalidMessageId(_))
560 ));
561 }
562
563 #[test]
564 fn symbol_table_parse() -> TestResult {
565 assert_eq!(SymbolTable::from_byte(b'/')?, SymbolTable::Primary);
566 assert_eq!(SymbolTable::from_byte(b'\\')?, SymbolTable::Alternate);
567 assert_eq!(SymbolTable::from_byte(b'9')?, SymbolTable::Overlay(b'9'));
568 assert_eq!(SymbolTable::from_byte(b'Z')?, SymbolTable::Overlay(b'Z'));
569 assert!(matches!(
570 SymbolTable::from_byte(b'a'),
571 Err(AprsError::InvalidSymbolTable(_))
572 ));
573 assert!(matches!(
574 SymbolTable::from_byte(b'!'),
575 Err(AprsError::InvalidSymbolTable(_))
576 ));
577 Ok(())
578 }
579
580 #[test]
581 fn symbol_table_round_trip() -> TestResult {
582 for b in [b'/', b'\\', b'0', b'5', b'A', b'Z'] {
583 let table = SymbolTable::from_byte(b)?;
584 assert_eq!(table.as_byte(), b);
585 }
586 Ok(())
587 }
588
589 #[test]
590 fn fahrenheit_valid_range() -> TestResult {
591 assert_eq!(Fahrenheit::new(-99)?.get(), -99);
592 assert_eq!(Fahrenheit::new(999)?.get(), 999);
593 assert_eq!(Fahrenheit::new(72)?.get(), 72);
594 Ok(())
595 }
596
597 #[test]
598 fn fahrenheit_rejects_out_of_range() {
599 assert!(matches!(
600 Fahrenheit::new(-100),
601 Err(AprsError::InvalidTemperature(_))
602 ));
603 assert!(matches!(
604 Fahrenheit::new(1000),
605 Err(AprsError::InvalidTemperature(_))
606 ));
607 }
608
609 #[test]
610 fn tocall_th_d75() {
611 assert_eq!(Tocall::th_d75().as_str(), "APK005");
612 assert_eq!(Tocall::TH_D75, "APK005");
613 }
614
615 #[test]
616 fn tocall_validates() -> TestResult {
617 let _tc = Tocall::new("APK005")?;
618 let _tc = Tocall::new("APXXXX")?;
619 assert!(matches!(
620 Tocall::new("toolongname"),
621 Err(AprsError::InvalidTocall(_))
622 ));
623 assert!(matches!(Tocall::new(""), Err(AprsError::InvalidTocall(_))));
624 Ok(())
625 }
626
627 #[test]
628 fn latitude_aprs_format_north() -> TestResult {
629 let lat = Latitude::new(49.058_333)?;
630 let s = lat.as_aprs_uncompressed();
631 assert_eq!(s.len(), 8);
632 assert!(s.ends_with('N'));
633 assert!(s.starts_with("49"));
634 Ok(())
635 }
636
637 #[test]
638 fn latitude_aprs_format_south() -> TestResult {
639 let lat = Latitude::new(-33.856)?;
640 let s = lat.as_aprs_uncompressed();
641 assert!(s.ends_with('S'));
642 Ok(())
643 }
644
645 #[test]
646 fn longitude_aprs_format_west() -> TestResult {
647 let lon = Longitude::new(-72.029_166)?;
648 let s = lon.as_aprs_uncompressed();
649 assert_eq!(s.len(), 9);
650 assert!(s.ends_with('W'));
651 assert!(s.starts_with("072"));
652 Ok(())
653 }
654
655 #[test]
656 fn longitude_aprs_format_east() -> TestResult {
657 let lon = Longitude::new(151.209)?;
658 let s = lon.as_aprs_uncompressed();
659 assert!(s.ends_with('E'));
660 assert!(s.starts_with("151"));
661 Ok(())
662 }
663}