1use crate::error::AprsError;
2use crate::types::compressed::{Altitude, CompressedCs};
3use crate::types::lonlat::{Latitude, Longitude, Precision};
4use crate::types::symbol::Symbol;
5use std::ops::RangeInclusive;
6
7#[derive(Debug, Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum Dao {
15 HumanReadable { lat_digit: u8, lon_digit: u8 },
18 Base91 { lat_offset: u8, lon_offset: u8 },
21}
22
23impl Dao {
24 pub fn offsets_degrees(&self) -> (f64, f64) {
28 match self {
29 Dao::HumanReadable {
30 lat_digit,
31 lon_digit,
32 } => {
33 (
35 (*lat_digit as f64) / 60_000.0,
36 (*lon_digit as f64) / 60_000.0,
37 )
38 }
39 Dao::Base91 {
40 lat_offset,
41 lon_offset,
42 } => {
43 (
45 (*lat_offset as f64) / (91.0 * 6000.0),
46 (*lon_offset as f64) / (91.0 * 6000.0),
47 )
48 }
49 }
50 }
51
52 pub(crate) fn find_in_comment(data: &[u8]) -> Option<Self> {
60 let end = data.iter().rposition(|&b| !b.is_ascii_whitespace())? + 1;
61 if end < 5 {
62 return None;
63 }
64 Self::parse_token(&data[end - 5..end])
65 }
66
67 fn parse_token(token: &[u8]) -> Option<Self> {
76 if token.len() != 5 || token[0] != b'!' || token[4] != b'!' {
77 return None;
78 }
79 let prefix = token[1];
80 let d1 = token[2];
81 let d2 = token[3];
82
83 if prefix.is_ascii_uppercase() {
84 return Some(Dao::HumanReadable {
85 lat_digit: hr_digit(d1)?,
86 lon_digit: hr_digit(d2)?,
87 });
88 }
89 if prefix.is_ascii_lowercase() {
90 return Some(Dao::Base91 {
91 lat_offset: b91_digit(d1)?,
92 lon_offset: b91_digit(d2)?,
93 });
94 }
95 None
96 }
97}
98
99fn hr_digit(b: u8) -> Option<u8> {
101 match b {
102 b'0'..=b'9' => Some(b - b'0'),
103 b' ' => Some(0),
104 _ => None,
105 }
106}
107
108fn b91_digit(b: u8) -> Option<u8> {
110 match b {
111 0x21..=0x7B => Some(b - 33),
112 b' ' => Some(0),
113 _ => None,
114 }
115}
116
117#[derive(Debug, Clone, PartialEq)]
122#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
123pub struct Position {
124 pub latitude: Latitude,
125 pub longitude: Longitude,
126 pub precision: Precision,
127 pub symbol: Symbol,
128 pub compressed_cs: Option<CompressedCs>,
130 pub altitude: Option<Altitude>,
132 pub dao: Option<Dao>,
134}
135
136impl Position {
137 pub fn latitude_bounding(&self) -> RangeInclusive<f64> {
138 self.precision.range(self.latitude.value())
139 }
140
141 pub fn longitude_bounding(&self) -> RangeInclusive<f64> {
142 self.precision.range(self.longitude.value())
143 }
144
145 pub(crate) fn parse(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
150 if b.is_empty() {
151 return Err(AprsError::UnsupportedPositionFormat);
152 }
153 if b[0].is_ascii_digit() {
154 Self::parse_uncompressed(b)
155 } else {
156 Self::parse_compressed(b)
157 }
158 }
159
160 fn parse_uncompressed(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
161 if b.len() < 19 {
162 return Err(AprsError::TruncatedPacket {
163 expected: 19,
164 got: b.len(),
165 });
166 }
167 let (lat, precision) = Latitude::parse_uncompressed(&b[0..8])?;
168 let symbol_table = b[8] as char;
169 let lon = Longitude::parse_uncompressed(&b[9..18], precision)?;
170 let symbol_code = b[18] as char;
171 let symbol = Symbol::new(symbol_table, symbol_code);
172
173 let comment = b.get(19..);
174 let comment_bytes = comment.unwrap_or_default();
175
176 let altitude = altitude_in_comment(comment_bytes);
177 let dao = Dao::find_in_comment(comment_bytes);
178
179 let (lat, lon) = if let Some(ref d) = dao {
181 let (dlat, dlon) = d.offsets_degrees();
182 let lat_sign = if lat.value() >= 0.0 { 1.0 } else { -1.0 };
183 let lon_sign = if lon.value() >= 0.0 { 1.0 } else { -1.0 };
184 let new_lat = Latitude::new(lat.value() + lat_sign * dlat).unwrap_or(lat);
185 let new_lon = Longitude::new(lon.value() + lon_sign * dlon).unwrap_or(lon);
186 (new_lat, new_lon)
187 } else {
188 (lat, lon)
189 };
190
191 Ok((
192 b.get(19..),
193 Self {
194 latitude: lat,
195 longitude: lon,
196 precision,
197 symbol,
198 compressed_cs: None,
199 altitude,
200 dao,
201 },
202 ))
203 }
204
205 fn parse_compressed(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
206 if b.len() < 13 {
207 return Err(AprsError::TruncatedPacket {
208 expected: 13,
209 got: b.len(),
210 });
211 }
212 let symbol_table = b[0] as char;
213 let lat = Latitude::parse_compressed(&b[1..5])?;
214 let lon = Longitude::parse_compressed(&b[5..9])?;
215 let symbol_code = b[9] as char;
216 let symbol = Symbol::new(symbol_table, symbol_code);
217
218 let cst = CompressedCs::parse(b[10], b[11], b[12])?;
219
220 let altitude = match &cst {
222 CompressedCs::Altitude(a, _) => Some(Altitude::new(a.feet)),
223 _ => None,
224 };
225
226 Ok((
227 b.get(13..),
228 Self {
229 latitude: lat,
230 longitude: lon,
231 precision: Precision::default(),
232 symbol,
233 compressed_cs: Some(cst),
234 altitude,
235 dao: None,
236 },
237 ))
238 }
239
240 pub(crate) fn encode_uncompressed(&self, out: &mut Vec<u8>) {
242 let (lat, lon) = self.base_coords();
247 lat.encode_uncompressed(out, self.precision);
248 out.push(self.symbol.table as u8);
249 lon.encode_uncompressed(out);
250 out.push(self.symbol.code as u8);
251 }
252
253 fn base_coords(&self) -> (Latitude, Longitude) {
256 let Some(ref d) = self.dao else {
257 return (self.latitude, self.longitude);
258 };
259 let (dlat, dlon) = d.offsets_degrees();
260 let lat = self.latitude.value();
261 let lon = self.longitude.value();
262 let lat_sign = if lat >= 0.0 { 1.0 } else { -1.0 };
263 let lon_sign = if lon >= 0.0 { 1.0 } else { -1.0 };
264 let base_lat = Latitude::new(lat - lat_sign * dlat).unwrap_or(self.latitude);
265 let base_lon = Longitude::new(lon - lon_sign * dlon).unwrap_or(self.longitude);
266 (base_lat, base_lon)
267 }
268
269 pub(crate) fn encode_compressed(&self, out: &mut Vec<u8>) {
271 out.push(self.symbol.table as u8);
272 self.latitude.encode_compressed(out);
273 self.longitude.encode_compressed(out);
274 out.push(self.symbol.code as u8);
275 if let Some(ref cst) = self.compressed_cs {
276 cst.encode(out);
277 } else {
278 out.extend_from_slice(b" sT");
280 }
281 }
282}
283
284pub(crate) fn altitude_in_comment(data: &[u8]) -> Option<Altitude> {
286 let s = std::str::from_utf8(data).ok()?;
287 let start = s.find("/A=")?;
288 let rest = &s[start + 3..];
289 let end = rest
290 .find(|c: char| !c.is_ascii_digit())
291 .unwrap_or(rest.len());
292 let feet: u32 = rest[..end].parse().ok()?;
293 Some(Altitude::new(feet as f64))
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use approx::assert_relative_eq;
300
301 #[test]
302 fn uncompressed_basic() {
303 let (rem, pos) = Position::parse(b"4903.50N/07201.75W-Hello").unwrap();
304 assert_relative_eq!(pos.latitude.value(), 49.05833333333333, epsilon = 1e-9);
305 assert_relative_eq!(pos.longitude.value(), -72.02916666666667, epsilon = 1e-9);
306 assert_eq!(pos.symbol.table, '/');
307 assert_eq!(pos.symbol.code, '-');
308 assert_eq!(rem.unwrap(), b"Hello");
309 }
310
311 #[test]
312 fn uncompressed_altitude_in_comment() {
313 let (_, pos) = Position::parse(b"4903.50N/07201.75W-/A=003054").unwrap();
314 assert!(pos.altitude.is_some());
315 let alt = pos.altitude.unwrap();
316 assert_relative_eq!(alt.feet, 3054.0, epsilon = 0.5);
317 }
318
319 #[test]
320 fn dao_human_readable_applied() {
321 let (_, pos) = Position::parse(b"4903.50N/07201.75W-!W56!").unwrap();
323 assert_relative_eq!(
324 pos.latitude.value(),
325 49.05833333333333 + 5.0 / 60_000.0,
326 epsilon = 1e-9
327 );
328 }
329
330 #[test]
331 fn dao_uppercase_is_human_readable() {
332 assert_eq!(
334 Dao::find_in_comment(b"!W56!"),
335 Some(Dao::HumanReadable {
336 lat_digit: 5,
337 lon_digit: 6
338 })
339 );
340 }
341
342 #[test]
343 fn dao_lowercase_is_base91() {
344 assert_eq!(
347 Dao::find_in_comment(b"!w56!"),
348 Some(Dao::Base91 {
349 lat_offset: b'5' - 33,
350 lon_offset: b'6' - 33
351 })
352 );
353 }
354
355 #[test]
356 fn dao_human_readable_space_is_unused_axis() {
357 assert_eq!(
358 Dao::find_in_comment(b"!W5 !"),
359 Some(Dao::HumanReadable {
360 lat_digit: 5,
361 lon_digit: 0
362 })
363 );
364 }
365
366 #[test]
367 fn dao_must_be_at_end() {
368 assert_eq!(Dao::find_in_comment(b"say!axy! ok"), None);
370 }
371
372 #[test]
373 fn dao_false_match_does_not_shift_coords() {
374 let clean = Position::parse(b"4903.50N/07201.75W-hello world")
375 .unwrap()
376 .1;
377 let texty = Position::parse(b"4903.50N/07201.75W-say!axy! ok")
378 .unwrap()
379 .1;
380 assert!(texty.dao.is_none());
381 assert_eq!(clean.latitude.value(), texty.latitude.value());
382 assert_eq!(clean.longitude.value(), texty.longitude.value());
383 }
384
385 #[test]
386 fn dao_non_letter_datum_rejected() {
387 assert_eq!(Dao::find_in_comment(b"!156!"), None);
389 }
390
391 #[test]
392 fn uncompressed_encode_round_trip() {
393 let raw = b"4903.50N/07201.75W-";
394 let (_, pos) = Position::parse(raw).unwrap();
395 let mut out = Vec::new();
396 pos.encode_uncompressed(&mut out);
397 assert_eq!(&out, raw);
398 }
399
400 #[test]
401 fn altitude_in_comment_extracted() {
402 let alt = altitude_in_comment(b"/A=001000extra").unwrap();
403 assert_relative_eq!(alt.feet, 1000.0, epsilon = 0.1);
404 }
405
406 #[test]
407 fn compressed_parse_known() {
408 let (_, pos) = Position::parse(b"/ABCD#$%^- sT").unwrap();
411 assert_relative_eq!(pos.latitude.value(), 25.97004667573229, epsilon = 0.001);
412 assert_relative_eq!(pos.longitude.value(), -171.95429033460567, epsilon = 0.001);
413 assert_eq!(pos.symbol.table, '/');
414 assert_eq!(pos.symbol.code, '-');
415 }
416}