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 { lat_digit, lon_digit } => {
30                // each digit = 0.001 minutes = 0.001/60 degrees
31                ((*lat_digit as f64) / 60_000.0, (*lon_digit as f64) / 60_000.0)
32            }
33            Dao::Base91 { lat_offset, lon_offset } => {
34                // value 0..90 → 0..0.01 minutes = 0..0.01/60 degrees
35                ((*lat_offset as f64) / (91.0 * 6000.0), (*lon_offset as f64) / (91.0 * 6000.0))
36            }
37        }
38    }
39
40    /// Scan `data` for the `!X__!` DAO pattern and return the first match.
41    pub(crate) fn find_in_comment(data: &[u8]) -> Option<Self> {
42        for i in 0..data.len().saturating_sub(4) {
43            if data[i] == b'!' && data.get(i + 4) == Some(&b'!') {
44                let prefix = data[i + 1];
45                let d1 = data[i + 2];
46                let d2 = data[i + 3];
47                if prefix.is_ascii_uppercase() && d1.is_ascii_digit() && d2.is_ascii_digit() {
48                    return Some(Dao::HumanReadable {
49                        lat_digit: d1 - b'0',
50                        lon_digit: d2 - b'0',
51                    });
52                }
53                if prefix.is_ascii_lowercase() && (0x21..=0x7B).contains(&d1) && (0x21..=0x7B).contains(&d2) {
54                    return Some(Dao::Base91 {
55                        lat_offset: d1 - 33,
56                        lon_offset: d2 - 33,
57                    });
58                }
59            }
60        }
61        None
62    }
63}
64
65/// A parsed APRS position, combining coordinates, symbol, and optional metadata.
66///
67/// DAO offsets (when present) are applied to `latitude` and `longitude` at parse
68/// time — callers receive the most precise available value directly.
69#[derive(Debug, Clone, PartialEq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
71pub struct Position {
72    pub latitude: Latitude,
73    pub longitude: Longitude,
74    pub precision: Precision,
75    pub symbol: Symbol,
76    /// The compressed csT block, or `None` for uncompressed positions.
77    pub compressed_cs: Option<CompressedCs>,
78    /// Altitude from `/A=NNNNNN` in the comment field. Not from compressed csT.
79    pub altitude: Option<Altitude>,
80    /// The raw DAO token, kept for round-trip fidelity.
81    pub dao: Option<Dao>,
82}
83
84impl Position {
85    pub fn latitude_bounding(&self) -> RangeInclusive<f64> {
86        self.precision.range(self.latitude.value())
87    }
88
89    pub fn longitude_bounding(&self) -> RangeInclusive<f64> {
90        self.precision.range(self.longitude.value())
91    }
92
93    /// Parse either a compressed or uncompressed position from the head of `b`.
94    ///
95    /// Returns `(remaining, position)` where `remaining` is the slice following the
96    /// parsed position bytes (i.e. the comment field).
97    pub(crate) fn parse(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
98        if b.is_empty() {
99            return Err(AprsError::UnsupportedPositionFormat);
100        }
101        if b[0].is_ascii_digit() {
102            Self::parse_uncompressed(b)
103        } else {
104            Self::parse_compressed(b)
105        }
106    }
107
108    fn parse_uncompressed(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
109        if b.len() < 19 {
110            return Err(AprsError::TruncatedPacket { expected: 19, got: b.len() });
111        }
112        let (lat, precision) = Latitude::parse_uncompressed(&b[0..8])?;
113        let symbol_table = b[8] as char;
114        let lon = Longitude::parse_uncompressed(&b[9..18], precision)?;
115        let symbol_code = b[18] as char;
116        let symbol = Symbol::new(symbol_table, symbol_code);
117
118        let comment = b.get(19..);
119        let comment_bytes = comment.unwrap_or_default();
120
121        let altitude = altitude_in_comment(comment_bytes);
122        let dao = Dao::find_in_comment(comment_bytes);
123
124        // Apply DAO offsets to refine coordinates
125        let (lat, lon) = if let Some(ref d) = dao {
126            let (dlat, dlon) = d.offsets_degrees();
127            let lat_sign = if lat.value() >= 0.0 { 1.0 } else { -1.0 };
128            let lon_sign = if lon.value() >= 0.0 { 1.0 } else { -1.0 };
129            let new_lat = Latitude::new(lat.value() + lat_sign * dlat).unwrap_or(lat);
130            let new_lon = Longitude::new(lon.value() + lon_sign * dlon).unwrap_or(lon);
131            (new_lat, new_lon)
132        } else {
133            (lat, lon)
134        };
135
136        Ok((
137            b.get(19..),
138            Self {
139                latitude: lat,
140                longitude: lon,
141                precision,
142                symbol,
143                compressed_cs: None,
144                altitude,
145                dao,
146            },
147        ))
148    }
149
150    fn parse_compressed(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
151        if b.len() < 13 {
152            return Err(AprsError::TruncatedPacket { expected: 13, got: b.len() });
153        }
154        let symbol_table = b[0] as char;
155        let lat = Latitude::parse_compressed(&b[1..5])?;
156        let lon = Longitude::parse_compressed(&b[5..9])?;
157        let symbol_code = b[9] as char;
158        let symbol = Symbol::new(symbol_table, symbol_code);
159
160        let cst = CompressedCs::parse(b[10], b[11], b[12])?;
161
162        // Altitude from compressed csT (only when NmeaSource is Gga)
163        let altitude = match &cst {
164            CompressedCs::Altitude(a, _) => Some(Altitude::new(a.feet)),
165            _ => None,
166        };
167
168        Ok((
169            b.get(13..),
170            Self {
171                latitude: lat,
172                longitude: lon,
173                precision: Precision::default(),
174                symbol,
175                compressed_cs: Some(cst),
176                altitude,
177                dao: None,
178            },
179        ))
180    }
181
182    /// Encode as uncompressed position bytes (19 bytes: lat + sym_table + lon + sym_code).
183    pub(crate) fn encode_uncompressed(&self, out: &mut Vec<u8>) {
184        self.latitude.encode_uncompressed(out, self.precision);
185        out.push(self.symbol.table as u8);
186        self.longitude.encode_uncompressed(out);
187        out.push(self.symbol.code as u8);
188    }
189
190    /// Encode as compressed position bytes (13 bytes: sym_table + lat(4) + lon(4) + sym_code + csT(3)).
191    pub(crate) fn encode_compressed(&self, out: &mut Vec<u8>) {
192        out.push(self.symbol.table as u8);
193        self.latitude.encode_compressed(out);
194        self.longitude.encode_compressed(out);
195        out.push(self.symbol.code as u8);
196        if let Some(ref cst) = self.compressed_cs {
197            cst.encode(out);
198        } else {
199            // Fallback: no csT data — use space + sT placeholder
200            out.extend_from_slice(b" sT");
201        }
202    }
203}
204
205/// Extract `/A=NNNNNN` altitude from a comment field.
206pub(crate) fn altitude_in_comment(data: &[u8]) -> Option<Altitude> {
207    let s = std::str::from_utf8(data).ok()?;
208    let start = s.find("/A=")?;
209    let rest = &s[start + 3..];
210    let end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
211    let feet: u32 = rest[..end].parse().ok()?;
212    Some(Altitude::new(feet as f64))
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use approx::assert_relative_eq;
219
220    #[test]
221    fn uncompressed_basic() {
222        let (rem, pos) = Position::parse(b"4903.50N/07201.75W-Hello").unwrap();
223        assert_relative_eq!(pos.latitude.value(), 49.05833333333333, epsilon = 1e-9);
224        assert_relative_eq!(pos.longitude.value(), -72.02916666666667, epsilon = 1e-9);
225        assert_eq!(pos.symbol.table, '/');
226        assert_eq!(pos.symbol.code, '-');
227        assert_eq!(rem.unwrap(), b"Hello");
228    }
229
230    #[test]
231    fn uncompressed_altitude_in_comment() {
232        let (_, pos) = Position::parse(b"4903.50N/07201.75W-/A=003054").unwrap();
233        assert!(pos.altitude.is_some());
234        let alt = pos.altitude.unwrap();
235        assert_relative_eq!(alt.feet, 3054.0, epsilon = 0.5);
236    }
237
238    #[test]
239    fn dao_human_readable_applied() {
240        // DAO !W56! refines lat by 5/60000 deg, lon by 6/60000 deg
241        let (_, pos) = Position::parse(b"4903.50N/07201.75W-!W56!").unwrap();
242        assert_relative_eq!(
243            pos.latitude.value(),
244            49.05833333333333 + 5.0 / 60_000.0,
245            epsilon = 1e-9
246        );
247    }
248
249    #[test]
250    fn uncompressed_encode_round_trip() {
251        let raw = b"4903.50N/07201.75W-";
252        let (_, pos) = Position::parse(raw).unwrap();
253        let mut out = Vec::new();
254        pos.encode_uncompressed(&mut out);
255        assert_eq!(&out, raw);
256    }
257
258    #[test]
259    fn altitude_in_comment_extracted() {
260        let alt = altitude_in_comment(b"/A=001000extra").unwrap();
261        assert_relative_eq!(alt.feet, 1000.0, epsilon = 0.1);
262    }
263
264    #[test]
265    fn compressed_parse_known() {
266        // From aprs-parser-rs test: "/ABCD#$%^- sT" (no-timestamp, compressed, no cs)
267        // symbol_table='/', lat=ABCD, lon=#$%^, symbol_code='-', c=' ', s='s', t='T'
268        let (_, pos) = Position::parse(b"/ABCD#$%^- sT").unwrap();
269        assert_relative_eq!(pos.latitude.value(), 25.97004667573229, epsilon = 0.001);
270        assert_relative_eq!(pos.longitude.value(), -171.95429033460567, epsilon = 0.001);
271        assert_eq!(pos.symbol.table, '/');
272        assert_eq!(pos.symbol.code, '-');
273    }
274}