#![cfg(feature = "aprs")]
use yodel::aprs::{
AprsError, AprsPacket, CompressedCs, CompressionOrigin, CompressionType, Latitude, Longitude,
NmeaSource, Position, PositionCs, PositionTimestamped, Symbol, Timestamp,
};
use yodel::geo::Ambiguity;
fn lat(v: i64) -> Latitude {
Latitude::new(v * yodel::geo::UNITS_PER_HUNDREDTH_MINUTE).unwrap()
}
fn lon(v: i64) -> Longitude {
Longitude::new(v * yodel::geo::UNITS_PER_HUNDREDTH_MINUTE).unwrap()
}
const COMP_LAT_STEP: i64 = yodel::geo::UNITS_PER_DEGREE / 380_926;
const COMP_LON_STEP: i64 = yodel::geo::UNITS_PER_DEGREE / 190_463;
fn spec_latlon() -> (Latitude, Longitude) {
let deg = yodel::geo::UNITS_PER_DEGREE;
(
Latitude::new(90 * deg - 15_427_503 * COMP_LAT_STEP).expect("in range"),
Longitude::new(20_427_156 * COMP_LON_STEP - 180 * deg).expect("in range"),
)
}
fn spec_position(messaging: bool, comment: &[u8]) -> Position<'_> {
let (latitude, longitude) = spec_latlon();
Position {
ambiguity: Ambiguity::EXACT,
latitude,
longitude,
symbol: Symbol::CAR,
messaging,
compressed: true,
extension: None,
comment,
}
}
fn t_rmc() -> CompressionType {
CompressionType {
current_fix: true,
nmea_source: NmeaSource::Rmc,
origin: CompressionOrigin::Software,
}
}
#[test]
fn spec_course_speed_vector() {
let packet = AprsPacket::parse(b"=/5L!!<*e7>7P[").unwrap();
match packet {
AprsPacket::PositionCs(p) => {
assert_eq!(p.position, spec_position(true, b""));
assert_eq!(
p.cs,
CompressedCs::CourseSpeed {
course: 88,
speed: 36
}
);
assert_eq!(p.compression_type, t_rmc());
}
other => panic!("wrong variant: {other:?}"),
}
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
assert_eq!(&buf[..len], b"=/5L!!<*e7>7P[");
}
#[test]
fn spec_radio_range_vector() {
let packet = AprsPacket::parse(b"!/5L!!<*e7>{?[").unwrap();
match packet {
AprsPacket::PositionCs(p) => {
assert_eq!(p.cs, CompressedCs::RadioRange { miles: 20 });
assert_eq!(p.compression_type, t_rmc());
}
other => panic!("wrong variant: {other:?}"),
}
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
assert_eq!(&buf[..len], b"!/5L!!<*e7>{?[");
}
#[test]
fn spec_altitude_vector() {
let packet = AprsPacket::parse(b"!/5L!!<*e7>S]S").unwrap();
match packet {
AprsPacket::PositionCs(p) => {
assert_eq!(p.cs, CompressedCs::Altitude { feet: 10004 });
assert_eq!(p.compression_type.nmea_source, NmeaSource::Gga);
assert!(p.compression_type.current_fix);
assert_eq!(p.compression_type.origin, CompressionOrigin::Software);
}
other => panic!("wrong variant: {other:?}"),
}
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
assert_eq!(&buf[..len], b"!/5L!!<*e7>S]S");
}
#[test]
fn no_data_trailer_stays_plain_position() {
let packet = AprsPacket::parse(b"!/5L!!<*e7> sT").unwrap();
match packet {
AprsPacket::Position(p) => assert_eq!(p, spec_position(false, b"")),
other => panic!("wrong variant: {other:?}"),
}
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
assert_eq!(&buf[..len], b"!/5L!!<*e7> sT");
}
#[test]
fn cs_round_trips_all_variants_and_quadrants() {
let coords = [
(49 * 6000 + 3000, -(72 * 6000 + 4500)),
(-(89 * 6000 + 5999), 179 * 6000 + 5999),
(0, 0),
(540_000, -1_080_000),
];
let variants = [
CompressedCs::NoData,
CompressedCs::CourseSpeed {
course: 0,
speed: 0,
},
CompressedCs::CourseSpeed {
course: 356,
speed: 36,
},
CompressedCs::RadioRange { miles: 20 },
CompressedCs::Altitude { feet: 10004 },
];
for (la, lo) in coords {
for cs in variants {
let packet = AprsPacket::PositionCs(PositionCs {
position: Position {
ambiguity: Ambiguity::EXACT,
latitude: lat(la),
longitude: lon(lo),
symbol: Symbol::BALLOON,
messaging: false,
compressed: true,
extension: None,
comment: b"rt",
},
cs,
compression_type: t_rmc(),
});
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
let parsed = AprsPacket::parse(&buf[..len]).unwrap();
match (parsed, cs) {
(AprsPacket::Position(p), CompressedCs::NoData) => {
assert!((p.latitude.units() - lat(la).units()).abs() <= COMP_LAT_STEP);
assert!((p.longitude.units() - lon(lo).units()).abs() <= COMP_LON_STEP);
}
(AprsPacket::PositionCs(p), _) => {
assert_eq!(p.cs, cs, "({la}, {lo}) {cs:?}");
assert!(
(p.position.latitude.units() - lat(la).units()).abs() <= COMP_LAT_STEP,
"({la}, {lo}) {cs:?}"
);
assert!(
(p.position.longitude.units() - lon(lo).units()).abs() <= COMP_LON_STEP,
"({la}, {lo}) {cs:?}"
);
}
(other, _) => panic!("wrong variant: {other:?}"),
}
}
}
}
#[test]
fn cs_boundary_values_round_trip() {
let cases = [
CompressedCs::CourseSpeed {
course: 0,
speed: 0,
},
CompressedCs::CourseSpeed {
course: 356,
speed: 1018,
},
CompressedCs::RadioRange { miles: 2 },
CompressedCs::RadioRange { miles: 2038 },
CompressedCs::Altitude { feet: 1 },
CompressedCs::Altitude { feet: 363 },
];
for cs in cases {
let packet = AprsPacket::PositionCs(PositionCs {
position: spec_position(false, b""),
cs,
compression_type: CompressionType::default(),
});
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
match AprsPacket::parse(&buf[..len]).unwrap() {
AprsPacket::PositionCs(p) => assert_eq!(p.cs, cs, "{cs:?}"),
other => panic!("wrong variant: {other:?}"),
}
}
}
#[test]
fn large_altitude_is_wire_stable() {
let requested: u32 = 15_000_000;
let packet = AprsPacket::PositionCs(PositionCs {
position: spec_position(false, b""),
cs: CompressedCs::Altitude { feet: requested },
compression_type: CompressionType::default(),
});
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
let decoded = match AprsPacket::parse(&buf[..len]).unwrap() {
AprsPacket::PositionCs(p) => match p.cs {
CompressedCs::Altitude { feet } => feet,
other => panic!("wrong cs: {other:?}"),
},
other => panic!("wrong variant: {other:?}"),
};
let diff = decoded.abs_diff(requested);
assert!(diff < requested / 400, "decoded {decoded}");
let rebuilt = AprsPacket::PositionCs(PositionCs {
position: spec_position(false, b""),
cs: CompressedCs::Altitude { feet: decoded },
compression_type: CompressionType::default(),
});
let mut buf2 = [0u8; 32];
let len2 = rebuilt.build(&mut buf2).unwrap();
assert_eq!(&buf2[..len2], &buf[..len]);
}
#[test]
fn course_rounds_to_nearest_step() {
for (course, expect) in [(358u16, 0u16), (87, 88), (2, 4), (1, 0)] {
let packet = AprsPacket::PositionCs(PositionCs {
position: spec_position(false, b""),
cs: CompressedCs::CourseSpeed { course, speed: 10 },
compression_type: CompressionType::default(),
});
let mut buf = [0u8; 32];
let len = packet.build(&mut buf).unwrap();
match AprsPacket::parse(&buf[..len]).unwrap() {
AprsPacket::PositionCs(p) => match p.cs {
CompressedCs::CourseSpeed { course: got, .. } => {
assert_eq!(got, expect, "course {course}");
}
other => panic!("wrong cs: {other:?}"),
},
other => panic!("wrong variant: {other:?}"),
}
}
}
#[test]
fn timestamped_positions_round_trip_both_dtis() {
let stamps = [
Timestamp::DhmZulu {
day: 9,
hour: 23,
minute: 45,
},
Timestamp::DhmLocal {
day: 31,
hour: 0,
minute: 0,
},
Timestamp::Hms {
hour: 23,
minute: 59,
second: 59,
},
];
for messaging in [false, true] {
for compressed in [false, true] {
for timestamp in stamps {
let packet = AprsPacket::PositionTimestamped(PositionTimestamped {
timestamp,
position: Position {
ambiguity: Ambiguity::EXACT,
latitude: lat(49 * 6000 + 350),
longitude: lon(-(72 * 6000 + 175)),
symbol: Symbol::CAR,
messaging,
compressed,
extension: None,
comment: b"ts",
},
cs: if compressed {
CompressedCs::CourseSpeed {
course: 88,
speed: 36,
}
} else {
CompressedCs::NoData
},
compression_type: if compressed {
t_rmc()
} else {
CompressionType::default()
},
});
let mut buf = [0u8; 64];
let len = packet.build(&mut buf).unwrap();
assert_eq!(buf[0], if messaging { b'@' } else { b'/' });
let back = AprsPacket::parse(&buf[..len]).unwrap();
if compressed {
let (AprsPacket::PositionTimestamped(a), AprsPacket::PositionTimestamped(b)) =
(&back, &packet)
else {
panic!("expected timestamped positions");
};
assert_eq!(a.timestamp, b.timestamp);
assert_eq!(a.cs, b.cs);
assert!(
(a.position.latitude.units() - b.position.latitude.units()).abs()
<= COMP_LAT_STEP
);
assert!(
(a.position.longitude.units() - b.position.longitude.units()).abs()
<= COMP_LON_STEP
);
} else {
assert_eq!(back, packet);
}
}
}
}
}
#[test]
fn timestamped_uncompressed_wire_bytes() {
let packet = AprsPacket::parse(b"@092345z4903.50N/07201.75W>Test1234").unwrap();
match packet {
AprsPacket::PositionTimestamped(p) => {
assert_eq!(
p.timestamp,
Timestamp::DhmZulu {
day: 9,
hour: 23,
minute: 45
}
);
assert_eq!(p.position.latitude, lat(49 * 6000 + 350));
assert_eq!(p.position.longitude, lon(-(72 * 6000 + 175)));
assert!(p.position.messaging);
assert!(!p.position.compressed);
assert_eq!(p.cs, CompressedCs::NoData);
assert_eq!(p.position.comment, b"Test1234");
}
other => panic!("wrong variant: {other:?}"),
}
let mut buf = [0u8; 64];
let len = packet.build(&mut buf).unwrap();
assert_eq!(&buf[..len], b"@092345z4903.50N/07201.75W>Test1234");
}
#[test]
fn timestamped_compressed_wire_bytes() {
let packet = AprsPacket::parse(b"/234517h/5L!!<*e7>{?[net").unwrap();
match packet {
AprsPacket::PositionTimestamped(p) => {
assert_eq!(
p.timestamp,
Timestamp::Hms {
hour: 23,
minute: 45,
second: 17
}
);
assert!(!p.position.messaging);
assert!(p.position.compressed);
assert_eq!(p.cs, CompressedCs::RadioRange { miles: 20 });
assert_eq!(p.position.comment, b"net");
}
other => panic!("wrong variant: {other:?}"),
}
let mut buf = [0u8; 64];
let len = packet.build(&mut buf).unwrap();
assert_eq!(&buf[..len], b"/234517h/5L!!<*e7>{?[net");
}
#[test]
fn rejects_bad_base91_in_trailer() {
assert_eq!(
AprsPacket::parse(b"!/5L!!<*e7>7\x20["),
Err(AprsError::BadBase91 {
got: b' ',
position: 12
})
);
assert_eq!(
AprsPacket::parse(b"!/5L!!<*e7>7P~"),
Err(AprsError::BadBase91 {
got: b'~',
position: 13
})
);
}
#[test]
fn rejects_truncated_trailer() {
assert_eq!(
AprsPacket::parse(b"!/5L!!<*e7>7P"),
Err(AprsError::Truncated {
expected: 14,
got: 13
})
);
}
#[test]
fn rejects_bad_timestamp() {
assert_eq!(
AprsPacket::parse(b"@09x345z4903.50N/07201.75W>"),
Err(AprsError::BadDigit {
got: b'x',
position: 3
})
);
assert_eq!(
AprsPacket::parse(b"@322345z4903.50N/07201.75W>"),
Err(AprsError::BadTimestamp {
field: b'D',
got: 32
})
);
assert_eq!(
AprsPacket::parse(b"/092345q4903.50N/07201.75W>"),
Err(AprsError::BadTimestamp {
field: b'?',
got: i32::from(b'q')
})
);
}
#[test]
fn rejects_out_of_range_builds() {
let base = PositionCs {
position: spec_position(false, b""),
cs: CompressedCs::NoData,
compression_type: CompressionType::default(),
};
let mut buf = [0u8; 32];
let with = |cs, t| PositionCs {
cs,
compression_type: t,
..base
};
assert_eq!(
with(
CompressedCs::CourseSpeed {
course: 360,
speed: 0
},
CompressionType::default()
)
.build(&mut buf),
Err(AprsError::BadCourse { got: 360 })
);
assert_eq!(
with(
CompressedCs::CourseSpeed {
course: 0,
speed: 60_000
},
CompressionType::default()
)
.build(&mut buf),
Err(AprsError::BadSpeed { got: 60_000 })
);
assert_eq!(
with(
CompressedCs::RadioRange { miles: 60_000 },
CompressionType::default()
)
.build(&mut buf),
Err(AprsError::BadRadioRange { got: 60_000 })
);
assert_eq!(
with(
CompressedCs::Altitude { feet: u32::MAX },
CompressionType::default()
)
.build(&mut buf),
Err(AprsError::BadAltitude { got: u32::MAX })
);
let gga = CompressionType {
nmea_source: NmeaSource::Gga,
..CompressionType::default()
};
assert_eq!(
with(
CompressedCs::CourseSpeed {
course: 88,
speed: 36
},
gga
)
.build(&mut buf),
Err(AprsError::NmeaSourceConflict)
);
assert_eq!(
with(CompressedCs::RadioRange { miles: 20 }, gga).build(&mut buf),
Err(AprsError::NmeaSourceConflict)
);
}
#[test]
fn compression_type_byte_round_trips() {
let sources = [
NmeaSource::Other,
NmeaSource::Gll,
NmeaSource::Gga,
NmeaSource::Rmc,
];
let origins = [
CompressionOrigin::Compressed,
CompressionOrigin::TncBtext,
CompressionOrigin::Software,
CompressionOrigin::Tbd,
CompressionOrigin::Kpc3,
CompressionOrigin::Pico,
CompressionOrigin::OtherTracker,
CompressionOrigin::Digipeater,
];
for current_fix in [false, true] {
for nmea_source in sources {
for origin in origins {
let t = CompressionType {
current_fix,
nmea_source,
origin,
};
let byte = t.to_byte();
assert!((0x21..=0x7b).contains(&byte));
assert_eq!(CompressionType::from_byte(byte, 0), Ok(t));
}
}
}
assert_eq!(
CompressionType::from_byte(b'~', 5),
Err(AprsError::BadBase91 {
got: b'~',
position: 5
})
);
}
#[test]
fn compression_type_byte_drops_bit_6_known_gap() {
let lone_bit6 = CompressionType::from_byte(b'a', 0).expect("'a' is inside '!'..='{'");
assert_eq!(
lone_bit6,
CompressionType {
current_fix: false,
nmea_source: NmeaSource::Other,
origin: CompressionOrigin::Compressed,
}
);
assert_eq!(
lone_bit6.to_byte(),
b'!',
"today bit 6 of 'a' is dropped, not preserved"
);
let top = CompressionType::from_byte(b'{', 0).expect("'{' is inside '!'..='{'");
assert_eq!(
top,
CompressionType {
current_fix: false,
nmea_source: NmeaSource::Rmc,
origin: CompressionOrigin::Software,
}
);
assert_eq!(
top.to_byte(),
b';',
"today bit 6 of the top base-91 digit is dropped, not preserved"
);
for value in 64u8..=90 {
let with_bit6 = 0x21 + value;
let without = 0x21 + (value & !(1u8 << 6));
assert_eq!(
CompressionType::from_byte(with_bit6, 0),
CompressionType::from_byte(without, 0),
"wire byte {with_bit6:#04x} aliases onto {without:#04x} today"
);
assert_eq!(
CompressionType::from_byte(with_bit6, 0).map(CompressionType::to_byte),
Ok(without),
"wire byte {with_bit6:#04x} re-encodes as {without:#04x}: bit 6 is lost"
);
}
}