use core::fmt;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::error::ParseError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum DeviceType {
Aranet4 = 0xF1,
Aranet2 = 0xF2,
AranetRadon = 0xF3,
AranetRadiation = 0xF4,
}
impl DeviceType {
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
let name_lower = name.to_lowercase();
if Self::contains_word(&name_lower, "aranet4") {
return Some(DeviceType::Aranet4);
}
if Self::contains_word(&name_lower, "aranet2") {
return Some(DeviceType::Aranet2);
}
if name_lower.contains("aranetrn+")
|| Self::contains_word(&name_lower, "rn+")
|| Self::contains_word(&name_lower, "aranet radon")
|| Self::contains_word(&name_lower, "radon")
{
return Some(DeviceType::AranetRadon);
}
if Self::contains_word(&name_lower, "radiation") || name_lower.contains('\u{2622}') {
return Some(DeviceType::AranetRadiation);
}
None
}
fn contains_word(haystack: &str, needle: &str) -> bool {
let mut start = 0;
while let Some(pos) = haystack[start..].find(needle) {
let abs_pos = start + pos;
let before_ok = abs_pos == 0
|| haystack[..abs_pos]
.chars()
.last()
.is_none_or(|c| !c.is_alphanumeric());
let end_pos = abs_pos + needle.len();
let after_ok = end_pos >= haystack.len()
|| haystack[end_pos..]
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric());
if before_ok && after_ok {
return true;
}
start = abs_pos + 1;
if start >= haystack.len() {
break;
}
}
false
}
#[must_use]
pub fn has_co2(&self) -> bool {
matches!(self, DeviceType::Aranet4)
}
#[must_use]
pub fn has_temperature(&self) -> bool {
!matches!(self, DeviceType::AranetRadiation)
}
#[must_use]
pub fn has_humidity(&self) -> bool {
self.has_temperature()
}
#[must_use]
pub fn has_pressure(&self) -> bool {
matches!(self, DeviceType::Aranet4 | DeviceType::AranetRadon)
}
#[must_use]
pub fn readings_characteristic(&self) -> uuid::Uuid {
match self {
DeviceType::Aranet4 => crate::uuid::CURRENT_READINGS_DETAIL,
_ => crate::uuid::CURRENT_READINGS_DETAIL_ALT,
}
}
}
impl TryFrom<u8> for DeviceType {
type Error = ParseError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0xF1 => Ok(DeviceType::Aranet4),
0xF2 => Ok(DeviceType::Aranet2),
0xF3 => Ok(DeviceType::AranetRadon),
0xF4 => Ok(DeviceType::AranetRadiation),
_ => Err(ParseError::UnknownDeviceType(value)),
}
}
}
impl fmt::Display for DeviceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DeviceType::Aranet4 => write!(f, "Aranet4"),
DeviceType::Aranet2 => write!(f, "Aranet2"),
DeviceType::AranetRadon => write!(f, "Aranet Radon"),
DeviceType::AranetRadiation => write!(f, "Aranet Radiation"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[non_exhaustive]
#[repr(u8)]
pub enum Status {
Error = 0,
Green = 1,
Yellow = 2,
Red = 3,
}
impl From<u8> for Status {
fn from(value: u8) -> Self {
match value {
1 => Status::Green,
2 => Status::Yellow,
3 => Status::Red,
_ => Status::Error,
}
}
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Status::Error => write!(f, "Error"),
Status::Green => write!(f, "Good"),
Status::Yellow => write!(f, "Moderate"),
Status::Red => write!(f, "High"),
}
}
}
pub const MIN_CURRENT_READING_BYTES: usize = 13;
pub const MIN_ARANET2_READING_BYTES: usize = 12;
pub const MIN_RADON_READING_BYTES: usize = 15;
pub const MIN_RADON_GATT_READING_BYTES: usize = 18;
pub const MIN_RADIATION_READING_BYTES: usize = 28;
pub const RADON_AVERAGE_IN_PROGRESS: u32 = 0xFF00_0000;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct CurrentReading {
pub co2: u16,
pub temperature: f32,
pub pressure: f32,
pub humidity: u8,
pub battery: u8,
pub status: Status,
pub interval: u16,
pub age: u16,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub captured_at: Option<time::OffsetDateTime>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radon: Option<u32>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radiation_rate: Option<f32>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radiation_total: Option<f64>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radon_avg_24h: Option<u32>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radon_avg_7d: Option<u32>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radon_avg_30d: Option<u32>,
}
impl Default for CurrentReading {
fn default() -> Self {
Self {
co2: 0,
temperature: 0.0,
pressure: 0.0,
humidity: 0,
battery: 0,
status: Status::Error,
interval: 0,
age: 0,
captured_at: None,
radon: None,
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
}
}
}
impl CurrentReading {
#[must_use = "parsing returns a Result that should be handled"]
pub fn from_bytes(data: &[u8]) -> Result<Self, ParseError> {
Self::from_bytes_aranet4(data)
}
#[must_use = "parsing returns a Result that should be handled"]
pub fn from_bytes_aranet4(data: &[u8]) -> Result<Self, ParseError> {
use bytes::Buf;
if data.len() < MIN_CURRENT_READING_BYTES {
return Err(ParseError::InsufficientBytes {
expected: MIN_CURRENT_READING_BYTES,
actual: 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();
Ok(CurrentReading {
co2,
temperature: f32::from(temp_raw) / 20.0,
pressure: f32::from(pressure_raw) / 10.0,
humidity,
battery,
status,
interval,
age,
captured_at: None,
radon: None,
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
})
}
#[must_use = "parsing returns a Result that should be handled"]
pub fn from_bytes_aranet2(data: &[u8]) -> Result<Self, ParseError> {
use bytes::Buf;
if data.len() < MIN_ARANET2_READING_BYTES {
return Err(ParseError::InsufficientBytes {
expected: MIN_ARANET2_READING_BYTES,
actual: data.len(),
});
}
let mut buf = data;
let _header = buf.get_u16_le();
let interval = buf.get_u16_le();
let age = buf.get_u16_le();
let battery = buf.get_u8();
let temp_raw = buf.get_i16_le();
let humidity_raw = buf.get_u16_le();
let status_flags = buf.get_u8();
let status = Status::from((status_flags >> 2) & 0x03);
Ok(CurrentReading {
co2: 0, temperature: f32::from(temp_raw) / 20.0,
pressure: 0.0, humidity: (humidity_raw / 10).min(100) as u8,
battery,
status,
interval,
age,
captured_at: None,
radon: None,
radiation_rate: None,
radiation_total: None,
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
})
}
#[must_use = "parsing returns a Result that should be handled"]
pub fn from_bytes_radon(data: &[u8]) -> Result<Self, ParseError> {
use bytes::Buf;
if data.len() < MIN_RADON_GATT_READING_BYTES {
return Err(ParseError::InsufficientBytes {
expected: MIN_RADON_GATT_READING_BYTES,
actual: data.len(),
});
}
let mut buf = data;
let _device_type = buf.get_u16_le(); let interval = buf.get_u16_le();
let age = buf.get_u16_le();
let battery = buf.get_u8();
let temp_raw = buf.get_i16_le();
let pressure_raw = buf.get_u16_le();
let humidity_raw = buf.get_u16_le();
let radon = buf.get_u32_le();
let status = Status::from(buf.get_u8());
let (radon_avg_24h, radon_avg_7d, radon_avg_30d) = if buf.remaining() >= 24 {
let _time_24h = buf.get_u32_le();
let avg_24h_raw = buf.get_u32_le();
let _time_7d = buf.get_u32_le();
let avg_7d_raw = buf.get_u32_le();
let _time_30d = buf.get_u32_le();
let avg_30d_raw = buf.get_u32_le();
let avg_24h = if avg_24h_raw >= RADON_AVERAGE_IN_PROGRESS {
None
} else {
Some(avg_24h_raw)
};
let avg_7d = if avg_7d_raw >= RADON_AVERAGE_IN_PROGRESS {
None
} else {
Some(avg_7d_raw)
};
let avg_30d = if avg_30d_raw >= RADON_AVERAGE_IN_PROGRESS {
None
} else {
Some(avg_30d_raw)
};
(avg_24h, avg_7d, avg_30d)
} else {
(None, None, None)
};
Ok(CurrentReading {
co2: 0,
temperature: f32::from(temp_raw) / 20.0,
pressure: f32::from(pressure_raw) / 10.0,
humidity: (humidity_raw / 10).min(100) as u8,
battery,
status,
interval,
age,
captured_at: None,
radon: Some(radon),
radiation_rate: None,
radiation_total: None,
radon_avg_24h,
radon_avg_7d,
radon_avg_30d,
})
}
#[must_use = "parsing returns a Result that should be handled"]
#[allow(clippy::similar_names, clippy::cast_precision_loss)]
pub fn from_bytes_radiation(data: &[u8]) -> Result<Self, ParseError> {
use bytes::Buf;
if data.len() < MIN_RADIATION_READING_BYTES {
return Err(ParseError::InsufficientBytes {
expected: MIN_RADIATION_READING_BYTES,
actual: data.len(),
});
}
let mut buf = data;
let _unknown = buf.get_u16_le();
let interval = buf.get_u16_le();
let age = buf.get_u16_le();
let battery = buf.get_u8();
let dose_rate_nsv = buf.get_u32_le();
let total_dose_nsv = buf.get_u64_le();
let _duration = buf.get_u64_le(); let status = Status::from(buf.get_u8());
let dose_rate_usv = dose_rate_nsv as f32 / 1000.0;
let total_dose_msv = total_dose_nsv as f64 / 1_000_000.0;
Ok(CurrentReading {
co2: 0,
temperature: 0.0, pressure: 0.0,
humidity: 0,
battery,
status,
interval,
age,
captured_at: None,
radon: None,
radiation_rate: Some(dose_rate_usv),
radiation_total: Some(total_dose_msv),
radon_avg_24h: None,
radon_avg_7d: None,
radon_avg_30d: None,
})
}
#[must_use = "parsing returns a Result that should be handled"]
pub fn from_bytes_for_device(data: &[u8], device_type: DeviceType) -> Result<Self, ParseError> {
match device_type {
DeviceType::Aranet4 => Self::from_bytes_aranet4(data),
DeviceType::Aranet2 => Self::from_bytes_aranet2(data),
DeviceType::AranetRadon => Self::from_bytes_radon(data),
DeviceType::AranetRadiation => Self::from_bytes_radiation(data),
}
}
#[must_use]
pub fn with_captured_at(mut self, now: time::OffsetDateTime) -> Self {
self.captured_at = Some(now - time::Duration::seconds(i64::from(self.age)));
self
}
pub fn builder() -> CurrentReadingBuilder {
CurrentReadingBuilder::default()
}
}
#[derive(Debug, Default)]
#[must_use]
pub struct CurrentReadingBuilder {
reading: CurrentReading,
}
impl CurrentReadingBuilder {
pub fn co2(mut self, co2: u16) -> Self {
self.reading.co2 = co2;
self
}
pub fn temperature(mut self, temp: f32) -> Self {
self.reading.temperature = temp;
self
}
pub fn pressure(mut self, pressure: f32) -> Self {
self.reading.pressure = pressure;
self
}
pub fn humidity(mut self, humidity: u8) -> Self {
self.reading.humidity = humidity;
self
}
pub fn battery(mut self, battery: u8) -> Self {
self.reading.battery = battery;
self
}
pub fn status(mut self, status: Status) -> Self {
self.reading.status = status;
self
}
pub fn interval(mut self, interval: u16) -> Self {
self.reading.interval = interval;
self
}
pub fn age(mut self, age: u16) -> Self {
self.reading.age = age;
self
}
pub fn captured_at(mut self, timestamp: time::OffsetDateTime) -> Self {
self.reading.captured_at = Some(timestamp);
self
}
pub fn radon(mut self, radon: u32) -> Self {
self.reading.radon = Some(radon);
self
}
pub fn radiation_rate(mut self, rate: f32) -> Self {
self.reading.radiation_rate = Some(rate);
self
}
pub fn radiation_total(mut self, total: f64) -> Self {
self.reading.radiation_total = Some(total);
self
}
pub fn radon_avg_24h(mut self, avg: u32) -> Self {
self.reading.radon_avg_24h = Some(avg);
self
}
pub fn radon_avg_7d(mut self, avg: u32) -> Self {
self.reading.radon_avg_7d = Some(avg);
self
}
pub fn radon_avg_30d(mut self, avg: u32) -> Self {
self.reading.radon_avg_30d = Some(avg);
self
}
#[must_use]
pub fn build(self) -> CurrentReading {
self.reading
}
pub fn try_build(self) -> Result<CurrentReading, ParseError> {
if self.reading.humidity > 100 {
return Err(ParseError::InvalidValue(format!(
"humidity {} exceeds maximum of 100",
self.reading.humidity
)));
}
if self.reading.battery > 100 {
return Err(ParseError::InvalidValue(format!(
"battery {} exceeds maximum of 100",
self.reading.battery
)));
}
if self.reading.temperature < -40.0 || self.reading.temperature > 100.0 {
return Err(ParseError::InvalidValue(format!(
"temperature {} is outside valid range (-40 to 100°C)",
self.reading.temperature
)));
}
if self.reading.pressure != 0.0
&& (self.reading.pressure < 800.0 || self.reading.pressure > 1200.0)
{
return Err(ParseError::InvalidValue(format!(
"pressure {} is outside valid range (800-1200 hPa)",
self.reading.pressure
)));
}
if self.reading.co2 > 10_000 {
return Err(ParseError::InvalidValue(format!(
"co2 {} exceeds maximum sensor range of 10000 ppm",
self.reading.co2
)));
}
if let Some(radon) = self.reading.radon
&& radon > 10_000
{
return Err(ParseError::InvalidValue(format!(
"radon {} exceeds maximum expected range of 10000 Bq/m³",
radon
)));
}
Ok(self.reading)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct DeviceInfo {
pub name: String,
pub model: String,
pub serial: String,
pub firmware: String,
pub hardware: String,
pub software: String,
pub manufacturer: String,
}
impl DeviceInfo {
pub fn builder() -> DeviceInfoBuilder {
DeviceInfoBuilder::default()
}
}
#[derive(Debug, Default, Clone)]
#[must_use]
pub struct DeviceInfoBuilder {
info: DeviceInfo,
}
impl DeviceInfoBuilder {
pub fn name(mut self, name: impl Into<String>) -> Self {
self.info.name = name.into();
self
}
pub fn model(mut self, model: impl Into<String>) -> Self {
self.info.model = model.into();
self
}
pub fn serial(mut self, serial: impl Into<String>) -> Self {
self.info.serial = serial.into();
self
}
pub fn firmware(mut self, firmware: impl Into<String>) -> Self {
self.info.firmware = firmware.into();
self
}
pub fn hardware(mut self, hardware: impl Into<String>) -> Self {
self.info.hardware = hardware.into();
self
}
pub fn software(mut self, software: impl Into<String>) -> Self {
self.info.software = software.into();
self
}
pub fn manufacturer(mut self, manufacturer: impl Into<String>) -> Self {
self.info.manufacturer = manufacturer.into();
self
}
#[must_use]
pub fn build(self) -> DeviceInfo {
self.info
}
}
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct HistoryRecord {
pub timestamp: time::OffsetDateTime,
pub co2: u16,
pub temperature: f32,
pub pressure: f32,
pub humidity: u8,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radon: Option<u32>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radiation_rate: Option<f32>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub radiation_total: Option<f64>,
}
impl Default for HistoryRecord {
fn default() -> Self {
Self {
timestamp: time::OffsetDateTime::UNIX_EPOCH,
co2: 0,
temperature: 0.0,
pressure: 0.0,
humidity: 0,
radon: None,
radiation_rate: None,
radiation_total: None,
}
}
}
impl HistoryRecord {
pub fn builder() -> HistoryRecordBuilder {
HistoryRecordBuilder::default()
}
}
#[derive(Debug, Default)]
#[must_use]
pub struct HistoryRecordBuilder {
record: HistoryRecord,
}
impl HistoryRecordBuilder {
pub fn timestamp(mut self, timestamp: time::OffsetDateTime) -> Self {
self.record.timestamp = timestamp;
self
}
pub fn co2(mut self, co2: u16) -> Self {
self.record.co2 = co2;
self
}
pub fn temperature(mut self, temp: f32) -> Self {
self.record.temperature = temp;
self
}
pub fn pressure(mut self, pressure: f32) -> Self {
self.record.pressure = pressure;
self
}
pub fn humidity(mut self, humidity: u8) -> Self {
self.record.humidity = humidity;
self
}
pub fn radon(mut self, radon: u32) -> Self {
self.record.radon = Some(radon);
self
}
pub fn radiation_rate(mut self, rate: f32) -> Self {
self.record.radiation_rate = Some(rate);
self
}
pub fn radiation_total(mut self, total: f64) -> Self {
self.record.radiation_total = Some(total);
self
}
#[must_use]
pub fn build(self) -> HistoryRecord {
self.record
}
}