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 { lat_digit, lon_digit } => {
30 ((*lat_digit as f64) / 60_000.0, (*lon_digit as f64) / 60_000.0)
32 }
33 Dao::Base91 { lat_offset, lon_offset } => {
34 ((*lat_offset as f64) / (91.0 * 6000.0), (*lon_offset as f64) / (91.0 * 6000.0))
36 }
37 }
38 }
39
40 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#[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 pub compressed_cs: Option<CompressedCs>,
78 pub altitude: Option<Altitude>,
80 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 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 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 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 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 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 out.extend_from_slice(b" sT");
201 }
202 }
203}
204
205pub(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 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 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}