use crate::error::AprsError;
use crate::types::compressed::{Altitude, CompressedCs};
use crate::types::lonlat::{Latitude, Longitude, Precision};
use crate::types::symbol::Symbol;
use std::ops::RangeInclusive;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Dao {
HumanReadable { lat_digit: u8, lon_digit: u8 },
Base91 { lat_offset: u8, lon_offset: u8 },
}
impl Dao {
pub fn offsets_degrees(&self) -> (f64, f64) {
match self {
Dao::HumanReadable {
lat_digit,
lon_digit,
} => {
(
(*lat_digit as f64) / 60_000.0,
(*lon_digit as f64) / 60_000.0,
)
}
Dao::Base91 {
lat_offset,
lon_offset,
} => {
(
(*lat_offset as f64) / (91.0 * 6000.0),
(*lon_offset as f64) / (91.0 * 6000.0),
)
}
}
}
pub(crate) fn find_in_comment(data: &[u8]) -> Option<Self> {
let end = data.iter().rposition(|&b| !b.is_ascii_whitespace())? + 1;
if end < 5 {
return None;
}
Self::parse_token(&data[end - 5..end])
}
fn parse_token(token: &[u8]) -> Option<Self> {
if token.len() != 5 || token[0] != b'!' || token[4] != b'!' {
return None;
}
let prefix = token[1];
let d1 = token[2];
let d2 = token[3];
if prefix.is_ascii_uppercase() {
return Some(Dao::HumanReadable {
lat_digit: hr_digit(d1)?,
lon_digit: hr_digit(d2)?,
});
}
if prefix.is_ascii_lowercase() {
return Some(Dao::Base91 {
lat_offset: b91_digit(d1)?,
lon_offset: b91_digit(d2)?,
});
}
None
}
}
fn hr_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b' ' => Some(0),
_ => None,
}
}
fn b91_digit(b: u8) -> Option<u8> {
match b {
0x21..=0x7B => Some(b - 33),
b' ' => Some(0),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Position {
pub latitude: Latitude,
pub longitude: Longitude,
pub precision: Precision,
pub symbol: Symbol,
pub compressed_cs: Option<CompressedCs>,
pub altitude: Option<Altitude>,
pub dao: Option<Dao>,
}
impl Position {
pub fn latitude_bounding(&self) -> RangeInclusive<f64> {
self.precision.range(self.latitude.value())
}
pub fn longitude_bounding(&self) -> RangeInclusive<f64> {
self.precision.range(self.longitude.value())
}
pub(crate) fn parse(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
if b.is_empty() {
return Err(AprsError::UnsupportedPositionFormat);
}
if b[0].is_ascii_digit() {
Self::parse_uncompressed(b)
} else {
Self::parse_compressed(b)
}
}
fn parse_uncompressed(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
if b.len() < 19 {
return Err(AprsError::TruncatedPacket {
expected: 19,
got: b.len(),
});
}
let (lat, precision) = Latitude::parse_uncompressed(&b[0..8])?;
let symbol_table = b[8] as char;
let lon = Longitude::parse_uncompressed(&b[9..18], precision)?;
let symbol_code = b[18] as char;
let symbol = Symbol::new(symbol_table, symbol_code);
let comment = b.get(19..);
let comment_bytes = comment.unwrap_or_default();
let altitude = altitude_in_comment(comment_bytes);
let dao = Dao::find_in_comment(comment_bytes);
let (lat, lon) = if let Some(ref d) = dao {
let (dlat, dlon) = d.offsets_degrees();
let lat_sign = if lat.value() >= 0.0 { 1.0 } else { -1.0 };
let lon_sign = if lon.value() >= 0.0 { 1.0 } else { -1.0 };
let new_lat = Latitude::new(lat.value() + lat_sign * dlat).unwrap_or(lat);
let new_lon = Longitude::new(lon.value() + lon_sign * dlon).unwrap_or(lon);
(new_lat, new_lon)
} else {
(lat, lon)
};
Ok((
b.get(19..),
Self {
latitude: lat,
longitude: lon,
precision,
symbol,
compressed_cs: None,
altitude,
dao,
},
))
}
fn parse_compressed(b: &[u8]) -> Result<(Option<&[u8]>, Self), AprsError> {
if b.len() < 13 {
return Err(AprsError::TruncatedPacket {
expected: 13,
got: b.len(),
});
}
let symbol_table = b[0] as char;
let lat = Latitude::parse_compressed(&b[1..5])?;
let lon = Longitude::parse_compressed(&b[5..9])?;
let symbol_code = b[9] as char;
let symbol = Symbol::new(symbol_table, symbol_code);
let cst = CompressedCs::parse(b[10], b[11], b[12])?;
let altitude = match &cst {
CompressedCs::Altitude(a, _) => Some(Altitude::new(a.feet)),
_ => None,
};
Ok((
b.get(13..),
Self {
latitude: lat,
longitude: lon,
precision: Precision::default(),
symbol,
compressed_cs: Some(cst),
altitude,
dao: None,
},
))
}
pub(crate) fn encode_uncompressed(&self, out: &mut Vec<u8>) {
let (lat, lon) = self.base_coords();
lat.encode_uncompressed(out, self.precision);
out.push(self.symbol.table as u8);
lon.encode_uncompressed(out);
out.push(self.symbol.code as u8);
}
fn base_coords(&self) -> (Latitude, Longitude) {
let Some(ref d) = self.dao else {
return (self.latitude, self.longitude);
};
let (dlat, dlon) = d.offsets_degrees();
let lat = self.latitude.value();
let lon = self.longitude.value();
let lat_sign = if lat >= 0.0 { 1.0 } else { -1.0 };
let lon_sign = if lon >= 0.0 { 1.0 } else { -1.0 };
let base_lat = Latitude::new(lat - lat_sign * dlat).unwrap_or(self.latitude);
let base_lon = Longitude::new(lon - lon_sign * dlon).unwrap_or(self.longitude);
(base_lat, base_lon)
}
pub(crate) fn encode_compressed(&self, out: &mut Vec<u8>) {
out.push(self.symbol.table as u8);
self.latitude.encode_compressed(out);
self.longitude.encode_compressed(out);
out.push(self.symbol.code as u8);
if let Some(ref cst) = self.compressed_cs {
cst.encode(out);
} else {
out.extend_from_slice(b" sT");
}
}
}
pub(crate) fn altitude_in_comment(data: &[u8]) -> Option<Altitude> {
let s = std::str::from_utf8(data).ok()?;
let start = s.find("/A=")?;
let rest = &s[start + 3..];
let end = rest
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(rest.len());
let feet: u32 = rest[..end].parse().ok()?;
Some(Altitude::new(feet as f64))
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn uncompressed_basic() {
let (rem, pos) = Position::parse(b"4903.50N/07201.75W-Hello").unwrap();
assert_relative_eq!(pos.latitude.value(), 49.05833333333333, epsilon = 1e-9);
assert_relative_eq!(pos.longitude.value(), -72.02916666666667, epsilon = 1e-9);
assert_eq!(pos.symbol.table, '/');
assert_eq!(pos.symbol.code, '-');
assert_eq!(rem.unwrap(), b"Hello");
}
#[test]
fn uncompressed_altitude_in_comment() {
let (_, pos) = Position::parse(b"4903.50N/07201.75W-/A=003054").unwrap();
assert!(pos.altitude.is_some());
let alt = pos.altitude.unwrap();
assert_relative_eq!(alt.feet, 3054.0, epsilon = 0.5);
}
#[test]
fn dao_human_readable_applied() {
let (_, pos) = Position::parse(b"4903.50N/07201.75W-!W56!").unwrap();
assert_relative_eq!(
pos.latitude.value(),
49.05833333333333 + 5.0 / 60_000.0,
epsilon = 1e-9
);
}
#[test]
fn dao_uppercase_is_human_readable() {
assert_eq!(
Dao::find_in_comment(b"!W56!"),
Some(Dao::HumanReadable {
lat_digit: 5,
lon_digit: 6
})
);
}
#[test]
fn dao_lowercase_is_base91() {
assert_eq!(
Dao::find_in_comment(b"!w56!"),
Some(Dao::Base91 {
lat_offset: b'5' - 33,
lon_offset: b'6' - 33
})
);
}
#[test]
fn dao_human_readable_space_is_unused_axis() {
assert_eq!(
Dao::find_in_comment(b"!W5 !"),
Some(Dao::HumanReadable {
lat_digit: 5,
lon_digit: 0
})
);
}
#[test]
fn dao_must_be_at_end() {
assert_eq!(Dao::find_in_comment(b"say!axy! ok"), None);
}
#[test]
fn dao_false_match_does_not_shift_coords() {
let clean = Position::parse(b"4903.50N/07201.75W-hello world")
.unwrap()
.1;
let texty = Position::parse(b"4903.50N/07201.75W-say!axy! ok")
.unwrap()
.1;
assert!(texty.dao.is_none());
assert_eq!(clean.latitude.value(), texty.latitude.value());
assert_eq!(clean.longitude.value(), texty.longitude.value());
}
#[test]
fn dao_non_letter_datum_rejected() {
assert_eq!(Dao::find_in_comment(b"!156!"), None);
}
#[test]
fn uncompressed_encode_round_trip() {
let raw = b"4903.50N/07201.75W-";
let (_, pos) = Position::parse(raw).unwrap();
let mut out = Vec::new();
pos.encode_uncompressed(&mut out);
assert_eq!(&out, raw);
}
#[test]
fn altitude_in_comment_extracted() {
let alt = altitude_in_comment(b"/A=001000extra").unwrap();
assert_relative_eq!(alt.feet, 1000.0, epsilon = 0.1);
}
#[test]
fn compressed_parse_known() {
let (_, pos) = Position::parse(b"/ABCD#$%^- sT").unwrap();
assert_relative_eq!(pos.latitude.value(), 25.97004667573229, epsilon = 0.001);
assert_relative_eq!(pos.longitude.value(), -171.95429033460567, epsilon = 0.001);
assert_eq!(pos.symbol.table, '/');
assert_eq!(pos.symbol.code, '-');
}
}