Skip to main content

aprs_decode/
position.rs

1use crate::error::AprsError;
2use crate::types::{Extension, Position, Timestamp};
3use crate::weather::AprsWeatherData;
4
5/// A decoded APRS position report.
6///
7/// Covers DTI bytes `!` `=` `/` `@`:
8/// - `!` = no timestamp, messaging not supported
9/// - `=` = no timestamp, messaging supported
10/// - `/` = timestamp present, messaging not supported
11/// - `@` = timestamp present, messaging supported
12#[derive(Debug, Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct AprsPosition {
15    pub timestamp: Option<Timestamp>,
16    pub messaging_supported: bool,
17    pub position: Position,
18    /// Data extension (course/speed, PHG, RNG, DFS) if one was found in the comment.
19    pub extension: Option<Extension>,
20    /// Weather data, populated when the symbol is `/_` (weather station).
21    pub weather: Option<AprsWeatherData>,
22    /// Frequency in MHz extracted from the start of the comment field, if present.
23    /// The raw comment is preserved in full for round-trip fidelity.
24    pub frequency_mhz: Option<f32>,
25    pub comment: Vec<u8>,
26}
27
28impl AprsPosition {
29    /// Decode the information field of a position report (including the leading DTI byte).
30    pub(crate) fn parse(info: &[u8]) -> Result<Self, AprsError> {
31        let dti = *info.first().ok_or(AprsError::EmptyPacket)?;
32        let messaging_supported = dti == b'=' || dti == b'@';
33        let has_timestamp = dti == b'@' || dti == b'/';
34
35        let (b, timestamp) = if has_timestamp {
36            let ts_bytes = info.get(1..8).ok_or(AprsError::TruncatedPacket {
37                expected: 8,
38                got: info.len(),
39            })?;
40            (
41                info.get(8..).unwrap_or_default(),
42                Some(Timestamp::parse(ts_bytes)?),
43            )
44        } else {
45            (info.get(1..).unwrap_or_default(), None)
46        };
47
48        let (remaining, position) = Position::parse(b)?;
49        let comment = remaining.unwrap_or_default().to_vec();
50
51        // Try to parse a data extension from the comment — failure is silently ignored
52        let extension = Extension::parse(&comment);
53
54        // Parse weather data when this is a weather-station position (symbol `/_`).
55        // The comment always starts with the DDD/SSS wind dir/speed block, so we
56        // always start from byte 0 regardless of whether an extension was also parsed.
57        let weather = if position.symbol.table == '/' && position.symbol.code == '_' {
58            crate::weather::AprsWeatherData::parse(&comment).ok()
59        } else {
60            None
61        };
62
63        // Extract frequency from the start of the comment (e.g. `146.520MHz T100`).
64        let frequency_mhz = crate::util::extract_frequency_mhz(&comment);
65
66        Ok(Self {
67            timestamp,
68            messaging_supported,
69            position,
70            extension,
71            weather,
72            frequency_mhz,
73            comment,
74        })
75    }
76
77    /// Encode this position report back to its information-field wire bytes.
78    pub fn encode(&self) -> Vec<u8> {
79        let mut out = Vec::new();
80        let dti = match (self.timestamp.is_some(), self.messaging_supported) {
81            (true, true) => b'@',
82            (true, false) => b'/',
83            (false, true) => b'=',
84            (false, false) => b'!',
85        };
86        out.push(dti);
87
88        if let Some(ref ts) = self.timestamp {
89            ts.encode(&mut out);
90        }
91
92        if self.position.compressed_cs.is_some() {
93            self.position.encode_compressed(&mut out);
94        } else {
95            self.position.encode_uncompressed(&mut out);
96        }
97
98        out.extend_from_slice(&self.comment);
99        out
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use approx::assert_relative_eq;
107
108    #[test]
109    fn no_timestamp_no_messaging() {
110        let pos = AprsPosition::parse(b"!4903.50N/07201.75W-").unwrap();
111        assert!(pos.timestamp.is_none());
112        assert!(!pos.messaging_supported);
113        assert_relative_eq!(
114            pos.position.latitude.value(),
115            49.05833333333333,
116            epsilon = 1e-9
117        );
118        assert_relative_eq!(
119            pos.position.longitude.value(),
120            -72.02916666666667,
121            epsilon = 1e-9
122        );
123        assert_eq!(pos.comment, b"");
124    }
125
126    #[test]
127    fn no_timestamp_with_messaging() {
128        let pos = AprsPosition::parse(b"=4903.50N/07201.75W-").unwrap();
129        assert!(pos.timestamp.is_none());
130        assert!(pos.messaging_supported);
131    }
132
133    #[test]
134    fn with_timestamp_no_messaging() {
135        let pos = AprsPosition::parse(b"/074849h4821.61N\\01224.49E^322/103/A=003054").unwrap();
136        assert_eq!(pos.timestamp.unwrap(), Timestamp::Hhmmss(7, 48, 49));
137        assert!(!pos.messaging_supported);
138        assert_relative_eq!(
139            pos.position.latitude.value(),
140            48.36016666666667,
141            epsilon = 1e-9
142        );
143        assert_relative_eq!(
144            pos.position.longitude.value(),
145            12.408166666666666,
146            epsilon = 1e-9
147        );
148        assert_eq!(pos.position.symbol.table, '\\');
149        assert_eq!(pos.position.symbol.code, '^');
150        assert_eq!(pos.comment, b"322/103/A=003054");
151    }
152
153    #[test]
154    fn with_timestamp_and_messaging() {
155        let pos = AprsPosition::parse(b"@074849h4821.61N\\01224.49E^322/103/A=003054").unwrap();
156        assert!(pos.timestamp.is_some());
157        assert!(pos.messaging_supported);
158    }
159
160    #[test]
161    fn with_comment_and_altitude() {
162        let pos = AprsPosition::parse(b"!4903.50N/07201.75W-Hello/A=001000").unwrap();
163        assert_eq!(pos.comment, b"Hello/A=001000");
164        assert!(pos.position.altitude.is_some());
165    }
166
167    #[test]
168    fn extension_course_speed_parsed() {
169        let pos = AprsPosition::parse(b"/074849h4821.61N\\01224.49E^322/103/A=003054").unwrap();
170        assert!(pos.extension.is_some());
171        assert!(matches!(
172            pos.extension.unwrap(),
173            Extension::DirectionSpeed {
174                direction_degrees: 322,
175                speed_knots: 103
176            }
177        ));
178    }
179
180    #[test]
181    fn compressed_no_timestamp() {
182        let pos = AprsPosition::parse(b"!/ABCD#$%^- sT").unwrap();
183        assert!(pos.timestamp.is_none());
184        assert_relative_eq!(
185            pos.position.latitude.value(),
186            25.97004667573229,
187            epsilon = 0.001
188        );
189        assert_relative_eq!(
190            pos.position.longitude.value(),
191            -171.95429033460567,
192            epsilon = 0.001
193        );
194    }
195
196    #[test]
197    fn encode_round_trip_uncompressed() {
198        let raw = b"!4903.50N/07201.75W-";
199        let pos = AprsPosition::parse(raw).unwrap();
200        let encoded = pos.encode();
201        assert_eq!(&encoded, raw);
202    }
203
204    #[test]
205    fn encode_round_trip_with_timestamp() {
206        let raw = b"/074849h4821.61N\\01224.49E^322/103/A=003054";
207        let pos = AprsPosition::parse(raw).unwrap();
208        let encoded = pos.encode();
209        assert_eq!(encoded, raw);
210    }
211
212    #[test]
213    fn encode_round_trip_compressed() {
214        let raw = b"!/ABCD#$%^- sT";
215        let pos = AprsPosition::parse(raw).unwrap();
216        let encoded = pos.encode();
217        assert_eq!(&encoded, raw);
218    }
219
220    #[test]
221    fn timestamp_validates_strictly() {
222        // Hour 24 is invalid — aprs-parser-rs would have accepted this
223        let err = AprsPosition::parse(b"/092460z4903.50N/07201.75W-");
224        assert!(err.is_err());
225    }
226}