use super::object::Timestamp;
use super::position::{
LATLON_LEN, LatLonBlock, byte_at, expect_byte, parse_digits, parse_latlon, write_digits,
write_latlon,
};
use super::{AprsError, Coordinates, Latitude, Longitude, Position, Symbol};
use crate::geo::Ambiguity;
use crate::units::{Humidity, Pressure, Rainfall, Speed, Temperature};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WeatherReport {
pub wind_direction: Option<u16>,
pub wind_speed: Option<Speed>,
pub gust: Option<Speed>,
pub temperature: Option<Temperature>,
pub rain_1h: Option<Rainfall>,
pub rain_24h: Option<Rainfall>,
pub rain_midnight: Option<Rainfall>,
pub humidity: Option<Humidity>,
pub barometric_pressure: Option<Pressure>,
pub luminosity: Option<u16>,
pub snowfall: Option<Rainfall>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PositionlessWeather<'a> {
pub month: u8,
pub day: u8,
pub hour: u8,
pub minute: u8,
pub weather: WeatherReport,
pub rest: &'a [u8],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PositionWeather<'a> {
pub latitude: Latitude,
pub longitude: Longitude,
pub ambiguity: Ambiguity,
pub symbol: Symbol,
pub messaging: bool,
pub timestamp: Option<Timestamp>,
pub weather: WeatherReport,
pub rest: &'a [u8],
}
const TAGGED_FIELDS: [(u8, usize); 11] = [
(b'c', 3),
(b's', 3),
(b'g', 3),
(b't', 3),
(b'r', 3),
(b'p', 3),
(b'P', 3),
(b'h', 2),
(b'b', 5),
(b'L', 3),
(b'l', 3),
];
const STANDARD_FIELDS: usize = 9;
fn parse_value(info: &[u8], position: usize, width: usize) -> Result<Option<u32>, AprsError> {
let first = byte_at(info, position)?;
if first == b'.' || first == b' ' {
for offset in 1..width {
expect_byte(info, position + offset, first)?;
}
return Ok(None);
}
#[allow(clippy::cast_sign_loss)]
let value = parse_digits(info, position, width)? as u32;
Ok(Some(value))
}
fn parse_temperature(info: &[u8], position: usize) -> Result<Option<i16>, AprsError> {
let first = byte_at(info, position)?;
if first == b'-' {
let value = parse_digits(info, position + 1, 2)?;
#[allow(clippy::cast_possible_truncation)]
return Ok(Some(-(value as i16)));
}
#[allow(clippy::cast_possible_truncation)]
Ok(parse_value(info, position, 3)?.map(|v| v as i16))
}
fn write_value(out: &mut [u8], value: Option<u32>) {
match value {
Some(v) => write_digits(out, u64::from(v)),
None => {
for slot in out.iter_mut() {
*slot = b'.';
}
}
}
}
fn write_temperature(out: &mut [u8], value: Option<Temperature>) {
match value.map(Temperature::fahrenheit) {
Some(v) if v < 0 => {
out[0] = b'-';
write_digits(&mut out[1..3], u64::from(v.unsigned_abs()));
}
#[allow(clippy::cast_sign_loss)]
Some(v) => write_digits(out, v as u64),
None => write_value(out, None),
}
}
const fn snowfall_inches(depth: Rainfall) -> i32 {
let hundredths = depth.hundredths_inch();
let half = if hundredths < 0 { -50 } else { 50 };
hundredths.saturating_add(half) / 100
}
#[allow(clippy::cast_lossless)]
const fn luminosity_wire(watts_per_square_meter: u16) -> (u8, u32) {
if watts_per_square_meter < 1000 {
(b'L', watts_per_square_meter as u32)
} else {
(b'l', (watts_per_square_meter - 1000) as u32)
}
}
fn wind_direction(parsed: Option<u32>) -> Option<u16> {
match parsed {
Some(v) if v <= 360 => u16::try_from(v).ok(),
_ => None,
}
}
fn check_range(field: u8, got: i32, min: i32, max: i32) -> Result<(), AprsError> {
if got < min || got > max {
Err(AprsError::BadWeatherValue { field, got })
} else {
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WindUnit {
MilesPerHour,
Knots,
}
impl WindUnit {
const fn wire(self, speed: Speed) -> i32 {
match self {
Self::MilesPerHour => speed.mph(),
Self::Knots => speed.knots(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WeatherLayout {
Positionless,
Complete,
}
impl WeatherLayout {
const fn wind_unit(self) -> WindUnit {
match self {
Self::Positionless => WindUnit::MilesPerHour,
Self::Complete => WindUnit::Knots,
}
}
}
impl WeatherReport {
const fn extras_len(&self) -> usize {
let luminosity = if self.luminosity.is_some() { 1 + 3 } else { 0 };
let snowfall = if self.snowfall.is_some() { 1 + 3 } else { 0 };
luminosity + snowfall
}
const fn fields_len(&self, layout: WeatherLayout) -> usize {
let wind_is_positional = matches!(layout, WeatherLayout::Complete);
let mut total = 0;
let mut i = 0;
while i < STANDARD_FIELDS {
let (tag, width) = TAGGED_FIELDS[i];
i += 1;
if wind_is_positional && (tag == b'c' || tag == b's') {
continue;
}
if self.has_tagged_value(tag, layout, wind_is_positional) {
total += 1 + width;
}
}
total + self.extras_len()
}
fn check(&self, layout: WeatherLayout) -> Result<(), AprsError> {
if let Some(v) = self.wind_direction {
check_range(b'c', i32::from(v), 0, 360)?;
}
if let Some(v) = self.wind_speed {
check_range(b's', layout.wind_unit().wire(v), 0, 999)?;
}
if let Some(v) = self.snowfall {
check_range(b's', snowfall_inches(v), 0, 999)?;
}
if let Some(v) = self.gust {
check_range(b'g', v.mph(), 0, 999)?;
}
if let Some(v) = self.temperature {
check_range(b't', v.fahrenheit(), -99, 999)?;
}
if let Some(v) = self.rain_1h {
check_range(b'r', v.hundredths_inch(), 0, 999)?;
}
if let Some(v) = self.rain_24h {
check_range(b'p', v.hundredths_inch(), 0, 999)?;
}
if let Some(v) = self.rain_midnight {
check_range(b'P', v.hundredths_inch(), 0, 999)?;
}
if let Some(v) = self.humidity {
check_range(b'h', i32::from(v.percent()), 1, 100)?;
}
if let Some(v) = self.barometric_pressure {
check_range(b'b', v.tenths_hpa(), 0, 99_999)?;
}
if let Some(v) = self.luminosity {
check_range(b'L', i32::from(v), 0, 1999)?;
}
Ok(())
}
fn read_tagged(
&mut self,
info: &[u8],
tag: u8,
position: usize,
layout: WeatherLayout,
wind_slot_spent: &mut bool,
) -> Result<usize, AprsError> {
let at = position + 1;
#[allow(clippy::cast_possible_truncation)]
match tag {
b'c' => match layout {
WeatherLayout::Positionless => {
self.wind_direction = wind_direction(parse_value(info, at, 3)?);
}
WeatherLayout::Complete => {
return Err(AprsError::UnknownWeatherField { got: b'c' });
}
},
b's' => {
if *wind_slot_spent {
self.snowfall = parse_value(info, at, 3)?
.map(|v| Rainfall::from_hundredths_inch((v * 100) as i32));
} else {
self.wind_speed = parse_value(info, at, 3)?.map(|v| Speed::from_mph(v as i32));
*wind_slot_spent = true;
}
}
b'g' => self.gust = parse_value(info, at, 3)?.map(|v| Speed::from_mph(v as i32)),
b't' => {
self.temperature = parse_temperature(info, at)?
.map(|f| Temperature::from_fahrenheit(i32::from(f)));
}
b'r' => {
self.rain_1h =
parse_value(info, at, 3)?.map(|v| Rainfall::from_hundredths_inch(v as i32));
}
b'p' => {
self.rain_24h =
parse_value(info, at, 3)?.map(|v| Rainfall::from_hundredths_inch(v as i32));
}
b'P' => {
self.rain_midnight =
parse_value(info, at, 3)?.map(|v| Rainfall::from_hundredths_inch(v as i32));
}
b'h' => {
self.humidity = match parse_value(info, at, 2)? {
None => None,
Some(v) => Some(Humidity::from_wire_percent(v as u8).map_err(|_| {
AprsError::BadWeatherValue {
field: b'h',
got: v as i32,
}
})?),
};
}
b'b' => {
self.barometric_pressure =
parse_value(info, at, 5)?.map(|v| Pressure::from_tenths_hpa(v as i32));
}
b'L' => self.luminosity = parse_value(info, at, 3)?.map(|v| v as u16),
b'l' => self.luminosity = parse_value(info, at, 3)?.map(|v| (v + 1000) as u16),
_ => return Err(AprsError::UnknownWeatherField { got: tag }),
}
let width = TAGGED_FIELDS
.iter()
.find(|&&(t, _)| t == tag)
.map_or(0, |&(_, w)| w);
Ok(position + 1 + width)
}
fn parse_tagged(
&mut self,
info: &[u8],
mut position: usize,
layout: WeatherLayout,
) -> Result<usize, AprsError> {
let mut wind_slot_spent = matches!(layout, WeatherLayout::Complete);
let mut parsed_any = matches!(layout, WeatherLayout::Complete);
while let Some(&tag) = info.get(position) {
let is_known = TAGGED_FIELDS.iter().any(|&(t, _)| t == tag);
if is_known {
match self.read_tagged(info, tag, position, layout, &mut wind_slot_spent) {
Ok(next) => {
position = next;
parsed_any = true;
continue;
}
Err(e) => {
if parsed_any {
break;
}
return Err(e);
}
}
}
let next = info.get(position + 1).copied();
if !parsed_any
&& tag.is_ascii_alphabetic()
&& matches!(next, Some(b) if b.is_ascii_digit() || b == b'.' || b == b'-')
{
return Err(AprsError::UnknownWeatherField { got: tag });
}
break;
}
Ok(position)
}
fn write_fields(&self, out: &mut [u8], layout: WeatherLayout) -> usize {
let wind_is_positional = matches!(layout, WeatherLayout::Complete);
let mut at = 0;
for &(tag, width) in &TAGGED_FIELDS[..STANDARD_FIELDS] {
if wind_is_positional && (tag == b'c' || tag == b's') {
continue;
}
if self.has_tagged_value(tag, layout, wind_is_positional) {
out[at] = tag;
at += 1;
match tag {
b't' => write_temperature(&mut out[at..at + width], self.temperature),
_ => write_value(
&mut out[at..at + width],
self.tagged_value(tag, layout, wind_is_positional),
),
}
at += width;
}
if tag == b'r' {
at += self.write_luminosity(&mut out[at..]);
}
}
at += self.write_snowfall(&mut out[at..], layout);
at
}
fn write_luminosity(&self, out: &mut [u8]) -> usize {
let Some(value) = self.luminosity else {
return 0;
};
let (tag, digits) = luminosity_wire(value);
out[0] = tag;
write_digits(&mut out[1..4], u64::from(digits));
1 + 3
}
fn write_snowfall(&self, out: &mut [u8], layout: WeatherLayout) -> usize {
if self.snowfall.is_none() {
return 0;
}
out[0] = b's';
write_value(&mut out[1..4], self.tagged_value(b's', layout, true));
1 + 3
}
#[allow(clippy::cast_sign_loss)]
fn wind_wire(&self, layout: WeatherLayout) -> Option<u32> {
self.wind_speed.map(|v| layout.wind_unit().wire(v) as u32)
}
const fn has_tagged_value(
&self,
tag: u8,
_layout: WeatherLayout,
wind_slot_spent: bool,
) -> bool {
match tag {
b'c' => self.wind_direction.is_some(),
b's' => {
if wind_slot_spent {
self.snowfall.is_some()
} else {
self.wind_speed.is_some() || self.snowfall.is_some()
}
}
b'g' => self.gust.is_some(),
b't' => self.temperature.is_some(),
b'r' => self.rain_1h.is_some(),
b'p' => self.rain_24h.is_some(),
b'P' => self.rain_midnight.is_some(),
b'h' => self.humidity.is_some(),
b'b' => self.barometric_pressure.is_some(),
_ => false,
}
}
#[allow(clippy::cast_sign_loss)]
fn tagged_value(&self, tag: u8, layout: WeatherLayout, wind_slot_spent: bool) -> Option<u32> {
match tag {
b'c' => self.wind_direction.map(u32::from),
b's' => {
if wind_slot_spent {
self.snowfall.map(|v| snowfall_inches(v) as u32)
} else {
self.wind_wire(layout)
}
}
b'g' => self.gust.map(|v| v.mph() as u32),
b'r' => self.rain_1h.map(|v| v.hundredths_inch() as u32),
b'p' => self.rain_24h.map(|v| v.hundredths_inch() as u32),
b'P' => self.rain_midnight.map(|v| v.hundredths_inch() as u32),
b'h' => self.humidity.map(|v| u32::from(v.wire_percent())),
b'b' => self.barometric_pressure.map(|v| v.tenths_hpa() as u32),
_ => None,
}
}
}
impl<'a> PositionlessWeather<'a> {
const PREFIX_LEN: usize = 1 + 8;
pub fn new(
month: u8,
day: u8,
hour: u8,
minute: u8,
weather: WeatherReport,
) -> Result<Self, AprsError> {
check_mdhm(
i32::from(month),
i32::from(day),
i32::from(hour),
i32::from(minute),
)?;
Ok(Self {
month,
day,
hour,
minute,
weather,
rest: b"",
})
}
#[must_use]
pub const fn with_rest(self, rest: &'a [u8]) -> Self {
Self { rest, ..self }
}
pub fn parse(info: &'a [u8]) -> Result<Self, AprsError> {
expect_byte(info, 0, b'_')?;
if info.len() < Self::PREFIX_LEN {
return Err(AprsError::Truncated {
expected: Self::PREFIX_LEN,
got: info.len(),
});
}
let month = parse_digits(info, 1, 2)?;
let day = parse_digits(info, 3, 2)?;
let hour = parse_digits(info, 5, 2)?;
let minute = parse_digits(info, 7, 2)?;
check_mdhm(month, day, hour, minute)?;
let mut weather = WeatherReport::default();
let rest_at = weather.parse_tagged(info, Self::PREFIX_LEN, WeatherLayout::Positionless)?;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
Ok(Self {
month: month as u8,
day: day as u8,
hour: hour as u8,
minute: minute as u8,
weather,
rest: info.get(rest_at..).unwrap_or(&[]),
})
}
#[must_use]
pub const fn encoded_len(&self) -> usize {
Self::PREFIX_LEN + self.weather.fields_len(WeatherLayout::Positionless) + self.rest.len()
}
pub fn build(&self, buf: &mut [u8]) -> Result<usize, AprsError> {
check_mdhm(
i32::from(self.month),
i32::from(self.day),
i32::from(self.hour),
i32::from(self.minute),
)?;
self.weather.check(WeatherLayout::Positionless)?;
let needed = self.encoded_len();
let max = buf.len();
let out = buf
.get_mut(..needed)
.ok_or(AprsError::BufferTooSmall { needed, max })?;
out[0] = b'_';
write_digits(&mut out[1..3], u64::from(self.month));
write_digits(&mut out[3..5], u64::from(self.day));
write_digits(&mut out[5..7], u64::from(self.hour));
write_digits(&mut out[7..9], u64::from(self.minute));
let written = self
.weather
.write_fields(&mut out[Self::PREFIX_LEN..], WeatherLayout::Positionless);
for (slot, byte) in out
.iter_mut()
.skip(Self::PREFIX_LEN + written)
.zip(self.rest.iter())
{
*slot = *byte;
}
Ok(needed)
}
}
fn check_mdhm(month: i32, day: i32, hour: i32, minute: i32) -> Result<(), AprsError> {
if !(1..=12).contains(&month) {
return Err(AprsError::BadTimestamp {
field: b'M',
got: month,
});
}
if !(1..=31).contains(&day) {
return Err(AprsError::BadTimestamp {
field: b'D',
got: day,
});
}
if !(0..=23).contains(&hour) {
return Err(AprsError::BadTimestamp {
field: b'H',
got: hour,
});
}
if !(0..=59).contains(&minute) {
return Err(AprsError::BadTimestamp {
field: b'm',
got: minute,
});
}
Ok(())
}
impl<'a> PositionWeather<'a> {
const fn body_at(&self) -> usize {
match self.timestamp {
Some(_) => 1 + Timestamp::LEN,
None => 1,
}
}
const fn wind_at(&self) -> usize {
self.body_at() + LATLON_LEN
}
#[must_use]
pub const fn new(latitude: Latitude, longitude: Longitude, weather: WeatherReport) -> Self {
Self {
latitude,
longitude,
ambiguity: Ambiguity::EXACT,
symbol: Symbol::WEATHER_STATION,
messaging: false,
timestamp: None,
weather,
rest: b"",
}
}
#[must_use]
pub const fn with_timestamp(self, timestamp: Timestamp) -> Self {
Self {
timestamp: Some(timestamp),
..self
}
}
#[must_use]
pub const fn with_table(self, symbol: Symbol) -> Self {
Self {
symbol: Symbol::from_wire(symbol.to_wire().0, b'_'),
..self
}
}
#[must_use]
pub const fn with_messaging(self, messaging: bool) -> Self {
Self { messaging, ..self }
}
#[must_use]
pub const fn with_rest(self, rest: &'a [u8]) -> Self {
Self { rest, ..self }
}
#[must_use]
pub const fn coordinates(&self) -> Coordinates {
let latitude = match Latitude::new(self.ambiguity.mask(self.latitude.units())) {
Ok(value) => value,
Err(_) => self.latitude,
};
let longitude = match Longitude::new(self.ambiguity.mask(self.longitude.units())) {
Ok(value) => value,
Err(_) => self.longitude,
};
Coordinates::new(latitude, longitude).with_ambiguity(self.ambiguity)
}
pub fn parse(info: &'a [u8]) -> Result<Self, AprsError> {
let dti = byte_at(info, 0)?;
let (messaging, timestamped) = match dti {
b'=' => (true, false),
b'!' => (false, false),
b'@' => (true, true),
b'/' => (false, true),
other => return Err(AprsError::InvalidDataType { got: other }),
};
let timestamp = if timestamped {
Some(Timestamp::parse(info, 1)?)
} else {
None
};
let body_at = if timestamped { 1 + Timestamp::LEN } else { 1 };
let prefix_len = body_at + LATLON_LEN;
if info.len() < prefix_len {
return Err(AprsError::Truncated {
expected: prefix_len,
got: info.len(),
});
}
let block = parse_latlon(info, body_at)?;
let (symbol_table, symbol_code) = block.symbol.to_wire();
if symbol_code != b'_' {
return Err(AprsError::ExpectedByte {
expected: b'_',
got: symbol_code,
position: prefix_len - 1,
});
}
let mut weather = WeatherReport::default();
#[allow(clippy::cast_possible_truncation)]
{
weather.wind_direction = wind_direction(parse_value(info, prefix_len, 3)?);
expect_byte(info, prefix_len + 3, b'/')?;
weather.wind_speed =
parse_value(info, prefix_len + 4, 3)?.map(|v| Speed::from_knots(v as i32));
}
let rest_at = weather.parse_tagged(info, prefix_len + 7, WeatherLayout::Complete)?;
Ok(Self {
latitude: block.latitude,
longitude: block.longitude,
ambiguity: block.ambiguity,
symbol: Symbol::from_wire(symbol_table, b'_'),
messaging,
timestamp,
weather,
rest: info.get(rest_at..).unwrap_or(&[]),
})
}
#[must_use]
pub const fn encoded_len(&self) -> usize {
self.wind_at() + 7 + self.weather.fields_len(WeatherLayout::Complete) + self.rest.len()
}
pub fn build(&self, buf: &mut [u8]) -> Result<usize, AprsError> {
self.weather.check(WeatherLayout::Complete)?;
let needed = self.encoded_len();
let max = buf.len();
let out = buf
.get_mut(..needed)
.ok_or(AprsError::BufferTooSmall { needed, max })?;
out[0] = match (self.timestamp.is_some(), self.messaging) {
(false, false) => b'!',
(false, true) => b'=',
(true, false) => b'/',
(true, true) => b'@',
};
if let Some(timestamp) = self.timestamp {
timestamp.write(&mut out[1..1 + Timestamp::LEN])?;
}
let body_at = self.body_at();
write_latlon(
&mut out[body_at..body_at + LATLON_LEN],
&LatLonBlock {
latitude: self.latitude,
longitude: self.longitude,
symbol: Symbol::from_wire(self.symbol.to_wire().0, b'_'),
ambiguity: self.ambiguity,
},
);
let mut at = self.wind_at();
write_value(
&mut out[at..at + 3],
self.weather.wind_direction.map(u32::from),
);
out[at + 3] = b'/';
write_value(
&mut out[at + 4..at + 7],
self.weather.wind_wire(WeatherLayout::Complete),
);
at += 7;
at += self
.weather
.write_fields(&mut out[at..], WeatherLayout::Complete);
for (slot, byte) in out.iter_mut().skip(at).zip(self.rest.iter()) {
*slot = *byte;
}
Ok(needed)
}
#[must_use]
pub const fn position(&self) -> Position<'static> {
Position {
latitude: self.latitude,
longitude: self.longitude,
ambiguity: self.ambiguity,
symbol: Symbol::from_wire(self.symbol.to_wire().0, b'_'),
messaging: self.messaging,
compressed: false,
extension: None,
comment: b"",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_value_digits_dots_and_spaces() {
assert_eq!(parse_value(b"220", 0, 3), Ok(Some(220)));
assert_eq!(parse_value(b"...", 0, 3), Ok(None));
assert_eq!(parse_value(b" ", 0, 3), Ok(None));
assert_eq!(
parse_value(b".2.", 0, 3),
Err(AprsError::ExpectedByte {
expected: b'.',
got: b'2',
position: 1
})
);
assert_eq!(
parse_value(b"2x0", 0, 3),
Err(AprsError::BadDigit {
got: b'x',
position: 1
})
);
assert_eq!(
parse_value(b"22", 0, 3),
Err(AprsError::Truncated {
expected: 3,
got: 2
})
);
}
#[test]
fn parse_temperature_signs() {
assert_eq!(parse_temperature(b"077", 0), Ok(Some(77)));
assert_eq!(parse_temperature(b"-01", 0), Ok(Some(-1)));
assert_eq!(parse_temperature(b"-99", 0), Ok(Some(-99)));
assert_eq!(parse_temperature(b"...", 0), Ok(None));
assert_eq!(
parse_temperature(b"-x1", 0),
Err(AprsError::BadDigit {
got: b'x',
position: 1
})
);
}
#[test]
fn write_value_and_temperature_layout() {
let mut out = [0u8; 3];
write_value(&mut out, Some(7));
assert_eq!(&out, b"007");
write_value(&mut out, None);
assert_eq!(&out, b"...");
write_temperature(&mut out, Some(Temperature::from_fahrenheit(-5)));
assert_eq!(&out, b"-05");
write_temperature(&mut out, Some(Temperature::from_fahrenheit(999)));
assert_eq!(&out, b"999");
write_temperature(&mut out, None);
assert_eq!(&out, b"...");
}
#[test]
fn coordinates_pair_the_fields() {
let report = match PositionWeather::parse(b"!4903.50N/07201.75W_220/004g005t077") {
Ok(r) => r,
Err(e) => panic!("{e}"),
};
assert_eq!(
report.coordinates(),
Coordinates::new(report.latitude, report.longitude)
);
}
#[test]
fn humidity_wire_convention() {
let report = WeatherReport {
humidity: Humidity::new(100).ok(),
..WeatherReport::default()
};
assert_eq!(
report.tagged_value(b'h', WeatherLayout::Positionless, false),
Some(0)
);
let mut parsed = WeatherReport::default();
let mut spent = false;
let end = match parsed.read_tagged(b"h00", b'h', 0, WeatherLayout::Positionless, &mut spent)
{
Ok(n) => n,
Err(e) => panic!("{e}"),
};
assert_eq!(end, 3);
assert_eq!(parsed.humidity.map(Humidity::percent), Some(100));
}
#[test]
fn tagged_scanner_stops_at_unknown_trailer() {
let mut report = WeatherReport::default();
let rest_at = match report.parse_tagged(b"c220s004Xyz", 0, WeatherLayout::Positionless) {
Ok(n) => n,
Err(e) => panic!("{e}"),
};
assert_eq!(rest_at, 8);
assert_eq!(report.wind_direction, Some(220));
assert_eq!(report.wind_speed, Some(Speed::from_mph(4)));
assert_eq!(report.snowfall, None);
let mut report = WeatherReport::default();
assert_eq!(
report.parse_tagged(b"c220X123", 0, WeatherLayout::Positionless),
Ok(4)
);
assert_eq!(report.wind_direction, Some(220));
let mut report = WeatherReport::default();
assert_eq!(
report.parse_tagged(b"c220s004g010t065v6", 0, WeatherLayout::Positionless),
Ok(16)
);
let mut report = WeatherReport::default();
assert_eq!(
report.parse_tagged(b"X123c220", 0, WeatherLayout::Positionless),
Err(AprsError::UnknownWeatherField { got: b'X' })
);
}
#[test]
fn tagged_s_is_wind_in_one_layout_and_snow_in_the_other() {
let mut positionless = WeatherReport::default();
assert_eq!(
positionless.parse_tagged(b"s050", 0, WeatherLayout::Positionless),
Ok(4)
);
assert_eq!(positionless.wind_speed, Some(Speed::from_mph(50)));
assert_eq!(positionless.snowfall, None);
let mut complete = WeatherReport {
wind_speed: Some(Speed::from_knots(4)),
..WeatherReport::default()
};
assert_eq!(
complete.parse_tagged(b"s050", 0, WeatherLayout::Complete),
Ok(4)
);
assert_eq!(complete.wind_speed, Some(Speed::from_knots(4)));
assert_eq!(
complete.snowfall,
Some(Rainfall::from_hundredths_inch(5_000))
);
assert_eq!(complete.snowfall.map(snowfall_inches), Some(50));
}
#[test]
fn second_tagged_s_is_snow_in_the_positionless_layout_too() {
let mut report = WeatherReport::default();
assert_eq!(
report.parse_tagged(b"s004g005s012", 0, WeatherLayout::Positionless),
Ok(12)
);
assert_eq!(report.wind_speed, Some(Speed::from_mph(4)));
assert_eq!(report.gust, Some(Speed::from_mph(5)));
assert_eq!(report.snowfall, Some(Rainfall::from_hundredths_inch(1_200)));
let mut dotted = WeatherReport::default();
assert_eq!(
dotted.parse_tagged(b"c...s...s012", 0, WeatherLayout::Positionless),
Ok(12)
);
assert_eq!(dotted.wind_direction, None);
assert_eq!(dotted.wind_speed, None);
assert_eq!(dotted.snowfall, Some(Rainfall::from_hundredths_inch(1_200)));
}
#[test]
fn tagged_c_ends_the_scan_in_the_complete_layout() {
let mut complete = WeatherReport {
wind_direction: Some(220),
..WeatherReport::default()
};
assert_eq!(
complete.parse_tagged(b"c123g005t077", 0, WeatherLayout::Complete),
Ok(0)
);
assert_eq!(complete.wind_direction, Some(220));
let mut spent = true;
assert_eq!(
WeatherReport::default().read_tagged(
b"c123",
b'c',
0,
WeatherLayout::Complete,
&mut spent
),
Err(AprsError::UnknownWeatherField { got: b'c' })
);
let mut positionless = WeatherReport::default();
assert_eq!(
positionless.parse_tagged(b"c123", 0, WeatherLayout::Positionless),
Ok(4)
);
assert_eq!(positionless.wind_direction, Some(123));
}
#[test]
fn luminosity_is_read_mid_block_and_spells_itself_back() {
let mut report = WeatherReport::default();
assert_eq!(
report.parse_tagged(b"r000L050p001P002", 0, WeatherLayout::Complete),
Ok(16)
);
assert_eq!(report.luminosity, Some(50));
assert_eq!(report.rain_1h, Some(Rainfall::from_hundredths_inch(0)));
assert_eq!(report.rain_24h, Some(Rainfall::from_hundredths_inch(1)));
assert_eq!(
report.rain_midnight,
Some(Rainfall::from_hundredths_inch(2))
);
let mut high = WeatherReport::default();
assert_eq!(
high.parse_tagged(b"l050", 0, WeatherLayout::Positionless),
Ok(4)
);
assert_eq!(high.luminosity, Some(1050));
assert_eq!(luminosity_wire(0), (b'L', 0));
assert_eq!(luminosity_wire(999), (b'L', 999));
assert_eq!(luminosity_wire(1000), (b'l', 0));
assert_eq!(luminosity_wire(1999), (b'l', 999));
}
#[test]
fn snowfall_wire_inches_round_half_away_from_zero() {
assert_eq!(snowfall_inches(Rainfall::ZERO), 0);
assert_eq!(snowfall_inches(Rainfall::from_hundredths_inch(49)), 0);
assert_eq!(snowfall_inches(Rainfall::from_hundredths_inch(50)), 1);
assert_eq!(snowfall_inches(Rainfall::from_hundredths_inch(149)), 1);
assert_eq!(snowfall_inches(Rainfall::from_hundredths_inch(150)), 2);
assert_eq!(snowfall_inches(Rainfall::from_hundredths_inch(-50)), -1);
for inches in [0, 1, 12, 999] {
assert_eq!(
snowfall_inches(Rainfall::from_hundredths_inch(inches * 100)),
inches
);
}
assert_eq!(
snowfall_inches(Rainfall::from_micrometers(i64::MAX)),
i32::MAX / 100
);
}
#[test]
fn mdhm_bounds() {
assert_eq!(check_mdhm(1, 1, 0, 0), Ok(()));
assert_eq!(check_mdhm(12, 31, 23, 59), Ok(()));
assert_eq!(
check_mdhm(0, 1, 0, 0),
Err(AprsError::BadTimestamp {
field: b'M',
got: 0
})
);
assert_eq!(
check_mdhm(13, 1, 0, 0),
Err(AprsError::BadTimestamp {
field: b'M',
got: 13
})
);
assert_eq!(
check_mdhm(1, 32, 0, 0),
Err(AprsError::BadTimestamp {
field: b'D',
got: 32
})
);
assert_eq!(
check_mdhm(1, 1, 24, 0),
Err(AprsError::BadTimestamp {
field: b'H',
got: 24
})
);
assert_eq!(
check_mdhm(1, 1, 0, 60),
Err(AprsError::BadTimestamp {
field: b'm',
got: 60
})
);
}
#[test]
fn range_checks_on_build() {
let bad = WeatherReport {
wind_direction: Some(361),
..WeatherReport::default()
};
assert_eq!(
bad.check(WeatherLayout::Positionless),
Err(AprsError::BadWeatherValue {
field: b'c',
got: 361
})
);
let bad = WeatherReport {
barometric_pressure: Some(Pressure::from_tenths_hpa(100_000)),
..WeatherReport::default()
};
assert_eq!(
bad.check(WeatherLayout::Positionless),
Err(AprsError::BadWeatherValue {
field: b'b',
got: 100_000
})
);
let gale = WeatherReport {
wind_speed: Some(Speed::from_kmh(2000)),
..WeatherReport::default()
};
assert_eq!(
gale.check(WeatherLayout::Positionless),
Err(AprsError::BadWeatherValue {
field: b's',
got: 1243
})
);
assert_eq!(
gale.check(WeatherLayout::Complete),
Err(AprsError::BadWeatherValue {
field: b's',
got: 1080
})
);
let brisk = WeatherReport {
wind_speed: Some(Speed::from_kmh(1800)),
..WeatherReport::default()
};
assert_eq!(brisk.check(WeatherLayout::Complete), Ok(()));
assert_eq!(
brisk.check(WeatherLayout::Positionless),
Err(AprsError::BadWeatherValue {
field: b's',
got: 1118
})
);
let snow = WeatherReport {
snowfall: Some(Rainfall::from_hundredths_inch(5_000)),
..WeatherReport::default()
};
assert_eq!(snow.check(WeatherLayout::Positionless), Ok(()));
assert_eq!(snow.check(WeatherLayout::Complete), Ok(()));
let blizzard = WeatherReport {
snowfall: Some(Rainfall::from_hundredths_inch(100_000)),
..WeatherReport::default()
};
for layout in [WeatherLayout::Positionless, WeatherLayout::Complete] {
assert_eq!(
blizzard.check(layout),
Err(AprsError::BadWeatherValue {
field: b's',
got: 1000
}),
"{layout:?}"
);
}
let bright = WeatherReport {
luminosity: Some(1999),
..WeatherReport::default()
};
assert_eq!(bright.check(WeatherLayout::Complete), Ok(()));
let brighter = WeatherReport {
luminosity: Some(2000),
..WeatherReport::default()
};
assert_eq!(
brighter.check(WeatherLayout::Complete),
Err(AprsError::BadWeatherValue {
field: b'L',
got: 2000
})
);
}
}