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)
37                .ok_or(AprsError::TruncatedPacket { expected: 8, got: info.len() })?;
38            (
39                info.get(8..).unwrap_or_default(),
40                Some(Timestamp::parse(ts_bytes)?),
41            )
42        } else {
43            (info.get(1..).unwrap_or_default(), None)
44        };
45
46        let (remaining, position) = Position::parse(b)?;
47        let comment = remaining.unwrap_or_default().to_vec();
48
49        // Try to parse a data extension from the comment — failure is silently ignored
50        let extension = Extension::parse(&comment);
51
52        // Parse weather data when this is a weather-station position (symbol `/_`).
53        // The comment always starts with the DDD/SSS wind dir/speed block, so we
54        // always start from byte 0 regardless of whether an extension was also parsed.
55        let weather = if position.symbol.table == '/' && position.symbol.code == '_' {
56            crate::weather::AprsWeatherData::parse(&comment).ok()
57        } else {
58            None
59        };
60
61        // Extract frequency from the start of the comment (e.g. `146.520MHz T100`).
62        let frequency_mhz = crate::util::extract_frequency_mhz(&comment);
63
64        Ok(Self {
65            timestamp,
66            messaging_supported,
67            position,
68            extension,
69            weather,
70            frequency_mhz,
71            comment,
72        })
73    }
74
75    /// Encode this position report back to its information-field wire bytes.
76    pub fn encode(&self) -> Vec<u8> {
77        let mut out = Vec::new();
78        let dti = match (self.timestamp.is_some(), self.messaging_supported) {
79            (true, true) => b'@',
80            (true, false) => b'/',
81            (false, true) => b'=',
82            (false, false) => b'!',
83        };
84        out.push(dti);
85
86        if let Some(ref ts) = self.timestamp {
87            ts.encode(&mut out);
88        }
89
90        if self.position.compressed_cs.is_some() {
91            self.position.encode_compressed(&mut out);
92        } else {
93            self.position.encode_uncompressed(&mut out);
94        }
95
96        out.extend_from_slice(&self.comment);
97        out
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use approx::assert_relative_eq;
105
106    #[test]
107    fn no_timestamp_no_messaging() {
108        let pos = AprsPosition::parse(b"!4903.50N/07201.75W-").unwrap();
109        assert!(pos.timestamp.is_none());
110        assert!(!pos.messaging_supported);
111        assert_relative_eq!(pos.position.latitude.value(), 49.05833333333333, epsilon = 1e-9);
112        assert_relative_eq!(pos.position.longitude.value(), -72.02916666666667, epsilon = 1e-9);
113        assert_eq!(pos.comment, b"");
114    }
115
116    #[test]
117    fn no_timestamp_with_messaging() {
118        let pos = AprsPosition::parse(b"=4903.50N/07201.75W-").unwrap();
119        assert!(pos.timestamp.is_none());
120        assert!(pos.messaging_supported);
121    }
122
123    #[test]
124    fn with_timestamp_no_messaging() {
125        let pos = AprsPosition::parse(b"/074849h4821.61N\\01224.49E^322/103/A=003054").unwrap();
126        assert_eq!(
127            pos.timestamp.unwrap(),
128            Timestamp::Hhmmss(7, 48, 49)
129        );
130        assert!(!pos.messaging_supported);
131        assert_relative_eq!(pos.position.latitude.value(), 48.36016666666667, epsilon = 1e-9);
132        assert_relative_eq!(pos.position.longitude.value(), 12.408166666666666, epsilon = 1e-9);
133        assert_eq!(pos.position.symbol.table, '\\');
134        assert_eq!(pos.position.symbol.code, '^');
135        assert_eq!(pos.comment, b"322/103/A=003054");
136    }
137
138    #[test]
139    fn with_timestamp_and_messaging() {
140        let pos = AprsPosition::parse(b"@074849h4821.61N\\01224.49E^322/103/A=003054").unwrap();
141        assert!(pos.timestamp.is_some());
142        assert!(pos.messaging_supported);
143    }
144
145    #[test]
146    fn with_comment_and_altitude() {
147        let pos = AprsPosition::parse(b"!4903.50N/07201.75W-Hello/A=001000").unwrap();
148        assert_eq!(pos.comment, b"Hello/A=001000");
149        assert!(pos.position.altitude.is_some());
150    }
151
152    #[test]
153    fn extension_course_speed_parsed() {
154        let pos = AprsPosition::parse(b"/074849h4821.61N\\01224.49E^322/103/A=003054").unwrap();
155        assert!(pos.extension.is_some());
156        assert!(matches!(
157            pos.extension.unwrap(),
158            Extension::DirectionSpeed { direction_degrees: 322, speed_knots: 103 }
159        ));
160    }
161
162    #[test]
163    fn compressed_no_timestamp() {
164        let pos = AprsPosition::parse(b"!/ABCD#$%^- sT").unwrap();
165        assert!(pos.timestamp.is_none());
166        assert_relative_eq!(pos.position.latitude.value(), 25.97004667573229, epsilon = 0.001);
167        assert_relative_eq!(pos.position.longitude.value(), -171.95429033460567, epsilon = 0.001);
168    }
169
170    #[test]
171    fn encode_round_trip_uncompressed() {
172        let raw = b"!4903.50N/07201.75W-";
173        let pos = AprsPosition::parse(raw).unwrap();
174        let encoded = pos.encode();
175        assert_eq!(&encoded, raw);
176    }
177
178    #[test]
179    fn encode_round_trip_with_timestamp() {
180        let raw = b"/074849h4821.61N\\01224.49E^322/103/A=003054";
181        let pos = AprsPosition::parse(raw).unwrap();
182        let encoded = pos.encode();
183        assert_eq!(encoded, raw);
184    }
185
186    #[test]
187    fn encode_round_trip_compressed() {
188        let raw = b"!/ABCD#$%^- sT";
189        let pos = AprsPosition::parse(raw).unwrap();
190        let encoded = pos.encode();
191        assert_eq!(&encoded, raw);
192    }
193
194    #[test]
195    fn timestamp_validates_strictly() {
196        // Hour 24 is invalid — aprs-parser-rs would have accepted this
197        let err = AprsPosition::parse(b"/092460z4903.50N/07201.75W-");
198        assert!(err.is_err());
199    }
200}