ogn_parser/
position.rs

1use std::fmt::Write;
2use std::str::FromStr;
3
4use flat_projection::FlatProjection;
5use serde::Serialize;
6
7use crate::AprsError;
8use crate::EncodeError;
9use crate::Timestamp;
10use crate::lonlat::{Latitude, Longitude, encode_latitude, encode_longitude};
11use crate::position_comment::PositionComment;
12
13pub struct Relation {
14    pub bearing: f64,
15    pub distance: f64,
16}
17
18#[derive(PartialEq, Debug, Clone, Serialize)]
19pub struct AprsPosition {
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub timestamp: Option<Timestamp>,
22    pub messaging_supported: bool,
23    pub latitude: Latitude,
24    pub longitude: Longitude,
25    pub symbol_table: char,
26    pub symbol_code: char,
27    #[serde(flatten)]
28    pub comment: PositionComment,
29}
30
31impl FromStr for AprsPosition {
32    type Err = AprsError;
33
34    fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
35        let messaging_supported = s.starts_with('=') || s.starts_with('@');
36        let has_timestamp = s.starts_with('@') || s.starts_with('/');
37
38        // check for minimal message length
39        if (!has_timestamp && s.len() < 20) || (has_timestamp && s.len() < 27) {
40            return Err(AprsError::InvalidPosition(s.to_owned()));
41        };
42
43        // Extract timestamp and remaining string
44        let (timestamp, s) = if has_timestamp {
45            (Some(s[1..8].parse()?), &s[8..])
46        } else {
47            (None, &s[1..])
48        };
49
50        // check for compressed position format
51        let is_uncompressed_position = s.chars().take(1).all(|c| c.is_numeric());
52        if !is_uncompressed_position {
53            return Err(AprsError::UnsupportedPositionFormat(s.to_owned()));
54        }
55
56        // parse position
57        let mut latitude: Latitude = s[0..8].parse()?;
58        let mut longitude: Longitude = s[9..18].parse()?;
59
60        let symbol_table = s.chars().nth(8).unwrap();
61        let symbol_code = s.chars().nth(18).unwrap();
62
63        let comment = &s[19..s.len()];
64
65        // parse the comment
66        let ogn = comment.parse::<PositionComment>().unwrap();
67
68        // The comment may contain additional position precision information that will be added to the current position
69        if let Some(additional_precision) = &ogn.additional_precision {
70            *latitude += latitude.signum() * additional_precision.lat as f64 / 60_000.;
71            *longitude += longitude.signum() * additional_precision.lon as f64 / 60_000.;
72        }
73
74        Ok(AprsPosition {
75            timestamp,
76            messaging_supported,
77            latitude,
78            longitude,
79            symbol_table,
80            symbol_code,
81            comment: ogn,
82        })
83    }
84}
85
86impl AprsPosition {
87    pub fn encode<W: Write>(&self, buf: &mut W) -> Result<(), EncodeError> {
88        let sym = match (self.timestamp.is_some(), self.messaging_supported) {
89            (true, true) => '@',
90            (true, false) => '/',
91            (false, true) => '=',
92            (false, false) => '!',
93        };
94
95        write!(buf, "{sym}")?;
96
97        if let Some(ts) = &self.timestamp {
98            write!(buf, "{ts}")?;
99        }
100
101        write!(
102            buf,
103            "{}{}{}{}{:#?}",
104            encode_latitude(self.latitude)?,
105            self.symbol_table,
106            encode_longitude(self.longitude)?,
107            self.symbol_code,
108            self.comment,
109        )?;
110
111        Ok(())
112    }
113
114    pub fn get_relation(&self, other: &Self) -> Relation {
115        let mean_longitude: f64 = (*self.longitude + *other.longitude) / 2.0;
116        let mean_latitude: f64 = (*self.latitude + *other.latitude) / 2.0;
117        let flat_projection = FlatProjection::new(mean_longitude, mean_latitude);
118
119        let p1 = flat_projection.project(*self.latitude, *self.longitude);
120        let p2 = flat_projection.project(*other.latitude, *other.longitude);
121
122        Relation {
123            bearing: (450.0 - p1.bearing(&p2)) % 360.0, // convert from unit circle (0 @ east, counterclockwise) to compass rose (0 @ north, clockwise)
124            distance: p1.distance(&p2) * 1000.0,        // convert from [km] to [m]
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use csv::WriterBuilder;
133    use std::io::stdout;
134
135    #[test]
136    fn parse_without_timestamp_or_messaging() {
137        let result = r"!4903.50N/07201.75W-".parse::<AprsPosition>().unwrap();
138        assert_eq!(result.timestamp, None);
139        assert_eq!(result.messaging_supported, false);
140        assert_relative_eq!(*result.latitude, 49.05833333333333);
141        assert_relative_eq!(*result.longitude, -72.02916666666667);
142        assert_eq!(result.symbol_table, '/');
143        assert_eq!(result.symbol_code, '-');
144        assert_eq!(result.comment, PositionComment::default());
145    }
146
147    #[test]
148    fn parse_with_comment() {
149        let result = r"!4903.50N/07201.75W-Hello/A=001000"
150            .parse::<AprsPosition>()
151            .unwrap();
152        assert_eq!(result.timestamp, None);
153        assert_relative_eq!(*result.latitude, 49.05833333333333);
154        assert_relative_eq!(*result.longitude, -72.02916666666667);
155        assert_eq!(result.symbol_table, '/');
156        assert_eq!(result.symbol_code, '-');
157        assert_eq!(result.comment.unparsed.unwrap(), "Hello/A=001000");
158    }
159
160    #[test]
161    fn parse_with_timestamp_without_messaging() {
162        let result = r"/074849h4821.61N\01224.49E^322/103/A=003054"
163            .parse::<AprsPosition>()
164            .unwrap();
165        assert_eq!(result.timestamp, Some(Timestamp::HHMMSS(7, 48, 49)));
166        assert_eq!(result.messaging_supported, false);
167        assert_relative_eq!(*result.latitude, 48.36016666666667);
168        assert_relative_eq!(*result.longitude, 12.408166666666666);
169        assert_eq!(result.symbol_table, '\\');
170        assert_eq!(result.symbol_code, '^');
171        assert_eq!(result.comment.altitude.unwrap(), 003054);
172        assert_eq!(result.comment.course.unwrap(), 322);
173        assert_eq!(result.comment.speed.unwrap(), 103);
174    }
175
176    #[test]
177    fn parse_without_timestamp_with_messaging() {
178        let result = r"=4903.50N/07201.75W-".parse::<AprsPosition>().unwrap();
179        assert_eq!(result.timestamp, None);
180        assert_eq!(result.messaging_supported, true);
181        assert_relative_eq!(*result.latitude, 49.05833333333333);
182        assert_relative_eq!(*result.longitude, -72.02916666666667);
183        assert_eq!(result.symbol_table, '/');
184        assert_eq!(result.symbol_code, '-');
185        assert_eq!(result.comment, PositionComment::default());
186    }
187
188    #[test]
189    fn parse_with_timestamp_and_messaging() {
190        let result = r"@074849h4821.61N\01224.49E^322/103/A=003054"
191            .parse::<AprsPosition>()
192            .unwrap();
193        assert_eq!(result.timestamp, Some(Timestamp::HHMMSS(7, 48, 49)));
194        assert_eq!(result.messaging_supported, true);
195        assert_relative_eq!(*result.latitude, 48.36016666666667);
196        assert_relative_eq!(*result.longitude, 12.408166666666666);
197        assert_eq!(result.symbol_table, '\\');
198        assert_eq!(result.symbol_code, '^');
199        assert_eq!(result.comment.altitude.unwrap(), 003054);
200        assert_eq!(result.comment.course.unwrap(), 322);
201        assert_eq!(result.comment.speed.unwrap(), 103);
202    }
203
204    #[test]
205    fn test_latitude_longitude() {
206        let result = r"/104337h5211.24N\00032.65W^124/081/A=004026 !W62!"
207            .parse::<AprsPosition>()
208            .unwrap();
209        assert_relative_eq!(*result.latitude, 52.18743333333334);
210        assert_relative_eq!(*result.longitude, -0.5442);
211    }
212
213    #[ignore = "position_comment serialization not implemented"]
214    #[test]
215    fn test_serialize() {
216        let aprs_position = r"@074849h4821.61N\01224.49E^322/103/A=003054"
217            .parse::<AprsPosition>()
218            .unwrap();
219        let mut wtr = WriterBuilder::new().from_writer(stdout());
220        wtr.serialize(aprs_position).unwrap();
221        wtr.flush().unwrap();
222    }
223
224    #[test]
225    fn test_input_string_with_timestamp_too_short() {
226        let result = r"/104337h5211.24N\00032.65W".parse::<AprsPosition>();
227        assert!(result.is_err(), "Short input string should return an error");
228    }
229
230    #[test]
231    fn test_input_string_without_timestamp_too_short() {
232        let result = r"!4903.50N/07201.75W".parse::<AprsPosition>();
233        assert!(result.is_err(), "Short input string should return an error");
234    }
235
236    #[test]
237    fn test_bearing() {
238        let receiver = r"!4903.50N/07201.75W-".parse::<AprsPosition>().unwrap();
239        let east = r"!4903.50N/07101.75W-".parse::<AprsPosition>().unwrap();
240        let north = r"!5004.50N/07201.75W-".parse::<AprsPosition>().unwrap();
241        let west = r"!4903.50N/07301.75W-".parse::<AprsPosition>().unwrap();
242        let south = r"!4803.50N/07201.75W-".parse::<AprsPosition>().unwrap();
243
244        let to_north = receiver.get_relation(&north);
245        assert_eq!(to_north.bearing, 0.0);
246
247        let to_east = receiver.get_relation(&east);
248        assert_eq!(to_east.bearing, 90.0);
249
250        let to_south = receiver.get_relation(&south);
251        assert_eq!(to_south.bearing, 180.0);
252
253        let to_west = receiver.get_relation(&west);
254        assert_eq!(to_west.bearing, 270.0);
255    }
256}