1use std::fmt;
7
8#[derive(Debug, Clone, Default)]
10pub struct Sourcetable {
11 pub streams: Vec<StreamEntry>,
13 pub casters: Vec<CasterEntry>,
15 pub networks: Vec<NetworkEntry>,
17}
18
19#[derive(Debug, Clone)]
21pub struct StreamEntry {
22 pub mountpoint: String,
24 pub identifier: String,
26 pub format: String,
28 pub format_details: String,
30 pub carrier: u8,
32 pub nav_system: String,
34 pub network: String,
36 pub country: String,
38 pub latitude: f64,
40 pub longitude: f64,
42 pub nmea_required: bool,
44 pub is_network: bool,
46 pub generator: String,
48 pub compression: String,
50 pub authentication: String,
52 pub fee: bool,
54 pub bitrate: u32,
56 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 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 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#[derive(Debug, Clone, Default)]
120pub struct CasterEntry {
121 pub host: String,
123 pub port: u16,
125 pub identifier: String,
127 pub operator: String,
129 pub nmea_required: bool,
131 pub country: String,
133 pub latitude: f64,
135 pub longitude: f64,
137 pub fallback_host: String,
139 pub fallback_port: u16,
141 pub misc: String,
143}
144
145#[derive(Debug, Clone, Default)]
147pub struct NetworkEntry {
148 pub identifier: String,
150 pub operator: String,
152 pub authentication: String,
154 pub fee: bool,
156 pub web: String,
158 pub stream_url: String,
160 pub registration_url: String,
162 pub misc: String,
164}
165
166impl Sourcetable {
167 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 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 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 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 pub fn rtcm_streams(&self) -> Vec<&StreamEntry> {
267 self.streams.iter().filter(|s| s.is_rtcm()).collect()
268 }
269
270 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 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 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 let dist = entry.distance_km(-23.6701, 133.8855);
335 assert!(dist < 0.001);
336
337 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 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); }
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}