ntrip_core/
sourcetable.rs

1//! NTRIP sourcetable parsing and mountpoint discovery.
2//!
3//! The sourcetable is a text document returned by NTRIP casters that lists
4//! available data streams (mountpoints), their formats, and locations.
5
6use std::fmt;
7
8/// A parsed NTRIP sourcetable containing available streams.
9#[derive(Debug, Clone, Default)]
10pub struct Sourcetable {
11    /// Stream entries (STR records)
12    pub streams: Vec<StreamEntry>,
13    /// Caster entries (CAS records)
14    pub casters: Vec<CasterEntry>,
15    /// Network entries (NET records)
16    pub networks: Vec<NetworkEntry>,
17}
18
19/// A stream (mountpoint) entry from the sourcetable.
20#[derive(Debug, Clone)]
21pub struct StreamEntry {
22    /// Mountpoint name
23    pub mountpoint: String,
24    /// Identifier/location description
25    pub identifier: String,
26    /// Data format (e.g., "RTCM 3.2", "RTCM 3.3")
27    pub format: String,
28    /// Format details (message types)
29    pub format_details: String,
30    /// Carrier phase info (0=No, 1=L1, 2=L1+L2)
31    pub carrier: u8,
32    /// Navigation system (e.g., "GPS+GLO+GAL+BDS")
33    pub nav_system: String,
34    /// Network name
35    pub network: String,
36    /// Country code (3-letter ISO)
37    pub country: String,
38    /// Latitude of reference station (degrees)
39    pub latitude: f64,
40    /// Longitude of reference station (degrees)
41    pub longitude: f64,
42    /// Whether NMEA GGA is required (0=No, 1=Yes)
43    pub nmea_required: bool,
44    /// Whether stream is generated from network (0=Single, 1=Network)
45    pub is_network: bool,
46    /// Generator software
47    pub generator: String,
48    /// Compression type
49    pub compression: String,
50    /// Authentication type (N=None, B=Basic, D=Digest)
51    pub authentication: String,
52    /// Fee required (N=No, Y=Yes)
53    pub fee: bool,
54    /// Bitrate in bits/second
55    pub bitrate: u32,
56    /// Miscellaneous info
57    pub misc: String,
58}
59
60impl Default for StreamEntry {
61    fn default() -> Self {
62        Self {
63            mountpoint: String::new(),
64            identifier: String::new(),
65            format: String::new(),
66            format_details: String::new(),
67            carrier: 0,
68            nav_system: String::new(),
69            network: String::new(),
70            country: String::new(),
71            latitude: 0.0,
72            longitude: 0.0,
73            nmea_required: false,
74            is_network: false,
75            generator: String::new(),
76            compression: String::new(),
77            authentication: String::new(),
78            fee: false,
79            bitrate: 0,
80            misc: String::new(),
81        }
82    }
83}
84
85impl StreamEntry {
86    /// Calculate distance to a reference position in kilometers.
87    /// Uses Haversine formula for great-circle distance.
88    pub fn distance_km(&self, lat: f64, lon: f64) -> f64 {
89        const EARTH_RADIUS_KM: f64 = 6371.0;
90
91        let lat1 = self.latitude.to_radians();
92        let lat2 = lat.to_radians();
93        let dlat = (lat - self.latitude).to_radians();
94        let dlon = (lon - self.longitude).to_radians();
95
96        let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
97        let c = 2.0 * a.sqrt().asin();
98
99        EARTH_RADIUS_KM * c
100    }
101
102    /// Check if this stream provides RTCM corrections.
103    pub fn is_rtcm(&self) -> bool {
104        self.format.to_uppercase().contains("RTCM")
105    }
106}
107
108impl fmt::Display for StreamEntry {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(
111            f,
112            "{} ({}) - {} @ ({:.4}, {:.4})",
113            self.mountpoint, self.format, self.nav_system, self.latitude, self.longitude
114        )
115    }
116}
117
118/// A caster entry from the sourcetable.
119#[derive(Debug, Clone, Default)]
120pub struct CasterEntry {
121    /// Caster hostname
122    pub host: String,
123    /// Caster port
124    pub port: u16,
125    /// Caster identifier
126    pub identifier: String,
127    /// Operator name
128    pub operator: String,
129    /// Whether NMEA is required
130    pub nmea_required: bool,
131    /// Country code
132    pub country: String,
133    /// Latitude
134    pub latitude: f64,
135    /// Longitude
136    pub longitude: f64,
137    /// Fallback host
138    pub fallback_host: String,
139    /// Fallback port
140    pub fallback_port: u16,
141    /// Miscellaneous info
142    pub misc: String,
143}
144
145/// A network entry from the sourcetable.
146#[derive(Debug, Clone, Default)]
147pub struct NetworkEntry {
148    /// Network identifier
149    pub identifier: String,
150    /// Network operator
151    pub operator: String,
152    /// Authentication type
153    pub authentication: String,
154    /// Fee required
155    pub fee: bool,
156    /// Web address
157    pub web: String,
158    /// Stream URL
159    pub stream_url: String,
160    /// Registration URL
161    pub registration_url: String,
162    /// Miscellaneous info
163    pub misc: String,
164}
165
166impl Sourcetable {
167    /// Parse a sourcetable from raw text.
168    pub fn parse(text: &str) -> Self {
169        let mut table = Sourcetable::default();
170
171        for line in text.lines() {
172            let line = line.trim();
173            if line.is_empty() || line == "ENDSOURCETABLE" {
174                continue;
175            }
176
177            if line.starts_with("STR;") {
178                if let Some(entry) = Self::parse_stream_entry(line) {
179                    table.streams.push(entry);
180                }
181            } else if line.starts_with("CAS;") {
182                if let Some(entry) = Self::parse_caster_entry(line) {
183                    table.casters.push(entry);
184                }
185            } else if line.starts_with("NET;") {
186                if let Some(entry) = Self::parse_network_entry(line) {
187                    table.networks.push(entry);
188                }
189            }
190        }
191
192        table
193    }
194
195    /// Parse a STR (stream) entry.
196    fn parse_stream_entry(line: &str) -> Option<StreamEntry> {
197        let parts: Vec<&str> = line.split(';').collect();
198        if parts.len() < 19 {
199            return None;
200        }
201
202        Some(StreamEntry {
203            mountpoint: parts[1].to_string(),
204            identifier: parts[2].to_string(),
205            format: parts[3].to_string(),
206            format_details: parts[4].to_string(),
207            carrier: parts[5].parse().unwrap_or(0),
208            nav_system: parts[6].to_string(),
209            network: parts[7].to_string(),
210            country: parts[8].to_string(),
211            latitude: parts[9].parse().unwrap_or(0.0),
212            longitude: parts[10].parse().unwrap_or(0.0),
213            nmea_required: parts[11] == "1",
214            is_network: parts[12] == "1",
215            generator: parts[13].to_string(),
216            compression: parts[14].to_string(),
217            authentication: parts[15].to_string(),
218            fee: parts[16] == "Y",
219            bitrate: parts[17].parse().unwrap_or(0),
220            misc: parts.get(18).unwrap_or(&"").to_string(),
221        })
222    }
223
224    /// Parse a CAS (caster) entry.
225    fn parse_caster_entry(line: &str) -> Option<CasterEntry> {
226        let parts: Vec<&str> = line.split(';').collect();
227        if parts.len() < 12 {
228            return None;
229        }
230
231        Some(CasterEntry {
232            host: parts[1].to_string(),
233            port: parts[2].parse().unwrap_or(2101),
234            identifier: parts[3].to_string(),
235            operator: parts[4].to_string(),
236            nmea_required: parts[5] == "1",
237            country: parts[6].to_string(),
238            latitude: parts[7].parse().unwrap_or(0.0),
239            longitude: parts[8].parse().unwrap_or(0.0),
240            fallback_host: parts[9].to_string(),
241            fallback_port: parts[10].parse().unwrap_or(0),
242            misc: parts.get(11).unwrap_or(&"").to_string(),
243        })
244    }
245
246    /// Parse a NET (network) entry.
247    fn parse_network_entry(line: &str) -> Option<NetworkEntry> {
248        let parts: Vec<&str> = line.split(';').collect();
249        if parts.len() < 9 {
250            return None;
251        }
252
253        Some(NetworkEntry {
254            identifier: parts[1].to_string(),
255            operator: parts[2].to_string(),
256            authentication: parts[3].to_string(),
257            fee: parts[4] == "Y",
258            web: parts[5].to_string(),
259            stream_url: parts[6].to_string(),
260            registration_url: parts[7].to_string(),
261            misc: parts.get(8).unwrap_or(&"").to_string(),
262        })
263    }
264
265    /// Get all RTCM streams.
266    pub fn rtcm_streams(&self) -> Vec<&StreamEntry> {
267        self.streams.iter().filter(|s| s.is_rtcm()).collect()
268    }
269
270    /// Find streams sorted by distance to a reference position.
271    pub fn streams_by_distance(&self, lat: f64, lon: f64) -> Vec<(&StreamEntry, f64)> {
272        let mut streams: Vec<_> = self
273            .streams
274            .iter()
275            .map(|s| (s, s.distance_km(lat, lon)))
276            .collect();
277        streams.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
278        streams
279    }
280
281    /// Find the nearest RTCM stream to a reference position.
282    pub fn nearest_rtcm_stream(&self, lat: f64, lon: f64) -> Option<(&StreamEntry, f64)> {
283        self.streams_by_distance(lat, lon)
284            .into_iter()
285            .find(|(s, _)| s.is_rtcm())
286    }
287
288    /// Find streams matching a mountpoint pattern (case-insensitive).
289    pub fn find_streams(&self, pattern: &str) -> Vec<&StreamEntry> {
290        let pattern_lower = pattern.to_lowercase();
291        self.streams
292            .iter()
293            .filter(|s| s.mountpoint.to_lowercase().contains(&pattern_lower))
294            .collect()
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    const SAMPLE_SOURCETABLE: &str = r#"CAS;rtk2go.com;2101;RTK2go;SNIP;0;USA;38.88;-77.02;;0;
303NET;RTK2go;SNIP;B;N;http://www.rtk2go.com;none;none;
304STR;ALIC00AUS0;Alice Springs;RTCM 3.2;1005(30),1077(1),1087(1),1097(1),1127(1),1230(1);2;GPS+GLO+GAL+BDS;GA;AUS;-23.6701;133.8855;0;0;SNIP;none;B;N;4800;
305STR;BRIS00AUS0;Brisbane;RTCM 3.3;1005(30),1077(1),1087(1);2;GPS+GLO;GA;AUS;-27.4678;153.0281;1;0;SNIP;none;B;N;5000;
306ENDSOURCETABLE
307"#;
308
309    #[test]
310    fn test_parse_sourcetable() {
311        let table = Sourcetable::parse(SAMPLE_SOURCETABLE);
312
313        assert_eq!(table.casters.len(), 1);
314        assert_eq!(table.networks.len(), 1);
315        assert_eq!(table.streams.len(), 2);
316
317        let alice = &table.streams[0];
318        assert_eq!(alice.mountpoint, "ALIC00AUS0");
319        assert_eq!(alice.identifier, "Alice Springs");
320        assert!((alice.latitude - (-23.6701)).abs() < 0.001);
321        assert!((alice.longitude - 133.8855).abs() < 0.001);
322        assert!(alice.is_rtcm());
323    }
324
325    #[test]
326    fn test_distance_calculation() {
327        let entry = StreamEntry {
328            latitude: -23.6701,
329            longitude: 133.8855,
330            ..Default::default()
331        };
332
333        // Distance to itself should be ~0
334        let dist = entry.distance_km(-23.6701, 133.8855);
335        assert!(dist < 0.001);
336
337        // Distance to Brisbane (Alice Springs to Brisbane ~1900km)
338        let dist = entry.distance_km(-27.4678, 153.0281);
339        assert!(
340            dist > 1800.0 && dist < 2100.0,
341            "Expected ~1900km, got {}",
342            dist
343        );
344    }
345
346    #[test]
347    fn test_nearest_stream() {
348        let table = Sourcetable::parse(SAMPLE_SOURCETABLE);
349
350        // From Brisbane, Brisbane should be nearest
351        let nearest = table.nearest_rtcm_stream(-27.4678, 153.0281);
352        assert!(nearest.is_some());
353        let (stream, dist) = nearest.unwrap();
354        assert_eq!(stream.mountpoint, "BRIS00AUS0");
355        assert!(dist < 1.0); // Should be very close
356    }
357
358    #[test]
359    fn test_find_streams() {
360        let table = Sourcetable::parse(SAMPLE_SOURCETABLE);
361
362        let results = table.find_streams("AUS");
363        assert_eq!(results.len(), 2);
364
365        let results = table.find_streams("BRIS");
366        assert_eq!(results.len(), 1);
367        assert_eq!(results[0].mountpoint, "BRIS00AUS0");
368    }
369}