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