aprs/
units.rs

1//! Strongly-typed primitives for APRS wire-format data.
2//!
3//! These newtypes are used by the APRS parsers and builders. Every type
4//! validates at construction and rejects out-of-range values, making
5//! illegal APRS packets unrepresentable.
6
7use core::fmt;
8
9use ax25_codec::Callsign;
10
11use crate::error::AprsError;
12
13// ---------------------------------------------------------------------------
14// Latitude / Longitude
15// ---------------------------------------------------------------------------
16
17/// Geographic latitude in decimal degrees, validated to `[-90.0, 90.0]`.
18///
19/// Positive = North, negative = South. Rejects NaN and out-of-range
20/// values. Use [`Self::new`] for fallible construction and
21/// [`Self::new_clamped`] when you prefer silent clamping.
22#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
23pub struct Latitude(f64);
24
25impl Latitude {
26    /// Minimum valid latitude (South Pole).
27    pub const MIN: f64 = -90.0;
28    /// Maximum valid latitude (North Pole).
29    pub const MAX: f64 = 90.0;
30
31    /// Create a latitude, rejecting NaN or out-of-range values.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`AprsError::InvalidLatitude`] if `degrees` is not finite
36    /// or not in `[-90.0, 90.0]`.
37    pub fn new(degrees: f64) -> Result<Self, AprsError> {
38        if !degrees.is_finite() || !(Self::MIN..=Self::MAX).contains(&degrees) {
39            return Err(AprsError::InvalidLatitude(
40                "must be finite and in [-90.0, 90.0]",
41            ));
42        }
43        Ok(Self(degrees))
44    }
45
46    /// Create a latitude, clamping any input to `[-90.0, 90.0]`. NaN
47    /// becomes `0.0`.
48    #[must_use]
49    #[allow(clippy::missing_const_for_fn)] // f64::clamp is not const stable
50    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    /// Return the latitude as decimal degrees.
58    #[must_use]
59    pub const fn as_degrees(self) -> f64 {
60        self.0
61    }
62
63    /// Format this latitude as the standard APRS uncompressed 8-byte
64    /// field `DDMM.HHN` (or `…S` for southern hemisphere).
65    #[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/// Geographic longitude in decimal degrees, validated to `[-180.0, 180.0]`.
77///
78/// Positive = East, negative = West. Rejects NaN and out-of-range values.
79#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
80pub struct Longitude(f64);
81
82impl Longitude {
83    /// Minimum valid longitude (International Date Line, west side).
84    pub const MIN: f64 = -180.0;
85    /// Maximum valid longitude (International Date Line, east side).
86    pub const MAX: f64 = 180.0;
87
88    /// Create a longitude, rejecting NaN or out-of-range values.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`AprsError::InvalidLongitude`] if `degrees` is not finite
93    /// or not in `[-180.0, 180.0]`.
94    pub fn new(degrees: f64) -> Result<Self, AprsError> {
95        if !degrees.is_finite() || !(Self::MIN..=Self::MAX).contains(&degrees) {
96            return Err(AprsError::InvalidLongitude(
97                "must be finite and in [-180.0, 180.0]",
98            ));
99        }
100        Ok(Self(degrees))
101    }
102
103    /// Create a longitude, clamping to `[-180.0, 180.0]`. NaN → `0.0`.
104    #[must_use]
105    #[allow(clippy::missing_const_for_fn)] // f64::clamp is not const stable
106    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    /// Return the longitude as decimal degrees.
114    #[must_use]
115    pub const fn as_degrees(self) -> f64 {
116        self.0
117    }
118
119    /// Format this longitude as the standard APRS uncompressed 9-byte
120    /// field `DDDMM.HHE` (or `…W` for western hemisphere).
121    #[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// ---------------------------------------------------------------------------
133// Speed
134// ---------------------------------------------------------------------------
135
136/// A ground-speed measurement with explicit units.
137///
138/// APRS uses multiple unit conventions depending on context:
139/// - **Knots** — Mic-E and course/speed extension on wire
140/// - **`Km/h`** — `SmartBeaconing` parameters
141/// - **Mph** — US weather station display convention
142///
143/// This enum keeps each unit distinct and provides lossless conversions
144/// so callers never accidentally mix them.
145#[derive(Debug, Clone, Copy, PartialEq)]
146pub enum Speed {
147    /// Nautical miles per hour.
148    Knots(u16),
149    /// Kilometres per hour (decimal to allow `SmartBeaconing` thresholds).
150    Kmh(f64),
151    /// Statute miles per hour.
152    Mph(u16),
153}
154
155impl Speed {
156    /// Conversion factor: 1 knot = `1.852` `km/h`.
157    pub const KNOTS_TO_KMH: f64 = 1.852;
158    /// Conversion factor: 1 mph = `1.609_344` `km/h`.
159    pub const MPH_TO_KMH: f64 = 1.609_344;
160
161    /// Convert to `km/h`.
162    #[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    /// Convert to knots (rounded to nearest integer).
172    #[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// ---------------------------------------------------------------------------
184// Course
185// ---------------------------------------------------------------------------
186
187/// A course-over-ground value, validated to `0..=360` degrees.
188///
189/// By APRS convention, `0` means "course not known" (per Mic-E) while any
190/// other value is a true-north bearing. To distinguish "not known" from
191/// "due north" callers typically use `Option<Course>`.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
193pub struct Course(u16);
194
195impl Course {
196    /// Maximum legal course value.
197    pub const MAX: u16 = 360;
198
199    /// Create a course, validating `0..=360`.
200    ///
201    /// # Errors
202    ///
203    /// Returns [`AprsError::InvalidCourse`] if `degrees > 360`.
204    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    /// Return the course in degrees.
213    #[must_use]
214    pub const fn as_degrees(self) -> u16 {
215        self.0
216    }
217}
218
219// ---------------------------------------------------------------------------
220// MessageId
221// ---------------------------------------------------------------------------
222
223/// An APRS message identifier: 1 to 5 alphanumeric characters.
224///
225/// Per APRS 1.0.1 §14, message IDs in the `{NNNNN` trailer and in ack/rej
226/// frames are 1-5 characters drawn from `[A-Za-z0-9]`. This type enforces
227/// those rules at construction.
228#[derive(Debug, Clone, PartialEq, Eq, Hash)]
229pub struct MessageId(String);
230
231impl MessageId {
232    /// Maximum length of a message ID.
233    pub const MAX_LEN: usize = 5;
234
235    /// Create a message ID, rejecting empty or non-alphanumeric input.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`AprsError::InvalidMessageId`] if the input is empty,
240    /// longer than 5 characters, or contains non-alphanumeric bytes.
241    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    /// Return the ID as a string slice.
252    #[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// ---------------------------------------------------------------------------
265// SymbolTable / AprsSymbol
266// ---------------------------------------------------------------------------
267
268/// An APRS symbol table selector.
269///
270/// Per APRS 1.0.1 §5.1, the first character of a position report's symbol
271/// pair selects the table:
272/// - `/` — Primary table (most common symbols)
273/// - `\` — Alternate table
274/// - `0-9` or `A-Z` — Overlay character (displays on top of the alternate
275///   table's symbol) used for groups and regional indicators
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
277pub enum SymbolTable {
278    /// Primary table (`/`).
279    Primary,
280    /// Alternate table (`\`).
281    Alternate,
282    /// Overlay character (digit or uppercase letter) on the alternate
283    /// table.
284    Overlay(u8),
285}
286
287impl SymbolTable {
288    /// Parse a single byte into a `SymbolTable`.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`AprsError::InvalidSymbolTable`] for anything other than
293    /// `/`, `\`, digits, or uppercase ASCII letters.
294    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    /// Convert back to the wire byte.
306    #[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/// A full APRS symbol (table selector + 1-byte code).
317///
318/// Example: `AprsSymbol { table: SymbolTable::Primary, code: b'>' }` is
319/// the car icon (`/>`).
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
321pub struct AprsSymbol {
322    /// Symbol table selector.
323    pub table: SymbolTable,
324    /// Symbol code character (1 byte, spec range is `!` through `~`).
325    pub code: u8,
326}
327
328impl AprsSymbol {
329    /// Car symbol on the primary table (`/>`).
330    pub const CAR: Self = Self {
331        table: SymbolTable::Primary,
332        code: b'>',
333    };
334    /// House QTH symbol on the primary table (`/-`).
335    pub const HOUSE: Self = Self {
336        table: SymbolTable::Primary,
337        code: b'-',
338    };
339    /// Weather station symbol (`/_`).
340    pub const WEATHER: Self = Self {
341        table: SymbolTable::Primary,
342        code: b'_',
343    };
344}
345
346// ---------------------------------------------------------------------------
347// Temperature (APRS weather)
348// ---------------------------------------------------------------------------
349
350/// Temperature in degrees Fahrenheit as used by APRS weather reports.
351///
352/// Per APRS 1.0.1 §12.4, weather `t` fields are 3 digits optionally with
353/// a leading minus, giving the range `-99` to `999`. This newtype enforces
354/// that range and rejects out-of-spec values.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
356pub struct Fahrenheit(i16);
357
358impl Fahrenheit {
359    /// Minimum valid value per APRS 1.0.1 §12.4.
360    pub const MIN: i16 = -99;
361    /// Maximum valid value per APRS 1.0.1 §12.4.
362    pub const MAX: i16 = 999;
363
364    /// Create a temperature, rejecting out-of-range input.
365    ///
366    /// # Errors
367    ///
368    /// Returns [`AprsError::InvalidTemperature`] if `f` is not in
369    /// `-99..=999`.
370    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    /// Return the raw Fahrenheit value.
378    #[must_use]
379    pub const fn get(self) -> i16 {
380        self.0
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Tocall
386// ---------------------------------------------------------------------------
387
388/// An APRS "tocall" — the destination callsign used to identify the
389/// originating software or device.
390///
391/// APRS tocalls follow the form `APxxxx` where the `xxxx` is registered
392/// with the APRS tocall registry. For the Kenwood TH-D75 the assigned
393/// tocall is `APK005`. This newtype bundles the validation (1-6 ASCII
394/// uppercase alphanumerics, just like [`Callsign`]) with well-known
395/// constants for common devices.
396#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
397pub struct Tocall(String);
398
399impl Tocall {
400    /// Maximum length of a tocall.
401    pub const MAX_LEN: usize = 6;
402
403    /// The tocall assigned to the Kenwood TH-D75 / TH-D74 family
404    /// (registered as `APK005` in the APRS tocall registry).
405    pub const TH_D75: &'static str = "APK005";
406
407    /// Create a tocall from a string, enforcing the same rules as
408    /// [`Callsign::new`] (1-6 uppercase ASCII alphanumerics).
409    ///
410    /// # Errors
411    ///
412    /// Returns [`AprsError::InvalidTocall`] on invalid input.
413    pub fn new(s: &str) -> Result<Self, AprsError> {
414        // Reuse Callsign's validation rules — tocalls are structurally
415        // identical to callsigns, they're just a different namespace.
416        // `Callsign::new` lives in ax25-codec and returns `Ax25Error`;
417        // map to this crate's `AprsError` at the boundary.
418        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    /// Build the TH-D75 tocall constant without going through validation.
424    #[must_use]
425    pub fn th_d75() -> Self {
426        Self(Self::TH_D75.to_owned())
427    }
428
429    /// Return the tocall as a string slice.
430    #[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// ---------------------------------------------------------------------------
443// Tests
444// ---------------------------------------------------------------------------
445
446#[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); // 100 / 1.852 ≈ 54.0
514        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}