ogn_parser/
position_comment.rs

1use serde::Serialize;
2use std::{convert::Infallible, str::FromStr};
3
4use crate::utils::{split_letter_number_pairs, split_value_unit};
5#[derive(Debug, PartialEq, Eq, Default, Clone, Serialize)]
6pub struct AdditionalPrecision {
7    pub lat: u8,
8    pub lon: u8,
9}
10
11#[derive(Debug, PartialEq, Eq, Default, Clone, Serialize)]
12pub struct ID {
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub reserved: Option<u16>,
15    pub address_type: u16,
16    pub aircraft_type: u8,
17    pub is_stealth: bool,
18    pub is_notrack: bool,
19    pub address: u32,
20}
21
22#[derive(Debug, PartialEq, Default, Clone, Serialize)]
23pub struct PositionComment {
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub course: Option<u16>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub speed: Option<u16>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub altitude: Option<u32>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub wind_direction: Option<u16>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub wind_speed: Option<u16>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub gust: Option<u16>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub temperature: Option<i16>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub rainfall_1h: Option<u16>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub rainfall_24h: Option<u16>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub rainfall_midnight: Option<u16>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub humidity: Option<u8>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub barometric_pressure: Option<u32>,
48    #[serde(skip_serializing)]
49    pub additional_precision: Option<AdditionalPrecision>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    #[serde(flatten)]
52    pub id: Option<ID>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub climb_rate: Option<i16>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub turn_rate: Option<f32>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub signal_quality: Option<f32>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub error: Option<u8>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub frequency_offset: Option<f32>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub gps_quality: Option<String>,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub flight_level: Option<f32>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub signal_power: Option<f32>,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub software_version: Option<f32>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub hardware_version: Option<u8>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub original_address: Option<u32>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub unparsed: Option<String>,
77}
78
79impl FromStr for PositionComment {
80    type Err = Infallible;
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        let mut position_comment = PositionComment {
83            ..Default::default()
84        };
85        let mut unparsed: Vec<_> = vec![];
86        for (idx, part) in s.split_ascii_whitespace().enumerate() {
87            // The first part can be course + speed + altitude: ccc/sss/A=aaaaaa
88            // ccc: course in degrees 0-360
89            // sss: speed in km/h
90            // aaaaaa: altitude in feet
91            if idx == 0
92                && part.len() == 16
93                && &part[3..4] == "/"
94                && &part[7..10] == "/A="
95                && position_comment.course.is_none()
96            {
97                let course = part[0..3].parse::<u16>().ok();
98                let speed = part[4..7].parse::<u16>().ok();
99                let altitude = part[10..16].parse::<u32>().ok();
100                if course.is_some()
101                    && course.unwrap() <= 360
102                    && speed.is_some()
103                    && altitude.is_some()
104                {
105                    position_comment.course = course;
106                    position_comment.speed = speed;
107                    position_comment.altitude = altitude;
108                } else {
109                    unparsed.push(part);
110                }
111            // ... or just the altitude: /A=aaaaaa
112            // aaaaaa: altitude in feet
113            } else if idx == 0
114                && part.len() == 9
115                && &part[0..3] == "/A="
116                && position_comment.altitude.is_none()
117            {
118                match part[3..].parse::<u32>().ok() {
119                    Some(altitude) => position_comment.altitude = Some(altitude),
120                    None => unparsed.push(part),
121                }
122            // ... or a complete weather report: ccc/sss/XXX...
123            // starting ccc/sss is now wind_direction and wind_speed
124            // XXX... is a string of data pairs, where each pair has one letter that indicates the type of data and a number that indicates the value
125            //
126            // mandatory fields:
127            // gddd: gust (peak wind speed in mph in the last 5 minutes)
128            // tddd: temperature (in degrees Fahrenheit). Temperatures below zero are expressed as -01 to -99
129            //
130            // optional fields:
131            // rddd: rainfall (in hundrets of inches) in the last hour
132            // pddd: rainfall (in hundrets of inches) in the last 24 hours
133            // Pddd: rainfall (in hundrets of inches) since midnight
134            // hdd: humidity (in % where 00 is 100%)
135            // bddddd: barometric pressure (in tenths of millibars/tenths of hPascal)
136            } else if idx == 0
137                && part.len() >= 15
138                && &part[3..4] == "/"
139                && position_comment.wind_direction.is_none()
140            {
141                let wind_direction = part[0..3].parse::<u16>().ok();
142                let wind_speed = part[4..7].parse::<u16>().ok();
143
144                if wind_direction.is_some() && wind_speed.is_some() {
145                    position_comment.wind_direction = wind_direction;
146                    position_comment.wind_speed = wind_speed;
147                } else {
148                    unparsed.push(part);
149                    continue;
150                }
151
152                let pairs = split_letter_number_pairs(&part[7..]);
153
154                // check if any type of data is not in the allowed set or if any type is duplicated
155                let mut seen = std::collections::HashSet::new();
156                if pairs
157                    .iter()
158                    .any(|(c, _)| !seen.insert(*c) || !"gtrpPhb".contains(*c))
159                {
160                    unparsed.push(part);
161                    continue;
162                }
163
164                for (c, number) in pairs {
165                    match c {
166                        'g' => position_comment.gust = Some(number as u16),
167                        't' => position_comment.temperature = Some(number as i16),
168                        'r' => position_comment.rainfall_1h = Some(number as u16),
169                        'p' => position_comment.rainfall_24h = Some(number as u16),
170                        'P' => position_comment.rainfall_midnight = Some(number as u16),
171                        'h' => position_comment.humidity = Some(number as u8),
172                        'b' => position_comment.barometric_pressure = Some(number as u32),
173                        _ => unreachable!(),
174                    }
175                }
176            // The second part can be the additional precision: !Wab!
177            // a: additional latitude precision
178            // b: additional longitude precision
179            } else if idx == 1
180                && part.len() == 5
181                && &part[0..2] == "!W"
182                && &part[4..] == "!"
183                && position_comment.additional_precision.is_none()
184            {
185                let add_lat = part[2..3].parse::<u8>().ok();
186                let add_lon = part[3..4].parse::<u8>().ok();
187                match (add_lat, add_lon) {
188                    (Some(add_lat), Some(add_lon)) => {
189                        position_comment.additional_precision = Some(AdditionalPrecision {
190                            lat: add_lat,
191                            lon: add_lon,
192                        })
193                    }
194                    _ => unparsed.push(part),
195                }
196            // generic ID format: idXXYYYYYY (4 bytes format)
197            // YYYYYY: 24 bit address in hex digits
198            // XX in hex digits encodes stealth mode, no-tracking flag and address type
199            // XX to binary-> STtt ttaa
200            // S: stealth flag
201            // T: no-tracking flag
202            // tttt: aircraft type
203            // aa: address type
204            } else if part.len() == 10 && &part[0..2] == "id" && position_comment.id.is_none() {
205                if let (Some(detail), Some(address)) = (
206                    u8::from_str_radix(&part[2..4], 16).ok(),
207                    u32::from_str_radix(&part[4..10], 16).ok(),
208                ) {
209                    let address_type = (detail & 0b0000_0011) as u16;
210                    let aircraft_type = (detail & 0b_0011_1100) >> 2;
211                    let is_notrack = (detail & 0b0100_0000) != 0;
212                    let is_stealth = (detail & 0b1000_0000) != 0;
213                    position_comment.id = Some(ID {
214                        address_type,
215                        aircraft_type,
216                        is_notrack,
217                        is_stealth,
218                        address,
219                        ..Default::default()
220                    });
221                } else {
222                    unparsed.push(part);
223                }
224            // NAVITER ID format: idXXXXYYYYYY (5 bytes)
225            // YYYYYY: 24 bit address in hex digits
226            // XXXX in hex digits encodes stealth mode, no-tracking flag and address type
227            // XXXX to binary-> STtt ttaa aaaa rrrr
228            // S: stealth flag
229            // T: no-tracking flag
230            // tttt: aircraft type
231            // aaaaaa: address type
232            // rrrr: (reserved)
233            } else if part.len() == 12 && &part[0..2] == "id" && position_comment.id.is_none() {
234                if let (Some(detail), Some(address)) = (
235                    u16::from_str_radix(&part[2..6], 16).ok(),
236                    u32::from_str_radix(&part[6..12], 16).ok(),
237                ) {
238                    let reserved = detail & 0b0000_0000_0000_1111;
239                    let address_type = (detail & 0b0000_0011_1111_0000) >> 4;
240                    let aircraft_type = ((detail & 0b0011_1100_0000_0000) >> 10) as u8;
241                    let is_notrack = (detail & 0b0100_0000_0000_0000) != 0;
242                    let is_stealth = (detail & 0b1000_0000_0000_0000) != 0;
243                    position_comment.id = Some(ID {
244                        reserved: Some(reserved),
245                        address_type,
246                        aircraft_type,
247                        is_notrack,
248                        is_stealth,
249                        address,
250                    });
251                } else {
252                    unparsed.push(part);
253                }
254            } else if let Some((value, unit)) = split_value_unit(part) {
255                if unit == "fpm" && position_comment.climb_rate.is_none() {
256                    position_comment.climb_rate = value.parse::<i16>().ok();
257                } else if unit == "rot" && position_comment.turn_rate.is_none() {
258                    position_comment.turn_rate = value.parse::<f32>().ok();
259                } else if unit == "dB" && position_comment.signal_quality.is_none() {
260                    position_comment.signal_quality = value.parse::<f32>().ok();
261                } else if unit == "kHz" && position_comment.frequency_offset.is_none() {
262                    position_comment.frequency_offset = value.parse::<f32>().ok();
263                } else if unit == "e" && position_comment.error.is_none() {
264                    position_comment.error = value.parse::<u8>().ok();
265                } else if unit == "dBm" && position_comment.signal_power.is_none() {
266                    position_comment.signal_power = value.parse::<f32>().ok();
267                } else {
268                    unparsed.push(part);
269                }
270            // Gps precision: gpsAxB
271            // A: integer
272            // B: integer
273            } else if part.len() >= 6
274                && &part[0..3] == "gps"
275                && position_comment.gps_quality.is_none()
276            {
277                if let Some((first, second)) = part[3..].split_once('x') {
278                    if first.parse::<u8>().is_ok() && second.parse::<u8>().is_ok() {
279                        position_comment.gps_quality = Some(part[3..].to_string());
280                    } else {
281                        unparsed.push(part);
282                    }
283                } else {
284                    unparsed.push(part);
285                }
286            // Flight level: FLxx.yy
287            // xx.yy: float value for flight level
288            } else if part.len() >= 3
289                && &part[0..2] == "FL"
290                && position_comment.flight_level.is_none()
291            {
292                if let Ok(flight_level) = part[2..].parse::<f32>() {
293                    position_comment.flight_level = Some(flight_level);
294                } else {
295                    unparsed.push(part);
296                }
297            // Software version: sXX.YY
298            // XX.YY: float value for software version
299            } else if part.len() >= 2
300                && &part[0..1] == "s"
301                && position_comment.software_version.is_none()
302            {
303                if let Ok(software_version) = part[1..].parse::<f32>() {
304                    position_comment.software_version = Some(software_version);
305                } else {
306                    unparsed.push(part);
307                }
308            // Hardware version: hXX
309            // XX: hexadecimal value for hardware version
310            } else if part.len() == 3
311                && &part[0..1] == "h"
312                && position_comment.hardware_version.is_none()
313            {
314                if part[1..3].chars().all(|c| c.is_ascii_hexdigit()) {
315                    position_comment.hardware_version = u8::from_str_radix(&part[1..3], 16).ok();
316                } else {
317                    unparsed.push(part);
318                }
319            // Original address: rXXXXXX
320            // XXXXXX: hex digits for 24 bit address
321            } else if part.len() == 7
322                && &part[0..1] == "r"
323                && position_comment.original_address.is_none()
324            {
325                if part[1..7].chars().all(|c| c.is_ascii_hexdigit()) {
326                    position_comment.original_address = u32::from_str_radix(&part[1..7], 16).ok();
327                } else {
328                    unparsed.push(part);
329                }
330            } else {
331                unparsed.push(part);
332            }
333        }
334        position_comment.unparsed = if !unparsed.is_empty() {
335            Some(unparsed.join(" "))
336        } else {
337            None
338        };
339
340        Ok(position_comment)
341    }
342}
343
344#[test]
345fn test_flr() {
346    let result = "255/045/A=003399 !W03! id06DDFAA3 -613fpm -3.9rot 22.5dB 7e -7.0kHz gps3x7 s7.07 h41 rD002F8".parse::<PositionComment>().unwrap();
347    assert_eq!(
348        result,
349        PositionComment {
350            course: Some(255),
351            speed: Some(45),
352            altitude: Some(3399),
353            additional_precision: Some(AdditionalPrecision { lat: 0, lon: 3 }),
354            id: Some(ID {
355                reserved: None,
356                address_type: 2,
357                aircraft_type: 1,
358                is_stealth: false,
359                is_notrack: false,
360                address: u32::from_str_radix("DDFAA3", 16).unwrap(),
361            }),
362            climb_rate: Some(-613),
363            turn_rate: Some(-3.9),
364            signal_quality: Some(22.5),
365            error: Some(7),
366            frequency_offset: Some(-7.0),
367            gps_quality: Some("3x7".into()),
368            software_version: Some(7.07),
369            hardware_version: Some(65),
370            original_address: u32::from_str_radix("D002F8", 16).ok(),
371            ..Default::default()
372        }
373    );
374}
375
376#[test]
377fn test_trk() {
378    let result =
379        "200/073/A=126433 !W05! id15B50BBB +4237fpm +2.2rot FL1267.81 10.0dB 19e +23.8kHz gps36x55"
380            .parse::<PositionComment>()
381            .unwrap();
382    assert_eq!(
383        result,
384        PositionComment {
385            course: Some(200),
386            speed: Some(73),
387            altitude: Some(126433),
388            wind_direction: None,
389            wind_speed: None,
390            gust: None,
391            temperature: None,
392            rainfall_1h: None,
393            rainfall_24h: None,
394            rainfall_midnight: None,
395            humidity: None,
396            barometric_pressure: None,
397            additional_precision: Some(AdditionalPrecision { lat: 0, lon: 5 }),
398            id: Some(ID {
399                address_type: 1,
400                aircraft_type: 5,
401                is_stealth: false,
402                is_notrack: false,
403                address: u32::from_str_radix("B50BBB", 16).unwrap(),
404                ..Default::default()
405            }),
406            climb_rate: Some(4237),
407            turn_rate: Some(2.2),
408            signal_quality: Some(10.0),
409            error: Some(19),
410            frequency_offset: Some(23.8),
411            gps_quality: Some("36x55".into()),
412            flight_level: Some(1267.81),
413            signal_power: None,
414            software_version: None,
415            hardware_version: None,
416            original_address: None,
417            unparsed: None
418        }
419    );
420}
421
422#[test]
423fn test_trk2() {
424    let result = "000/000/A=002280 !W59! id07395004 +000fpm +0.0rot FL021.72 40.2dB -15.1kHz gps9x13 +15.8dBm".parse::<PositionComment>().unwrap();
425    assert_eq!(
426        result,
427        PositionComment {
428            course: Some(0),
429            speed: Some(0),
430            altitude: Some(2280),
431            additional_precision: Some(AdditionalPrecision { lat: 5, lon: 9 }),
432            id: Some(ID {
433                address_type: 3,
434                aircraft_type: 1,
435                is_stealth: false,
436                is_notrack: false,
437                address: u32::from_str_radix("395004", 16).unwrap(),
438                ..Default::default()
439            }),
440            climb_rate: Some(0),
441            turn_rate: Some(0.0),
442            signal_quality: Some(40.2),
443            frequency_offset: Some(-15.1),
444            gps_quality: Some("9x13".into()),
445            flight_level: Some(21.72),
446            signal_power: Some(15.8),
447            ..Default::default()
448        }
449    );
450}
451
452#[test]
453fn test_trk2_different_order() {
454    // Check if order doesn't matter
455    let result = "000/000/A=002280 !W59! -15.1kHz id07395004 +15.8dBm +0.0rot +000fpm FL021.72 40.2dB gps9x13".parse::<PositionComment>().unwrap();
456    assert_eq!(
457        result,
458        PositionComment {
459            course: Some(0),
460            speed: Some(0),
461            altitude: Some(2280),
462            additional_precision: Some(AdditionalPrecision { lat: 5, lon: 9 }),
463            id: Some(ID {
464                address_type: 3,
465                aircraft_type: 1,
466                is_stealth: false,
467                is_notrack: false,
468                address: u32::from_str_radix("395004", 16).unwrap(),
469                ..Default::default()
470            }),
471            climb_rate: Some(0),
472            turn_rate: Some(0.0),
473            signal_quality: Some(40.2),
474            frequency_offset: Some(-15.1),
475            gps_quality: Some("9x13".into()),
476            flight_level: Some(21.72),
477            signal_power: Some(15.8),
478            ..Default::default()
479        }
480    );
481}
482
483#[test]
484fn test_bad_gps() {
485    let result = "208/063/A=003222 !W97! id06D017DC -395fpm -2.4rot 8.2dB -6.1kHz gps2xFLRD0"
486        .parse::<PositionComment>()
487        .unwrap();
488    assert_eq!(result.frequency_offset, Some(-6.1));
489    assert_eq!(result.gps_quality.is_some(), false);
490    assert_eq!(result.unparsed, Some("gps2xFLRD0".to_string()));
491}
492
493#[test]
494fn test_naviter_id() {
495    let result = "000/000/A=000000 !W0! id985F579BDF"
496        .parse::<PositionComment>()
497        .unwrap();
498    assert_eq!(result.id.is_some(), true);
499    let id = result.id.unwrap();
500
501    assert_eq!(id.reserved, Some(15));
502    assert_eq!(id.address_type, 5);
503    assert_eq!(id.aircraft_type, 6);
504    assert_eq!(id.is_stealth, true);
505    assert_eq!(id.is_notrack, false);
506    assert_eq!(id.address, 0x579BDF);
507}
508
509#[test]
510fn parse_weather() {
511    let result = "187/004g007t075h78b63620"
512        .parse::<PositionComment>()
513        .unwrap();
514    assert_eq!(result.wind_direction, Some(187));
515    assert_eq!(result.wind_speed, Some(4));
516    assert_eq!(result.gust, Some(7));
517    assert_eq!(result.temperature, Some(75));
518    assert_eq!(result.humidity, Some(78));
519    assert_eq!(result.barometric_pressure, Some(63620));
520}
521
522#[test]
523fn parse_weather_bad_type() {
524    let result = "187/004g007X075h78b63620"
525        .parse::<PositionComment>()
526        .unwrap();
527    assert_eq!(
528        result.unparsed,
529        Some("187/004g007X075h78b63620".to_string())
530    );
531}
532
533#[test]
534fn parse_weather_duplicate_type() {
535    let result = "187/004g007t075g78b63620"
536        .parse::<PositionComment>()
537        .unwrap();
538    assert_eq!(
539        result.unparsed,
540        Some("187/004g007t075g78b63620".to_string())
541    );
542}