1use std::str::FromStr;
2
3use geoutils::Location;
4use isocountry::CountryCode;
5use strum::{Display, EnumString, VariantNames};
6use tracing::{debug, trace};
7
8#[derive(Clone, PartialEq, Debug)]
10#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
11pub struct ServerInfo {
12 pub server: Option<String>,
13 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#[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#[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#[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#[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 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 pub fn find_nearest(&self, location: &Location) -> Option<(&MountInfo, f64)> {
123 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 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 let network = parts
181 .get(7)
182 .and_then(|s| Network::from_str(s).ok())
183 .unwrap_or(Network::Unknown);
184
185 let country = parts.get(8).and_then(|s| CountryCode::for_alpha3(s).ok());
187
188 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 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}