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