nmeasis 26.4.1

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

/// GGA - Global Positioning System Fix Data
///
/// Time, Position and fix related data for a GPS receiver.
#[derive(Debug)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Gga<'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. Fix quality (0-8)
    pub fix_quality: &'a str,
    /// 7. Number of satellites in use (00-12)
    pub satellites: &'a str,
    /// 8. Horizontal dilution of precision
    pub hdop: &'a str,
    /// 9. Altitude above mean sea level, meters
    pub altitude: &'a str,
    /// 10. Units of antenna altitude, meters
    pub altitude_units: &'a str,
    /// 11. Height of geoid above WGS84 ellipsoid, meters
    pub geoid_separation: &'a str,
    /// 12. Units of geoidal seperation, meters
    pub geoid_separation_units: &'a str,
    /// 13. Age of differential GPS data in seconds, empty if not using DGPS
    pub dgps_age: Option<&'a str>,
    /// 14. Differential reference station ID (0000-1023), empty if not using DGPS
    pub dgps_station_id: Option<&'a str>,
}

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

        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)?,
            fix_quality: f.next().ok_or(NmeaMessageError::MissingField)?,
            satellites: f.next().ok_or(NmeaMessageError::MissingField)?,
            hdop: f.next().ok_or(NmeaMessageError::MissingField)?,
            altitude: f.next().ok_or(NmeaMessageError::MissingField)?,
            altitude_units: f.next().ok_or(NmeaMessageError::MissingField)?,
            geoid_separation: f.next().ok_or(NmeaMessageError::MissingField)?,
            geoid_separation_units: 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()),
        })
    }
}

impl NmeaEncode for Gga<'_> {
    fn encoded_len(&self) -> usize {
        self.time.len()
            + self.latitude.len()
            + self.latitude_dir.len()
            + self.longitude.len()
            + self.longitude_dir.len()
            + self.fix_quality.len()
            + self.satellites.len()
            + self.hdop.len()
            + self.altitude.len()
            + self.altitude_units.len()
            + self.geoid_separation.len()
            + self.geoid_separation_units.len()
            + self.dgps_age.map_or(0, str::len)
            + self.dgps_station_id.map_or(0, str::len)
            + 13
    }

    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.fix_quality);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.satellites);
        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.altitude_units);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.geoid_separation);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.geoid_separation_units);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.dgps_age.unwrap_or(""));
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.dgps_station_id.unwrap_or(""));
        pos
    }
}

#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GgaFixQuality {
    Invalid = 0,
    GpsFix = 1,
    DifferentialGpsFix = 2,
    PpsFix = 3,
    RtkFixed = 4,
    RtkFloat = 5,
    DeadReckoning = 6,
    ManualInput = 7,
    Simulation = 8,
}

impl GgaFixQuality {
    #[must_use]
    pub fn parse(raw: u8) -> Option<Self> {
        match raw {
            0 => Some(GgaFixQuality::Invalid),
            1 => Some(GgaFixQuality::GpsFix),
            2 => Some(GgaFixQuality::DifferentialGpsFix),
            3 => Some(GgaFixQuality::PpsFix),
            4 => Some(GgaFixQuality::RtkFixed),
            5 => Some(GgaFixQuality::RtkFloat),
            6 => Some(GgaFixQuality::DeadReckoning),
            7 => Some(GgaFixQuality::ManualInput),
            8 => Some(GgaFixQuality::Simulation),
            _ => None,
        }
    }
}

impl Gga<'_> {
    #[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 fix_quality(&self) -> Option<GgaFixQuality> {
        GgaFixQuality::parse(self.fix_quality.parse().ok()?)
    }

    #[must_use]
    pub fn has_fix(&self) -> bool {
        self.fix_quality()
            .is_some_and(|fq| fq != GgaFixQuality::Invalid)
    }

    #[must_use]
    pub fn satellites(&self) -> Option<u8> {
        self.satellites.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 geoid_separation(&self) -> Option<NmeaNumber> {
        NmeaNumber::parse(self.geoid_separation)
    }

    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(|s| s.parse().ok())
    }
}

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

    const FIELDS: &str = "092750.000,5321.6802,N,00630.3372,W,1,8,1.03,61.7,M,55.2,M,,";

    #[test]
    fn parses_raw_fields() {
        let gga = Gga::parse(FIELDS).unwrap();
        assert_eq!(gga.latitude, "5321.6802");
        assert_eq!(gga.latitude_dir, "N");
        assert_eq!(gga.longitude, "00630.3372");
        assert_eq!(gga.longitude_dir, "W");
        assert_eq!(gga.satellites, "8");
        assert_eq!(gga.altitude, "61.7");
        assert_eq!(gga.geoid_separation, "55.2");
    }

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

    #[test]
    fn gps_fix_quality() {
        let gga = Gga::parse(FIELDS).unwrap();
        assert_eq!(gga.fix_quality().unwrap(), GgaFixQuality::GpsFix);
        assert!(gga.has_fix());
    }

    #[test]
    fn no_fix() {
        let gga = Gga::parse("092750.000,,,,,0,0,,,M,,M,,").unwrap();
        assert_eq!(gga.fix_quality().unwrap(), GgaFixQuality::Invalid);
        assert!(!gga.has_fix());
    }

    #[test]
    fn rtk_fixed() {
        let gga =
            Gga::parse("092750.000,5321.6802,N,00630.3372,W,4,12,0.8,61.7,M,55.2,M,,").unwrap();
        assert_eq!(gga.fix_quality().unwrap(), GgaFixQuality::RtkFixed);
    }

    #[test]
    fn satellites_parsed() {
        let gga = Gga::parse(FIELDS).unwrap();
        assert_eq!(gga.satellites(), Some(8));
    }

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

    #[test]
    fn unknown_fix_quality_treated_as_none() {
        let gga =
            Gga::parse("092750.000,5321.6802,N,00630.3372,W,9,8,1.03,61.7,M,55.2,M,,").unwrap();
        assert!(gga.fix_quality().is_none());
    }

    #[test]
    fn optional_dgps_fields_empty() {
        let gga = Gga::parse(FIELDS).unwrap();
        assert!(gga.dgps_age().is_none());
        assert!(gga.dgps_station_id().is_none());
    }

    #[test]
    fn dgps_fields_present() {
        let gga = Gga::parse("092750.000,5321.6802,N,00630.3372,W,2,8,1.03,61.7,M,55.2,M,2.5,0023")
            .unwrap();
        assert!(gga.dgps_age().is_some());
        assert_eq!(gga.dgps_station_id(), Some(23));
    }
}