use crate::geo::UNITS_PER_DEGREE;
use super::AprsError;
use super::symbol::Symbol;
const WEATHER_SYMBOL_CODE: u8 = b'_';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bearing {
degrees: Option<u16>,
wire: [u8; 3],
}
impl Bearing {
#[must_use]
pub const fn degrees(&self) -> Option<u16> {
self.degrees
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Speed {
knots: Option<u16>,
wire: [u8; 3],
}
impl Speed {
#[must_use]
pub const fn knots(&self) -> Option<u16> {
self.knots
}
}
fn field3(b: &[u8]) -> Option<(Option<u16>, [u8; 3])> {
let wire = [b[0], b[1], b[2]];
if b == b"..." || b == b" " {
return Some((None, wire));
}
if !b.iter().all(u8::is_ascii_digit) {
return None;
}
let v = u16::from(b[0] - b'0') * 100 + u16::from(b[1] - b'0') * 10 + u16::from(b[2] - b'0');
Some((Some(v), wire))
}
const ZERO_TRIPLE: [u8; 3] = *b"000";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Phg {
power: u8,
height: u8,
gain: u8,
directivity: u8,
rate: Option<PhgRate>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PhgRate {
PerHour(u8),
Unscheduled,
}
pub const MAX_HEIGHT_CODE: u8 = 28;
impl Phg {
pub const fn new(power: u8, height: u8, gain: u8, directivity: u8) -> Result<Self, AprsError> {
if power > 9 {
return Err(AprsError::BadDigit {
got: b'0'.wrapping_add(power),
position: 0,
});
}
if gain > 9 {
return Err(AprsError::BadDigit {
got: b'0'.wrapping_add(gain),
position: 2,
});
}
if height > MAX_HEIGHT_CODE {
return Err(AprsError::BadDigit {
got: b'0'.wrapping_add(height),
position: 1,
});
}
Ok(Self {
power,
height,
gain,
directivity,
rate: None,
})
}
#[must_use]
pub const fn with_rate(self, rate: PhgRate) -> Self {
Self {
rate: Some(rate),
..self
}
}
#[must_use]
pub const fn power_watts(&self) -> u16 {
let d = self.power as u16;
d * d
}
#[must_use]
pub const fn height_feet(&self) -> u32 {
10u32 << self.height
}
#[must_use]
pub const fn gain_dbi(&self) -> u8 {
self.gain
}
#[must_use]
pub const fn directivity_degrees(&self) -> Option<u16> {
match self.directivity {
1..=8 => Some(self.directivity as u16 * 45),
_ => None,
}
}
#[must_use]
pub const fn rate(&self) -> Option<PhgRate> {
self.rate
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dfs {
strength: u8,
height: u8,
gain: u8,
directivity: u8,
}
impl Dfs {
pub const fn new(
strength: u8,
height: u8,
gain: u8,
directivity: u8,
) -> Result<Self, AprsError> {
if strength > 9 {
return Err(AprsError::BadDigit {
got: b'0'.wrapping_add(strength),
position: 0,
});
}
if gain > 9 {
return Err(AprsError::BadDigit {
got: b'0'.wrapping_add(gain),
position: 2,
});
}
if height > MAX_HEIGHT_CODE {
return Err(AprsError::BadDigit {
got: b'0'.wrapping_add(height),
position: 1,
});
}
Ok(Self {
strength,
height,
gain,
directivity,
})
}
#[must_use]
pub const fn strength_s_points(&self) -> u8 {
self.strength
}
#[must_use]
pub const fn height_feet(&self) -> u32 {
10u32 << self.height
}
#[must_use]
pub const fn gain_db(&self) -> u8 {
self.gain
}
#[must_use]
pub const fn directivity_degrees(&self) -> Option<u16> {
match self.directivity {
1..=8 => Some(self.directivity as u16 * 45),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum DataExtension {
CourseSpeed {
course: Bearing,
speed: Speed,
},
Wind {
direction: Bearing,
speed: Speed,
},
Phg(Phg),
Range {
miles: u16,
},
Dfs(Dfs),
}
impl DataExtension {
pub const LEN: usize = 7;
pub const LEN_PHGR: usize = 9;
#[must_use]
pub const fn wire_len(&self) -> usize {
match self {
Self::Phg(p) if p.rate.is_some() => Self::LEN_PHGR,
_ => Self::LEN,
}
}
#[must_use]
pub fn parse(bytes: &[u8], symbol: Symbol) -> Option<Self> {
if bytes.len() < Self::LEN {
return None;
}
if bytes[3] == b'/' {
let (a, aw) = field3(&bytes[..3])?;
let (b, bw) = field3(&bytes[4..7])?;
if a.is_some_and(|v| v > 360) {
return None;
}
let pair_unknown = aw == ZERO_TRIPLE && bw == ZERO_TRIPLE;
let first = Bearing {
degrees: a.filter(|&v| v != 0),
wire: aw,
};
let second = Speed {
knots: if pair_unknown { None } else { b },
wire: bw,
};
return Some(if symbol.to_wire().1 == WEATHER_SYMBOL_CODE {
Self::Wind {
direction: first,
speed: second,
}
} else {
Self::CourseSpeed {
course: first,
speed: second,
}
});
}
match &bytes[..3] {
b"PHG" => {
let codes = &bytes[3..7];
if !codes[0].is_ascii_digit()
|| !codes[2].is_ascii_digit()
|| !codes[3].is_ascii_digit()
|| codes[1] < b'0'
{
return None;
}
let phg = Phg::new(
codes[0] - b'0',
codes[1] - b'0',
codes[2] - b'0',
codes[3] - b'0',
)
.ok()?;
if bytes.len() >= Self::LEN_PHGR
&& bytes[Self::LEN_PHGR - 1] == b'/'
&& let Some(rate) = phgr_rate(bytes[Self::LEN])
{
return Some(Self::Phg(phg.with_rate(rate)));
}
Some(Self::Phg(phg))
}
b"RNG" => {
if !bytes[3..7].iter().all(u8::is_ascii_digit) {
return None;
}
let miles = bytes[3..7]
.iter()
.fold(0u16, |a, &c| a * 10 + u16::from(c - b'0'));
Some(Self::Range { miles })
}
b"DFS" => {
let codes = &bytes[3..7];
if !codes[0].is_ascii_digit()
|| !codes[2].is_ascii_digit()
|| !codes[3].is_ascii_digit()
|| codes[1] < b'0'
{
return None;
}
Some(Self::Dfs(
Dfs::new(
codes[0] - b'0',
codes[1] - b'0',
codes[2] - b'0',
codes[3] - b'0',
)
.ok()?,
))
}
_ => None,
}
}
pub fn write(&self, out: &mut [u8]) -> usize {
let n = self.wire_len();
if out.len() < n {
return 0;
}
match self {
Self::CourseSpeed { course, speed } => {
out[..3].copy_from_slice(&course.wire);
out[3] = b'/';
out[4..7].copy_from_slice(&speed.wire);
}
Self::Wind { direction, speed } => {
out[..3].copy_from_slice(&direction.wire);
out[3] = b'/';
out[4..7].copy_from_slice(&speed.wire);
}
Self::Phg(p) => {
out[..3].copy_from_slice(b"PHG");
out[3] = b'0' + p.power;
out[4] = b'0' + p.height;
out[5] = b'0' + p.gain;
out[6] = b'0' + p.directivity;
if let Some(rate) = p.rate {
out[7] = match rate {
PhgRate::Unscheduled => b'0',
PhgRate::PerHour(n @ 1..=9) => b'0' + n,
PhgRate::PerHour(n) => b'A' + (n - 10).min(25),
};
out[8] = b'/';
}
}
Self::Range { miles } => {
out[..3].copy_from_slice(b"RNG");
let m = *miles;
out[3] = b'0' + (m / 1000 % 10) as u8;
out[4] = b'0' + (m / 100 % 10) as u8;
out[5] = b'0' + (m / 10 % 10) as u8;
out[6] = b'0' + (m % 10) as u8;
}
Self::Dfs(d) => {
out[..3].copy_from_slice(b"DFS");
out[3] = b'0' + d.strength;
out[4] = b'0' + d.height;
out[5] = b'0' + d.gain;
out[6] = b'0' + d.directivity;
}
}
n
}
}
fn phgr_rate(c: u8) -> Option<PhgRate> {
match c {
b'0' => Some(PhgRate::Unscheduled),
b'1'..=b'9' => Some(PhgRate::PerHour(c - b'0')),
b'A'..=b'Z' => Some(PhgRate::PerHour(10 + (c - b'A'))),
_ => None,
}
}
#[must_use]
pub fn altitude_feet(bytes: &[u8]) -> Option<i32> {
bytes.windows(9).find_map(|w| {
if &w[..3] != b"/A=" {
return None;
}
let f = &w[3..];
if f.iter().all(u8::is_ascii_digit) {
Some(f.iter().fold(0i32, |a, &c| a * 10 + i32::from(c - b'0')))
} else if f[0] == b'-' && f[1..].iter().all(u8::is_ascii_digit) {
Some(
-f[1..]
.iter()
.fold(0i32, |a, &c| a * 10 + i32::from(c - b'0')),
)
} else {
None
}
})
}
const BASE91_LOW: u8 = b'!';
const BASE91_HIGH: u8 = b'{';
const COMMENT_TELEMETRY_VALUES_MAX: usize = 7;
const COMMENT_TELEMETRY_VALUES_MIN: usize = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommentTelemetry {
pub seq: u16,
pub analog: [Option<u16>; 5],
pub digital: Option<[bool; 8]>,
}
const fn base91_pair(hi: u8, lo: u8) -> Option<u16> {
if hi < BASE91_LOW || hi > BASE91_HIGH || lo < BASE91_LOW || lo > BASE91_HIGH {
return None;
}
Some((hi - BASE91_LOW) as u16 * 91 + (lo - BASE91_LOW) as u16)
}
fn telemetry_span(bytes: &[u8]) -> Option<(usize, usize)> {
let open = bytes.iter().position(|&b| b == b'|')?;
let rest = bytes.get(open + 1..)?;
let len = rest.iter().position(|&b| b == b'|')?;
let payload = rest.get(..len)?;
if !is_comment_telemetry_payload(payload) {
return None;
}
Some((open, open + 1 + len))
}
fn is_comment_telemetry_payload(payload: &[u8]) -> bool {
let len = payload.len();
(COMMENT_TELEMETRY_VALUES_MIN * 2..=COMMENT_TELEMETRY_VALUES_MAX * 2).contains(&len)
&& len.is_multiple_of(2)
&& payload
.iter()
.all(|&b| (BASE91_LOW..=BASE91_HIGH).contains(&b))
}
#[must_use]
pub fn comment_telemetry(bytes: &[u8]) -> Option<CommentTelemetry> {
let (open, close) = telemetry_span(bytes)?;
let payload = bytes.get(open + 1..close)?;
let count = payload.len() / 2;
let mut values = [0u16; COMMENT_TELEMETRY_VALUES_MAX];
for (index, slot) in values.iter_mut().take(count).enumerate() {
let pair = payload.get(index * 2..index * 2 + 2)?;
*slot = base91_pair(pair[0], pair[1])?;
}
let has_digital = count == COMMENT_TELEMETRY_VALUES_MAX;
let analog_count = if has_digital { 5 } else { count - 1 };
let mut analog = [None; 5];
for (index, slot) in analog.iter_mut().take(analog_count).enumerate() {
*slot = Some(values[index + 1]);
}
let digital = has_digital.then(|| {
let packed = values[COMMENT_TELEMETRY_VALUES_MAX - 1];
let mut bits = [false; 8];
for (index, bit) in bits.iter_mut().enumerate() {
*bit = packed & (1 << index) != 0;
}
bits
});
Some(CommentTelemetry {
seq: values[0],
analog,
digital,
})
}
const UNITS_PER_MILLI_MINUTE: i64 = UNITS_PER_DEGREE / 60_000;
const UNITS_PER_BASE91_STEP: i64 = UNITS_PER_DEGREE / 546_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Dao {
pub datum: u8,
pub latitude_units: i64,
pub longitude_units: i64,
}
impl Dao {
#[must_use]
pub const fn datum_is_assigned(self) -> bool {
matches!(self.datum.to_ascii_uppercase(), b'W' | b'N' | b'O')
}
}
const fn dao_addend(byte: u8, base91: bool) -> Option<i64> {
if byte == b' ' {
return Some(0);
}
if base91 {
if byte < BASE91_LOW || byte > BASE91_HIGH {
return None;
}
return Some((byte - BASE91_LOW) as i64 * UNITS_PER_BASE91_STEP);
}
if !byte.is_ascii_digit() {
return None;
}
Some((byte - b'0') as i64 * UNITS_PER_MILLI_MINUTE)
}
#[must_use]
pub fn dao(bytes: &[u8]) -> Option<Dao> {
let skip = telemetry_span(bytes);
bytes.windows(5).enumerate().find_map(|(at, w)| {
if let Some((open, close)) = skip
&& at + 4 >= open
&& at <= close
{
return None;
}
if w[0] != b'!' || w[4] != b'!' {
return None;
}
let datum = w[1];
if !datum.is_ascii_alphabetic() {
return None;
}
let base91 = datum.is_ascii_lowercase();
Some(Dao {
datum,
latitude_units: dao_addend(w[2], base91)?,
longitude_units: dao_addend(w[3], base91)?,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comment_telemetry_spec_vector() {
let t = comment_telemetry(b"|ss1122334455!\"|").expect("the spec's full form");
assert_eq!(t.seq, 7544);
assert_eq!(t.analog[0], Some(1472));
assert_eq!(t.analog[1], Some(1564));
assert_eq!(t.analog[2], Some(1656));
assert_eq!(t.analog[3], Some(1748));
assert_eq!(t.analog[4], Some(1840));
assert_eq!(
t.digital,
Some([true, false, false, false, false, false, false, false])
);
let short = comment_telemetry(b"|ss11|").expect("seq and one channel");
assert_eq!(short.seq, 7544);
assert_eq!(short.analog[0], Some(1472));
assert_eq!(short.analog[1], None);
assert_eq!(short.digital, None);
let three = comment_telemetry(b"|ss112233|").expect("seq and three channels");
assert_eq!(three.analog[2], Some(1656));
assert_eq!(three.analog[3], None);
assert_eq!(
three.digital, None,
"six bytes cannot carry the digital word"
);
}
#[test]
fn comment_telemetry_rejects_operator_text() {
assert_eq!(
comment_telemetry(b"see you|73|"),
None,
"not base-91 length"
);
assert_eq!(comment_telemetry(b"|abc|"), None, "odd length");
assert_eq!(comment_telemetry(b"|ab|"), None, "one value, needs two");
assert_eq!(comment_telemetry(b"|ss1122334455!\"66|"), None, "too long");
assert_eq!(comment_telemetry(b"|ss 1|"), None, "space is not base-91");
assert_eq!(comment_telemetry(b"no pipes here"), None);
assert_eq!(comment_telemetry(b"|unterminated"), None);
}
#[test]
fn dao_spec_vectors() {
let d = dao(b"!W23!").expect("the spec's first example");
assert_eq!(d.datum, b'W');
assert!(d.datum_is_assigned());
assert_eq!(d.latitude_units, 2 * UNITS_PER_MILLI_MINUTE);
assert_eq!(d.longitude_units, 3 * UNITS_PER_MILLI_MINUTE);
let d = dao(b"!wAb!").expect("the spec's second example");
assert_eq!(d.datum, b'w');
assert_eq!(d.latitude_units, 32 * UNITS_PER_BASE91_STEP);
assert_eq!(d.longitude_units, 65 * UNITS_PER_BASE91_STEP);
let d = dao(b"!w:\\!").expect("parses, whatever the spec says it means");
assert_eq!(d.latitude_units, 25 * UNITS_PER_BASE91_STEP);
assert_eq!(d.longitude_units, 59 * UNITS_PER_BASE91_STEP);
let d = dao(b"!W !").expect("the NULL form");
assert_eq!(d.latitude_units, 0);
assert_eq!(d.longitude_units, 0);
}
#[test]
fn dao_addend_is_always_under_a_hundredth_of_a_minute() {
let hundredth = UNITS_PER_DEGREE / 6_000;
assert_eq!(9 * UNITS_PER_MILLI_MINUTE, hundredth * 9 / 10);
assert!(9 * UNITS_PER_MILLI_MINUTE < hundredth, "widest decimal");
assert!(90 * UNITS_PER_BASE91_STEP < hundredth, "widest base-91");
assert_eq!(UNITS_PER_DEGREE % 60_000, 0);
assert_eq!(UNITS_PER_DEGREE % 546_000, 0);
}
#[test]
fn dao_is_found_anywhere_in_the_comment() {
assert!(dao(b"hello !w!!! there").is_some());
assert!(dao(b"trailing !W12!").is_some());
assert_eq!(dao(b"no dao here"), None);
assert_eq!(dao(b"!512!"), None);
assert_eq!(dao(b"!W1!"), None);
assert_eq!(dao(b"!W1X!"), None, "X is not a decimal digit");
}
#[test]
fn telemetry_bytes_are_not_mistaken_for_dao() {
let comment = b"APRS Digi|$d!X!Y!U&!!(|";
assert_eq!(&comment[12..17], b"!X!Y!");
assert!(comment_telemetry(comment).is_some());
assert_eq!(
dao(comment),
None,
"a telemetry payload must not be read as DAO"
);
let both = b"hi|$d!X!Y!U&!!(|!w12!";
assert!(comment_telemetry(both).is_some());
assert_eq!(dao(both).map(|d| d.datum), Some(b'w'));
}
fn car() -> Symbol {
Symbol::from_wire(b'/', b'>')
}
fn wx() -> Symbol {
Symbol::from_wire(b'/', b'_')
}
#[test]
fn wire_round_trip_is_byte_exact() {
for s in [
&b"125/007"[..],
b"000/000",
b".../...",
b" / ",
b"360/999",
b"PHG2360",
b"PHG0000",
b"PHG9998",
b"PHG72604/", b"PHG52605/", b"PHG92603/",
b"PHG5260A/", b"PHG52600/", b"RNG0050",
b"RNG9999",
b"DFS2360",
b"DFS0000",
] {
for sym in [car(), wx()] {
let ext = DataExtension::parse(s, sym)
.unwrap_or_else(|| panic!("did not parse: {:?}", core::str::from_utf8(s)));
assert_eq!(ext.wire_len(), s.len(), "wire_len for {s:?}");
let mut out = [0u8; DataExtension::LEN_PHGR];
let n = ext.write(&mut out);
assert_eq!(&out[..n], s, "round trip for {:?}", core::str::from_utf8(s));
}
}
}
#[test]
fn phgr_needs_the_mandatory_slash() {
let Some(DataExtension::Phg(p)) = DataExtension::parse(b"PHG72604/", car()) else {
panic!("expected PHGR")
};
assert_eq!(p.rate(), Some(PhgRate::PerHour(4)));
assert_eq!(p.power_watts(), 49);
let e = DataExtension::parse(b"PHG2100/WinAPRS", car()).unwrap();
assert_eq!(e.wire_len(), 7);
let DataExtension::Phg(q) = e else { panic!() };
assert_eq!(q.rate(), None);
let e = DataExtension::parse(b"PHG5260146.520MHz", car()).unwrap();
assert_eq!(e.wire_len(), 7, "must not swallow the frequency");
let Some(DataExtension::Phg(r)) = DataExtension::parse(b"PHG5260A/", car()) else {
panic!()
};
assert_eq!(r.rate(), Some(PhgRate::PerHour(10)));
let Some(DataExtension::Phg(z)) = DataExtension::parse(b"PHG52600/", car()) else {
panic!()
};
assert_eq!(z.rate(), Some(PhgRate::Unscheduled));
}
#[test]
fn phg_code_tables() {
let Some(DataExtension::Phg(p)) = DataExtension::parse(b"PHG2360", car()) else {
panic!()
};
assert_eq!((p.power_watts(), p.height_feet(), p.gain_dbi()), (4, 80, 6));
assert_eq!(p.directivity_degrees(), None);
let Some(DataExtension::Phg(hi)) = DataExtension::parse(b"PHG9998", car()) else {
panic!()
};
assert_eq!(
(hi.power_watts(), hi.height_feet(), hi.gain_dbi()),
(81, 5120, 9)
);
assert_eq!(hi.directivity_degrees(), Some(360));
let Some(DataExtension::Phg(d9)) = DataExtension::parse(b"PHG5139", car()) else {
panic!("directivity 9 must not reject the whole extension")
};
assert_eq!(d9.power_watts(), 25);
assert_eq!(d9.directivity_degrees(), None);
}
#[test]
fn height_codes_above_nine() {
let Some(DataExtension::Phg(p)) = DataExtension::parse(b"PHG5:32", car()) else {
panic!("':' is a legal height code")
};
assert_eq!(p.height_feet(), 10_240);
let Some(DataExtension::Phg(q)) = DataExtension::parse(b"PHG5;32", car()) else {
panic!()
};
assert_eq!(q.height_feet(), 20_480);
let mut out = [0u8; 9];
let n = DataExtension::parse(b"PHG5:32", car())
.unwrap()
.write(&mut out);
assert_eq!(&out[..n], b"PHG5:32");
}
#[test]
fn wind_versus_course_depends_on_the_symbol() {
let bytes = b"220/004";
assert!(matches!(
DataExtension::parse(bytes, wx()),
Some(DataExtension::Wind { .. })
));
assert!(matches!(
DataExtension::parse(bytes, car()),
Some(DataExtension::CourseSpeed { .. })
));
}
#[test]
fn unknown_spellings_are_preserved_and_distinguished() {
for s in [&b"000/000"[..], b".../...", b" / "] {
let Some(DataExtension::CourseSpeed { course, speed }) = DataExtension::parse(s, car())
else {
panic!("{:?} is a spec-legal unknown form", core::str::from_utf8(s))
};
assert_eq!(course.degrees(), None, "{s:?}");
assert_eq!(speed.knots(), None, "{s:?}");
}
let Some(DataExtension::CourseSpeed { course, speed }) =
DataExtension::parse(b"360/012", car())
else {
panic!()
};
assert_eq!(course.degrees(), Some(360));
assert_eq!(speed.knots(), Some(12));
}
#[test]
fn zero_speed_is_only_unknown_as_a_whole_pair() {
for (wire, course, knots) in [
(&b"000/000"[..], None, None),
(b"315/000", Some(315), Some(0)),
(b"194/000", Some(194), Some(0)),
(b"035/000", Some(35), Some(0)),
(b"000/048", None, Some(48)),
(b".../...", None, None),
(b" / ", None, None),
(b"360/010", Some(360), Some(10)),
(b"001/010", Some(1), Some(10)),
] {
let Some(DataExtension::CourseSpeed {
course: c,
speed: s,
}) = DataExtension::parse(wire, car())
else {
panic!("{:?} is a legal extension", core::str::from_utf8(wire))
};
assert_eq!(c.degrees(), course, "course of {wire:?}");
assert_eq!(s.knots(), knots, "speed of {wire:?}");
}
}
#[test]
fn wind_reads_zero_the_same_way_the_weather_decoder_does() {
for (wire, direction, knots) in [
(&b"240/000"[..], Some(240), Some(0)),
(b"090/000", Some(90), Some(0)),
(b"000/000", None, None),
(b"000/012", None, Some(12)),
(b"360/004", Some(360), Some(4)),
] {
let Some(DataExtension::Wind {
direction: d,
speed: s,
}) = DataExtension::parse(wire, wx())
else {
panic!("{:?} is a legal wind extension", core::str::from_utf8(wire))
};
assert_eq!(d.degrees(), direction, "direction of {wire:?}");
assert_eq!(s.knots(), knots, "wind speed of {wire:?}");
}
}
#[test]
fn every_ddd_sss_round_trips_byte_exactly() {
let mut wire = *b"000/000";
for d in 0u16..=999 {
wire[0] = b'0' + (d / 100) as u8;
wire[1] = b'0' + (d / 10 % 10) as u8;
wire[2] = b'0' + (d % 10) as u8;
for s in 0u16..=999 {
wire[4] = b'0' + (s / 100) as u8;
wire[5] = b'0' + (s / 10 % 10) as u8;
wire[6] = b'0' + (s % 10) as u8;
for sym in [car(), wx()] {
let parsed = DataExtension::parse(&wire, sym);
if d > 360 {
assert_eq!(parsed, None, "{d:03} is not a bearing");
continue;
}
let ext = parsed.unwrap_or_else(|| panic!("did not parse: {wire:?}"));
let mut out = [0u8; DataExtension::LEN_PHGR];
let n = ext.write(&mut out);
assert_eq!(n, DataExtension::LEN);
assert_eq!(&out[..n], &wire[..], "round trip for {wire:?}");
}
}
}
for s in [&b".../..."[..], b" / ", b"000/...", b".../000"] {
for sym in [car(), wx()] {
let ext = DataExtension::parse(s, sym).expect("legal unknown spelling");
let mut out = [0u8; DataExtension::LEN_PHGR];
let n = ext.write(&mut out);
assert_eq!(&out[..n], s, "round trip for {s:?}");
}
}
}
#[test]
fn the_other_extensions_have_no_zero_collapse() {
assert_eq!(
DataExtension::parse(b"RNG0000", car()),
Some(DataExtension::Range { miles: 0 })
);
let Some(DataExtension::Phg(p)) = DataExtension::parse(b"PHG0000", car()) else {
panic!()
};
assert_eq!((p.power_watts(), p.height_feet(), p.gain_dbi()), (0, 10, 0));
let Some(DataExtension::Dfs(d)) = DataExtension::parse(b"DFS0000", car()) else {
panic!()
};
assert_eq!(d.strength_s_points(), 0);
}
#[test]
fn out_of_range_bearing_is_not_an_extension() {
assert_eq!(DataExtension::parse(b"361/000", car()), None);
assert_eq!(DataExtension::parse(b"999/999", car()), None);
}
#[test]
fn plain_text_is_not_an_extension() {
for s in [
&b"hello there"[..],
b"",
b"short",
b"/A=001234",
b"Ed's remote WX",
b"PHG",
b"ab/cdef",
b"Hwy/101 north of town",
b"KG6/W6ABC portable",
b"abc/def letters with slash",
] {
assert_eq!(
DataExtension::parse(s, car()),
None,
"{:?}",
core::str::from_utf8(s)
);
}
}
#[test]
fn range_and_dfs() {
assert_eq!(
DataExtension::parse(b"RNG0050", car()),
Some(DataExtension::Range { miles: 50 })
);
let Some(DataExtension::Dfs(d)) = DataExtension::parse(b"DFS2360", car()) else {
panic!()
};
assert_eq!(d.strength_s_points(), 2);
assert_eq!((d.height_feet(), d.gain_db()), (80, 6));
assert_eq!(d.directivity_degrees(), None);
assert_eq!(DataExtension::parse(b"RNG00X0", car()), None);
}
#[test]
fn altitude_anywhere_in_the_comment_including_negative() {
assert_eq!(altitude_feet(b"/A=004530 hello"), Some(4530));
assert_eq!(altitude_feet(b"hello /A=000600 there"), Some(600));
assert_eq!(altitude_feet(b"125/007/A=000984"), Some(984));
assert_eq!(altitude_feet(b"/A=-00123 below sea level"), Some(-123));
assert_eq!(altitude_feet(b"/A=-0123"), None); assert_eq!(altitude_feet(b"/A=00098"), None); assert_eq!(altitude_feet(b"/A=0009X4"), None);
assert_eq!(altitude_feet(b"no altitude here"), None);
}
}