Skip to main content

aprs_decode/types/
position.rs

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/// DAO (Datum Ambiguity Override) precision extension parsed from the comment field.
8///
9/// When present, DAO provides sub-hundredth-of-a-minute precision beyond what the
10/// standard DDmm.mm format can encode. The offsets are applied to lat/lon at parse
11/// time so callers always receive refined coordinates.
12#[derive(Debug, Clone, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub enum Dao {
15    /// Human-readable form `!WXY!`: X and Y are decimal digits 0–9 encoding an extra
16    /// digit of minute resolution (thousandths of a minute ≈ 1.85 m).
17    HumanReadable { lat_digit: u8, lon_digit: u8 },
18    /// Base-91 form `!wxy!`: two base-91 characters encoding the sub-hundredth position
19    /// within the current cell (≈ 0.2 m resolution).
20    Base91 { lat_offset: u8, lon_offset: u8 },
21}
22
23impl Dao {
24    /// Latitude and longitude adjustments in degrees that refine the base position.
25    /// These are always non-negative; the caller applies them in the direction of the
26    /// base coordinate's sign.
27    pub fn offsets_degrees(&self) -> (f64, f64) {
28        match self {
29            Dao::HumanReadable {
30                lat_digit,
31                lon_digit,
32            } => {
33                // each digit = 0.001 minutes = 0.001/60 degrees
34                (
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                // value 0..90 → 0..0.01 minutes = 0..0.01/60 degrees
44                (
45                    (*lat_offset as f64) / (91.0 * 6000.0),
46                    (*lon_offset as f64) / (91.0 * 6000.0),
47                )
48            }
49        }
50    }
51
52    /// Decode a trailing DAO token of the form `!Xyy!` from the comment field.
53    ///
54    /// The DAO extension is, per spec, appended to the **end** of the comment. We
55    /// therefore only accept it as the final non-whitespace token rather than
56    /// scanning the whole comment for any `!..!` substring — the latter readily
57    /// false-matches arbitrary comment text and would silently perturb the
58    /// reported coordinates.
59    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    /// Decode a single 5-byte `!Xyy!` DAO token.
68    ///
69    /// The **case** of the datum letter `X` selects the encoding of the two data
70    /// bytes (per `aprs.org/aprs12/datum.txt`):
71    /// - uppercase (e.g. `W` = WGS84) → human-readable decimal digits `0`–`9`,
72    /// - lowercase (e.g. `w` = WGS84) → base-91 encoded offsets.
73    ///
74    /// A space in a data position marks an unused axis (no added precision).
75    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
99/// Decode a human-readable DAO data byte: a decimal digit, or a space (unused → 0).
100fn 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
108/// Decode a base-91 DAO data byte (`!`..`{`), or a space (unused → 0).
109fn 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/// A parsed APRS position, combining coordinates, symbol, and optional metadata.
118///
119/// DAO offsets (when present) are applied to `latitude` and `longitude` at parse
120/// time — callers receive the most precise available value directly.
121#[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    /// The compressed csT block, or `None` for uncompressed positions.
129    pub compressed_cs: Option<CompressedCs>,
130    /// Altitude from `/A=NNNNNN` in the comment field. Not from compressed csT.
131    pub altitude: Option<Altitude>,
132    /// The raw DAO token, kept for round-trip fidelity.
133    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    /// Parse either a compressed or uncompressed position from the head of `b`.
146    ///
147    /// Returns `(remaining, position)` where `remaining` is the slice following the
148    /// parsed position bytes (i.e. the comment field).
149    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        // Apply DAO offsets to refine coordinates
180        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        // Altitude from compressed csT (only when NmeaSource is Gga)
221        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    /// Encode as uncompressed position bytes (19 bytes: lat + sym_table + lon + sym_code).
241    pub(crate) fn encode_uncompressed(&self, out: &mut Vec<u8>) {
242        // DAO offsets are baked into `latitude`/`longitude` at parse time, and the DAO
243        // token itself is preserved in the comment field (re-emitted on encode). The
244        // base DDmm.mm field must therefore exclude the offset, or a decode→encode→decode
245        // round-trip would apply it twice.
246        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    /// Coordinates with any DAO refinement removed, matching the raw base position field.
254    /// Mirrors the offset application in [`Position::parse_uncompressed`].
255    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    /// Encode as compressed position bytes (13 bytes: sym_table + lat(4) + lon(4) + sym_code + csT(3)).
270    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            // Fallback: no csT data — use space + sT placeholder
279            out.extend_from_slice(b" sT");
280        }
281    }
282}
283
284/// Extract `/A=NNNNNN` altitude from a comment field.
285pub(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        // DAO !W56! refines lat by 5/60000 deg, lon by 6/60000 deg
322        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        // `W` (uppercase) selects the human-readable digit form.
333        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        // `w` (lowercase) selects the base-91 form; same trailing bytes decode
345        // to base-91 offsets, NOT digits.
346        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        // A `!..!` substring buried in comment text must not be treated as DAO.
369        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        // Digits / punctuation in the datum position are not valid DAO.
388        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        // From aprs-parser-rs test: "/ABCD#$%^- sT" (no-timestamp, compressed, no cs)
409        // symbol_table='/', lat=ABCD, lon=#$%^, symbol_code='-', c=' ', s='s', t='T'
410        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}