Skip to main content

robust_ntrip_client/
lib.rs

1//! # `robust-ntrip-client` - Robust NTRIP Client
2//!
3//! This crate provides a client to connect to a Network Transport of RTCM via
4//! Internet Protocol (NTRIP) server. RTCM stands for Radio Technical Commission
5//! for Maritime services and is the message type carrying GNSS correction
6//! signals to enable centimeter-resolution GNSS position finding.
7//!
8//! The implementation in this crate attempts to be robust against network
9//! interruptions and other transient errors. The [`RobustNtripClient`] handles
10//! the low-level interaction with the NTRIP server and would allow plugging an
11//! RTCM parsing library. The [`ParsingNtripClient`] wraps this low-level client
12//! and parses and validates the RTCM messages.
13//!
14//! See also the [`ntrip-client` crate](https://crates.io/crates/ntrip-client).
15//! I was unaware of this other crate at the time I began writing
16//! `robust-ntrip-client`.
17//!
18//! ## Example usage
19//! ```rust,no_run
20//! #[tokio::main]
21//! async fn main() -> eyre::Result<()> {
22//!    let raw_client = robust_ntrip_client::RobustNtripClient::new(
23//!        "ntrip://username:password@example-ntrip-server.com/mountpoint",
24//!        Default::default()
25//!    ).await?;
26//!    let mut ntrip = robust_ntrip_client::ParsingNtripClient::new(raw_client);
27//!
28//!    loop {
29//!        let msg = ntrip.next().await?;
30//!        println!(
31//!            "message {}: {} bytes",
32//!            msg.message_number(),
33//!            msg.frame_data().len()
34//!        );
35//!    }
36//!}
37//! ```
38use eyre::{Context, Result};
39use std::str::FromStr;
40
41/// Options for connecting to the NTRIP server.
42pub struct RobustNtripClientOptions {
43    /// Maximal interval to retry the NTRIP connection.
44    pub max_backoff_duration: std::time::Duration,
45
46    /// Reset the NTRIP connection after this duration of not receiving updates.
47    pub timeout: Option<std::time::Duration>,
48}
49
50impl std::default::Default for RobustNtripClientOptions {
51    fn default() -> Self {
52        Self {
53            max_backoff_duration: std::time::Duration::from_secs(30),
54            timeout: Some(std::time::Duration::from_secs(10)),
55        }
56    }
57}
58
59/// A client which automatically reconnects to an NTRIP server in case of
60/// interruption.
61pub struct RobustNtripClient {
62    request_url: String,
63    user_pass: Option<(String, String)>,
64    client: reqwest::Client,
65    timeout: Option<std::time::Duration>,
66    max_backoff_duration: std::time::Duration,
67
68    response: reqwest::Response,
69}
70
71impl RobustNtripClient {
72    /// Create a new connection to an NTRIP server.
73    pub async fn new(url: &str, opts: RobustNtripClientOptions) -> Result<Self> {
74        let uri: http::Uri = url
75            .parse()
76            .with_context(|| format!("While parsing NTRIP URL \"{url}\"."))?;
77
78        let (need_tls, default_port) = if let Some(scheme) = uri.scheme() {
79            let ntrip = http::uri::Scheme::from_str("ntrip").unwrap();
80            let http = http::uri::Scheme::from_str("http").unwrap();
81            let https = http::uri::Scheme::from_str("https").unwrap();
82            if scheme == &ntrip {
83                (false, Some(2101))
84            } else if scheme == &http {
85                (false, None)
86            } else if scheme == &https {
87                (true, None)
88            } else {
89                eyre::bail!("Unexpected URI scheme (found \"{scheme}\").");
90            }
91        } else {
92            // I'm not sure how this would be possible. I think parsing above would
93            // fail.
94            eyre::bail!("No URI scheme.");
95        };
96
97        let parts = uri.into_parts();
98        let (host_port, user_pass) = if let Some(auth) = &parts.authority {
99            parse_authority(auth)?
100        } else {
101            eyre::bail!("No authority section of URL");
102        };
103        let auth = http::uri::Authority::from_maybe_shared(host_port)?;
104
105        let host = auth.host();
106        let port = auth.port_u16().or(default_port);
107        let mountpoint = if let Some(pq) = parts.path_and_query {
108            pq.path().to_string()
109        } else {
110            "/".to_string()
111        };
112
113        let scheme = if need_tls { "https" } else { "http" };
114        let port = if let Some(port) = port {
115            format!(":{port}")
116        } else {
117            "".to_string()
118        };
119        let request_url = format!("{scheme}://{host}{port}{mountpoint}");
120
121        let mut headers = reqwest::header::HeaderMap::new();
122        // See https://support.pointonenav.com/polaris-ntrip-api-docs
123        headers.insert(
124            "Ntrip-Version",
125            reqwest::header::HeaderValue::from_static("ntrip/2.0"),
126        );
127
128        let client = reqwest::ClientBuilder::new()
129            .tcp_keepalive(std::time::Duration::from_secs(5))
130            .default_headers(headers)
131            .user_agent(format!(
132                "NTRIP {}/{}",
133                env!("CARGO_PKG_NAME"),
134                env!("CARGO_PKG_VERSION")
135            ))
136            .build()?;
137
138        let max_backoff_duration = opts.max_backoff_duration;
139        let timeout = opts.timeout;
140        let response = establish_connection(
141            &client,
142            &request_url,
143            user_pass.as_ref(),
144            max_backoff_duration,
145        )
146        .await?;
147
148        Ok(Self {
149            request_url,
150            user_pass,
151            client,
152            timeout,
153            max_backoff_duration,
154
155            response,
156        })
157    }
158
159    /// Get the next chunk of raw bytes from the NTRIP server.
160    pub async fn chunk(&mut self) -> Result<bytes::Bytes> {
161        if let Some(duration) = self.timeout {
162            self.next_chunk_with_timeout(duration).await
163        } else {
164            self.next_chunk_infinite_wait().await
165        }
166    }
167
168    async fn next_chunk_with_timeout(
169        &mut self,
170        duration: std::time::Duration,
171    ) -> Result<bytes::Bytes> {
172        match tokio::time::timeout(duration, self.next_chunk_infinite_wait()).await {
173            Ok(next) => next, // normal case: new data before timeout
174            Err(_) => {
175                tracing::warn!("Reconnecting due to timeout elapsed.");
176                self.reconnect_and_get_first_chunk().await
177            }
178        }
179    }
180
181    async fn next_chunk_infinite_wait(&mut self) -> Result<bytes::Bytes> {
182        match self.response.chunk().await {
183            Ok(Some(next)) => Ok(next), // normal case: new data
184            Ok(None) => {
185                tracing::warn!("Reconnecting due to end of HTTP stream.");
186                self.reconnect_and_get_first_chunk().await
187            }
188            Err(_) => {
189                tracing::warn!("Reconnecting due to error with HTTP stream.");
190                self.reconnect_and_get_first_chunk().await
191            }
192        }
193    }
194
195    async fn reconnect_and_get_first_chunk(&mut self) -> Result<bytes::Bytes> {
196        let mut backoff = min_dur(std::time::Duration::from_secs(1), self.max_backoff_duration);
197        loop {
198            self.response = establish_connection(
199                &self.client,
200                &self.request_url,
201                self.user_pass.as_ref(),
202                self.max_backoff_duration,
203            )
204            .await?;
205
206            match self.response.chunk().await {
207                Ok(Some(chunk)) => return Ok(chunk),
208                Ok(None) => {
209                    tracing::warn!(
210                        "NTRIP stream ended before yielding data after reconnect; retrying."
211                    );
212                }
213                Err(error) => {
214                    tracing::warn!(
215                        %error,
216                        "Error reading NTRIP stream after reconnect; retrying."
217                    );
218                }
219            }
220
221            tokio::time::sleep(backoff).await;
222            backoff = min_dur(backoff * 2, self.max_backoff_duration);
223        }
224    }
225}
226
227async fn establish_connection(
228    client: &reqwest::Client,
229    request_url: &str,
230    user_pass: Option<&(String, String)>,
231    max_backoff_duration: std::time::Duration,
232) -> Result<reqwest::Response> {
233    let mut backoff = std::time::Duration::from_secs(1);
234    loop {
235        tracing::info!("Establishing connection to {request_url}.");
236        let mut req_builder = client.get(request_url);
237        if let Some((username, password)) = &user_pass {
238            req_builder = req_builder.basic_auth(username, Some(password));
239        }
240        let result_response = req_builder.send().await;
241        match result_response {
242            Ok(response) => {
243                tracing::debug!("Sent request");
244
245                if !response.status().is_success() {
246                    eyre::bail!("Error getting NTRIP URL: HTTP status {}", response.status());
247                }
248
249                return Ok(response);
250            }
251            Err(e) => {
252                let error = eyre::Report::from(e);
253                let mut err_msg = format!("Could not open NTRIP URL: {error}");
254                for cause in error.chain() {
255                    err_msg = format!("{err_msg}\n   cause: {cause}");
256                }
257                tracing::warn!("{err_msg}");
258
259                tokio::time::sleep(backoff).await;
260                backoff = min_dur(backoff * 2, max_backoff_duration);
261            }
262        }
263    }
264}
265
266fn min_dur(a: std::time::Duration, b: std::time::Duration) -> std::time::Duration {
267    if a < b { a } else { b }
268}
269
270fn parse_authority(auth: &http::uri::Authority) -> Result<(String, Option<(String, String)>)> {
271    // Replace when https://github.com/hyperium/http/pull/399 is merged.
272    let auth_vec = auth.as_str().split("@").collect::<Vec<_>>();
273    match auth_vec.len() {
274        1 => {
275            // Only "host:port"
276            let host_port = auth_vec[0].to_string();
277            Ok((host_port, None))
278        }
279        2 => {
280            // "username:password@host:port"
281            let user_pass = auth_vec[0];
282            let host_port = auth_vec[1].to_string();
283            let up = user_pass.split(":").collect::<Vec<_>>();
284            if up.len() != 2 {
285                eyre::bail!("Could not parse username and password from URL");
286            }
287            let username = up[0].to_string();
288            let password = up[1].to_string();
289            Ok((host_port, Some((username, password))))
290        }
291        _ => {
292            eyre::bail!("Expected zero or one '@' symbols in authority");
293        }
294    }
295}
296
297#[test]
298fn test_parse_example_url() {
299    let uri: http::Uri = "ntrip://hostname.com:2101/mountpoint".parse().unwrap();
300    let my_scheme = http::uri::Scheme::from_str("ntrip").unwrap();
301    assert_eq!(uri.scheme(), Some(&my_scheme));
302    let authority = uri.authority().unwrap();
303    assert_eq!(authority.host(), "hostname.com");
304    assert_eq!(authority.port_u16(), Some(2101));
305    let (host_port, user_pass) = parse_authority(authority).unwrap();
306    assert!(user_pass.is_none());
307    assert_eq!(host_port, "hostname.com:2101");
308    let path_and_query = uri.path_and_query().unwrap();
309    assert_eq!(path_and_query.path(), "/mountpoint");
310}
311
312#[test]
313fn test_parse_example_url_with_user_pass() {
314    let uri: http::Uri = "ntrip://username:password@hostname.com:2101/mountpoint"
315        .parse()
316        .unwrap();
317    let my_scheme = http::uri::Scheme::from_str("ntrip").unwrap();
318    assert_eq!(uri.scheme(), Some(&my_scheme));
319    let authority = uri.authority().unwrap();
320    assert_eq!(authority.host(), "hostname.com");
321    assert_eq!(authority.port_u16(), Some(2101));
322    let (host_port, user_pass) = parse_authority(authority).unwrap();
323    let (username, password) = user_pass.unwrap();
324    assert_eq!(username, "username");
325    assert_eq!(password, "password");
326    assert_eq!(host_port, "hostname.com:2101");
327    let path_and_query = uri.path_and_query().unwrap();
328    assert_eq!(path_and_query.path(), "/mountpoint");
329}
330
331#[cfg(test)]
332#[tokio::test]
333async fn reconnect_retries_a_truncated_first_chunk() {
334    use std::io::{Read, Write};
335
336    let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
337    let address = listener.local_addr().unwrap();
338    let server = std::thread::spawn(move || {
339        let responses: [&[u8]; 3] = [
340            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n",
341            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\ninvalid\r\n",
342            b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\ntest\r\n0\r\n\r\n",
343        ];
344
345        for response in responses {
346            let (mut stream, _) = listener.accept().unwrap();
347            let mut request = Vec::new();
348            let mut byte = [0_u8; 1];
349            while !request.ends_with(b"\r\n\r\n") {
350                stream.read_exact(&mut byte).unwrap();
351                request.push(byte[0]);
352            }
353            stream.write_all(response).unwrap();
354        }
355    });
356
357    let mut client = RobustNtripClient::new(
358        &format!("http://{address}/mountpoint"),
359        RobustNtripClientOptions {
360            max_backoff_duration: std::time::Duration::ZERO,
361            timeout: None,
362        },
363    )
364    .await
365    .unwrap();
366
367    let chunk = client.reconnect_and_get_first_chunk().await.unwrap();
368    assert_eq!(chunk, "test");
369    server.join().unwrap();
370}
371
372/// One valid frame of RTCM data from the NTRIP server.
373pub struct FrameData {
374    frame_data: bytes::BytesMut,
375    message_number: u16,
376}
377
378impl FrameData {
379    /// Get the RTCM data.
380    pub fn frame_data(&self) -> &[u8] {
381        &self.frame_data
382    }
383    /// Get the RTCM message number.
384    pub fn message_number(&self) -> u16 {
385        self.message_number
386    }
387}
388
389impl From<FrameData> for Vec<u8> {
390    fn from(val: FrameData) -> Self {
391        val.frame_data.into()
392    }
393}
394
395/// A client which parses RTCM messages from the NTRIP stream.
396pub struct ParsingNtripClient {
397    client: RobustNtripClient,
398    buf: bytes::BytesMut,
399}
400
401impl ParsingNtripClient {
402    /// Create a parsing NTRIP client by wrapping a low-level NTRIP client.
403    pub fn new(client: RobustNtripClient) -> Self {
404        let buf = bytes::BytesMut::new();
405        Self { client, buf }
406    }
407
408    /// Get the next RTCM message from the NTRIP server.
409    pub async fn next(&mut self) -> Result<FrameData> {
410        loop {
411            let mut advance_info = None;
412            for (i, start_byte) in (&self.buf).into_iter().enumerate() {
413                if *start_byte == 0xd3 {
414                    match rtcm_rs::MessageFrame::new(&self.buf[i..]) {
415                        Ok(m) => {
416                            tracing::debug!(
417                                "Found RTCM message {} frame with length {}",
418                                m.message_number().unwrap(),
419                                m.frame_len()
420                            );
421                            advance_info = Some((
422                                i,
423                                false,
424                                Some((m.frame_len(), m.message_number().unwrap())),
425                            ));
426                            break;
427                        }
428                        Err(rtcm_rs::rtcm_error::RtcmError::Incomplete) => {
429                            advance_info = Some((i, true, None)); // discard data prior to the start byte.
430                            break;
431                        }
432                        Err(rtcm_rs::rtcm_error::RtcmError::NotValid) => {
433                            advance_info = Some((i + 1, false, None)); // advance past the invalid "start byte".
434                            break;
435                        }
436                        _ => unreachable!(),
437                    }
438                }
439            }
440
441            let (n_discard, do_read_more, msg_info) = if let Some(x) = advance_info {
442                x
443            } else {
444                // no start byte found, so we need to read more data.
445                (self.buf.len(), true, None)
446            };
447
448            let _discard_bytes = self.buf.split_to(n_discard);
449            if let Some((frame_len, message_number)) = msg_info {
450                assert!(!do_read_more);
451                let frame_data = self.buf.split_to(frame_len);
452                return Ok(FrameData {
453                    frame_data,
454                    message_number,
455                });
456            }
457
458            if do_read_more {
459                // Fetch more data.
460                let this_buf = self.client.chunk().await?;
461                self.buf.extend_from_slice(&this_buf);
462            }
463        }
464    }
465}