use bytes::Buf;
use crate::error::{Error, Result};
use aranet_types::{CurrentReading, DeviceType, Status};
fn from_parse_error(e: aranet_types::ParseError) -> Error {
Error::InvalidData(e.to_string())
}
#[derive(Debug, Clone)]
pub struct ExtendedReading {
pub reading: CurrentReading,
pub radiation_duration: Option<u64>,
}
pub fn parse_aranet4_reading(data: &[u8]) -> Result<CurrentReading> {
CurrentReading::from_bytes(data).map_err(|e| Error::InvalidData(e.to_string()))
}
pub fn parse_aranet2_reading(data: &[u8]) -> Result<CurrentReading> {
CurrentReading::from_bytes_aranet2(data).map_err(from_parse_error)
}
pub fn parse_aranet_radon_reading(data: &[u8]) -> Result<ExtendedReading> {
if data.len() < 15 {
return Err(Error::InvalidData(format!(
"Aranet Radon reading requires 15 bytes, got {}",
data.len()
)));
}
let mut buf = data;
let co2 = buf.get_u16_le();
let temp_raw = buf.get_i16_le();
let pressure_raw = buf.get_u16_le();
let humidity = buf.get_u8();
let battery = buf.get_u8();
let status = Status::from(buf.get_u8());
let interval = buf.get_u16_le();
let age = buf.get_u16_le();
let radon = buf.get_u16_le() as u32;
let reading = CurrentReading {
co2,
temperature: temp_raw as f32 / 20.0,
pressure: pressure_raw as f32 / 10.0,
humidity,
battery,
status,
interval,
age,
captured_at: None,
radon: Some(radon),
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
};
Ok(ExtendedReading {
reading,
radiation_duration: None,
})
}
pub fn parse_aranet_radon_gatt(data: &[u8]) -> Result<CurrentReading> {
CurrentReading::from_bytes_radon(data).map_err(from_parse_error)
}
pub fn parse_aranet_radiation_gatt(data: &[u8]) -> Result<ExtendedReading> {
let reading = CurrentReading::from_bytes_radiation(data).map_err(from_parse_error)?;
let duration = (&data[19..27]).get_u64_le();
Ok(ExtendedReading {
reading,
radiation_duration: Some(duration),
})
}
pub fn parse_reading_for_device(data: &[u8], device_type: DeviceType) -> Result<CurrentReading> {
CurrentReading::from_bytes_for_device(data, device_type).map_err(from_parse_error)
}
pub fn parse_extended_reading(data: &[u8], device_type: DeviceType) -> Result<ExtendedReading> {
match device_type {
DeviceType::AranetRadiation => parse_aranet_radiation_gatt(data),
_ => {
let reading = parse_reading_for_device(data, device_type)?;
Ok(ExtendedReading {
reading,
radiation_duration: None,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_aranet2_reading() {
let data: [u8; 12] = [
0x02, 0x00, 0x2C, 0x01, 0x78, 0x00, 90, 0xC2, 0x01, 0x26, 0x02, 0x04, ];
let reading = parse_aranet2_reading(&data).unwrap();
assert_eq!(reading.co2, 0);
assert!((reading.temperature - 22.5).abs() < 0.01);
assert_eq!(reading.humidity, 55);
assert_eq!(reading.battery, 90);
assert_eq!(reading.status, Status::Green);
assert_eq!(reading.interval, 300);
assert_eq!(reading.age, 120);
}
#[test]
fn test_parse_aranet2_reading_all_status_values() {
for (status_flags, expected_status) in [
(0x00, Status::Error), (0x04, Status::Green), (0x08, Status::Yellow), (0x0C, Status::Red), ] {
let data: [u8; 12] = [
0x02,
0x00, 0x2C,
0x01, 0x78,
0x00, 90, 0xC2,
0x01, 0x26,
0x02, status_flags,
];
let reading = parse_aranet2_reading(&data).unwrap();
assert_eq!(reading.status, expected_status);
}
}
#[test]
fn test_parse_aranet2_reading_insufficient_bytes() {
let data: [u8; 8] = [0x02, 0x00, 0x2C, 0x01, 0x78, 0x00, 90, 0xC2];
let result = parse_aranet2_reading(&data);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("expected 12"));
assert!(err.to_string().contains("got 8"));
}
#[test]
fn test_parse_aranet2_reading_edge_values() {
let data: [u8; 12] = [0; 12];
let reading = parse_aranet2_reading(&data).unwrap();
assert_eq!(reading.co2, 0);
assert!((reading.temperature - 0.0).abs() < 0.01);
assert_eq!(reading.humidity, 0);
assert_eq!(reading.battery, 0);
assert_eq!(reading.status, Status::Error);
assert_eq!(reading.interval, 0);
assert_eq!(reading.age, 0);
}
#[test]
fn test_parse_aranet2_reading_max_values() {
let data: [u8; 12] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 100, 0xFF, 0xFF, 0xFF, 0xFF, 0x0C, ];
let reading = parse_aranet2_reading(&data).unwrap();
assert!((reading.temperature - (-0.05)).abs() < 0.01); assert_eq!(reading.battery, 100);
assert_eq!(reading.status, Status::Red);
assert_eq!(reading.interval, 65535);
assert_eq!(reading.age, 65535);
}
#[test]
fn test_parse_aranet4_reading() {
let data: [u8; 13] = [
0x20, 0x03, 0xC2, 0x01, 0x94, 0x27, 45, 85, 1, 0x2C, 0x01, 0x78, 0x00, ];
let reading = parse_aranet4_reading(&data).unwrap();
assert_eq!(reading.co2, 800);
assert!((reading.temperature - 22.5).abs() < 0.01);
assert!((reading.pressure - 1013.2).abs() < 0.1);
assert_eq!(reading.humidity, 45);
assert_eq!(reading.battery, 85);
assert_eq!(reading.status, Status::Green);
assert_eq!(reading.interval, 300);
assert_eq!(reading.age, 120);
}
#[test]
fn test_parse_aranet4_reading_high_co2() {
let data: [u8; 13] = [
0xD0, 0x07, 0x90, 0x01, 0x88, 0x27, 60, 75, 3, 0x3C, 0x00, 0x1E, 0x00, ];
let reading = parse_aranet4_reading(&data).unwrap();
assert_eq!(reading.co2, 2000);
assert_eq!(reading.status, Status::Red);
}
#[test]
fn test_parse_aranet4_reading_insufficient_bytes() {
let data: [u8; 10] = [0; 10];
let result = parse_aranet4_reading(&data);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("expected 13"));
assert!(err.to_string().contains("got 10"));
}
#[test]
fn test_parse_aranet_radon_reading() {
let data: [u8; 15] = [
0x00, 0x00, 0xC2, 0x01, 0x94, 0x27, 50, 80, 1, 0x2C, 0x01, 0x3C, 0x00, 0x64, 0x00, ];
let result = parse_aranet_radon_reading(&data).unwrap();
assert_eq!(result.reading.radon, Some(100));
assert!(result.reading.radiation_rate.is_none());
assert!((result.reading.temperature - 22.5).abs() < 0.01);
assert_eq!(result.reading.humidity, 50);
}
#[test]
fn test_parse_aranet_radon_reading_high_radon() {
let mut data: [u8; 15] = [0; 15];
data[13] = 0xF4;
data[14] = 0x01;
let result = parse_aranet_radon_reading(&data).unwrap();
assert_eq!(result.reading.radon, Some(500));
}
#[test]
fn test_parse_aranet_radon_reading_insufficient_bytes() {
let data: [u8; 12] = [0; 12];
let result = parse_aranet_radon_reading(&data);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("requires 15 bytes")
);
}
#[test]
fn test_parse_aranet_radon_gatt() {
let mut data: [u8; 18] = [0; 18];
data[0] = 0x03;
data[1] = 0x00;
data[2] = 0x58;
data[3] = 0x02;
data[4] = 0x78;
data[5] = 0x00;
data[6] = 85;
data[7] = 0xC2;
data[8] = 0x01;
data[9] = 0x94;
data[10] = 0x27;
data[11] = 0xC2;
data[12] = 0x01;
data[13] = 0x64;
data[14] = 0x00;
data[15] = 0x00;
data[16] = 0x00;
data[17] = 1;
let reading = parse_aranet_radon_gatt(&data).unwrap();
assert_eq!(reading.battery, 85);
assert!((reading.temperature - 22.5).abs() < 0.01);
assert_eq!(reading.radon, Some(100)); assert_eq!(reading.co2, 0); assert_eq!(reading.status, Status::Green);
assert_eq!(reading.interval, 600);
assert_eq!(reading.age, 120);
}
#[test]
fn test_parse_aranet_radon_gatt_insufficient_bytes() {
let data: [u8; 15] = [0; 15];
let result = parse_aranet_radon_gatt(&data);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("expected 18"));
}
#[test]
fn test_parse_aranet_radon_gatt_high_radon() {
let mut data: [u8; 18] = [0; 18];
data[0] = 0x03; data[13] = 0xA0;
data[14] = 0x86;
data[15] = 0x01;
data[16] = 0x00;
let reading = parse_aranet_radon_gatt(&data).unwrap();
assert_eq!(reading.radon, Some(100000)); }
#[test]
fn test_parse_reading_for_device_aranet4() {
let data: [u8; 13] = [
0x20, 0x03, 0xC2, 0x01, 0x94, 0x27, 45, 85, 1, 0x2C, 0x01, 0x78, 0x00, ];
let reading = parse_reading_for_device(&data, DeviceType::Aranet4).unwrap();
assert_eq!(reading.co2, 800);
}
#[test]
fn test_parse_reading_for_device_aranet2() {
let data: [u8; 12] = [
0x02, 0x00, 0x2C, 0x01, 0x78, 0x00, 90, 0xC2, 0x01, 0x26, 0x02, 0x04, ];
let reading = parse_reading_for_device(&data, DeviceType::Aranet2).unwrap();
assert_eq!(reading.co2, 0); assert!((reading.temperature - 22.5).abs() < 0.01);
}
#[test]
fn test_extended_reading_with_radon() {
let reading = CurrentReading {
co2: 0,
temperature: 22.5,
pressure: 1013.2,
humidity: 50,
battery: 80,
status: Status::Green,
interval: 300,
age: 60,
captured_at: None,
radon: Some(150),
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
};
let extended = ExtendedReading {
reading,
radiation_duration: None,
};
assert_eq!(extended.reading.radon, Some(150));
assert!(extended.reading.radiation_rate.is_none());
assert!((extended.reading.temperature - 22.5).abs() < 0.01);
}
#[test]
fn test_extended_reading_with_radiation() {
let reading = CurrentReading {
co2: 0,
temperature: 20.0,
pressure: 1000.0,
humidity: 45,
battery: 90,
status: Status::Green,
interval: 60,
age: 30,
captured_at: None,
radon: None,
radiation_rate: Some(0.15),
radiation_total: Some(0.001),
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
};
let extended = ExtendedReading {
reading,
radiation_duration: Some(3600),
};
assert!(extended.reading.radon.is_none());
assert!((extended.reading.radiation_rate.unwrap() - 0.15).abs() < 0.001);
assert_eq!(extended.radiation_duration, Some(3600));
}
#[test]
fn test_extended_reading_debug() {
let reading = CurrentReading {
co2: 800,
temperature: 22.5,
pressure: 1013.2,
humidity: 50,
battery: 80,
status: Status::Green,
interval: 300,
age: 60,
captured_at: None,
radon: Some(100),
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
};
let extended = ExtendedReading {
reading,
radiation_duration: None,
};
let debug_str = format!("{:?}", extended);
assert!(debug_str.contains("radon"));
assert!(debug_str.contains("100"));
}
#[test]
fn test_extended_reading_clone() {
let reading = CurrentReading {
co2: 800,
temperature: 22.5,
pressure: 1013.2,
humidity: 50,
battery: 80,
status: Status::Green,
interval: 300,
age: 60,
captured_at: None,
radon: Some(100),
radiation_rate: Some(0.1),
radiation_total: Some(0.001),
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
};
let extended = ExtendedReading {
reading,
radiation_duration: Some(3600),
};
let cloned = extended.clone();
assert_eq!(cloned.reading.radon, extended.reading.radon);
assert_eq!(
cloned.reading.radiation_rate,
extended.reading.radiation_rate
);
assert_eq!(cloned.reading.co2, extended.reading.co2);
assert_eq!(cloned.radiation_duration, extended.radiation_duration);
}
#[test]
fn test_parse_aranet_radiation_gatt() {
let data = [
0x00, 0x00, 0x3C, 0x00, 0x1E, 0x00, 0x5A, 0xE8, 0x03, 0x00, 0x00, 0x40, 0x42, 0x0F, 0x00, 0x00, 0x00, 0x00,
0x00, 0x10, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, ];
let result = parse_aranet_radiation_gatt(&data).unwrap();
assert_eq!(result.reading.interval, 60);
assert_eq!(result.reading.age, 30);
assert_eq!(result.reading.battery, 90);
assert!((result.reading.radiation_rate.unwrap() - 1.0).abs() < 0.001);
assert!((result.reading.radiation_total.unwrap() - 1.0).abs() < 0.001);
assert_eq!(result.radiation_duration, Some(3600));
assert_eq!(result.reading.status, Status::Green);
assert!(result.reading.radon.is_none());
}
#[test]
fn test_parse_aranet_radiation_gatt_insufficient_bytes() {
let data = [0x00; 20]; let result = parse_aranet_radiation_gatt(&data);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("expected 28"));
}
#[test]
fn test_parse_aranet_radiation_gatt_high_values() {
let data = [
0x00, 0x00, 0x2C, 0x01, 0x0A, 0x00, 0x64, 0x10, 0x27, 0x00, 0x00, 0x00, 0xE1, 0xF5, 0x05, 0x00, 0x00, 0x00,
0x00, 0x80, 0x51, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x02, ];
let result = parse_aranet_radiation_gatt(&data).unwrap();
assert_eq!(result.reading.interval, 300);
assert!((result.reading.radiation_rate.unwrap() - 10.0).abs() < 0.001);
assert!((result.reading.radiation_total.unwrap() - 100.0).abs() < 0.001);
assert_eq!(result.radiation_duration, Some(86400));
assert_eq!(result.reading.status, Status::Yellow);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn parse_aranet4_never_panics(data: Vec<u8>) {
let _ = parse_aranet4_reading(&data);
}
#[test]
fn parse_aranet2_never_panics(data: Vec<u8>) {
let _ = parse_aranet2_reading(&data);
}
#[test]
fn parse_aranet_radon_never_panics(data: Vec<u8>) {
let _ = parse_aranet_radon_reading(&data);
}
#[test]
fn parse_aranet_radon_gatt_never_panics(data: Vec<u8>) {
let _ = parse_aranet_radon_gatt(&data);
}
#[test]
fn parse_aranet_radiation_gatt_never_panics(data: Vec<u8>) {
let _ = parse_aranet_radiation_gatt(&data);
}
#[test]
fn parse_reading_for_device_never_panics(
data: Vec<u8>,
device_type_byte in 0xF1u8..=0xF4u8,
) {
if let Ok(device_type) = DeviceType::try_from(device_type_byte) {
let _ = parse_reading_for_device(&data, device_type);
}
}
#[test]
fn aranet4_valid_bytes_parse_correctly(
co2 in 0u16..10000u16,
temp_raw in 0u16..2000u16,
pressure_raw in 8000u16..12000u16,
humidity in 0u8..100u8,
battery in 0u8..100u8,
status_byte in 0u8..4u8,
interval in 60u16..3600u16,
age in 0u16..3600u16,
) {
let mut data = [0u8; 13];
data[0..2].copy_from_slice(&co2.to_le_bytes());
data[2..4].copy_from_slice(&temp_raw.to_le_bytes());
data[4..6].copy_from_slice(&pressure_raw.to_le_bytes());
data[6] = humidity;
data[7] = battery;
data[8] = status_byte;
data[9..11].copy_from_slice(&interval.to_le_bytes());
data[11..13].copy_from_slice(&age.to_le_bytes());
let result = parse_aranet4_reading(&data);
prop_assert!(result.is_ok());
let reading = result.unwrap();
prop_assert_eq!(reading.co2, co2);
prop_assert_eq!(reading.humidity, humidity);
prop_assert_eq!(reading.battery, battery);
prop_assert_eq!(reading.interval, interval);
prop_assert_eq!(reading.age, age);
}
#[test]
fn aranet2_valid_bytes_parse_correctly(
temp_raw in 0u16..2000u16,
humidity_raw in 0u16..1000u16,
battery in 0u8..100u8,
status_flags in 0u8..16u8,
interval in 60u16..3600u16,
age in 0u16..3600u16,
) {
let mut data = [0u8; 12];
data[0..2].copy_from_slice(&0x0002u16.to_le_bytes()); data[2..4].copy_from_slice(&interval.to_le_bytes());
data[4..6].copy_from_slice(&age.to_le_bytes());
data[6] = battery;
data[7..9].copy_from_slice(&temp_raw.to_le_bytes());
data[9..11].copy_from_slice(&humidity_raw.to_le_bytes());
data[11] = status_flags;
let result = parse_aranet2_reading(&data);
prop_assert!(result.is_ok());
let reading = result.unwrap();
prop_assert_eq!(reading.co2, 0); prop_assert_eq!(reading.humidity, (humidity_raw / 10) as u8);
prop_assert_eq!(reading.battery, battery);
prop_assert_eq!(reading.interval, interval);
prop_assert_eq!(reading.age, age);
}
}
}