nmeasis 26.4.0

A memory-safe NMEA 0183 parser with a C FFI
Documentation
use crate::{
    coordinates::NmeaCoordinates,
    encoder::NmeaEncode,
    faa::FaaMode,
    macros::{write_byte, write_str},
    message::NmeaMessageError,
    number::NmeaNumber,
    parser::NmeaParse,
    time::NmeaTime,
};

#[repr(u8)]
pub enum GnsModeIdentifier {
    Gps = 0,
    Glonass = 1,
    Galileo = 2,
    BeiDou = 3,
    Qzss = 4,
}

impl GnsModeIdentifier {
    #[must_use]
    pub fn parse(raw: u8) -> Option<Self> {
        match raw {
            0 => Some(Self::Gps),
            1 => Some(Self::Glonass),
            2 => Some(Self::Galileo),
            3 => Some(Self::BeiDou),
            4 => Some(Self::Qzss),
            _ => None,
        }
    }
}

impl From<GnsModeIdentifier> for usize {
    fn from(value: GnsModeIdentifier) -> Self {
        value as usize
    }
}

#[repr(u8)]
pub enum GnsNavStatus {
    Safe = b'S',
    Caution = b'C',
    Unsafe = b'U',
    Invalid = b'V',
}

impl GnsNavStatus {
    #[must_use]
    pub fn parse(raw: &str) -> Option<Self> {
        match raw.as_bytes().first() {
            Some(&b'S') => Some(Self::Safe),
            Some(&b'C') => Some(Self::Caution),
            Some(&b'U') => Some(Self::Unsafe),
            Some(&b'V') => Some(Self::Invalid),
            _ => None,
        }
    }
}

/// GNS - GNSS Fix Data
///
/// GNSS capable receivers will always output this message with the GN talker ID.
#[derive(Debug)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Gns<'a> {
    /// 1. UTC time of fix (HHMMSS.ss)
    pub time: &'a str,
    /// 2. Latitude (DDMM.MMMM)
    pub latitude: &'a str,
    /// 3. N or S
    pub latitude_dir: &'a str,
    /// 4. Longitude (DDDMM.MMMM)
    pub longitude: &'a str,
    /// 5. E or W
    pub longitude_dir: &'a str,
    /// 6. Mode Indicators
    ///
    ///    Each constellation is represented by an index into modes.
    ///    1 -> `GPS`
    ///    2 -> `GLONASS`
    ///    3 -> `Galileo`
    ///    4 -> `BeiDou`
    pub modes: &'a str,
    /// 7. Number of satellites
    pub satellite_count: &'a str,
    /// 8. Horizontal Dilution of Precision
    pub hdop: &'a str,
    /// 9. Antenna Altitude in Meters
    pub altitude: &'a str,
    /// 10. Geoidal Seperation in Meters
    pub geoidal_seperation: &'a str,
    /// 11. Age of differential GPS data in seconds, empty if not using DGPS
    pub dgps_age: Option<&'a str>,
    /// 12. Differential reference station ID (0000-1023), empty if not using DGPS
    pub dgps_station_id: Option<&'a str>,
    /// 13. Nav Status (>= NMEA 4.10)
    pub nav_status: Option<&'a str>,
}

impl<'a> NmeaParse<'a> for Gns<'a> {
    fn parse(fields: &'a str) -> Result<Self, NmeaMessageError> {
        let mut f = fields.splitn(13, ',');

        Ok(Self {
            time: f.next().ok_or(NmeaMessageError::MissingField)?,
            latitude: f.next().ok_or(NmeaMessageError::MissingField)?,
            latitude_dir: f.next().ok_or(NmeaMessageError::MissingField)?,
            longitude: f.next().ok_or(NmeaMessageError::MissingField)?,
            longitude_dir: f.next().ok_or(NmeaMessageError::MissingField)?,
            modes: f.next().ok_or(NmeaMessageError::MissingField)?,
            satellite_count: f.next().ok_or(NmeaMessageError::MissingField)?,
            hdop: f.next().ok_or(NmeaMessageError::MissingField)?,
            altitude: f.next().ok_or(NmeaMessageError::MissingField)?,
            geoidal_seperation: f.next().ok_or(NmeaMessageError::MissingField)?,
            dgps_age: f.next().filter(|s| !s.is_empty()),
            dgps_station_id: f.next().filter(|s| !s.is_empty()),
            nav_status: f.next().filter(|s| !s.is_empty()),
        })
    }
}

impl NmeaEncode for Gns<'_> {
    fn encoded_len(&self) -> usize {
        self.time.len()
            + self.latitude.len()
            + self.latitude_dir.len()
            + self.longitude.len()
            + self.longitude_dir.len()
            + self.modes.len()
            + self.satellite_count.len()
            + self.hdop.len()
            + self.altitude.len()
            + self.geoidal_seperation.len()
            + self.dgps_age.map_or(0, str::len)
            + self.dgps_station_id.map_or(0, str::len)
            + self.nav_status.map_or(0, |s| s.len() + 1)
            + 11
    }

    fn encode(&self, buf: &mut [u8]) -> usize {
        let mut pos = 0;
        write_str!(buf, pos, self.time);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.latitude);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.latitude_dir);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.longitude);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.longitude_dir);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.modes);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.satellite_count);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.hdop);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.altitude);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.geoidal_seperation);
        write_byte!(buf, pos, b',');
        if let Some(dgps_age) = self.dgps_age {
            write_str!(buf, pos, dgps_age);
        }
        write_byte!(buf, pos, b',');
        if let Some(dgps_station_id) = self.dgps_station_id {
            write_str!(buf, pos, dgps_station_id);
        }
        if let Some(nav_status) = self.nav_status {
            write_byte!(buf, pos, b',');
            write_str!(buf, pos, nav_status);
        }
        pos
    }
}

impl Gns<'_> {
    #[must_use]
    pub fn time(&self) -> Option<NmeaTime> {
        NmeaTime::parse(self.time)
    }

    #[must_use]
    pub fn coordinates(&self) -> Option<NmeaCoordinates> {
        NmeaCoordinates::parse(
            self.latitude,
            self.latitude_dir,
            self.longitude,
            self.longitude_dir,
        )
    }

    #[must_use]
    pub fn mode(&self, id: GnsModeIdentifier) -> Option<FaaMode> {
        let index: usize = id.into();
        self.modes.get(index..=index).and_then(FaaMode::parse)
    }

    #[must_use]
    pub fn satellite_count(&self) -> Option<u8> {
        self.satellite_count.parse().ok()
    }

    #[must_use]
    pub fn hdop(&self) -> Option<NmeaNumber> {
        NmeaNumber::parse(self.hdop)
    }

    #[must_use]
    pub fn altitude(&self) -> Option<NmeaNumber> {
        NmeaNumber::parse(self.altitude)
    }

    #[must_use]
    pub fn geoidal_seperation(&self) -> Option<NmeaNumber> {
        NmeaNumber::parse(self.geoidal_seperation)
    }

    #[must_use]
    pub fn dgps_age(&self) -> Option<NmeaNumber> {
        self.dgps_age.and_then(NmeaNumber::parse)
    }

    #[must_use]
    pub fn dgps_station_id(&self) -> Option<u16> {
        self.dgps_station_id.and_then(|d| d.parse::<u16>().ok())
    }

    #[must_use]
    pub fn nav_status(&self) -> Option<GnsNavStatus> {
        self.nav_status.and_then(GnsNavStatus::parse)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // A well-formed GNS sentence with all optional fields present (NMEA 4.10+)
    const FIELDS: &str = "092750.000,5321.6802,N,00630.3372,W,AAN,08,1.03,61.7,55.2,2.5,0023,S";

    // Minimal sentence: no DGPS, no nav status
    const FIELDS_MINIMAL: &str = "092750.000,5321.6802,N,00630.3372,W,AAN,08,1.03,61.7,55.2,,";

    #[test]
    fn parses_raw_fields() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert_eq!(gns.time, "092750.000");
        assert_eq!(gns.latitude, "5321.6802");
        assert_eq!(gns.latitude_dir, "N");
        assert_eq!(gns.longitude, "00630.3372");
        assert_eq!(gns.longitude_dir, "W");
        assert_eq!(gns.modes, "AAN");
        assert_eq!(gns.satellite_count, "08");
        assert_eq!(gns.hdop, "1.03");
        assert_eq!(gns.altitude, "61.7");
        assert_eq!(gns.geoidal_seperation, "55.2");
        assert_eq!(gns.dgps_age, Some("2.5"));
        assert_eq!(gns.dgps_station_id, Some("0023"));
        assert_eq!(gns.nav_status, Some("S"));
    }

    #[test]
    fn time_parsed() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert_eq!(
            gns.time(),
            Some(NmeaTime {
                hours: 9,
                minutes: 27,
                seconds: 50,
                subseconds: 0,
            })
        );
    }

    #[test]
    fn coordinates_parsed() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert!(gns.coordinates().is_some());
    }

    #[test]
    fn satellite_count_parsed() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert_eq!(gns.satellite_count(), Some(8));
    }

    #[test]
    fn hdop_parsed() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert!(gns.hdop().is_some());
    }

    #[test]
    fn altitude_parsed() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert!(gns.altitude().is_some());
    }

    #[test]
    fn geoidal_separation_parsed() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert!(gns.geoidal_seperation().is_some());
    }

    #[test]
    fn dgps_fields_present() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert!(gns.dgps_age().is_some());
        assert_eq!(gns.dgps_station_id(), Some(23));
    }

    #[test]
    fn optional_dgps_fields_empty() {
        let gns = Gns::parse(FIELDS_MINIMAL).unwrap();
        assert!(gns.dgps_age().is_none());
        assert!(gns.dgps_station_id().is_none());
    }

    #[test]
    fn nav_status_safe() {
        let gns = Gns::parse(FIELDS).unwrap();
        assert!(matches!(gns.nav_status(), Some(GnsNavStatus::Safe)));
    }

    #[test]
    fn nav_status_caution() {
        let gns =
            Gns::parse("092750.000,5321.6802,N,00630.3372,W,AAN,08,1.03,61.7,55.2,,,C").unwrap();
        assert!(matches!(gns.nav_status(), Some(GnsNavStatus::Caution)));
    }

    #[test]
    fn nav_status_unsafe() {
        let gns =
            Gns::parse("092750.000,5321.6802,N,00630.3372,W,AAN,08,1.03,61.7,55.2,,,U").unwrap();
        assert!(matches!(gns.nav_status(), Some(GnsNavStatus::Unsafe)));
    }

    #[test]
    fn nav_status_invalid() {
        let gns =
            Gns::parse("092750.000,5321.6802,N,00630.3372,W,AAN,08,1.03,61.7,55.2,,,V").unwrap();
        assert!(matches!(gns.nav_status(), Some(GnsNavStatus::Invalid)));
    }

    #[test]
    fn nav_status_absent_when_pre_4_10() {
        let gns = Gns::parse(FIELDS_MINIMAL).unwrap();
        assert!(gns.nav_status().is_none());
    }

    #[test]
    fn mode_gps_parsed() {
        let gns = Gns::parse(FIELDS).unwrap(); // modes = "AAN"
        assert!(gns.mode(GnsModeIdentifier::Gps).is_some());
    }

    #[test]
    fn mode_out_of_range_returns_none() {
        // modes = "A" — only GPS index (0) is present
        let gns = Gns::parse("092750.000,5321.6802,N,00630.3372,W,A,08,1.03,61.7,55.2,,,").unwrap();
        assert!(gns.mode(GnsModeIdentifier::Glonass).is_none());
    }

    #[test]
    fn missing_required_field_returns_error() {
        assert!(Gns::parse("092750.000,5321.6802,N").is_err());
    }

    #[test]
    fn empty_optional_strings_become_none() {
        let gns = Gns::parse(FIELDS_MINIMAL).unwrap();
        assert!(gns.dgps_age.is_none());
        assert!(gns.dgps_station_id.is_none());
        assert!(gns.nav_status.is_none());
    }
}