aranet_types/
types.rs

1//! Core types for Aranet sensor data.
2
3use core::fmt;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8use crate::error::ParseError;
9
10/// Type of Aranet device.
11///
12/// This enum is marked `#[non_exhaustive]` to allow adding new device types
13/// in future versions without breaking downstream code.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16#[non_exhaustive]
17#[repr(u8)]
18pub enum DeviceType {
19    /// Aranet4 CO2, temperature, humidity, and pressure sensor.
20    Aranet4 = 0xF1,
21    /// Aranet2 temperature and humidity sensor.
22    Aranet2 = 0xF2,
23    /// Aranet Radon sensor.
24    AranetRadon = 0xF3,
25    /// Aranet Radiation sensor.
26    AranetRadiation = 0xF4,
27}
28
29impl DeviceType {
30    /// Detect device type from a device name.
31    ///
32    /// Analyzes the device name (case-insensitive) to determine the device type
33    /// based on common naming patterns. Uses word-boundary-aware matching to avoid
34    /// false positives (e.g., `"Aranet4"` won't match `"NotAranet4Device"`).
35    ///
36    /// # Examples
37    ///
38    /// ```
39    /// use aranet_types::DeviceType;
40    ///
41    /// assert_eq!(DeviceType::from_name("Aranet4 12345"), Some(DeviceType::Aranet4));
42    /// assert_eq!(DeviceType::from_name("Aranet2 Home"), Some(DeviceType::Aranet2));
43    /// assert_eq!(DeviceType::from_name("Aranet4"), Some(DeviceType::Aranet4));
44    /// assert_eq!(DeviceType::from_name("AranetRn+ 306B8"), Some(DeviceType::AranetRadon));
45    /// assert_eq!(DeviceType::from_name("RN+ Radon"), Some(DeviceType::AranetRadon));
46    /// assert_eq!(DeviceType::from_name("Aranet Radiation"), Some(DeviceType::AranetRadiation));
47    /// assert_eq!(DeviceType::from_name("Unknown Device"), None);
48    /// ```
49    #[must_use]
50    pub fn from_name(name: &str) -> Option<Self> {
51        let name_lower = name.to_lowercase();
52
53        // Check for Aranet4 - must be at word boundary (start or after non-alphanumeric)
54        if Self::contains_word(&name_lower, "aranet4") {
55            return Some(DeviceType::Aranet4);
56        }
57
58        // Check for Aranet2
59        if Self::contains_word(&name_lower, "aranet2") {
60            return Some(DeviceType::Aranet2);
61        }
62
63        // Check for Radon devices (AranetRn+, RN+, or Radon keyword)
64        if name_lower.contains("aranetrn+")
65            || Self::contains_word(&name_lower, "rn+")
66            || Self::contains_word(&name_lower, "aranet radon")
67            || (name_lower.starts_with("radon") || name_lower.contains(" radon"))
68        {
69            return Some(DeviceType::AranetRadon);
70        }
71
72        // Check for Radiation devices
73        if Self::contains_word(&name_lower, "radiation")
74            || Self::contains_word(&name_lower, "aranet radiation")
75        {
76            return Some(DeviceType::AranetRadiation);
77        }
78
79        None
80    }
81
82    /// Check if a string contains a word at a word boundary.
83    ///
84    /// A word boundary is defined as the start/end of the string or a non-alphanumeric character.
85    fn contains_word(haystack: &str, needle: &str) -> bool {
86        if let Some(pos) = haystack.find(needle) {
87            // Check character before the match (if any)
88            let before_ok = pos == 0
89                || haystack[..pos]
90                    .chars()
91                    .last()
92                    .is_none_or(|c| !c.is_alphanumeric());
93
94            // Check character after the match (if any)
95            let end_pos = pos + needle.len();
96            let after_ok = end_pos >= haystack.len()
97                || haystack[end_pos..]
98                    .chars()
99                    .next()
100                    .is_none_or(|c| !c.is_alphanumeric());
101
102            before_ok && after_ok
103        } else {
104            false
105        }
106    }
107
108    /// Returns the BLE characteristic UUID for reading current sensor values.
109    ///
110    /// - **Aranet4**: Uses `CURRENT_READINGS_DETAIL` (f0cd3001)
111    /// - **Other devices**: Use `CURRENT_READINGS_DETAIL_ALT` (f0cd3003)
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use aranet_types::DeviceType;
117    /// use aranet_types::ble;
118    ///
119    /// assert_eq!(DeviceType::Aranet4.readings_characteristic(), ble::CURRENT_READINGS_DETAIL);
120    /// assert_eq!(DeviceType::Aranet2.readings_characteristic(), ble::CURRENT_READINGS_DETAIL_ALT);
121    /// ```
122    #[must_use]
123    pub fn readings_characteristic(&self) -> uuid::Uuid {
124        match self {
125            DeviceType::Aranet4 => crate::uuid::CURRENT_READINGS_DETAIL,
126            _ => crate::uuid::CURRENT_READINGS_DETAIL_ALT,
127        }
128    }
129}
130
131impl TryFrom<u8> for DeviceType {
132    type Error = ParseError;
133
134    /// Convert a byte value to a `DeviceType`.
135    ///
136    /// # Examples
137    ///
138    /// ```
139    /// use aranet_types::DeviceType;
140    ///
141    /// assert_eq!(DeviceType::try_from(0xF1), Ok(DeviceType::Aranet4));
142    /// assert_eq!(DeviceType::try_from(0xF2), Ok(DeviceType::Aranet2));
143    /// assert!(DeviceType::try_from(0x00).is_err());
144    /// ```
145    fn try_from(value: u8) -> Result<Self, Self::Error> {
146        match value {
147            0xF1 => Ok(DeviceType::Aranet4),
148            0xF2 => Ok(DeviceType::Aranet2),
149            0xF3 => Ok(DeviceType::AranetRadon),
150            0xF4 => Ok(DeviceType::AranetRadiation),
151            _ => Err(ParseError::UnknownDeviceType(value)),
152        }
153    }
154}
155
156impl fmt::Display for DeviceType {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            DeviceType::Aranet4 => write!(f, "Aranet4"),
160            DeviceType::Aranet2 => write!(f, "Aranet2"),
161            DeviceType::AranetRadon => write!(f, "Aranet Radon"),
162            DeviceType::AranetRadiation => write!(f, "Aranet Radiation"),
163        }
164    }
165}
166
167/// CO2 level status indicator.
168///
169/// This enum is marked `#[non_exhaustive]` to allow adding new status levels
170/// in future versions without breaking downstream code.
171///
172/// # Ordering
173///
174/// Status values are ordered by severity: `Error < Green < Yellow < Red`.
175/// This allows threshold comparisons like `if status >= Status::Yellow { warn!(...) }`.
176///
177/// # Display vs Serialization
178///
179/// **Note:** The `Display` trait returns human-readable labels ("Good", "Moderate", "High"),
180/// while serde serialization uses the variant names ("Green", "Yellow", "Red").
181///
182/// ```
183/// use aranet_types::Status;
184///
185/// // Display is human-readable
186/// assert_eq!(format!("{}", Status::Green), "Good");
187///
188/// // Ordering works for threshold comparisons
189/// assert!(Status::Red > Status::Yellow);
190/// assert!(Status::Yellow > Status::Green);
191/// ```
192#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
193#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
194#[non_exhaustive]
195#[repr(u8)]
196pub enum Status {
197    /// Error or invalid reading.
198    Error = 0,
199    /// CO2 level is good (green).
200    Green = 1,
201    /// CO2 level is moderate (yellow).
202    Yellow = 2,
203    /// CO2 level is high (red).
204    Red = 3,
205}
206
207impl From<u8> for Status {
208    fn from(value: u8) -> Self {
209        match value {
210            1 => Status::Green,
211            2 => Status::Yellow,
212            3 => Status::Red,
213            _ => Status::Error,
214        }
215    }
216}
217
218impl fmt::Display for Status {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        match self {
221            Status::Error => write!(f, "Error"),
222            Status::Green => write!(f, "Good"),
223            Status::Yellow => write!(f, "Moderate"),
224            Status::Red => write!(f, "High"),
225        }
226    }
227}
228
229/// Minimum number of bytes required to parse an Aranet4 [`CurrentReading`].
230pub const MIN_CURRENT_READING_BYTES: usize = 13;
231
232/// Minimum number of bytes required to parse an Aranet2 [`CurrentReading`].
233pub const MIN_ARANET2_READING_BYTES: usize = 7;
234
235/// Minimum number of bytes required to parse an Aranet Radon [`CurrentReading`] (advertisement format).
236pub const MIN_RADON_READING_BYTES: usize = 15;
237
238/// Minimum number of bytes required to parse an Aranet Radon GATT [`CurrentReading`].
239pub const MIN_RADON_GATT_READING_BYTES: usize = 18;
240
241/// Minimum number of bytes required to parse an Aranet Radiation [`CurrentReading`].
242pub const MIN_RADIATION_READING_BYTES: usize = 28;
243
244/// Current reading from an Aranet sensor.
245///
246/// This struct supports all Aranet device types:
247/// - **Aranet4**: CO2, temperature, pressure, humidity
248/// - **Aranet2**: Temperature, humidity (co2 and pressure will be 0)
249/// - **`AranetRn+` (Radon)**: Radon, temperature, pressure, humidity (co2 will be 0)
250/// - **Aranet Radiation**: Radiation dose, temperature (uses `radiation_*` fields)
251#[derive(Debug, Clone, Copy, PartialEq)]
252#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
253pub struct CurrentReading {
254    /// CO2 concentration in ppm (Aranet4 only, 0 for other devices).
255    pub co2: u16,
256    /// Temperature in degrees Celsius.
257    pub temperature: f32,
258    /// Atmospheric pressure in hPa (0 for Aranet2).
259    pub pressure: f32,
260    /// Relative humidity percentage (0-100).
261    pub humidity: u8,
262    /// Battery level percentage (0-100).
263    pub battery: u8,
264    /// CO2 status indicator.
265    pub status: Status,
266    /// Measurement interval in seconds.
267    pub interval: u16,
268    /// Age of reading in seconds since last measurement.
269    pub age: u16,
270    /// Timestamp when the reading was captured (if known).
271    ///
272    /// This is typically set by the library when reading from a device,
273    /// calculated as `now - age`.
274    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
275    pub captured_at: Option<time::OffsetDateTime>,
276    /// Radon concentration in Bq/m³ (`AranetRn+` only).
277    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
278    pub radon: Option<u32>,
279    /// Radiation dose rate in µSv/h (Aranet Radiation only).
280    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
281    pub radiation_rate: Option<f32>,
282    /// Total radiation dose in mSv (Aranet Radiation only).
283    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
284    pub radiation_total: Option<f64>,
285    /// 24-hour average radon concentration in Bq/m³ (`AranetRn+` only).
286    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
287    pub radon_avg_24h: Option<u32>,
288    /// 7-day average radon concentration in Bq/m³ (`AranetRn+` only).
289    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
290    pub radon_avg_7d: Option<u32>,
291    /// 30-day average radon concentration in Bq/m³ (`AranetRn+` only).
292    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
293    pub radon_avg_30d: Option<u32>,
294}
295
296impl Default for CurrentReading {
297    fn default() -> Self {
298        Self {
299            co2: 0,
300            temperature: 0.0,
301            pressure: 0.0,
302            humidity: 0,
303            battery: 0,
304            status: Status::Error,
305            interval: 0,
306            age: 0,
307            captured_at: None,
308            radon: None,
309            radiation_rate: None,
310            radiation_total: None,
311            radon_avg_24h: None,
312            radon_avg_7d: None,
313            radon_avg_30d: None,
314        }
315    }
316}
317
318impl CurrentReading {
319    /// Parse a `CurrentReading` from raw bytes (Aranet4 format).
320    ///
321    /// The byte format is:
322    /// - bytes 0-1: CO2 (u16 LE)
323    /// - bytes 2-3: Temperature (u16 LE, divide by 20 for Celsius)
324    /// - bytes 4-5: Pressure (u16 LE, divide by 10 for hPa)
325    /// - byte 6: Humidity (u8)
326    /// - byte 7: Battery (u8)
327    /// - byte 8: Status (u8)
328    /// - bytes 9-10: Interval (u16 LE)
329    /// - bytes 11-12: Age (u16 LE)
330    ///
331    /// # Errors
332    ///
333    /// Returns [`ParseError::InsufficientBytes`] if `data` contains fewer than
334    /// [`MIN_CURRENT_READING_BYTES`] (13) bytes.
335    #[must_use = "parsing returns a Result that should be handled"]
336    pub fn from_bytes(data: &[u8]) -> Result<Self, ParseError> {
337        Self::from_bytes_aranet4(data)
338    }
339
340    /// Parse a `CurrentReading` from raw bytes (Aranet4 format).
341    ///
342    /// This is an alias for [`from_bytes`](Self::from_bytes) for explicit device type parsing.
343    ///
344    /// # Errors
345    ///
346    /// Returns [`ParseError::InsufficientBytes`] if `data` contains fewer than
347    /// [`MIN_CURRENT_READING_BYTES`] (13) bytes.
348    #[must_use = "parsing returns a Result that should be handled"]
349    pub fn from_bytes_aranet4(data: &[u8]) -> Result<Self, ParseError> {
350        use bytes::Buf;
351
352        if data.len() < MIN_CURRENT_READING_BYTES {
353            return Err(ParseError::InsufficientBytes {
354                expected: MIN_CURRENT_READING_BYTES,
355                actual: data.len(),
356            });
357        }
358
359        let mut buf = data;
360        let co2 = buf.get_u16_le();
361        let temp_raw = buf.get_u16_le();
362        let pressure_raw = buf.get_u16_le();
363        let humidity = buf.get_u8();
364        let battery = buf.get_u8();
365        let status = Status::from(buf.get_u8());
366        let interval = buf.get_u16_le();
367        let age = buf.get_u16_le();
368
369        Ok(CurrentReading {
370            co2,
371            temperature: f32::from(temp_raw) / 20.0,
372            pressure: f32::from(pressure_raw) / 10.0,
373            humidity,
374            battery,
375            status,
376            interval,
377            age,
378            captured_at: None,
379            radon: None,
380            radiation_rate: None,
381            radiation_total: None,
382            radon_avg_24h: None,
383            radon_avg_7d: None,
384            radon_avg_30d: None,
385        })
386    }
387
388    /// Parse a `CurrentReading` from raw bytes (Aranet2 format).
389    ///
390    /// The byte format is:
391    /// - bytes 0-1: Temperature (u16 LE, divide by 20 for Celsius)
392    /// - byte 2: Humidity (u8)
393    /// - byte 3: Battery (u8)
394    /// - byte 4: Status (u8)
395    /// - bytes 5-6: Interval (u16 LE)
396    ///
397    /// # Errors
398    ///
399    /// Returns [`ParseError::InsufficientBytes`] if `data` contains fewer than
400    /// [`MIN_ARANET2_READING_BYTES`] (7) bytes.
401    #[must_use = "parsing returns a Result that should be handled"]
402    pub fn from_bytes_aranet2(data: &[u8]) -> Result<Self, ParseError> {
403        use bytes::Buf;
404
405        if data.len() < MIN_ARANET2_READING_BYTES {
406            return Err(ParseError::InsufficientBytes {
407                expected: MIN_ARANET2_READING_BYTES,
408                actual: data.len(),
409            });
410        }
411
412        let mut buf = data;
413        let temp_raw = buf.get_u16_le();
414        let humidity = buf.get_u8();
415        let battery = buf.get_u8();
416        let status = Status::from(buf.get_u8());
417        let interval = buf.get_u16_le();
418
419        Ok(CurrentReading {
420            co2: 0, // Aranet2 doesn't have CO2
421            temperature: f32::from(temp_raw) / 20.0,
422            pressure: 0.0, // Aranet2 doesn't have pressure
423            humidity,
424            battery,
425            status,
426            interval,
427            age: 0,
428            captured_at: None,
429            radon: None,
430            radiation_rate: None,
431            radiation_total: None,
432            radon_avg_24h: None,
433            radon_avg_7d: None,
434            radon_avg_30d: None,
435        })
436    }
437
438    /// Parse a `CurrentReading` from raw bytes (Aranet Radon GATT format).
439    ///
440    /// The byte format is:
441    /// - bytes 0-1: Device type marker (u16 LE, 0x0003 for radon)
442    /// - bytes 2-3: Interval (u16 LE, seconds)
443    /// - bytes 4-5: Age (u16 LE, seconds since update)
444    /// - byte 6: Battery (u8)
445    /// - bytes 7-8: Temperature (u16 LE, divide by 20 for Celsius)
446    /// - bytes 9-10: Pressure (u16 LE, divide by 10 for hPa)
447    /// - bytes 11-12: Humidity (u16 LE, divide by 10 for percent)
448    /// - bytes 13-16: Radon (u32 LE, Bq/m³)
449    /// - byte 17: Status (u8)
450    ///
451    /// Extended format (47 bytes) includes working averages:
452    /// - bytes 18-21: 24h average time (u32 LE)
453    /// - bytes 22-25: 24h average value (u32 LE, Bq/m³)
454    /// - bytes 26-29: 7d average time (u32 LE)
455    /// - bytes 30-33: 7d average value (u32 LE, Bq/m³)
456    /// - bytes 34-37: 30d average time (u32 LE)
457    /// - bytes 38-41: 30d average value (u32 LE, Bq/m³)
458    /// - bytes 42-45: Initial progress (u32 LE, optional)
459    /// - byte 46: Display type (u8, optional)
460    ///
461    /// Note: If an average value >= 0xff000000, it indicates the average
462    /// is still being calculated (in progress) and is not yet available.
463    ///
464    /// # Errors
465    ///
466    /// Returns [`ParseError::InsufficientBytes`] if `data` contains fewer than
467    /// [`MIN_RADON_GATT_READING_BYTES`] (18) bytes.
468    #[must_use = "parsing returns a Result that should be handled"]
469    pub fn from_bytes_radon(data: &[u8]) -> Result<Self, ParseError> {
470        use bytes::Buf;
471
472        if data.len() < MIN_RADON_GATT_READING_BYTES {
473            return Err(ParseError::InsufficientBytes {
474                expected: MIN_RADON_GATT_READING_BYTES,
475                actual: data.len(),
476            });
477        }
478
479        let mut buf = data;
480
481        // Parse header
482        let _device_type = buf.get_u16_le(); // 0x0003 for radon
483        let interval = buf.get_u16_le();
484        let age = buf.get_u16_le();
485        let battery = buf.get_u8();
486
487        // Parse sensor values
488        let temp_raw = buf.get_u16_le();
489        let pressure_raw = buf.get_u16_le();
490        let humidity_raw = buf.get_u16_le();
491        let radon = buf.get_u32_le();
492        let status = if buf.has_remaining() {
493            Status::from(buf.get_u8())
494        } else {
495            Status::Green
496        };
497
498        // Parse optional working averages (extended format, 47 bytes)
499        // Each average is a pair: (time: u32, value: u32)
500        // If value >= 0xff000000, the average is still being calculated
501        let (radon_avg_24h, radon_avg_7d, radon_avg_30d) = if buf.remaining() >= 24 {
502            let _time_24h = buf.get_u32_le();
503            let avg_24h_raw = buf.get_u32_le();
504            let _time_7d = buf.get_u32_le();
505            let avg_7d_raw = buf.get_u32_le();
506            let _time_30d = buf.get_u32_le();
507            let avg_30d_raw = buf.get_u32_le();
508
509            // Values >= 0xff000000 indicate "in progress" (not yet available)
510            let avg_24h = if avg_24h_raw >= 0xff00_0000 {
511                None
512            } else {
513                Some(avg_24h_raw)
514            };
515            let avg_7d = if avg_7d_raw >= 0xff00_0000 {
516                None
517            } else {
518                Some(avg_7d_raw)
519            };
520            let avg_30d = if avg_30d_raw >= 0xff00_0000 {
521                None
522            } else {
523                Some(avg_30d_raw)
524            };
525
526            (avg_24h, avg_7d, avg_30d)
527        } else {
528            (None, None, None)
529        };
530
531        Ok(CurrentReading {
532            co2: 0,
533            temperature: f32::from(temp_raw) / 20.0,
534            pressure: f32::from(pressure_raw) / 10.0,
535            humidity: (humidity_raw / 10).min(255) as u8, // Convert from 10ths to percent
536            battery,
537            status,
538            interval,
539            age,
540            captured_at: None,
541            radon: Some(radon),
542            radiation_rate: None,
543            radiation_total: None,
544            radon_avg_24h,
545            radon_avg_7d,
546            radon_avg_30d,
547        })
548    }
549
550    /// Parse a `CurrentReading` from raw bytes (Aranet Radiation GATT format).
551    ///
552    /// The byte format is:
553    /// - bytes 0-1: Unknown/header (u16 LE)
554    /// - bytes 2-3: Interval (u16 LE, seconds)
555    /// - bytes 4-5: Age (u16 LE, seconds)
556    /// - byte 6: Battery (u8)
557    /// - bytes 7-10: Dose rate (u32 LE, nSv/h, divide by 1000 for µSv/h)
558    /// - bytes 11-18: Total dose (u64 LE, nSv, divide by `1_000_000` for mSv)
559    /// - bytes 19-26: Duration (u64 LE, seconds) - not stored in `CurrentReading`
560    /// - byte 27: Status (u8)
561    ///
562    /// # Errors
563    ///
564    /// Returns [`ParseError::InsufficientBytes`] if `data` contains fewer than
565    /// [`MIN_RADIATION_READING_BYTES`] (28) bytes.
566    #[must_use = "parsing returns a Result that should be handled"]
567    #[allow(clippy::similar_names, clippy::cast_precision_loss)]
568    pub fn from_bytes_radiation(data: &[u8]) -> Result<Self, ParseError> {
569        use bytes::Buf;
570
571        if data.len() < MIN_RADIATION_READING_BYTES {
572            return Err(ParseError::InsufficientBytes {
573                expected: MIN_RADIATION_READING_BYTES,
574                actual: data.len(),
575            });
576        }
577
578        let mut buf = data;
579
580        // Parse header
581        let _unknown = buf.get_u16_le();
582        let interval = buf.get_u16_le();
583        let age = buf.get_u16_le();
584        let battery = buf.get_u8();
585
586        // Parse radiation values
587        let dose_rate_nsv = buf.get_u32_le();
588        let total_dose_nsv = buf.get_u64_le();
589        let _duration = buf.get_u64_le(); // Duration in seconds (not stored)
590        let status = if buf.has_remaining() {
591            Status::from(buf.get_u8())
592        } else {
593            Status::Green
594        };
595
596        // Convert units: nSv/h -> µSv/h, nSv -> mSv
597        let dose_rate_usv = dose_rate_nsv as f32 / 1000.0;
598        let total_dose_msv = total_dose_nsv as f64 / 1_000_000.0;
599
600        Ok(CurrentReading {
601            co2: 0,
602            temperature: 0.0, // Radiation devices don't report temperature
603            pressure: 0.0,
604            humidity: 0,
605            battery,
606            status,
607            interval,
608            age,
609            captured_at: None,
610            radon: None,
611            radiation_rate: Some(dose_rate_usv),
612            radiation_total: Some(total_dose_msv),
613            radon_avg_24h: None,
614            radon_avg_7d: None,
615            radon_avg_30d: None,
616        })
617    }
618
619    /// Parse a `CurrentReading` from raw bytes based on device type.
620    ///
621    /// This dispatches to the appropriate parsing method based on the device type.
622    ///
623    /// # Errors
624    ///
625    /// Returns [`ParseError::InsufficientBytes`] if `data` doesn't contain enough bytes
626    /// for the specified device type.
627    #[must_use = "parsing returns a Result that should be handled"]
628    pub fn from_bytes_for_device(data: &[u8], device_type: DeviceType) -> Result<Self, ParseError> {
629        match device_type {
630            DeviceType::Aranet4 => Self::from_bytes_aranet4(data),
631            DeviceType::Aranet2 => Self::from_bytes_aranet2(data),
632            DeviceType::AranetRadon => Self::from_bytes_radon(data),
633            DeviceType::AranetRadiation => Self::from_bytes_radiation(data),
634        }
635    }
636
637    /// Set the captured timestamp to the current time minus the age.
638    ///
639    /// This is useful for setting the timestamp when reading from a device.
640    #[must_use]
641    pub fn with_captured_at(mut self, now: time::OffsetDateTime) -> Self {
642        self.captured_at = Some(now - time::Duration::seconds(i64::from(self.age)));
643        self
644    }
645
646    /// Create a builder for constructing `CurrentReading` with optional fields.
647    pub fn builder() -> CurrentReadingBuilder {
648        CurrentReadingBuilder::default()
649    }
650}
651
652/// Builder for constructing `CurrentReading` with device-specific fields.
653///
654/// Use [`build`](Self::build) for unchecked construction, or [`try_build`](Self::try_build)
655/// for validation of field values.
656#[derive(Debug, Default)]
657#[must_use]
658pub struct CurrentReadingBuilder {
659    reading: CurrentReading,
660}
661
662impl CurrentReadingBuilder {
663    /// Set CO2 concentration (Aranet4).
664    pub fn co2(mut self, co2: u16) -> Self {
665        self.reading.co2 = co2;
666        self
667    }
668
669    /// Set temperature.
670    pub fn temperature(mut self, temp: f32) -> Self {
671        self.reading.temperature = temp;
672        self
673    }
674
675    /// Set pressure.
676    pub fn pressure(mut self, pressure: f32) -> Self {
677        self.reading.pressure = pressure;
678        self
679    }
680
681    /// Set humidity (0-100).
682    pub fn humidity(mut self, humidity: u8) -> Self {
683        self.reading.humidity = humidity;
684        self
685    }
686
687    /// Set battery level (0-100).
688    pub fn battery(mut self, battery: u8) -> Self {
689        self.reading.battery = battery;
690        self
691    }
692
693    /// Set status.
694    pub fn status(mut self, status: Status) -> Self {
695        self.reading.status = status;
696        self
697    }
698
699    /// Set measurement interval.
700    pub fn interval(mut self, interval: u16) -> Self {
701        self.reading.interval = interval;
702        self
703    }
704
705    /// Set reading age.
706    pub fn age(mut self, age: u16) -> Self {
707        self.reading.age = age;
708        self
709    }
710
711    /// Set the captured timestamp.
712    pub fn captured_at(mut self, timestamp: time::OffsetDateTime) -> Self {
713        self.reading.captured_at = Some(timestamp);
714        self
715    }
716
717    /// Set radon concentration (`AranetRn+`).
718    pub fn radon(mut self, radon: u32) -> Self {
719        self.reading.radon = Some(radon);
720        self
721    }
722
723    /// Set radiation dose rate (Aranet Radiation).
724    pub fn radiation_rate(mut self, rate: f32) -> Self {
725        self.reading.radiation_rate = Some(rate);
726        self
727    }
728
729    /// Set total radiation dose (Aranet Radiation).
730    pub fn radiation_total(mut self, total: f64) -> Self {
731        self.reading.radiation_total = Some(total);
732        self
733    }
734
735    /// Set 24-hour average radon concentration (`AranetRn+`).
736    pub fn radon_avg_24h(mut self, avg: u32) -> Self {
737        self.reading.radon_avg_24h = Some(avg);
738        self
739    }
740
741    /// Set 7-day average radon concentration (`AranetRn+`).
742    pub fn radon_avg_7d(mut self, avg: u32) -> Self {
743        self.reading.radon_avg_7d = Some(avg);
744        self
745    }
746
747    /// Set 30-day average radon concentration (`AranetRn+`).
748    pub fn radon_avg_30d(mut self, avg: u32) -> Self {
749        self.reading.radon_avg_30d = Some(avg);
750        self
751    }
752
753    /// Build the `CurrentReading` without validation.
754    #[must_use]
755    pub fn build(self) -> CurrentReading {
756        self.reading
757    }
758
759    /// Build the `CurrentReading` with validation.
760    ///
761    /// Validates:
762    /// - `humidity` is 0-100
763    /// - `battery` is 0-100
764    /// - `temperature` is within reasonable range (-40 to 100°C)
765    /// - `pressure` is within reasonable range (800-1200 hPa) or 0
766    ///
767    /// # Errors
768    ///
769    /// Returns [`ParseError::InvalidValue`] if any field has an invalid value.
770    pub fn try_build(self) -> Result<CurrentReading, ParseError> {
771        if self.reading.humidity > 100 {
772            return Err(ParseError::InvalidValue(format!(
773                "humidity {} exceeds maximum of 100",
774                self.reading.humidity
775            )));
776        }
777
778        if self.reading.battery > 100 {
779            return Err(ParseError::InvalidValue(format!(
780                "battery {} exceeds maximum of 100",
781                self.reading.battery
782            )));
783        }
784
785        // Temperature range check (typical sensor range)
786        if self.reading.temperature < -40.0 || self.reading.temperature > 100.0 {
787            return Err(ParseError::InvalidValue(format!(
788                "temperature {} is outside valid range (-40 to 100°C)",
789                self.reading.temperature
790            )));
791        }
792
793        // Pressure range check (0 is valid for devices without pressure sensor)
794        if self.reading.pressure != 0.0
795            && (self.reading.pressure < 800.0 || self.reading.pressure > 1200.0)
796        {
797            return Err(ParseError::InvalidValue(format!(
798                "pressure {} is outside valid range (800-1200 hPa)",
799                self.reading.pressure
800            )));
801        }
802
803        Ok(self.reading)
804    }
805}
806
807/// Device information from an Aranet sensor.
808#[derive(Debug, Clone, PartialEq, Eq, Default)]
809#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
810pub struct DeviceInfo {
811    /// Device name.
812    pub name: String,
813    /// Model number.
814    pub model: String,
815    /// Serial number.
816    pub serial: String,
817    /// Firmware version.
818    pub firmware: String,
819    /// Hardware revision.
820    pub hardware: String,
821    /// Software revision.
822    pub software: String,
823    /// Manufacturer name.
824    pub manufacturer: String,
825}
826
827impl DeviceInfo {
828    /// Create a builder for constructing `DeviceInfo`.
829    pub fn builder() -> DeviceInfoBuilder {
830        DeviceInfoBuilder::default()
831    }
832}
833
834/// Builder for constructing `DeviceInfo`.
835#[derive(Debug, Default, Clone)]
836#[must_use]
837pub struct DeviceInfoBuilder {
838    info: DeviceInfo,
839}
840
841impl DeviceInfoBuilder {
842    /// Set the device name.
843    pub fn name(mut self, name: impl Into<String>) -> Self {
844        self.info.name = name.into();
845        self
846    }
847
848    /// Set the model number.
849    pub fn model(mut self, model: impl Into<String>) -> Self {
850        self.info.model = model.into();
851        self
852    }
853
854    /// Set the serial number.
855    pub fn serial(mut self, serial: impl Into<String>) -> Self {
856        self.info.serial = serial.into();
857        self
858    }
859
860    /// Set the firmware version.
861    pub fn firmware(mut self, firmware: impl Into<String>) -> Self {
862        self.info.firmware = firmware.into();
863        self
864    }
865
866    /// Set the hardware revision.
867    pub fn hardware(mut self, hardware: impl Into<String>) -> Self {
868        self.info.hardware = hardware.into();
869        self
870    }
871
872    /// Set the software revision.
873    pub fn software(mut self, software: impl Into<String>) -> Self {
874        self.info.software = software.into();
875        self
876    }
877
878    /// Set the manufacturer name.
879    pub fn manufacturer(mut self, manufacturer: impl Into<String>) -> Self {
880        self.info.manufacturer = manufacturer.into();
881        self
882    }
883
884    /// Build the `DeviceInfo`.
885    #[must_use]
886    pub fn build(self) -> DeviceInfo {
887        self.info
888    }
889}
890
891/// A historical reading record from an Aranet sensor.
892///
893/// This struct supports all Aranet device types:
894/// - **Aranet4**: CO2, temperature, pressure, humidity
895/// - **Aranet2**: Temperature, humidity (co2 and pressure will be 0)
896/// - **`AranetRn+`**: Radon, temperature, pressure, humidity (co2 will be 0)
897/// - **Aranet Radiation**: Radiation rate/total, temperature (uses `radiation_*` fields)
898#[derive(Debug, Clone, PartialEq)]
899#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
900pub struct HistoryRecord {
901    /// Timestamp of the reading.
902    pub timestamp: time::OffsetDateTime,
903    /// CO2 concentration in ppm (Aranet4) or 0 for other devices.
904    pub co2: u16,
905    /// Temperature in degrees Celsius.
906    pub temperature: f32,
907    /// Atmospheric pressure in hPa (0 for Aranet2).
908    pub pressure: f32,
909    /// Relative humidity percentage (0-100).
910    pub humidity: u8,
911    /// Radon concentration in Bq/m³ (`AranetRn+` only).
912    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
913    pub radon: Option<u32>,
914    /// Radiation dose rate in µSv/h (Aranet Radiation only).
915    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
916    pub radiation_rate: Option<f32>,
917    /// Total radiation dose in mSv (Aranet Radiation only).
918    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
919    pub radiation_total: Option<f64>,
920}
921
922impl Default for HistoryRecord {
923    fn default() -> Self {
924        Self {
925            timestamp: time::OffsetDateTime::UNIX_EPOCH,
926            co2: 0,
927            temperature: 0.0,
928            pressure: 0.0,
929            humidity: 0,
930            radon: None,
931            radiation_rate: None,
932            radiation_total: None,
933        }
934    }
935}
936
937impl HistoryRecord {
938    /// Create a builder for constructing `HistoryRecord` with optional fields.
939    pub fn builder() -> HistoryRecordBuilder {
940        HistoryRecordBuilder::default()
941    }
942}
943
944/// Builder for constructing `HistoryRecord` with device-specific fields.
945#[derive(Debug, Default)]
946#[must_use]
947pub struct HistoryRecordBuilder {
948    record: HistoryRecord,
949}
950
951impl HistoryRecordBuilder {
952    /// Set the timestamp.
953    pub fn timestamp(mut self, timestamp: time::OffsetDateTime) -> Self {
954        self.record.timestamp = timestamp;
955        self
956    }
957
958    /// Set CO2 concentration (Aranet4).
959    pub fn co2(mut self, co2: u16) -> Self {
960        self.record.co2 = co2;
961        self
962    }
963
964    /// Set temperature.
965    pub fn temperature(mut self, temp: f32) -> Self {
966        self.record.temperature = temp;
967        self
968    }
969
970    /// Set pressure.
971    pub fn pressure(mut self, pressure: f32) -> Self {
972        self.record.pressure = pressure;
973        self
974    }
975
976    /// Set humidity.
977    pub fn humidity(mut self, humidity: u8) -> Self {
978        self.record.humidity = humidity;
979        self
980    }
981
982    /// Set radon concentration (`AranetRn+`).
983    pub fn radon(mut self, radon: u32) -> Self {
984        self.record.radon = Some(radon);
985        self
986    }
987
988    /// Set radiation dose rate (Aranet Radiation).
989    pub fn radiation_rate(mut self, rate: f32) -> Self {
990        self.record.radiation_rate = Some(rate);
991        self
992    }
993
994    /// Set total radiation dose (Aranet Radiation).
995    pub fn radiation_total(mut self, total: f64) -> Self {
996        self.record.radiation_total = Some(total);
997        self
998    }
999
1000    /// Build the `HistoryRecord`.
1001    #[must_use]
1002    pub fn build(self) -> HistoryRecord {
1003        self.record
1004    }
1005}