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