use super::AprsError;
use super::object::Timestamp;
use super::symbol::{Symbol, SymbolTable};
use crate::geo::MaidenheadGrid;
use crate::units::{Bearing, Power};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BeamHeading {
pub heading: Bearing,
pub erp: Power,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatusGrid {
pub grid: MaidenheadGrid,
pub symbol: Symbol,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Status<'a> {
pub text: &'a [u8],
}
impl<'a> Status<'a> {
pub fn parse(info: &'a [u8]) -> Result<Self, AprsError> {
let dti = *info.first().ok_or(AprsError::Truncated {
expected: 1,
got: 0,
})?;
if dti != b'>' {
return Err(AprsError::InvalidDataType { got: dti });
}
Ok(Self {
text: info.get(1..).unwrap_or(&[]),
})
}
#[must_use]
pub const fn encoded_len(&self) -> usize {
1 + self.text.len()
}
pub fn build(&self, buf: &mut [u8]) -> Result<usize, AprsError> {
let needed = self.encoded_len();
let max = buf.len();
let out = buf
.get_mut(..needed)
.ok_or(AprsError::BufferTooSmall { needed, max })?;
let mut bytes = core::iter::once(&b'>').chain(self.text.iter());
for slot in out.iter_mut() {
match bytes.next() {
Some(b) => *slot = *b,
None => break,
}
}
Ok(needed)
}
#[must_use]
pub fn timestamp(&self) -> Option<Timestamp> {
let head = self.text.get(..Timestamp::LEN)?;
if head[6] != b'z' || !head[..6].iter().all(u8::is_ascii_digit) {
return None;
}
match Timestamp::parse(self.text, 0) {
Ok(timestamp @ Timestamp::DhmZulu { .. }) => Some(timestamp),
Ok(_) | Err(_) => None,
}
}
#[must_use]
pub fn grid(&self) -> Option<StatusGrid> {
for len in [6usize, 4] {
if let Some(found) = self.grid_of_length(len) {
return Some(found);
}
}
None
}
fn grid_of_length(&self, len: usize) -> Option<StatusGrid> {
let grid = MaidenheadGrid::new(core::str::from_utf8(self.text.get(..len)?).ok()?).ok()?;
let table = *self.text.get(len)?;
let code = *self.text.get(len + 1)?;
SymbolTable::from_wire(table)?;
let symbol = Symbol::from_wire(table, code);
symbol.code()?;
match self.text.get(len + 2) {
None => Some(StatusGrid { grid, symbol }),
Some(b' ') => Some(StatusGrid { grid, symbol }),
Some(_) => None,
}
}
#[must_use]
pub fn beam(&self) -> Option<BeamHeading> {
let [b'^', h, p] = *self.text.get(self.text.len().checked_sub(3)?..)? else {
return None;
};
let tens = match h {
b'0'..=b'9' => u16::from(h - b'0'),
b'A'..=b'Z' => 10 + u16::from(h - b'A'),
_ => return None,
};
if !(b'1'..=b'K').contains(&p) {
return None;
}
let code = i32::from(p - b'0');
Some(BeamHeading {
heading: Bearing::new(tens * 10).ok()?,
erp: Power::from_watts(code * code * 10),
})
}
#[must_use]
pub fn message(&self) -> &'a [u8] {
let mut text = self.text;
if self.timestamp().is_some() {
text = text.get(Timestamp::LEN..).unwrap_or(&[]);
} else if let Some(found) = self.grid() {
let skip = found.grid.as_bytes().len() + 2;
text = text.get(skip..).unwrap_or(&[]);
if let [b' ', rest @ ..] = text {
text = rest;
}
}
if self.beam().is_some() {
text = text.get(..text.len().saturating_sub(3)).unwrap_or(&[]);
}
text
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip() {
let status = Status {
text: b"Net Control Center",
};
let mut buf = [0u8; 32];
let len = match status.build(&mut buf) {
Ok(n) => n,
Err(e) => panic!("{e}"),
};
assert_eq!(&buf[..len], b">Net Control Center");
assert_eq!(Status::parse(&buf[..len]), Ok(status));
}
#[test]
fn empty_text_round_trips() {
assert_eq!(Status::parse(b">"), Ok(Status { text: b"" }));
}
#[test]
fn rejections() {
assert_eq!(
Status::parse(b""),
Err(AprsError::Truncated {
expected: 1,
got: 0
})
);
assert_eq!(
Status::parse(b"!x"),
Err(AprsError::InvalidDataType { got: b'!' })
);
}
#[test]
fn build_overflow() {
let status = Status { text: b"hello" };
let mut buf = [0u8; 3];
assert_eq!(
status.build(&mut buf),
Err(AprsError::BufferTooSmall { needed: 6, max: 3 })
);
}
#[test]
fn beam_heading_and_erp_known_answers() {
let beam = Status { text: b"Hello^B7" }.beam().expect("trailer");
assert_eq!(beam.heading.degrees(), 110);
assert_eq!(beam.erp.watts(), 490);
for (code, watts) in [(b'1', 10), (b':', 1000), (b'K', 7290)] {
let text = [b'^', b'0', code];
let beam = Status { text: &text }.beam().expect("trailer");
assert_eq!(beam.erp.watts(), watts, "ERP code {:?}", code as char);
}
for (code, watts) in [
(b'2', 40),
(b'3', 90),
(b'4', 160),
(b'5', 250),
(b'6', 360),
(b'7', 490),
(b'8', 640),
(b'9', 810),
(b';', 1210),
(b'<', 1440),
(b'=', 1690),
(b'>', 1960),
(b'?', 2250),
(b'@', 2560),
(b'A', 2890),
(b'B', 3240),
(b'C', 3610),
(b'D', 4000),
(b'E', 4410),
(b'F', 4840),
(b'G', 5290),
(b'H', 5760),
(b'I', 6250),
(b'J', 6760),
] {
let text = [b'^', b'0', code];
assert_eq!(
Status { text: &text }.beam().map(|b| b.erp.watts()),
Some(watts),
"ERP code {:?}",
code as char
);
}
for (h, degrees) in [
(b'0', 0u16),
(b'9', 90),
(b'A', 100),
(b'B', 110),
(b'Z', 350),
] {
let text = [b'^', h, b'1'];
assert_eq!(
Status { text: &text }.beam().map(|b| b.heading.degrees()),
Some(degrees),
"heading {:?}",
h as char
);
}
for text in [
&b"Hello~B7"[..],
&b"Hello^B0"[..],
&b"Hello^BL"[..],
&b"^B"[..],
&b""[..],
] {
assert_eq!(Status { text }.beam(), None, "{text:?}");
}
}
#[test]
fn maidenhead_locator_form() {
let status = Status {
text: b"IO91SX/G Hello world",
};
let found = status.grid().expect("a locator");
assert_eq!(found.grid.as_bytes(), b"IO91sx");
assert_eq!(found.symbol.to_wire(), (b'/', b'G'));
assert_eq!(status.message(), b"Hello world");
let status = Status { text: b"FN42/G" };
assert_eq!(
status.grid().map(|g| g.grid.as_bytes().to_vec()),
Some(b"FN42".to_vec())
);
assert_eq!(status.message(), b"");
assert!(
Status {
text: b"io91sx/G x"
}
.grid()
.is_some()
);
let ambiguous = Status {
text: b"FN42AB some text",
};
let found = ambiguous.grid().expect("the four-character reading");
assert_eq!(found.grid.as_bytes(), b"FN42");
assert_eq!(found.symbol.to_wire(), (b'A', b'B'));
assert_eq!(ambiguous.message(), b"some text");
assert_eq!(
Status {
text: b"FN42ab some text"
}
.grid(),
None
);
assert_eq!(
Status {
text: b"FN42ab some text"
}
.message(),
b"FN42ab some text"
);
assert_eq!(Status { text: b"FN42" }.grid(), None);
assert_eq!(Status { text: b"FN42/Gx" }.grid(), None);
}
#[test]
fn timestamp_is_dhm_zulu_only() {
let status = Status {
text: b"092345zNet Control Center",
};
assert_eq!(
status.timestamp(),
Some(Timestamp::DhmZulu {
day: 9,
hour: 23,
minute: 45
})
);
assert_eq!(status.message(), b"Net Control Center");
for text in [&b"092345hHello"[..], &b"092345/Hello"[..]] {
assert_eq!(Status { text }.timestamp(), None, "{text:?}");
assert_eq!(Status { text }.message(), text);
}
assert_eq!(
Status {
text: b"12345 miles"
}
.timestamp(),
None
);
}
#[test]
fn views_compose_and_message_is_the_remainder() {
let status = Status {
text: b"IO91SX/G Hello^B7",
};
assert!(status.grid().is_some());
assert_eq!(status.beam().map(|b| b.erp.watts()), Some(490));
assert_eq!(status.message(), b"Hello");
let status = Status {
text: b"092345zNet Control^A1",
};
assert!(status.timestamp().is_some());
assert_eq!(status.beam().map(|b| b.heading.degrees()), Some(100));
assert_eq!(status.message(), b"Net Control");
let status = Status {
text: b"just talking",
};
assert_eq!(status.timestamp(), None);
assert_eq!(status.grid(), None);
assert_eq!(status.beam(), None);
assert_eq!(status.message(), b"just talking");
}
}