ntrip_core/
gga.rs

1//! GGA sentence generation for NTRIP position reporting.
2//!
3//! NTRIP casters often require periodic GGA position reports to select
4//! the appropriate correction stream for the rover's location.
5
6use chrono::{Timelike, Utc};
7
8/// GGA sentence builder for NTRIP position reporting.
9#[derive(Debug, Clone)]
10pub struct GgaSentence {
11    /// Latitude in degrees (positive = North)
12    pub latitude: f64,
13    /// Longitude in degrees (positive = East)
14    pub longitude: f64,
15    /// Altitude above mean sea level in meters
16    pub altitude: f64,
17    /// Fix quality (0=invalid, 1=GPS, 2=DGPS, 4=RTK fixed, 5=RTK float)
18    pub quality: u8,
19    /// Number of satellites in use
20    pub num_satellites: u8,
21    /// Horizontal dilution of precision
22    pub hdop: f32,
23}
24
25impl GgaSentence {
26    /// Create a new GGA sentence with the given position.
27    pub fn new(latitude: f64, longitude: f64, altitude: f64) -> Self {
28        Self {
29            latitude,
30            longitude,
31            altitude,
32            quality: 1, // Default to GPS fix
33            num_satellites: 10,
34            hdop: 1.0,
35        }
36    }
37
38    /// Set the fix quality.
39    pub fn with_quality(mut self, quality: u8) -> Self {
40        self.quality = quality;
41        self
42    }
43
44    /// Set the number of satellites.
45    pub fn with_satellites(mut self, num: u8) -> Self {
46        self.num_satellites = num;
47        self
48    }
49
50    /// Set the HDOP.
51    pub fn with_hdop(mut self, hdop: f32) -> Self {
52        self.hdop = hdop;
53        self
54    }
55
56    /// Generate the NMEA GGA sentence string.
57    ///
58    /// Format: `$GPGGA,hhmmss.ss,llll.ll,a,yyyyy.yy,a,x,xx,x.x,x.x,M,x.x,M,x.x,xxxx*hh\r\n`
59    pub fn to_nmea(&self) -> String {
60        let now = Utc::now();
61        let time = format!("{:02}{:02}{:02}.00", now.hour(), now.minute(), now.second());
62
63        // Convert latitude to NMEA format (ddmm.mmmm)
64        let (lat_str, lat_dir) = self.format_latitude();
65        // Convert longitude to NMEA format (dddmm.mmmm)
66        let (lon_str, lon_dir) = self.format_longitude();
67
68        // Build the sentence without checksum
69        let sentence = format!(
70            "GPGGA,{},{},{},{},{},{},{:02},{:.1},{:.1},M,0.0,M,,",
71            time,
72            lat_str,
73            lat_dir,
74            lon_str,
75            lon_dir,
76            self.quality,
77            self.num_satellites.min(99),
78            self.hdop,
79            self.altitude
80        );
81
82        // Calculate NMEA checksum (XOR of all bytes between $ and *)
83        let checksum = sentence.bytes().fold(0u8, |acc, b| acc ^ b);
84
85        format!("${}*{:02X}\r\n", sentence, checksum)
86    }
87
88    /// Format latitude as NMEA ddmm.mmmm string.
89    fn format_latitude(&self) -> (String, char) {
90        let dir = if self.latitude >= 0.0 { 'N' } else { 'S' };
91        let lat_abs = self.latitude.abs();
92        let degrees = lat_abs.floor() as u32;
93        let minutes = (lat_abs - degrees as f64) * 60.0;
94        (format!("{:02}{:07.4}", degrees, minutes), dir)
95    }
96
97    /// Format longitude as NMEA dddmm.mmmm string.
98    fn format_longitude(&self) -> (String, char) {
99        let dir = if self.longitude >= 0.0 { 'E' } else { 'W' };
100        let lon_abs = self.longitude.abs();
101        let degrees = lon_abs.floor() as u32;
102        let minutes = (lon_abs - degrees as f64) * 60.0;
103        (format!("{:03}{:07.4}", degrees, minutes), dir)
104    }
105}
106
107impl Default for GgaSentence {
108    fn default() -> Self {
109        Self {
110            latitude: 0.0,
111            longitude: 0.0,
112            altitude: 0.0,
113            quality: 0,
114            num_satellites: 0,
115            hdop: 99.9,
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn test_gga_creation() {
126        let gga = GgaSentence::new(-27.4698, 153.0251, 50.0);
127        assert_eq!(gga.latitude, -27.4698);
128        assert_eq!(gga.longitude, 153.0251);
129        assert_eq!(gga.altitude, 50.0);
130    }
131
132    #[test]
133    fn test_gga_builder() {
134        let gga = GgaSentence::new(0.0, 0.0, 0.0)
135            .with_quality(4)
136            .with_satellites(15)
137            .with_hdop(0.8);
138
139        assert_eq!(gga.quality, 4);
140        assert_eq!(gga.num_satellites, 15);
141        assert_eq!(gga.hdop, 0.8);
142    }
143
144    #[test]
145    fn test_latitude_format_north() {
146        let gga = GgaSentence::new(27.4698, 0.0, 0.0);
147        let (lat, dir) = gga.format_latitude();
148        assert_eq!(dir, 'N');
149        assert!(lat.starts_with("27")); // 27 degrees
150    }
151
152    #[test]
153    fn test_latitude_format_south() {
154        let gga = GgaSentence::new(-27.4698, 0.0, 0.0);
155        let (lat, dir) = gga.format_latitude();
156        assert_eq!(dir, 'S');
157        assert!(lat.starts_with("27")); // 27 degrees (absolute)
158    }
159
160    #[test]
161    fn test_longitude_format_east() {
162        let gga = GgaSentence::new(0.0, 153.0251, 0.0);
163        let (lon, dir) = gga.format_longitude();
164        assert_eq!(dir, 'E');
165        assert!(lon.starts_with("153")); // 153 degrees
166    }
167
168    #[test]
169    fn test_longitude_format_west() {
170        let gga = GgaSentence::new(0.0, -122.4194, 0.0);
171        let (lon, dir) = gga.format_longitude();
172        assert_eq!(dir, 'W');
173        assert!(lon.starts_with("122")); // 122 degrees (absolute)
174    }
175
176    #[test]
177    fn test_nmea_format() {
178        let gga = GgaSentence::new(-27.4698, 153.0251, 50.0)
179            .with_quality(4)
180            .with_satellites(12);
181
182        let nmea = gga.to_nmea();
183
184        // Should start with $GPGGA
185        assert!(nmea.starts_with("$GPGGA,"));
186        // Should end with checksum and CRLF
187        assert!(nmea.ends_with("\r\n"));
188        // Should contain asterisk before checksum
189        assert!(nmea.contains('*'));
190        // Should have S for southern latitude
191        assert!(nmea.contains(",S,"));
192        // Should have E for eastern longitude
193        assert!(nmea.contains(",E,"));
194    }
195
196    #[test]
197    fn test_nmea_checksum() {
198        let gga = GgaSentence::new(0.0, 0.0, 0.0);
199        let nmea = gga.to_nmea();
200
201        // Extract checksum from sentence
202        let parts: Vec<&str> = nmea.trim().split('*').collect();
203        assert_eq!(parts.len(), 2);
204
205        let calculated_checksum = parts[0][1..].bytes().fold(0u8, |acc, b| acc ^ b);
206        let reported_checksum = u8::from_str_radix(parts[1], 16).unwrap();
207
208        assert_eq!(calculated_checksum, reported_checksum);
209    }
210}