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}