use core::fmt;
pub mod capabilities;
pub mod extension;
pub mod message;
#[cfg(feature = "micE")]
pub mod mic_e;
pub mod monitor;
pub mod nmea;
pub mod object;
pub mod position;
pub mod status;
pub mod symbol;
pub mod telemetry;
pub mod thirdparty;
pub mod ultimeter;
pub mod weather;
pub use capabilities::Capabilities;
pub use extension::{Bearing, DataExtension, Dfs, Phg, PhgRate, Speed};
pub use message::{Addressee, Message, MessageContent};
#[cfg(feature = "micE")]
pub use mic_e::{MicE, MicEError, MicEFix, MicEMessage};
pub use nmea::{NmeaData, NmeaError, NmeaSentence};
pub use object::{Item, Object, Timestamp};
pub use position::{
CompressedCs, CompressionOrigin, CompressionType, NmeaSource, Position, PositionCs,
PositionTimestamped,
};
pub use crate::geo::{
Ambiguity, Coordinates, DegreesMinutes, GeoError, GridPrecision, Latitude, LatitudeHemisphere,
Longitude, LongitudeHemisphere, MaidenheadGrid,
};
pub use extension::{CommentTelemetry, Dao, comment_telemetry, dao};
pub use status::{BeamHeading, Status, StatusGrid};
pub use symbol::{OverlayId, Symbol, SymbolCode, SymbolDescription, SymbolTable};
pub use telemetry::{
Telemetry, TelemetryBitSense, TelemetryDefinition, TelemetryEquations, TelemetryLabels,
TelemetryValue,
};
pub use thirdparty::ThirdParty;
pub use ultimeter::{UltimeterError, UltimeterFormat, UltimeterRecord};
pub use weather::{PositionWeather, PositionlessWeather, WeatherReport};
use crate::ax25::{Address, Ax25Error, UiFrame};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AprsError {
InvalidDataType {
got: u8,
},
BadDigit {
got: u8,
position: usize,
},
ExpectedByte {
expected: u8,
got: u8,
position: usize,
},
BadHemisphere {
got: u8,
},
BadLatitude {
got: i64,
},
BadLongitude {
got: i64,
},
BadAmbiguity {
got: u8,
},
BadGridLength {
got: usize,
},
BadGridChar {
got: u8,
position: usize,
},
BadSymbolTable {
got: u8,
},
BadOverlay {
got: u8,
},
BadSymbolCode {
got: u8,
},
BadBase91 {
got: u8,
position: usize,
},
BadCourse {
got: u16,
},
BadSpeed {
got: u16,
},
BadRadioRange {
got: u16,
},
BadAltitude {
got: u32,
},
NmeaSourceConflict,
AddresseeTooLong {
len: usize,
},
AddresseeEmpty,
InvalidAddresseeChar {
got: u8,
},
MessageIdLengthInvalid {
len: usize,
},
Truncated {
expected: usize,
got: usize,
},
BadTimestamp {
field: u8,
got: i32,
},
UnknownWeatherField {
got: u8,
},
BadWeatherValue {
field: u8,
got: i32,
},
BadTelemetrySequence {
got: u8,
},
TelemetrySequenceOutOfRange {
got: u32,
},
BadAnalogValue {
position: usize,
},
TelemetryDecimalsOutOfRange {
got: u8,
},
BadDigitalBit {
got: u8,
position: usize,
},
BadLiveKilled {
got: u8,
},
BadCallsignLength {
len: usize,
},
NameLengthInvalid {
len: usize,
min: usize,
max: usize,
},
BadNameChar {
got: u8,
position: usize,
},
BufferTooSmall {
needed: usize,
max: usize,
},
#[cfg(feature = "micE")]
MicE(MicEError),
}
impl fmt::Display for AprsError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
AprsError::InvalidDataType { got } => write!(
f,
"data-type identifier 0x{got:02X} is not a supported APRS packet type"
),
AprsError::BadDigit { got, position } => write!(
f,
"byte 0x{got:02X} at offset {position} is invalid: an ASCII digit is required"
),
AprsError::ExpectedByte {
expected,
got,
position,
} => write!(
f,
"byte 0x{got:02X} at offset {position} is invalid: 0x{expected:02X} is required"
),
AprsError::BadHemisphere { got } => write!(
f,
"hemisphere byte 0x{got:02X} is invalid: must be N/S (latitude) or E/W (longitude)"
),
AprsError::BadLatitude { got } => write!(
f,
"latitude of {got} 1/100 arc-minutes is out of range: must be within \u{b1}90\u{b0} with minutes below 60"
),
AprsError::BadLongitude { got } => write!(
f,
"longitude of {got} 1/100 arc-minutes is out of range: must be within \u{b1}180\u{b0} with minutes below 60"
),
AprsError::BadAmbiguity { got } => write!(
f,
"position ambiguity {got} is out of range: 0..=4 digits may be masked"
),
AprsError::BadGridLength { got } => write!(
f,
"Maidenhead locator length {got} is invalid: must be 4, 6 or 8 characters"
),
AprsError::BadGridChar { got, position } => write!(
f,
"byte 0x{got:02X} at offset {position} is invalid in a Maidenhead locator"
),
AprsError::BadSymbolTable { got } => write!(
f,
"symbol table byte 0x{got:02X} is invalid: must be '/', '\\' or an overlay character"
),
AprsError::BadOverlay { got } => write!(
f,
"overlay byte 0x{got:02X} is invalid: must be '0'-'9' or 'A'-'Z'"
),
AprsError::BadSymbolCode { got } => write!(
f,
"symbol code byte 0x{got:02X} is invalid: printable ASCII '!'..='~' is required"
),
AprsError::BadBase91 { got, position } => write!(
f,
"byte 0x{got:02X} at offset {position} is outside the base-91 alphabet '!'..='{{'"
),
AprsError::BadCourse { got } => write!(
f,
"course of {got} degrees is out of range: 0..=359 is required"
),
AprsError::BadSpeed { got } => write!(
f,
"speed of {got} knots exceeds the largest compressed-encodable value"
),
AprsError::BadRadioRange { got } => write!(
f,
"radio range of {got} miles exceeds the largest compressed-encodable value"
),
AprsError::BadAltitude { got } => write!(
f,
"altitude of {got} feet exceeds the largest compressed-encodable value"
),
AprsError::NmeaSourceConflict => write!(
f,
"NMEA source GGA selects the altitude form of the csT trailer: course/speed and radio range require another source"
),
AprsError::AddresseeTooLong { len } => write!(
f,
"addressee of {len} bytes is too long: at most 9 characters fit the field"
),
AprsError::AddresseeEmpty => {
write!(f, "addressee is empty: at least one character is required")
}
AprsError::InvalidAddresseeChar { got } => write!(
f,
"addressee byte 0x{got:02X} is invalid: printable ASCII excluding space and ':' is required"
),
AprsError::MessageIdLengthInvalid { len } => write!(
f,
"message id of {len} bytes is invalid: must be 1..=5 characters"
),
AprsError::Truncated { expected, got } => write!(
f,
"information field of {got} bytes is truncated: at least {expected} bytes are required"
),
AprsError::BadTimestamp { field, got } => write!(
f,
"timestamp component '{}' value {got} is out of range",
field as char
),
AprsError::UnknownWeatherField { got } => write!(
f,
"weather field tag 0x{got:02X} is not a recognized measurement"
),
AprsError::BadWeatherValue { field, got } => write!(
f,
"weather field '{}' value {got} is out of range",
field as char
),
AprsError::BadTelemetrySequence { got } => write!(
f,
"telemetry sequence byte 0x{got:02X} is invalid: one to five ASCII digits are required (the MIC form is unsupported)"
),
AprsError::TelemetrySequenceOutOfRange { got } => write!(
f,
"telemetry sequence {got} is out of range: at most five digits fit the wire"
),
AprsError::BadAnalogValue { position } => write!(
f,
"telemetry analog field at offset {position} is not a number this crate can hold"
),
AprsError::TelemetryDecimalsOutOfRange { got } => write!(
f,
"telemetry value with {got} decimal places is out of range: at most 18 fit an i64 mantissa"
),
AprsError::BadDigitalBit { got, position } => write!(
f,
"telemetry digital byte 0x{got:02X} at offset {position} is invalid: '0' or '1' is required"
),
AprsError::BadLiveKilled { got } => write!(
f,
"live/killed byte 0x{got:02X} is invalid: '*'/'_' (object) or '!'/'_' (item) is required"
),
AprsError::BadCallsignLength { len } => write!(
f,
"third-party callsign of {len} bytes is invalid: 1..={} characters are required",
thirdparty::CALLSIGN_MAX
),
AprsError::NameLengthInvalid { len, min, max } => write!(
f,
"name of {len} bytes is invalid: {min}..={max} characters are required"
),
AprsError::BadNameChar { got, position } => write!(
f,
"name byte 0x{got:02X} at offset {position} is invalid: printable ASCII is required"
),
AprsError::BufferTooSmall { needed, max } => write!(
f,
"information field of {needed} bytes does not fit: the buffer holds at most {max} bytes"
),
#[cfg(feature = "micE")]
AprsError::MicE(e) => write!(f, "Mic-E report: {e}"),
}
}
}
impl core::error::Error for AprsError {}
impl From<GeoError> for AprsError {
fn from(error: GeoError) -> Self {
match error {
GeoError::BadLatitude { got } => Self::BadLatitude { got },
GeoError::BadLongitude { got } => Self::BadLongitude { got },
GeoError::BadAmbiguity { got } => Self::BadAmbiguity { got },
GeoError::BadGridLength { got } => Self::BadGridLength { got },
GeoError::BadGridChar { got, position } => Self::BadGridChar { got, position },
}
}
}
#[cfg(feature = "micE")]
impl From<MicEError> for AprsError {
fn from(error: MicEError) -> Self {
Self::MicE(error)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AprsPacket<'a> {
Position(Position<'a>),
PositionCs(PositionCs<'a>),
PositionTimestamped(PositionTimestamped<'a>),
PositionWeather(PositionWeather<'a>),
Weather(PositionlessWeather<'a>),
Telemetry(Telemetry<'a>),
Object(Object<'a>),
Item(Item<'a>),
Status(Status<'a>),
Message(Message<'a>),
Capabilities(Capabilities<'a>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct Decoded<'a> {
pub info: &'a [u8],
pub kind: DecodedKind<'a>,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum DecodedKind<'a> {
Packet(AprsPacket<'a>),
#[cfg(feature = "micE")]
MicE(MicE<'a>),
Nmea(nmea::NmeaSentence<'a>),
Ultimeter(ultimeter::UltimeterRecord<'a>),
ThirdParty(thirdparty::ThirdParty<'a>),
Text {
text: &'a [u8],
},
NeedsDestination {
dti: u8,
},
Unsupported {
dti: u8,
},
Malformed {
dti: u8,
error: AprsError,
},
}
impl<'a> Decoded<'a> {
#[must_use]
pub fn decode(info: &'a [u8]) -> Self {
Self {
info,
kind: DecodedKind::classify(None, info),
}
}
#[must_use]
pub fn decode_frame(dest: Address, info: &'a [u8]) -> Self {
Self {
info,
kind: DecodedKind::classify(Some(dest), info),
}
}
#[must_use]
pub fn packet(&self) -> Option<&AprsPacket<'a>> {
match &self.kind {
DecodedKind::Packet(p) => Some(p),
_ => None,
}
}
#[cfg(feature = "micE")]
#[must_use]
pub fn mic_e(&self) -> Option<&MicE<'a>> {
match &self.kind {
DecodedKind::MicE(m) => Some(m),
_ => None,
}
}
#[must_use]
pub const fn is_typed(&self) -> bool {
match self.kind {
DecodedKind::Packet(_)
| DecodedKind::Nmea(_)
| DecodedKind::Ultimeter(_)
| DecodedKind::ThirdParty(_) => true,
#[cfg(feature = "micE")]
DecodedKind::MicE(_) => true,
DecodedKind::Text { .. }
| DecodedKind::NeedsDestination { .. }
| DecodedKind::Unsupported { .. }
| DecodedKind::Malformed { .. } => false,
}
}
#[must_use]
pub const fn is_aprs(&self) -> bool {
!matches!(self.kind, DecodedKind::Text { .. })
}
}
impl<'a> DecodedKind<'a> {
fn classify(dest: Option<Address>, info: &'a [u8]) -> Self {
#[cfg(not(feature = "micE"))]
let _ = dest;
let Some(&dti) = info.first() else {
return DecodedKind::Unsupported { dti: 0 };
};
#[cfg(feature = "micE")]
if matches!(dti, b'`' | b'\'') {
let Some(dest) = dest else {
return DecodedKind::NeedsDestination { dti };
};
return match mic_e::decode_address(dest, info) {
Ok(report) => DecodedKind::MicE(report),
Err(error) => DecodedKind::Malformed {
dti,
error: error.into(),
},
};
}
match dti {
b'$' | b'!' | b'*' | b'#' if ultimeter::detect(info).is_some() => {
return match ultimeter::parse(info) {
Ok(record) => DecodedKind::Ultimeter(record),
Err(_) => DecodedKind::Unsupported { dti },
};
}
b'$' => {
return match nmea::parse(info) {
Ok(sentence) => DecodedKind::Nmea(sentence),
Err(_) => DecodedKind::Unsupported { dti },
};
}
b'}' => {
return match thirdparty::ThirdParty::parse(info) {
Ok(tp) => DecodedKind::ThirdParty(tp),
Err(error) => DecodedKind::Malformed { dti, error },
};
}
_ => {}
}
match AprsPacket::parse(info) {
Ok(packet) => DecodedKind::Packet(packet),
Err(AprsError::InvalidDataType { got }) => {
if !is_data_type_identifier(got) && info.iter().any(u8::is_ascii_graphic) {
DecodedKind::Text { text: info }
} else {
DecodedKind::Unsupported { dti: got }
}
}
Err(error) => DecodedKind::Malformed { dti, error },
}
}
}
const fn is_data_type_identifier(byte: u8) -> bool {
match byte {
b'A'..=b'S' | b'U'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'|' | b'~' => false,
0x1c | 0x1d => true,
b'T' => true,
b'!'..=b'/' | b':'..=b'@' | b'['..=b'`' | b'{' | b'}' => true,
_ => false,
}
}
impl<'a> AprsPacket<'a> {
pub fn parse(info: &'a [u8]) -> Result<Self, AprsError> {
let dti = *info.first().ok_or(AprsError::Truncated {
expected: 1,
got: 0,
})?;
match dti {
b'!' | b'=' => {
let with_cs = PositionCs::parse(info)?;
let position = with_cs.position;
if !position.compressed
&& position.symbol.to_wire().1 == b'_'
&& let Ok(weather) = PositionWeather::parse(info)
{
return Ok(AprsPacket::PositionWeather(weather));
}
if with_cs.cs == CompressedCs::NoData {
Ok(AprsPacket::Position(position))
} else {
Ok(AprsPacket::PositionCs(with_cs))
}
}
b'/' | b'@' => {
let timestamped = PositionTimestamped::parse(info)?;
if !timestamped.position.compressed
&& timestamped.position.symbol.to_wire().1 == b'_'
&& let Ok(weather) = PositionWeather::parse(info)
{
return Ok(AprsPacket::PositionWeather(weather));
}
Ok(AprsPacket::PositionTimestamped(timestamped))
}
b'_' => PositionlessWeather::parse(info).map(AprsPacket::Weather),
b'T' => Telemetry::parse(info).map(AprsPacket::Telemetry),
b';' => Object::parse(info).map(AprsPacket::Object),
b')' => Item::parse(info).map(AprsPacket::Item),
b'>' => Status::parse(info).map(AprsPacket::Status),
b':' => Message::parse(info).map(AprsPacket::Message),
b'<' => Capabilities::parse(info).map(AprsPacket::Capabilities),
other => Err(AprsError::InvalidDataType { got: other }),
}
}
pub fn build(&self, buf: &mut [u8]) -> Result<usize, AprsError> {
self.build_inner(buf)
}
#[cfg(feature = "alloc")]
pub fn to_vec(&self) -> Result<alloc::vec::Vec<u8>, AprsError> {
let mut buf = alloc::vec![0u8; 256];
let written = match self.build_inner(&mut buf) {
Ok(n) => n,
Err(AprsError::BufferTooSmall { needed, .. }) => {
buf.resize(needed, 0);
self.build_inner(&mut buf)?
}
Err(e) => return Err(e),
};
buf.truncate(written);
Ok(buf)
}
fn build_inner(&self, buf: &mut [u8]) -> Result<usize, AprsError> {
match *self {
AprsPacket::Position(ref p) => p.build(buf),
AprsPacket::PositionCs(ref p) => p.build(buf),
AprsPacket::PositionTimestamped(ref p) => p.build(buf),
AprsPacket::PositionWeather(ref w) => w.build(buf),
AprsPacket::Weather(ref w) => w.build(buf),
AprsPacket::Telemetry(ref t) => t.build(buf),
AprsPacket::Object(ref o) => o.build(buf),
AprsPacket::Item(ref i) => i.build(buf),
AprsPacket::Status(ref s) => s.build(buf),
AprsPacket::Message(ref m) => m.build(buf),
AprsPacket::Capabilities(ref c) => c.build(buf),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AprsUiError {
Aprs(AprsError),
Ax25(Ax25Error),
}
impl fmt::Display for AprsUiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
AprsUiError::Aprs(ref e) => write!(f, "APRS layer: {e}"),
AprsUiError::Ax25(ref e) => write!(f, "AX.25 layer: {e}"),
}
}
}
impl core::error::Error for AprsUiError {}
impl From<AprsError> for AprsUiError {
fn from(e: AprsError) -> Self {
AprsUiError::Aprs(e)
}
}
impl From<Ax25Error> for AprsUiError {
fn from(e: Ax25Error) -> Self {
AprsUiError::Ax25(e)
}
}
pub fn build_ui_frame(
packet: &AprsPacket<'_>,
dest: Address,
src: Address,
path: &[Address],
info_buf: &mut [u8],
frame_buf: &mut [u8],
) -> Result<usize, AprsUiError> {
let info_len = packet.build(info_buf)?;
let info = info_buf.get(..info_len).ok_or(AprsError::BufferTooSmall {
needed: info_len,
max: info_buf.len(),
})?;
let frame = UiFrame::with_path(dest, src, path, info)?;
Ok(frame.build(frame_buf)?)
}
#[cfg(feature = "alloc")]
pub fn build_ui_frame_to_vec(
packet: &AprsPacket<'_>,
dest: Address,
src: Address,
path: &[Address],
) -> Result<alloc::vec::Vec<u8>, AprsUiError> {
let info = packet.to_vec()?;
Ok(UiFrame::with_path(dest, src, path, &info)?.to_vec())
}
pub fn packet_from_ui<'a>(frame: &UiFrame<'a>) -> Result<AprsPacket<'a>, AprsError> {
AprsPacket::parse(frame.info)
}
#[must_use]
pub fn decoded_from_ui<'a>(frame: &UiFrame<'a>) -> Decoded<'a> {
Decoded::decode_frame(frame.dest, frame.info)
}
#[cfg(test)]
mod tests {
extern crate std;
use std::format;
use std::string::ToString;
use super::*;
#[test]
fn empty_info_is_truncated() {
assert_eq!(
AprsPacket::parse(b""),
Err(AprsError::Truncated {
expected: 1,
got: 0
})
);
}
#[test]
fn unknown_data_type_is_typed_error() {
assert_eq!(
AprsPacket::parse(b"?query"),
Err(AprsError::InvalidDataType { got: b'?' })
);
assert_eq!(
AprsPacket::parse(b"$GPGGA,..."),
Err(AprsError::InvalidDataType { got: b'$' })
);
}
#[test]
fn errors_render() {
let samples = [
AprsError::InvalidDataType { got: b'?' }.to_string(),
AprsError::BadDigit {
got: b'x',
position: 3,
}
.to_string(),
AprsError::ExpectedByte {
expected: b'.',
got: b',',
position: 5,
}
.to_string(),
AprsError::BadHemisphere { got: b'Q' }.to_string(),
AprsError::BadLatitude { got: 540_100 }.to_string(),
AprsError::BadLongitude { got: -1_080_100 }.to_string(),
AprsError::BadSymbolTable { got: b'~' }.to_string(),
AprsError::BadOverlay { got: b'a' }.to_string(),
AprsError::BadSymbolCode { got: 0x1F }.to_string(),
AprsError::BadBase91 {
got: b' ',
position: 2,
}
.to_string(),
AprsError::BadCourse { got: 360 }.to_string(),
AprsError::BadSpeed { got: 2000 }.to_string(),
AprsError::BadRadioRange { got: 3000 }.to_string(),
AprsError::BadAltitude { got: 20_000_000 }.to_string(),
AprsError::NmeaSourceConflict.to_string(),
AprsError::AddresseeTooLong { len: 10 }.to_string(),
AprsError::AddresseeEmpty.to_string(),
AprsError::InvalidAddresseeChar { got: b':' }.to_string(),
AprsError::MessageIdLengthInvalid { len: 6 }.to_string(),
AprsError::Truncated {
expected: 9,
got: 4,
}
.to_string(),
AprsError::BadTimestamp {
field: b'M',
got: 13,
}
.to_string(),
AprsError::UnknownWeatherField { got: b'q' }.to_string(),
AprsError::BadWeatherValue {
field: b'c',
got: 361,
}
.to_string(),
AprsError::BadTelemetrySequence { got: b'M' }.to_string(),
AprsError::TelemetrySequenceOutOfRange { got: 1000 }.to_string(),
AprsError::BadAnalogValue { position: 6 }.to_string(),
AprsError::TelemetryDecimalsOutOfRange { got: 19 }.to_string(),
AprsError::BadDigitalBit {
got: b'2',
position: 26,
}
.to_string(),
AprsError::BadLiveKilled { got: b'x' }.to_string(),
AprsError::NameLengthInvalid {
len: 10,
min: 3,
max: 9,
}
.to_string(),
AprsError::BadNameChar {
got: 0x07,
position: 2,
}
.to_string(),
AprsError::BufferTooSmall { needed: 20, max: 8 }.to_string(),
];
for s in samples {
assert!(!s.is_empty());
}
#[cfg(feature = "micE")]
{
let inner = MicEError::BadDestChar {
got: b'a',
column: 0,
};
assert_eq!(AprsError::from(inner), AprsError::MicE(inner));
let rendered = AprsError::MicE(inner).to_string();
assert!(rendered.starts_with("Mic-E report: "), "{rendered}");
assert!(rendered.ends_with(&inner.to_string()), "{rendered}");
}
let ui = AprsUiError::from(AprsError::AddresseeEmpty);
assert!(format!("{ui}").starts_with("APRS layer"));
let ui = AprsUiError::from(Ax25Error::SsidOutOfRange { got: 99 });
assert!(format!("{ui}").starts_with("AX.25 layer"));
}
}