use core::fmt;
use super::weather::WeatherReport;
use crate::units::{Humidity, Pressure, Rainfall, Speed, Temperature};
const PACKET_PREFIX: [u8; 4] = *b"ULTW";
const ABSENT: u8 = b'-';
const MAX_FIELD_WIDTH: usize = 4;
const PACKET_LENGTHS: [usize; 3] = [44, 48, 52];
const DATA_LOGGER_LENGTHS: [usize; 3] = [40, 44, 48];
const ULTIMETER_TWO_LENGTHS: [usize; 1] = [13];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindUnit {
Mph,
Kph,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UltimeterFormat {
Packet,
DataLogger,
UltimeterTwo(WindUnit),
}
impl UltimeterFormat {
#[must_use]
pub const fn identifier(self) -> u8 {
match self {
UltimeterFormat::Packet => b'$',
UltimeterFormat::DataLogger => b'!',
UltimeterFormat::UltimeterTwo(WindUnit::Mph) => b'*',
UltimeterFormat::UltimeterTwo(WindUnit::Kph) => b'#',
}
}
const fn lengths(self) -> &'static [usize] {
match self {
UltimeterFormat::Packet => &PACKET_LENGTHS,
UltimeterFormat::DataLogger => &DATA_LOGGER_LENGTHS,
UltimeterFormat::UltimeterTwo(_) => &ULTIMETER_TWO_LENGTHS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UltimeterError {
Truncated {
expected: usize,
got: usize,
},
InvalidDataType {
got: u8,
},
NotPacketMode {
got: [u8; 4],
},
NotDataLogger {
got: u8,
},
BadBodyLength {
format: UltimeterFormat,
got: usize,
},
BadHexDigit {
got: u8,
position: usize,
},
MixedDashField {
position: usize,
},
}
impl fmt::Display for UltimeterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match *self {
UltimeterError::Truncated { expected, got } => write!(
f,
"information field of {got} bytes is truncated: at least {expected} bytes are required"
),
UltimeterError::InvalidDataType { got } => write!(
f,
"data-type identifier 0x{got:02X} is not Ultimeter: '$', '!', '*' or '#' is required"
),
UltimeterError::NotPacketMode { got } => write!(
f,
"'$' body starts 0x{:02X}{:02X}{:02X}{:02X}, not 'ULTW': this is not an Ultimeter packet-mode record",
got[0], got[1], got[2], got[3]
),
UltimeterError::NotDataLogger { got } => write!(
f,
"'!' followed by 0x{got:02X} is a position report, not an Ultimeter data-logger record: a second '!' is required"
),
UltimeterError::BadBodyLength { format, got } => {
let allowed = match format {
UltimeterFormat::Packet => "44, 48 or 52",
UltimeterFormat::DataLogger => "40, 44 or 48",
UltimeterFormat::UltimeterTwo(_) => "exactly 13",
};
write!(
f,
"field block of {got} characters is invalid: {allowed} hex characters are required"
)
}
UltimeterError::BadHexDigit { got, position } => write!(
f,
"field byte 0x{got:02X} at offset {position} is not a hex digit or the '-' absent marker"
),
UltimeterError::MixedDashField { position } => write!(
f,
"field at offset {position} mixes hex digits with '-': an absent sensor fills the whole field"
),
}
}
}
impl core::error::Error for UltimeterError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PacketMode<'a> {
pub wind_peak: Option<u16>,
pub wind_direction: Option<u8>,
pub temperature: Option<i16>,
pub rain_total: Option<u16>,
pub barometer: Option<u16>,
pub barometer_delta: Option<i16>,
pub barometer_correction_lsw: Option<u16>,
pub barometer_correction_msw: Option<u16>,
pub humidity: Option<u16>,
pub date: Option<u16>,
pub time: Option<u16>,
pub rain_today: Option<u16>,
pub wind_average: Option<u16>,
pub rest: &'a [u8],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DataLoggerMode<'a> {
pub wind_speed: Option<u16>,
pub wind_direction: Option<u8>,
pub temperature: Option<i16>,
pub rain_total: Option<u16>,
pub barometer: Option<u16>,
pub indoor_temperature: Option<i16>,
pub humidity: Option<u16>,
pub indoor_humidity: Option<u16>,
pub date: Option<u16>,
pub time: Option<u16>,
pub rain_today: Option<u16>,
pub wind_average: Option<u16>,
pub rest: &'a [u8],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UltimeterTwo<'a> {
pub unit: WindUnit,
pub wind_direction: Option<u8>,
pub wind_speed: Option<u8>,
pub temperature_bias: Option<u8>,
pub rain_upper: Option<u16>,
pub rain_lower: Option<u16>,
pub rest: &'a [u8],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UltimeterRecord<'a> {
Packet(PacketMode<'a>),
DataLogger(DataLoggerMode<'a>),
UltimeterTwo(UltimeterTwo<'a>),
}
impl<'a> UltimeterRecord<'a> {
#[must_use]
pub const fn format(self) -> UltimeterFormat {
match self {
UltimeterRecord::Packet(_) => UltimeterFormat::Packet,
UltimeterRecord::DataLogger(_) => UltimeterFormat::DataLogger,
UltimeterRecord::UltimeterTwo(two) => UltimeterFormat::UltimeterTwo(two.unit),
}
}
#[must_use]
pub const fn rest(self) -> &'a [u8] {
match self {
UltimeterRecord::Packet(packet) => packet.rest,
UltimeterRecord::DataLogger(logger) => logger.rest,
UltimeterRecord::UltimeterTwo(two) => two.rest,
}
}
#[must_use]
pub fn to_weather_report(self) -> WeatherReport {
match self {
UltimeterRecord::Packet(packet) => packet.to_weather_report(),
UltimeterRecord::DataLogger(logger) => logger.to_weather_report(),
UltimeterRecord::UltimeterTwo(two) => two.to_weather_report(),
}
}
}
impl PacketMode<'_> {
#[must_use]
pub fn wind_direction_degrees(self) -> Option<u16> {
self.wind_direction.map(direction_degrees)
}
#[must_use]
pub fn day_of_year(self) -> Option<u16> {
self.date.map(|day| day.saturating_add(1))
}
#[must_use]
pub fn time_of_day(self) -> Option<(u8, u8)> {
self.time.and_then(time_of_day)
}
#[must_use]
pub fn to_weather_report(self) -> WeatherReport {
WeatherReport {
wind_direction: self.wind_direction_degrees(),
wind_speed: self.wind_average.map(tenth_kph_to_speed),
gust: self.wind_peak.map(tenth_kph_to_speed),
temperature: self.temperature.map(tenths_f_to_temperature),
rain_1h: None,
rain_24h: None,
rain_midnight: self
.rain_today
.map(|v| Rainfall::from_hundredths_inch(i32::from(v))),
humidity: self.humidity.and_then(tenths_to_humidity),
barometric_pressure: self
.barometer
.map(|v| Pressure::from_tenths_hpa(i32::from(v))),
luminosity: None,
snowfall: None,
}
}
}
impl DataLoggerMode<'_> {
#[must_use]
pub fn wind_direction_degrees(self) -> Option<u16> {
self.wind_direction.map(direction_degrees)
}
#[must_use]
pub fn day_of_year(self) -> Option<u16> {
self.date.map(|day| day.saturating_add(1))
}
#[must_use]
pub fn time_of_day(self) -> Option<(u8, u8)> {
self.time.and_then(time_of_day)
}
#[must_use]
pub fn to_weather_report(self) -> WeatherReport {
WeatherReport {
wind_direction: self.wind_direction_degrees(),
wind_speed: self
.wind_average
.or(self.wind_speed)
.map(tenth_kph_to_speed),
gust: None,
temperature: self.temperature.map(tenths_f_to_temperature),
rain_1h: None,
rain_24h: None,
rain_midnight: self
.rain_today
.map(|v| Rainfall::from_hundredths_inch(i32::from(v))),
humidity: self.humidity.and_then(tenths_to_humidity),
barometric_pressure: self
.barometer
.map(|v| Pressure::from_tenths_hpa(i32::from(v))),
luminosity: None,
snowfall: None,
}
}
}
impl UltimeterTwo<'_> {
#[must_use]
pub fn wind_direction_degrees(self) -> Option<u16> {
self.wind_direction.map(point_degrees)
}
#[must_use]
pub fn temperature(self) -> Option<i16> {
self.temperature_bias
.map(|raw| i16::from(raw) - TEMPERATURE_BIAS)
}
#[must_use]
pub fn wind_speed_mph(self) -> Option<u16> {
self.wind_speed_typed()
.map(|speed| u16::try_from(speed.mph()).unwrap_or(u16::MAX))
}
#[must_use]
pub const fn wind_speed_typed(self) -> Option<Speed> {
match self.wind_speed {
None => None,
Some(speed) => Some(match self.unit {
WindUnit::Mph => Speed::from_mph(speed as i32),
WindUnit::Kph => tenth_kph_to_speed(speed as u16 * 10),
}),
}
}
#[must_use]
pub fn to_weather_report(self) -> WeatherReport {
WeatherReport {
wind_direction: self.wind_direction_degrees(),
wind_speed: self.wind_speed_typed(),
gust: None,
temperature: self
.temperature()
.map(|f| Temperature::from_fahrenheit(i32::from(f))),
rain_1h: None,
rain_24h: None,
rain_midnight: None,
humidity: None,
barometric_pressure: None,
luminosity: None,
snowfall: None,
}
}
}
const TEMPERATURE_BIAS: i16 = 56;
#[must_use]
pub fn detect(info: &[u8]) -> Option<UltimeterFormat> {
match *info.first()? {
b'$' if info.get(1..5) == Some(&PACKET_PREFIX[..]) => Some(UltimeterFormat::Packet),
b'!' if info.get(1) == Some(&b'!') => Some(UltimeterFormat::DataLogger),
b'*' => Some(UltimeterFormat::UltimeterTwo(WindUnit::Mph)),
b'#' => Some(UltimeterFormat::UltimeterTwo(WindUnit::Kph)),
_ => None,
}
}
pub fn parse(info: &[u8]) -> Result<UltimeterRecord<'_>, UltimeterError> {
match info.first().copied() {
Some(b'$') => parse_packet(info),
Some(b'!') => parse_data_logger(info),
Some(b'*') => parse_ultimeter_two(info, WindUnit::Mph),
Some(b'#') => parse_ultimeter_two(info, WindUnit::Kph),
Some(got) => Err(UltimeterError::InvalidDataType { got }),
None => Err(UltimeterError::Truncated {
expected: 1,
got: 0,
}),
}
}
fn parse_packet(info: &[u8]) -> Result<UltimeterRecord<'_>, UltimeterError> {
let header = 1 + PACKET_PREFIX.len();
if info.len() < header {
return Err(UltimeterError::Truncated {
expected: header,
got: info.len(),
});
}
let prefix = &info[1..header];
if prefix != &PACKET_PREFIX[..] {
let mut got = [0u8; 4];
got.copy_from_slice(prefix);
return Err(UltimeterError::NotPacketMode { got });
}
let (block, rest) = split_block(&info[header..], UltimeterFormat::Packet)?;
Ok(UltimeterRecord::Packet(PacketMode {
wind_peak: word(block, 0)?,
wind_direction: direction(block, 4)?,
temperature: signed_word(block, 8)?,
rain_total: word(block, 12)?,
barometer: word(block, 16)?,
barometer_delta: signed_word(block, 20)?,
barometer_correction_lsw: word(block, 24)?,
barometer_correction_msw: word(block, 28)?,
humidity: word(block, 32)?,
date: word(block, 36)?,
time: word(block, 40)?,
rain_today: word(block, 44)?,
wind_average: word(block, 48)?,
rest,
}))
}
fn parse_data_logger(info: &[u8]) -> Result<UltimeterRecord<'_>, UltimeterError> {
let second = *info.get(1).ok_or(UltimeterError::Truncated {
expected: 2,
got: info.len(),
})?;
if second != b'!' {
return Err(UltimeterError::NotDataLogger { got: second });
}
let (block, rest) = split_block(&info[2..], UltimeterFormat::DataLogger)?;
Ok(UltimeterRecord::DataLogger(DataLoggerMode {
wind_speed: word(block, 0)?,
wind_direction: direction(block, 4)?,
temperature: signed_word(block, 8)?,
rain_total: word(block, 12)?,
barometer: word(block, 16)?,
indoor_temperature: signed_word(block, 20)?,
humidity: word(block, 24)?,
indoor_humidity: word(block, 28)?,
date: word(block, 32)?,
time: word(block, 36)?,
rain_today: word(block, 40)?,
wind_average: word(block, 44)?,
rest,
}))
}
fn parse_ultimeter_two(info: &[u8], unit: WindUnit) -> Result<UltimeterRecord<'_>, UltimeterError> {
let (block, rest) = split_block(&info[1..], UltimeterFormat::UltimeterTwo(unit))?;
Ok(UltimeterRecord::UltimeterTwo(UltimeterTwo {
unit,
wind_direction: byte_field(block, 0, 1)?,
wind_speed: byte_field(block, 1, 2)?,
temperature_bias: byte_field(block, 3, 2)?,
rain_upper: hex_field(block, 5, 4)?,
rain_lower: hex_field(block, 9, 4)?,
rest,
}))
}
fn split_block(body: &[u8], format: UltimeterFormat) -> Result<(&[u8], &[u8]), UltimeterError> {
let run = body
.iter()
.take_while(|&&byte| byte == ABSENT || hex_digit(byte).is_some())
.count();
if !format.lengths().contains(&run) {
return Err(UltimeterError::BadBodyLength { format, got: run });
}
Ok(body.split_at(run))
}
const fn hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn hex_field(block: &[u8], offset: usize, width: usize) -> Result<Option<u16>, UltimeterError> {
debug_assert!(width <= MAX_FIELD_WIDTH, "field accumulator is a u16");
let Some(field) = block.get(offset..offset + width) else {
return Ok(None);
};
let mut value = 0u16;
let mut dashes = 0usize;
for (index, &byte) in field.iter().enumerate() {
if byte == ABSENT {
dashes += 1;
continue;
}
let Some(digit) = hex_digit(byte) else {
return Err(UltimeterError::BadHexDigit {
got: byte,
position: offset + index,
});
};
value = value * 16 + u16::from(digit);
}
if dashes == width {
return Ok(None);
}
if dashes != 0 {
return Err(UltimeterError::MixedDashField { position: offset });
}
Ok(Some(value))
}
fn word(block: &[u8], offset: usize) -> Result<Option<u16>, UltimeterError> {
hex_field(block, offset, MAX_FIELD_WIDTH)
}
fn signed_word(block: &[u8], offset: usize) -> Result<Option<i16>, UltimeterError> {
Ok(word(block, offset)?.map(u16::cast_signed))
}
fn direction(block: &[u8], offset: usize) -> Result<Option<u8>, UltimeterError> {
Ok(word(block, offset)?.map(|raw| narrow(raw & 0xFF)))
}
fn byte_field(block: &[u8], offset: usize, width: usize) -> Result<Option<u8>, UltimeterError> {
Ok(hex_field(block, offset, width)?.map(narrow))
}
fn narrow(value: u16) -> u8 {
u8::try_from(value).unwrap_or(u8::MAX)
}
fn direction_degrees(raw: u8) -> u16 {
let degrees = (u32::from(raw) * 360 + 127) / 255;
u16::try_from(degrees).unwrap_or(360)
}
fn point_degrees(point: u8) -> u16 {
(u16::from(point & 0x0F) * 45).div_ceil(2)
}
const fn tenth_kph_to_speed(tenth_kph: u16) -> Speed {
Speed::from_millimeters_per_hour(tenth_kph as i64 * 100_000)
}
const fn tenths_f_to_temperature(tenths: i16) -> Temperature {
Temperature::from_tenths_fahrenheit(tenths as i32)
}
fn tenths_to_humidity(tenths: u16) -> Option<Humidity> {
let percent = (u32::from(tenths) + 5) / 10;
Humidity::new(u8::try_from(percent).unwrap_or(u8::MAX)).ok()
}
fn time_of_day(minute_of_day: u16) -> Option<(u8, u8)> {
if minute_of_day >= 24 * 60 {
return None;
}
let hour = u8::try_from(minute_of_day / 60).ok()?;
let minute = u8::try_from(minute_of_day % 60).ok()?;
Some((hour, minute))
}
#[cfg(test)]
mod tests {
use core::fmt::Write as _;
use super::*;
const VERIFIED: &[u8] = b"$ULTW0000000001FF000427C70002CCD30001026E003A050F00040000";
const LOGGER: &[u8] = b"!!006B005803500000----03E9--------002105140000005D";
fn packet(info: &[u8]) -> PacketMode<'_> {
match parse(info) {
Ok(UltimeterRecord::Packet(p)) => p,
other => panic!("expected packet mode, got {other:?}"),
}
}
fn logger(info: &[u8]) -> DataLoggerMode<'_> {
match parse(info) {
Ok(UltimeterRecord::DataLogger(l)) => l,
other => panic!("expected data-logger mode, got {other:?}"),
}
}
fn two(info: &[u8]) -> UltimeterTwo<'_> {
match parse(info) {
Ok(UltimeterRecord::UltimeterTwo(t)) => t,
other => panic!("expected Ultimeter II, got {other:?}"),
}
}
fn packet_with_temperature(hex: &[u8; 4], out: &mut [u8; 57]) {
out.copy_from_slice(VERIFIED);
out[13..17].copy_from_slice(hex);
}
#[test]
fn packet_mode_verified_vector() {
let p = packet(VERIFIED);
assert_eq!(p.wind_peak, Some(0)); assert_eq!(p.wind_direction, Some(0)); assert_eq!(p.temperature, Some(511)); assert_eq!(p.rain_total, Some(4)); assert_eq!(p.barometer, Some(10_183)); assert_eq!(p.barometer_delta, Some(2)); assert_eq!(p.barometer_correction_lsw, Some(0xCCD3));
assert_eq!(p.barometer_correction_msw, Some(1));
assert_eq!(p.humidity, Some(622)); assert_eq!(p.date, Some(58));
assert_eq!(p.day_of_year(), Some(59));
assert_eq!(p.time, Some(1295));
assert_eq!(p.time_of_day(), Some((21, 35)));
assert_eq!(p.rain_today, Some(4)); assert_eq!(p.wind_average, Some(0)); assert_eq!(p.rest, b"");
let w = p.to_weather_report();
assert_eq!(w.wind_direction, Some(0));
assert_eq!(w.wind_speed.map(Speed::mph), Some(0));
assert_eq!(w.gust.map(Speed::mph), Some(0));
assert_eq!(w.temperature.map(Temperature::fahrenheit), Some(51));
assert_eq!(w.rain_1h, None);
assert_eq!(w.rain_24h, None);
assert_eq!(w.rain_midnight.map(Rainfall::hundredths_inch), Some(4));
assert_eq!(w.humidity.map(Humidity::percent), Some(62));
assert_eq!(
w.barometric_pressure.map(Pressure::tenths_hpa),
Some(10_183)
);
}
#[test]
fn packet_mode_truncation_lengths() {
assert_eq!(VERIFIED.len(), 5 + 52);
let full = packet(VERIFIED);
assert_eq!(full.rain_today, Some(4));
assert_eq!(full.wind_average, Some(0));
let short = packet(&VERIFIED[..5 + 48]);
assert_eq!(short.time, Some(1295));
assert_eq!(short.rain_today, Some(4));
assert_eq!(short.wind_average, None);
let shorter = packet(&VERIFIED[..5 + 44]);
assert_eq!(shorter.time, Some(1295));
assert_eq!(shorter.rain_today, None);
assert_eq!(shorter.wind_average, None);
assert_eq!(shorter.to_weather_report().wind_speed, None);
assert_eq!(shorter.to_weather_report().rain_midnight, None);
assert_eq!(
parse(&VERIFIED[..5 + 46]),
Err(UltimeterError::BadBodyLength {
format: UltimeterFormat::Packet,
got: 46
})
);
}
#[test]
fn data_logger_spec_vector() {
let l = logger(LOGGER);
assert_eq!(l.wind_speed, Some(0x6B));
assert_eq!(l.wind_direction, Some(0x58));
assert_eq!(l.temperature, Some(848)); assert_eq!(l.rain_total, Some(0));
assert_eq!(l.barometer, None);
assert_eq!(l.indoor_temperature, Some(1001));
assert_eq!(l.humidity, None);
assert_eq!(l.indoor_humidity, None);
assert_eq!(l.date, Some(33));
assert_eq!(l.day_of_year(), Some(34));
assert_eq!(l.time, Some(1300));
assert_eq!(l.time_of_day(), Some((21, 40)));
assert_eq!(l.rain_today, Some(0));
assert_eq!(l.wind_average, Some(93));
let w = l.to_weather_report();
assert_eq!(w.wind_speed.map(Speed::mph), Some(6)); assert_eq!(w.gust, None); assert_eq!(w.temperature.map(Temperature::fahrenheit), Some(85)); assert_eq!(w.barometric_pressure, None);
assert_eq!(w.humidity, None);
assert_eq!(w.rain_midnight.map(Rainfall::hundredths_inch), Some(0));
}
#[test]
fn data_logger_field_order_differs_from_packet_mode() {
let body = b"000000A600B5000027C7FF9C";
let mut packet_info = [0u8; 5 + 52];
packet_info[..5].copy_from_slice(b"$ULTW");
packet_info[5..5 + body.len()].copy_from_slice(body);
for slot in &mut packet_info[5 + body.len()..] {
*slot = b'0';
}
let mut logger_info = [0u8; 2 + 48];
logger_info[..2].copy_from_slice(b"!!");
logger_info[2..2 + body.len()].copy_from_slice(body);
for slot in &mut logger_info[2 + body.len()..] {
*slot = b'0';
}
let p = packet(&packet_info);
let l = logger(&logger_info);
assert_eq!(p.barometer, l.barometer);
assert_eq!(p.barometer_delta, Some(-100));
assert_eq!(l.indoor_temperature, Some(-100));
assert_eq!(p.humidity, Some(0)); assert_eq!(l.humidity, Some(0)); }
#[test]
fn data_logger_truncation_lengths() {
assert_eq!(LOGGER.len(), 2 + 48);
let full = logger(LOGGER);
assert_eq!(full.rain_today, Some(0));
assert_eq!(full.wind_average, Some(93));
let short = logger(&LOGGER[..2 + 44]);
assert_eq!(short.rain_today, Some(0));
assert_eq!(short.wind_average, None);
assert_eq!(
short.to_weather_report().wind_speed.map(Speed::mph),
Some(7)
);
let shorter = logger(&LOGGER[..2 + 40]);
assert_eq!(shorter.time, Some(1300));
assert_eq!(shorter.rain_today, None);
assert_eq!(shorter.wind_average, None);
assert_eq!(
parse(&LOGGER[..2 + 42]),
Err(UltimeterError::BadBodyLength {
format: UltimeterFormat::DataLogger,
got: 42
})
);
}
#[test]
fn negative_temperatures_are_twos_complement() {
let mut info = [0u8; 57];
packet_with_temperature(b"FF9C", &mut info);
let p = packet(&info);
assert_eq!(p.temperature, Some(-100)); assert_eq!(
p.to_weather_report()
.temperature
.map(Temperature::fahrenheit),
Some(-10)
);
packet_with_temperature(b"FFFF", &mut info);
let p = packet(&info);
assert_eq!(p.temperature, Some(-1)); assert_eq!(
p.to_weather_report()
.temperature
.map(Temperature::fahrenheit),
Some(0)
);
packet_with_temperature(b"8000", &mut info);
assert_eq!(packet(&info).temperature, Some(-32_768));
packet_with_temperature(b"7FFF", &mut info);
assert_eq!(packet(&info).temperature, Some(32_767));
packet_with_temperature(b"0000", &mut info);
assert_eq!(packet(&info).temperature, Some(0));
packet_with_temperature(b"ff9c", &mut info);
assert_eq!(packet(&info).temperature, Some(-100));
}
#[test]
fn absent_sensors_are_none_and_mixed_fields_are_corrupt() {
let mut info = [0u8; 57];
packet_with_temperature(b"----", &mut info);
let p = packet(&info);
assert_eq!(p.temperature, None);
assert_eq!(p.to_weather_report().temperature, None);
assert_eq!(p.wind_direction, Some(0));
assert_eq!(p.rain_total, Some(4));
packet_with_temperature(b"12--", &mut info);
assert_eq!(
parse(&info),
Err(UltimeterError::MixedDashField { position: 8 })
);
packet_with_temperature(b"--12", &mut info);
assert_eq!(
parse(&info),
Err(UltimeterError::MixedDashField { position: 8 })
);
packet_with_temperature(b"-1-2", &mut info);
assert_eq!(
parse(&info),
Err(UltimeterError::MixedDashField { position: 8 })
);
}
#[test]
fn wind_direction_masks_the_top_byte_and_divides_by_255() {
let mut info = [0u8; 57];
info.copy_from_slice(VERIFIED);
info[9..13].copy_from_slice(b"FFFF");
let p = packet(&info);
assert_eq!(p.wind_direction, Some(0xFF));
assert_eq!(p.wind_direction_degrees(), Some(360));
info[9..13].copy_from_slice(b"FF40");
let p = packet(&info);
assert_eq!(p.wind_direction, Some(0x40));
assert_eq!(p.wind_direction_degrees(), Some(90)); assert_eq!(p.to_weather_report().wind_direction, Some(90));
info[9..13].copy_from_slice(b"0080");
assert_eq!(packet(&info).wind_direction_degrees(), Some(181)); info[9..13].copy_from_slice(b"00C0");
assert_eq!(packet(&info).wind_direction_degrees(), Some(271)); info[9..13].copy_from_slice(b"----");
assert_eq!(packet(&info).wind_direction_degrees(), None);
}
#[test]
fn ultimeter_two_speed_unit_follows_the_identifier() {
let mph = two(b"*41EA0006400C8");
let kph = two(b"#41EA0006400C8");
assert_eq!(mph.unit, WindUnit::Mph);
assert_eq!(kph.unit, WindUnit::Kph);
assert_eq!(mph.wind_speed, Some(0x1E)); assert_eq!(kph.wind_speed, Some(0x1E));
assert_eq!(mph.wind_speed_mph(), Some(30));
assert_eq!(kph.wind_speed_mph(), Some(19)); assert_eq!(mph.to_weather_report().wind_speed.map(Speed::mph), Some(30));
assert_eq!(kph.to_weather_report().wind_speed.map(Speed::mph), Some(19));
assert_eq!(
UltimeterRecord::UltimeterTwo(mph).format(),
UltimeterFormat::UltimeterTwo(WindUnit::Mph)
);
assert_eq!(
UltimeterRecord::UltimeterTwo(kph).format(),
UltimeterFormat::UltimeterTwo(WindUnit::Kph)
);
}
#[test]
fn ultimeter_two_fields_use_their_own_scaling() {
let t = two(b"*41EA0006400C8\r\n");
assert_eq!(t.wind_direction, Some(4));
assert_eq!(t.wind_direction_degrees(), Some(90));
assert_eq!(t.temperature_bias, Some(0xA0));
assert_eq!(t.temperature(), Some(104)); assert_eq!(t.rain_upper, Some(100)); assert_eq!(t.rain_lower, Some(200)); assert_eq!(t.rest, b"\r\n");
let w = t.to_weather_report();
assert_eq!(w.rain_1h, None);
assert_eq!(w.rain_24h, None);
assert_eq!(w.rain_midnight, None);
assert_eq!(w.humidity, None);
assert_eq!(w.barometric_pressure, None);
assert_eq!(w.gust, None);
assert_eq!(two(b"*0000000000000").wind_direction_degrees(), Some(0));
assert_eq!(two(b"*8000000000000").wind_direction_degrees(), Some(180));
assert_eq!(two(b"*C000000000000").wind_direction_degrees(), Some(270));
assert_eq!(two(b"*1000000000000").wind_direction_degrees(), Some(23));
assert_eq!(two(b"*0000000000000").temperature(), Some(-56));
assert_eq!(two(b"*000FF00000000").temperature(), Some(199));
assert_eq!(two(b"*41EA0----00C8").rain_upper, None);
}
#[test]
fn identifiers_are_disambiguated_from_their_neighbours() {
assert_eq!(detect(b"!4903.50N/07201.75W#"), None);
assert_eq!(
parse(b"!4903.50N/07201.75W#"),
Err(UltimeterError::NotDataLogger { got: b'4' })
);
assert_eq!(
parse(b"!/5L!!<*e7>7P["),
Err(UltimeterError::NotDataLogger { got: b'/' })
);
assert_eq!(detect(b"$GPRMC,063740,A,3349.2153,N"), None);
assert_eq!(
parse(b"$GPRMC,063740,A,3349.2153,N"),
Err(UltimeterError::NotPacketMode { got: *b"GPRM" })
);
assert_eq!(
parse(b"$ULTI0000000001FF"),
Err(UltimeterError::NotPacketMode { got: *b"ULTI" })
);
assert_eq!(detect(VERIFIED), Some(UltimeterFormat::Packet));
assert_eq!(detect(LOGGER), Some(UltimeterFormat::DataLogger));
assert_eq!(
detect(b"*41EA0006400C8"),
Some(UltimeterFormat::UltimeterTwo(WindUnit::Mph))
);
assert_eq!(
detect(b"#41EA0006400C8"),
Some(UltimeterFormat::UltimeterTwo(WindUnit::Kph))
);
assert_eq!(detect(b""), None);
assert_eq!(detect(b"_10090556c220"), None);
}
#[test]
fn identifier_round_trip() {
assert_eq!(UltimeterFormat::Packet.identifier(), b'$');
assert_eq!(UltimeterFormat::DataLogger.identifier(), b'!');
assert_eq!(
UltimeterFormat::UltimeterTwo(WindUnit::Mph).identifier(),
b'*'
);
assert_eq!(
UltimeterFormat::UltimeterTwo(WindUnit::Kph).identifier(),
b'#'
);
}
#[test]
fn trailers_are_borrowed_not_interpreted() {
let mut info = [0u8; 59];
info[..57].copy_from_slice(VERIFIED);
info[57] = b'\r';
info[58] = b'\n';
let record = match parse(&info) {
Ok(r) => r,
Err(e) => panic!("{e}"),
};
assert_eq!(record.rest(), b"\r\n");
assert_eq!(record.format(), UltimeterFormat::Packet);
assert_eq!(
record
.to_weather_report()
.temperature
.map(Temperature::fahrenheit),
Some(51)
);
}
#[test]
fn malformed_records_are_typed_errors() {
assert_eq!(
parse(b""),
Err(UltimeterError::Truncated {
expected: 1,
got: 0
})
);
assert_eq!(
parse(b"!"),
Err(UltimeterError::Truncated {
expected: 2,
got: 1
})
);
assert_eq!(
parse(b"$ULT"),
Err(UltimeterError::Truncated {
expected: 5,
got: 4
})
);
assert_eq!(
parse(b"T#005,199,000,255,073,123,01101001"),
Err(UltimeterError::InvalidDataType { got: b'T' })
);
assert_eq!(
parse(b"$ULTW0000000001FF000427C70002CCD3000!026E003A050F00040000"),
Err(UltimeterError::BadBodyLength {
format: UltimeterFormat::Packet,
got: 31
})
);
assert_eq!(
hex_field(b"00\x000", 0, 4),
Err(UltimeterError::BadHexDigit {
got: 0,
position: 2
})
);
assert_eq!(
parse(b"*41EA0006400C"),
Err(UltimeterError::BadBodyLength {
format: UltimeterFormat::UltimeterTwo(WindUnit::Mph),
got: 12
})
);
}
#[test]
fn unit_conversions_round_to_nearest() {
assert_eq!(tenth_kph_to_speed(0).mph(), 0);
assert_eq!(tenth_kph_to_speed(161).mph(), 10); assert_eq!(tenth_kph_to_speed(100).mph(), 6); assert_eq!(tenth_kph_to_speed(80).mph(), 5); assert_eq!(tenth_kph_to_speed(1000).mph(), 62); assert_eq!(tenth_kph_to_speed(1000).kmh(), 100); assert_eq!(tenth_kph_to_speed(u16::MAX).mph(), 4072);
assert_eq!(tenths_f_to_temperature(0).fahrenheit(), 0);
assert_eq!(tenths_f_to_temperature(5).fahrenheit(), 1);
assert_eq!(tenths_f_to_temperature(4).fahrenheit(), 0);
assert_eq!(tenths_f_to_temperature(-5).fahrenheit(), -1);
assert_eq!(tenths_f_to_temperature(-4).fahrenheit(), 0);
assert_eq!(tenths_f_to_temperature(i16::MAX).fahrenheit(), 3277);
assert_eq!(tenths_f_to_temperature(i16::MIN).fahrenheit(), -3277);
assert_eq!(tenths_f_to_temperature(320).celsius(), 0);
assert_eq!(tenths_f_to_temperature(2120).celsius(), 100);
assert_eq!(tenths_to_humidity(0), None);
assert_eq!(tenths_to_humidity(622).map(Humidity::percent), Some(62));
assert_eq!(tenths_to_humidity(1000).map(Humidity::percent), Some(100));
assert_eq!(tenths_to_humidity(u16::MAX), None);
assert_eq!(time_of_day(0), Some((0, 0)));
assert_eq!(time_of_day(1295), Some((21, 35)));
assert_eq!(time_of_day(1439), Some((23, 59)));
assert_eq!(time_of_day(1440), None);
assert_eq!(time_of_day(u16::MAX), None);
}
#[test]
fn error_messages_render() {
let errors = [
UltimeterError::Truncated {
expected: 5,
got: 2,
},
UltimeterError::InvalidDataType { got: b'T' },
UltimeterError::NotPacketMode { got: *b"GPRM" },
UltimeterError::NotDataLogger { got: b'4' },
UltimeterError::BadBodyLength {
format: UltimeterFormat::Packet,
got: 46,
},
UltimeterError::BadBodyLength {
format: UltimeterFormat::DataLogger,
got: 42,
},
UltimeterError::BadBodyLength {
format: UltimeterFormat::UltimeterTwo(WindUnit::Kph),
got: 12,
},
UltimeterError::BadHexDigit {
got: b'x',
position: 3,
},
UltimeterError::MixedDashField { position: 8 },
];
for error in errors {
let mut sink = Sink::new();
match write!(&mut sink, "{error}") {
Ok(()) => {}
Err(e) => panic!("{e}"),
}
assert!(sink.len > 16, "{error:?} rendered {} bytes", sink.len);
}
}
#[test]
fn byte_soup_never_panics() {
let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
const ALPHABET: &[u8] = b"0123456789abcdefABCDEF------$!*#ULTW \r\n,.";
const MAX_LEN: u64 = 64;
let mut buf = [0u8; MAX_LEN as usize];
for _ in 0..20_000 {
let draw = rng.next_u64();
let len = usize::try_from(draw % (MAX_LEN + 1)).unwrap_or(0);
let biased = (draw & (1u64 << 63)) == 0;
for slot in &mut buf[..len] {
let byte = u8::try_from(rng.next_u64() & 0xFF).unwrap_or(0);
*slot = if biased {
ALPHABET[usize::from(byte) % ALPHABET.len()]
} else {
byte
};
}
let info = &buf[..len];
let detected = detect(info);
match parse(info) {
Ok(record) => {
assert_eq!(detected, Some(record.format()));
assert!(record.rest().len() < info.len());
let weather = record.to_weather_report();
assert!(weather.wind_direction.is_none_or(|d| d <= 360));
let clock = match record {
UltimeterRecord::Packet(p) => {
assert!(p.day_of_year().is_none_or(|d| d >= 1));
p.time_of_day()
}
UltimeterRecord::DataLogger(l) => {
assert!(l.day_of_year().is_none_or(|d| d >= 1));
l.time_of_day()
}
UltimeterRecord::UltimeterTwo(t) => {
assert!(t.temperature().is_none_or(|c| c >= -56));
assert!(t.wind_direction_degrees().is_none_or(|d| d <= 338));
None
}
};
assert!(clock.is_none_or(|(h, m)| h < 24 && m < 60));
}
Err(e) => {
assert!(
detected.is_some()
|| matches!(
e,
UltimeterError::Truncated { .. }
| UltimeterError::InvalidDataType { .. }
| UltimeterError::NotPacketMode { .. }
| UltimeterError::NotDataLogger { .. }
),
"{e:?} for {info:?}"
);
}
}
}
}
struct Xorshift(u64);
impl Xorshift {
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
}
struct Sink {
buf: [u8; 160],
len: usize,
}
impl Sink {
fn new() -> Self {
Sink {
buf: [0; 160],
len: 0,
}
}
}
impl fmt::Write for Sink {
fn write_str(&mut self, s: &str) -> fmt::Result {
let end = (self.len + s.len()).min(self.buf.len());
let taken = end - self.len;
self.buf[self.len..end].copy_from_slice(&s.as_bytes()[..taken]);
self.len = end;
Ok(())
}
}
}