Skip to main content

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