Skip to main content

ntrip_client/
snip.rs

1use std::str::FromStr;
2
3use geoutils::Location;
4use isocountry::CountryCode;
5use strum::{Display, EnumString, VariantNames};
6use tracing::{debug, trace};
7
8/// Information about an NTRIP / SNIP server and its mounts
9#[derive(Clone, PartialEq, Debug)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct ServerInfo {
12    pub server: Option<String>,
13    // TODO: parse this out to a date?
14    pub date: Option<String>,
15    pub content_type: Option<String>,
16    pub content_length: Option<usize>,
17
18    pub services: Vec<MountInfo>,
19}
20
21/// Information about a specific NTRIP mount point
22#[derive(Clone, PartialEq, Debug)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24pub struct MountInfo {
25    pub name: String,
26    pub details: String,
27    pub protocol: Protocol,
28    pub messages: Vec<String>,
29    pub constellations: Vec<Constellation>,
30    pub network: Network,
31    pub country: Option<CountryCode>,
32    pub location: Location,
33}
34
35/// NTRIP protocol types
36#[derive(Clone, PartialEq, Debug, EnumString, Display, VariantNames)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
38pub enum Protocol {
39    #[strum(serialize = "RTCM 3")]
40    Rtcm3,
41    #[strum(serialize = "RTCM 3.0")]
42    Rtcm3_0,
43    #[strum(serialize = "RTCM 3.2")]
44    Rtcm3_2,
45    #[strum(serialize = "RTCM 3.3")]
46    Rtcm3_3,
47    #[strum(serialize = "RAW")]
48    Raw,
49    #[strum(serialize = "CMRx")]
50    CMRx,
51    #[strum(serialize = "UNKNOWN")]
52    Unknown,
53}
54
55/// NTRIP network types
56#[derive(Clone, PartialEq, Debug, EnumString, Display, VariantNames)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
58pub enum Network {
59    #[strum(serialize = "SNIP")]
60    Snip,
61    #[strum(serialize = "UNKNOWN")]
62    Unknown,
63}
64
65/// GNSS Constellation types
66#[derive(Clone, PartialEq, Debug, EnumString, Display, VariantNames)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub enum Constellation {
69    #[strum(serialize = "GPS")]
70    Gps,
71    #[strum(serialize = "GLO")]
72    Glonass,
73    #[strum(serialize = "GAL")]
74    Galileo,
75    #[strum(serialize = "BDS")]
76    BeiDou,
77    #[strum(serialize = "UNKNOWN")]
78    Unknown,
79}
80
81impl ServerInfo {
82    /// Parse SNIP server info from an iterator of lines
83    pub fn parse<'a>(lines: impl Iterator<Item = &'a str>) -> Self {
84        let mut server = None;
85        let mut date = None;
86        let mut content_type = None;
87        let mut content_length = None;
88        let mut services = Vec::new();
89
90        for line in lines {
91            if line.starts_with("Server: ") {
92                server = Some(line.trim_start_matches("Server: ").to_string());
93            } else if line.starts_with("Date: ") {
94                date = Some(line.trim_start_matches("Date: ").to_string());
95            } else if line.starts_with("Content-Type: ") {
96                content_type = Some(line.trim_start_matches("Content-Type: ").to_string());
97            } else if line.starts_with("Content-Length: ") {
98                content_length =
99                    Some(line.trim_start_matches("Content-Length: ").parse().ok()).flatten();
100            } else if line.starts_with("STR;") {
101                match MountInfo::parse(line) {
102                    Some(info) => {
103                        services.push(info);
104                    },
105                    None => {
106                        debug!("Failed to parse STR line: {}", line);
107                    },
108                }
109            }
110        }
111
112        ServerInfo {
113            server,
114            date,
115            content_type,
116            content_length,
117            services,
118        }
119    }
120
121    /// Find the nearest mount point to a given location
122    pub fn find_nearest(&self, location: &Location) -> Option<(&MountInfo, f64)> {
123        // If they're more than 100km away, we don't want to know
124        let mut min_distance = 100_000f64;
125        let mut min_entry = None;
126
127        for (i, s) in self.services.iter().enumerate() {
128            if let Ok(d) = s.location.distance_to(location) {
129                trace!("Distance to {}: {:.3}", s.name, d);
130                if d.meters() < min_distance {
131                    min_distance = d.meters();
132                    min_entry = Some(i);
133                }
134            }
135        }
136
137        min_entry.map(|i| (&self.services[i], min_distance))
138    }
139}
140
141impl MountInfo {
142    pub fn parse(info: &str) -> Option<Self> {
143        let parts: Vec<&str> = info.split(';').collect();
144        if parts.len() < 2 {
145            return None;
146        }
147
148        if parts[0] != "STR" {
149            return None;
150        }
151
152        let name = parts[1].to_string();
153        let details = parts[2].trim().to_string();
154        let protocol = parts
155            .get(3)
156            .and_then(|s| Protocol::from_str(s).ok())
157            .unwrap_or(Protocol::Raw);
158
159        let messages = match parts.get(4) {
160            Some(msgs) => msgs.split(",").map(|m| m.trim().to_string()).collect(),
161            None => vec![],
162        };
163
164        // What is part 5?
165
166        // Part 6: constellations
167        let constellations = match parts.get(6) {
168            Some(c) => c
169                .split('+')
170                .map(|s| {
171                    Constellation::from_str(s)
172                        .ok()
173                        .unwrap_or(Constellation::Unknown)
174                })
175                .collect::<Vec<_>>(),
176            None => vec![],
177        };
178
179        // Part 7: network
180        let network = parts
181            .get(7)
182            .and_then(|s| Network::from_str(s).ok())
183            .unwrap_or(Network::Unknown);
184
185        // Part 8: country
186        let country = parts.get(8).and_then(|s| CountryCode::for_alpha3(s).ok());
187
188        // Parts 9-11: lat, lon, (alt?)
189        let location = Location::new(
190            parts.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
191            parts.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
192        );
193
194        // TODO: the rest of the fields
195
196        Some(MountInfo {
197            name,
198            details,
199            protocol,
200            messages,
201            constellations,
202            network,
203            country,
204            location,
205        })
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use http::Method;
212    use tracing::{debug, info, trace};
213
214    use super::*;
215
216    fn setup_logging() {
217        let _ = tracing_subscriber::FmtSubscriber::builder()
218            .compact()
219            .without_time()
220            .with_max_level(tracing::level_filters::LevelFilter::DEBUG)
221            .try_init();
222    }
223
224    #[test]
225    fn test_parse_server_info() {
226        setup_logging();
227
228        let info = "STR;VargaRTKhr;Is near: Zagreb, Zagreb;RTCM 3.2;1006(1),1033(1),1074(1),1084(1),1094(1),1124(1),1230(1);;GPS+GLO+GAL+BDS;SNIP;HRV;46.44;16.50;1;0;sNTRIP;none;B;N;0;\n";
229
230        let server_info = MountInfo::parse(info).unwrap();
231
232        assert_eq!(server_info.name, "VargaRTKhr");
233        assert_eq!(server_info.details, "Is near: Zagreb, Zagreb");
234        assert_eq!(server_info.protocol, Protocol::Rtcm3_2);
235        assert_eq!(
236            server_info.messages,
237            vec!["1006(1)", "1033(1)", "1074(1)", "1084(1)", "1094(1)", "1124(1)", "1230(1)"]
238        );
239        assert_eq!(
240            server_info.constellations,
241            vec![
242                Constellation::Gps,
243                Constellation::Glonass,
244                Constellation::Galileo,
245                Constellation::BeiDou
246            ]
247        );
248        assert_eq!(server_info.network, Network::Snip);
249        assert_eq!(
250            server_info.country,
251            Some(CountryCode::for_alpha3("HRV").unwrap())
252        );
253        assert!((server_info.location.latitude() - 46.44).abs() < 0.001);
254        assert!((server_info.location.longitude() - 16.50).abs() < 0.001);
255    }
256
257    #[test]
258    fn test_parse_snip_info() {
259        setup_logging();
260
261        let snip_response = "
262            SOURCETABLE 200 OK\n
263            Server: NTRIP SNIP/2.0\n
264            Date: Wed, 26 Jun 2024 12:00:00 GMT\n
265            Content-Type: text/plain; charset=utf-8\n
266            Content-Length: 1234\n
267            STR;warrakam;Is near: Sydney, New South Wales;RTCM 3;1004(1), 1005(10), 1008(10), 1012(1), 1019(2), 1020(2), 1033(10), 1042(2), 1046(2), 1077(1), 1087(1), 1097(1), 1127(1), 1230(30);2;;SNIP;AUS;-36.37;144.46;1;0;SNIP;none;B;N;11740;\n
268            STR;VargaRTKhr;Is near: Zagreb, Zagreb;RTCM 3.2;1006(1),1033(1),1074(1),1084(1),1094(1),1124(1),1230(1);;GPS+GLO+GAL+BDS;SNIP;HRV;46.44;16.50;1;0;sNTRIP;none;B;N;0;\n
269        ";
270
271        let lines = snip_response
272            .lines()
273            .map(|l| l.trim())
274            .collect::<Vec<&str>>();
275
276        debug!("Lines: {:?}", &lines[..10]);
277
278        let snip_info = ServerInfo::parse(lines.iter().cloned());
279
280        debug!("SNIP Info: {:#?}", snip_info);
281    }
282
283    #[tokio::test]
284    #[ignore = "Requires network access"]
285    async fn test_ntrip_rtk2go() {
286        setup_logging();
287
288        let client = reqwest::Client::builder()
289            .http1_ignore_invalid_headers_in_responses(true)
290            .http09_responses()
291            .user_agent(format!(
292                "NTRIP {}/{}",
293                env!("CARGO_PKG_NAME"),
294                env!("CARGO_PKG_VERSION")
295            ))
296            .build()
297            .unwrap();
298
299        let req = client
300            .request(Method::GET, "http://rtk2go.com:2101")
301            .header("Ntrip-Version", "Ntrip/2.0")
302            .build()
303            .unwrap();
304
305        let res = client.execute(req).await.expect("Fetch failed");
306
307        info!("Fetched NTRIP response: {:?}", res.status());
308
309        assert!(res.status().is_success());
310
311        let body = res.text().await.unwrap();
312
313        let lines = body.lines().collect::<Vec<&str>>();
314
315        trace!("Lines: {:?}", &lines[..10]);
316
317        let snip_info = ServerInfo::parse(lines.iter().cloned());
318
319        trace!("SNIP Info: {:#?}", snip_info);
320    }
321}