nmeasis 26.4.1

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

#[derive(Debug)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct GsvSatelliteInfo<'a> {
    /// Satellite PRN number
    pub prn: &'a str,
    /// Elevation in degrees (0-90)
    pub elevation: &'a str,
    /// Azimuth in degrees (0-359)
    pub azimuth: &'a str,
    /// Signal to noise ratio (0-99 dBHz), empty when not tracking
    pub snr: &'a str,
}

impl GsvSatelliteInfo<'_> {
    #[must_use]
    pub fn prn(&self) -> Option<u8> {
        self.prn.parse().ok()
    }

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

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

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

/// GSV - Satellites in View
#[derive(Debug)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Gsv<'a> {
    /// 1. Total number of messages
    pub total_messages: &'a str,
    /// 2. Message number
    pub message_number: &'a str,
    /// 3. Total satellites in view
    pub satellites_in_view: &'a str,
    /// 4-19. Up to 4 satellite entries
    pub satellites: [Option<GsvSatelliteInfo<'a>>; 4],
}

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

        let total_messages = f.next().ok_or(NmeaMessageError::MissingField)?;
        let message_number = f.next().ok_or(NmeaMessageError::MissingField)?;
        let satellites_in_view = f.next().ok_or(NmeaMessageError::MissingField)?;

        let mut satellites = [None, None, None, None];
        for slot in &mut satellites {
            match (f.next(), f.next(), f.next(), f.next()) {
                (Some(prn), Some(elevation), Some(azimuth), Some(snr)) if !prn.is_empty() => {
                    *slot = Some(GsvSatelliteInfo {
                        prn,
                        elevation,
                        azimuth,
                        snr,
                    });
                }
                _ => break,
            }
        }

        Ok(Self {
            total_messages,
            message_number,
            satellites_in_view,
            satellites,
        })
    }
}

impl NmeaEncode for Gsv<'_> {
    fn encoded_len(&self) -> usize {
        let sat_len: usize = self
            .satellites
            .iter()
            .flatten()
            .map(|s| s.prn.len() + s.elevation.len() + s.azimuth.len() + s.snr.len() + 4)
            .sum();

        self.total_messages.len()
            + self.message_number.len()
            + self.satellites_in_view.len()
            + sat_len
            + 2
    }

    fn encode(&self, buf: &mut [u8]) -> usize {
        let mut pos = 0;
        write_str!(buf, pos, self.total_messages);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.message_number);
        write_byte!(buf, pos, b',');
        write_str!(buf, pos, self.satellites_in_view);

        for slot in self.satellites.iter().flatten() {
            write_byte!(buf, pos, b',');
            write_str!(buf, pos, slot.prn);
            write_byte!(buf, pos, b',');
            write_str!(buf, pos, slot.elevation);
            write_byte!(buf, pos, b',');
            write_str!(buf, pos, slot.azimuth);
            write_byte!(buf, pos, b',');
            write_str!(buf, pos, slot.snr);
        }

        pos
    }
}

impl<'a> Gsv<'a> {
    #[must_use]
    pub fn total_messages(&self) -> Option<u8> {
        self.total_messages.parse().ok()
    }

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

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

    #[must_use]
    pub fn satellite(&self, index: usize) -> Option<&GsvSatelliteInfo<'a>> {
        self.satellites.get(index)?.as_ref()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{encoder::NmeaEncode, parser::NmeaParse};

    fn gsv(fields: &str) -> Gsv<'_> {
        Gsv::parse(fields).expect("valid parse")
    }

    #[test]
    fn parse_single_satellite() {
        let s = gsv("1,1,4,05,45,180,42");
        assert_eq!(s.total_messages, "1");
        assert_eq!(s.message_number, "1");
        assert_eq!(s.satellites_in_view, "4");
        let sat = s.satellites[0].as_ref().unwrap();
        assert_eq!(sat.prn, "05");
        assert_eq!(sat.elevation, "45");
        assert_eq!(sat.azimuth, "180");
        assert_eq!(sat.snr, "42");
        assert!(s.satellites[1].is_none());
    }

    #[test]
    fn parse_four_satellites() {
        let s = gsv("2,1,8,05,45,180,42,10,30,090,38,15,20,270,35,20,10,045,30");
        for i in 0..4 {
            assert!(s.satellites[i].is_some());
        }
        assert_eq!(s.satellites[0].as_ref().unwrap().prn, "05");
        assert_eq!(s.satellites[3].as_ref().unwrap().prn, "20");
    }

    #[test]
    fn parse_no_satellites() {
        let s = gsv("1,1,0");
        assert_eq!(s.satellites_in_view, "0");
        for i in 0..4 {
            assert!(s.satellites[i].is_none());
        }
    }

    #[test]
    fn parse_missing_fields_returns_error() {
        assert!(Gsv::parse("1").is_err());
        assert!(Gsv::parse("1,1").is_err());
        assert!(Gsv::parse("").is_err());
    }

    #[test]
    fn parse_empty_prn_stops_satellite_parsing() {
        // Empty prn in first satellite slot means no satellites parsed
        let s = gsv("1,1,4,,45,180,42");
        assert!(s.satellites[0].is_none());
    }

    fn roundtrip(input: &str) {
        let s = gsv(input);
        let len = s.encoded_len();
        let mut buf = [0u8; 256];
        let written = s.encode(&mut buf);
        assert_eq!(
            written, len,
            "encode() returned {written} but encoded_len() said {len}"
        );
        assert_eq!(&buf[..written], input.as_bytes());
    }

    #[test]
    fn encode_roundtrip_no_satellites() {
        roundtrip("1,1,0");
    }

    #[test]
    fn encode_roundtrip_one_satellite() {
        roundtrip("1,1,4,05,45,180,42");
    }

    #[test]
    fn encode_roundtrip_four_satellites() {
        roundtrip("2,1,8,05,45,180,42,10,30,090,38,15,20,270,35,20,10,045,30");
    }

    #[test]
    fn total_messages_valid() {
        assert_eq!(gsv("3,1,12,05,45,180,42").total_messages(), Some(3));
    }

    #[test]
    fn total_messages_invalid_returns_none() {
        assert!(gsv("X,1,0").total_messages().is_none());
        assert!(gsv(",1,0").total_messages().is_none());
    }

    #[test]
    fn message_number_valid() {
        assert_eq!(gsv("3,2,12,05,45,180,42").message_number(), Some(2));
    }

    #[test]
    fn satellites_in_view_valid() {
        assert_eq!(gsv("1,1,7,05,45,180,42").satellites_in_view(), Some(7));
    }

    #[test]
    fn satellites_in_view_invalid_returns_none() {
        assert!(gsv("1,1,X").satellites_in_view().is_none());
        assert!(gsv("1,1,").satellites_in_view().is_none());
    }

    #[test]
    fn satellite_accessor_valid() {
        let s = gsv("1,1,4,05,45,180,42");
        assert!(s.satellite(0).is_some());
        assert!(s.satellite(1).is_none());
    }

    #[test]
    fn satellite_accessor_out_of_bounds_returns_none() {
        let s = gsv("1,1,4,05,45,180,42");
        assert!(s.satellite(4).is_none());
        assert!(s.satellite(99).is_none());
    }

    #[test]
    fn satellite_info_prn_valid() {
        let s = gsv("1,1,4,05,45,180,42");
        assert_eq!(s.satellite(0).unwrap().prn(), Some(5));
    }

    #[test]
    fn satellite_info_elevation_valid() {
        let s = gsv("1,1,4,05,45,180,42");
        assert_eq!(s.satellite(0).unwrap().elevation(), Some(45));
    }

    #[test]
    fn satellite_info_azimuth_valid() {
        let s = gsv("1,1,4,05,45,180,42");
        assert_eq!(s.satellite(0).unwrap().azimuth(), Some(180));
    }

    #[test]
    fn satellite_info_snr_valid() {
        let s = gsv("1,1,4,05,45,180,42");
        assert_eq!(s.satellite(0).unwrap().snr(), Some(42));
    }

    #[test]
    fn satellite_info_snr_empty_returns_none() {
        let s = gsv("1,1,4,05,45,180,");
        assert_eq!(s.satellite(0).unwrap().snr(), None);
    }

    #[test]
    fn satellite_info_invalid_fields_return_none() {
        let s = Gsv {
            total_messages: "1",
            message_number: "1",
            satellites_in_view: "1",
            satellites: [
                Some(GsvSatelliteInfo {
                    prn: "XX",
                    elevation: "YY",
                    azimuth: "ZZZ",
                    snr: "WW",
                }),
                None,
                None,
                None,
            ],
        };
        assert_eq!(s.satellite(0).unwrap().prn(), None);
        assert_eq!(s.satellite(0).unwrap().elevation(), None);
        assert_eq!(s.satellite(0).unwrap().azimuth(), None);
        assert_eq!(s.satellite(0).unwrap().snr(), None);
    }
}