1use crate::error::AprsError;
2use crate::types::{Extension, Position, Timestamp};
3use crate::weather::AprsWeatherData;
4
5#[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 pub extension: Option<Extension>,
20 pub weather: Option<AprsWeatherData>,
22 pub frequency_mhz: Option<f32>,
25 pub comment: Vec<u8>,
26}
27
28impl AprsPosition {
29 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 let extension = Extension::parse(&comment);
51
52 let weather = if position.symbol.table == '/' && position.symbol.code == '_' {
56 crate::weather::AprsWeatherData::parse(&comment).ok()
57 } else {
58 None
59 };
60
61 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 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 let err = AprsPosition::parse(b"/092460z4903.50N/07201.75W-");
198 assert!(err.is_err());
199 }
200}