aranet_core/
readings.rs

1//! Reading current sensor values.
2//!
3//! This module provides functionality to read the current sensor
4//! values from a connected Aranet device.
5//!
6//! The primary methods for reading are on the [`Device`](crate::device::Device) struct,
7//! but this module provides parsing utilities for different device types.
8
9use bytes::Buf;
10
11use crate::error::{Error, Result};
12use aranet_types::{CurrentReading, DeviceType, Status};
13
14/// Extended reading that includes all available sensor data.
15///
16/// This struct wraps `CurrentReading` and adds fields that don't fit
17/// in the base reading structure (like measurement duration).
18///
19/// Note: Radon, radiation rate, and radiation total are now part of
20/// `CurrentReading` directly.
21#[derive(Debug, Clone)]
22pub struct ExtendedReading {
23    /// The current reading with all sensor values.
24    pub reading: CurrentReading,
25    /// Measurement duration in seconds (Aranet Radiation only).
26    pub radiation_duration: Option<u64>,
27}
28
29/// Parse Aranet4 current readings from the detailed characteristic.
30///
31/// Format (13 bytes):
32/// - bytes 0-1: CO2 (u16 LE)
33/// - bytes 2-3: Temperature (u16 LE, /20 for °C)
34/// - bytes 4-5: Pressure (u16 LE, /10 for hPa)
35/// - byte 6: Humidity (u8)
36/// - byte 7: Battery (u8)
37/// - byte 8: Status (u8)
38/// - bytes 9-10: Interval (u16 LE, seconds)
39/// - bytes 11-12: Age (u16 LE, seconds since last reading)
40pub fn parse_aranet4_reading(data: &[u8]) -> Result<CurrentReading> {
41    CurrentReading::from_bytes(data).map_err(|e| Error::InvalidData(e.to_string()))
42}
43
44/// Parse Aranet2 current readings (temperature and humidity only).
45///
46/// Format (7 bytes):
47/// - bytes 0-1: Temperature (u16 LE, /20 for °C)
48/// - byte 2: Humidity (u8)
49/// - byte 3: Battery (u8)
50/// - byte 4: Status (u8)
51/// - bytes 5-6: Interval (u16 LE, seconds)
52pub fn parse_aranet2_reading(data: &[u8]) -> Result<CurrentReading> {
53    if data.len() < 7 {
54        return Err(Error::InvalidData(format!(
55            "Aranet2 reading requires 7 bytes, got {}",
56            data.len()
57        )));
58    }
59
60    let mut buf = data;
61    let temp_raw = buf.get_u16_le();
62    let humidity = buf.get_u8();
63    let battery = buf.get_u8();
64    let status = Status::from(buf.get_u8());
65    let interval = buf.get_u16_le();
66
67    Ok(CurrentReading {
68        co2: 0, // Aranet2 doesn't have CO2
69        temperature: temp_raw as f32 / 20.0,
70        pressure: 0.0, // Aranet2 doesn't have pressure
71        humidity,
72        battery,
73        status,
74        interval,
75        age: 0,
76        captured_at: None,
77        radon: None,
78        radiation_rate: None,
79        radiation_total: None,
80        radon_avg_24h: None,
81        radon_avg_7d: None,
82        radon_avg_30d: None,
83    })
84}
85
86/// Parse Aranet Radon readings from advertisement data.
87///
88/// Format includes radon concentration in Bq/m³.
89pub fn parse_aranet_radon_reading(data: &[u8]) -> Result<ExtendedReading> {
90    if data.len() < 15 {
91        return Err(Error::InvalidData(format!(
92            "Aranet Radon reading requires 15 bytes, got {}",
93            data.len()
94        )));
95    }
96
97    let mut buf = data;
98
99    // Standard fields
100    let co2 = buf.get_u16_le();
101    let temp_raw = buf.get_u16_le();
102    let pressure_raw = buf.get_u16_le();
103    let humidity = buf.get_u8();
104    let battery = buf.get_u8();
105    let status = Status::from(buf.get_u8());
106    let interval = buf.get_u16_le();
107    let age = buf.get_u16_le();
108
109    // Radon-specific field (store as u32 for consistency)
110    let radon = buf.get_u16_le() as u32;
111
112    let reading = CurrentReading {
113        co2,
114        temperature: temp_raw as f32 / 20.0,
115        pressure: pressure_raw as f32 / 10.0,
116        humidity,
117        battery,
118        status,
119        interval,
120        age,
121        captured_at: None,
122        radon: Some(radon),
123        radiation_rate: None,
124        radiation_total: None,
125        radon_avg_24h: None,
126        radon_avg_7d: None,
127        radon_avg_30d: None,
128    };
129
130    Ok(ExtendedReading {
131        reading,
132        radiation_duration: None,
133    })
134}
135
136/// Parse Aranet Radon readings from GATT characteristic (f0cd3003 or f0cd1504).
137///
138/// Format (47 bytes):
139/// - Bytes 0-1: Device type marker (0x0003 for radon)
140/// - Bytes 2-3: Interval (LE16, seconds)
141/// - Bytes 4-5: Seconds since update (LE16)
142/// - Byte 6: Battery (0-100%)
143/// - Bytes 7-8: Temperature (LE16, raw / 20 = °C)
144/// - Bytes 9-10: Pressure (LE16, raw / 10 = hPa)
145/// - Bytes 11-12: Humidity (LE16, raw / 10 = %)
146/// - Bytes 13-16: Radon concentration (LE32, Bq/m³)
147/// - Byte 17: Status/color
148/// - Bytes 18-21: 24h average time (LE32)
149/// - Bytes 22-25: 24h average value (LE32, Bq/m³)
150/// - Bytes 26-29: 7d average time (LE32)
151/// - Bytes 30-33: 7d average value (LE32, Bq/m³)
152/// - Bytes 34-37: 30d average time (LE32)
153/// - Bytes 38-41: 30d average value (LE32, Bq/m³)
154///
155/// Note: If an average value >= 0xff000000, it indicates the average
156/// is still being calculated (in progress) and is not yet available.
157pub fn parse_aranet_radon_gatt(data: &[u8]) -> Result<CurrentReading> {
158    if data.len() < 18 {
159        return Err(Error::InvalidData(format!(
160            "Aranet Radon GATT reading requires at least 18 bytes, got {}",
161            data.len()
162        )));
163    }
164
165    let mut buf = data;
166
167    // Parse header
168    let _device_type = buf.get_u16_le(); // 0x0003 for radon
169    let interval = buf.get_u16_le();
170    let age = buf.get_u16_le();
171    let battery = buf.get_u8();
172
173    // Parse sensor values
174    let temp_raw = buf.get_u16_le();
175    let pressure_raw = buf.get_u16_le();
176    let humidity_raw = buf.get_u16_le();
177    let radon = buf.get_u32_le();
178    let status = if buf.has_remaining() {
179        Status::from(buf.get_u8())
180    } else {
181        Status::Green
182    };
183
184    // Parse optional working averages (extended format, 47 bytes)
185    // Each average is a pair: (time: u32, value: u32)
186    // If value >= 0xff000000, the average is still being calculated
187    let (radon_avg_24h, radon_avg_7d, radon_avg_30d) = if buf.remaining() >= 24 {
188        let _time_24h = buf.get_u32_le();
189        let avg_24h_raw = buf.get_u32_le();
190        let _time_7d = buf.get_u32_le();
191        let avg_7d_raw = buf.get_u32_le();
192        let _time_30d = buf.get_u32_le();
193        let avg_30d_raw = buf.get_u32_le();
194
195        // Values >= 0xff000000 indicate "in progress" (not yet available)
196        let avg_24h = if avg_24h_raw >= 0xff00_0000 {
197            None
198        } else {
199            Some(avg_24h_raw)
200        };
201        let avg_7d = if avg_7d_raw >= 0xff00_0000 {
202            None
203        } else {
204            Some(avg_7d_raw)
205        };
206        let avg_30d = if avg_30d_raw >= 0xff00_0000 {
207            None
208        } else {
209            Some(avg_30d_raw)
210        };
211
212        (avg_24h, avg_7d, avg_30d)
213    } else {
214        (None, None, None)
215    };
216
217    Ok(CurrentReading {
218        co2: 0,
219        temperature: temp_raw as f32 / 20.0,
220        pressure: pressure_raw as f32 / 10.0,
221        humidity: (humidity_raw / 10).min(255) as u8, // Convert from 10ths to percent
222        battery,
223        status,
224        interval,
225        age,
226        captured_at: None,
227        radon: Some(radon),
228        radiation_rate: None,
229        radiation_total: None,
230        radon_avg_24h,
231        radon_avg_7d,
232        radon_avg_30d,
233    })
234}
235
236/// Parse Aranet Radiation readings from GATT characteristic.
237///
238/// Format (28 bytes):
239/// - bytes 0-1: Unknown
240/// - bytes 2-3: Interval (LE16, seconds)
241/// - bytes 4-5: Age (LE16, seconds)
242/// - byte 6: Battery
243/// - bytes 7-10: Dose rate (LE32, nSv/h)
244/// - bytes 11-18: Total dose (LE64, nSv)
245/// - bytes 19-26: Duration (LE64, seconds)
246/// - byte 27: Status
247pub fn parse_aranet_radiation_gatt(data: &[u8]) -> Result<ExtendedReading> {
248    if data.len() < 28 {
249        return Err(Error::InvalidData(format!(
250            "Aranet Radiation GATT reading requires at least 28 bytes, got {}",
251            data.len()
252        )));
253    }
254
255    let mut buf = data;
256
257    // Skip 2 unknown bytes
258    buf.advance(2);
259
260    let interval = buf.get_u16_le();
261    let age = buf.get_u16_le();
262    let battery = buf.get_u8();
263
264    // Dose rate in nSv/h, convert to µSv/h
265    let dose_rate_nsv = buf.get_u32_le();
266    let dose_rate_usv = dose_rate_nsv as f32 / 1000.0;
267
268    // Total dose in nSv, convert to mSv
269    let total_dose_nsv = buf.get_u64_le();
270    let total_dose_msv = total_dose_nsv as f64 / 1_000_000.0;
271
272    // Duration in seconds
273    let duration = buf.get_u64_le();
274
275    let status = if buf.has_remaining() {
276        Status::from(buf.get_u8())
277    } else {
278        Status::Green
279    };
280
281    let reading = CurrentReading {
282        co2: 0,
283        temperature: 0.0,
284        pressure: 0.0,
285        humidity: 0,
286        battery,
287        status,
288        interval,
289        age,
290        captured_at: None,
291        radon: None,
292        radiation_rate: Some(dose_rate_usv),
293        radiation_total: Some(total_dose_msv),
294        radon_avg_24h: None,
295        radon_avg_7d: None,
296        radon_avg_30d: None,
297    };
298
299    Ok(ExtendedReading {
300        reading,
301        radiation_duration: Some(duration),
302    })
303}
304
305/// Parse a reading based on device type.
306pub fn parse_reading_for_device(data: &[u8], device_type: DeviceType) -> Result<CurrentReading> {
307    match device_type {
308        DeviceType::Aranet4 => parse_aranet4_reading(data),
309        DeviceType::Aranet2 => parse_aranet2_reading(data),
310        DeviceType::AranetRadon => parse_aranet_radon_reading(data).map(|ext| ext.reading),
311        DeviceType::AranetRadiation => parse_aranet_radiation_gatt(data).map(|ext| ext.reading),
312        // Handle future device types - default to Aranet4 parsing
313        _ => parse_aranet4_reading(data),
314    }
315}
316
317/// Parse an extended reading based on device type.
318pub fn parse_extended_reading(data: &[u8], device_type: DeviceType) -> Result<ExtendedReading> {
319    match device_type {
320        DeviceType::Aranet4 => {
321            let reading = parse_aranet4_reading(data)?;
322            Ok(ExtendedReading {
323                reading,
324                radiation_duration: None,
325            })
326        }
327        DeviceType::Aranet2 => {
328            let reading = parse_aranet2_reading(data)?;
329            Ok(ExtendedReading {
330                reading,
331                radiation_duration: None,
332            })
333        }
334        DeviceType::AranetRadon => parse_aranet_radon_reading(data),
335        DeviceType::AranetRadiation => parse_aranet_radiation_gatt(data),
336        // Handle future device types - default to Aranet4 parsing
337        _ => {
338            let reading = parse_aranet4_reading(data)?;
339            Ok(ExtendedReading {
340                reading,
341                radiation_duration: None,
342            })
343        }
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    // --- Aranet2 parsing tests ---
352
353    #[test]
354    fn test_parse_aranet2_reading() {
355        // Temperature: 450 raw (22.5°C)
356        // Humidity: 55
357        // Battery: 90
358        // Status: Green (1)
359        // Interval: 300 (5 min)
360        let data: [u8; 7] = [
361            0xC2, 0x01, // temp = 450
362            55,   // humidity
363            90,   // battery
364            1,    // status = Green
365            0x2C, 0x01, // interval = 300
366        ];
367
368        let reading = parse_aranet2_reading(&data).unwrap();
369        assert_eq!(reading.co2, 0);
370        assert!((reading.temperature - 22.5).abs() < 0.01);
371        assert_eq!(reading.humidity, 55);
372        assert_eq!(reading.battery, 90);
373        assert_eq!(reading.status, Status::Green);
374        assert_eq!(reading.interval, 300);
375    }
376
377    #[test]
378    fn test_parse_aranet2_reading_all_status_values() {
379        // Test different status values
380        for (status_byte, expected_status) in [
381            (0, Status::Error),
382            (1, Status::Green),
383            (2, Status::Yellow),
384            (3, Status::Red),
385            (4, Status::Error), // Unknown maps to Error
386        ] {
387            let data: [u8; 7] = [
388                0xC2,
389                0x01, // temp = 450
390                55,
391                90,
392                status_byte,
393                0x2C,
394                0x01,
395            ];
396
397            let reading = parse_aranet2_reading(&data).unwrap();
398            assert_eq!(reading.status, expected_status);
399        }
400    }
401
402    #[test]
403    fn test_parse_aranet2_reading_insufficient_bytes() {
404        let data: [u8; 5] = [0xC2, 0x01, 55, 90, 1]; // Only 5 bytes, need 7
405
406        let result = parse_aranet2_reading(&data);
407        assert!(result.is_err());
408
409        let err = result.unwrap_err();
410        assert!(err.to_string().contains("requires 7 bytes"));
411        assert!(err.to_string().contains("got 5"));
412    }
413
414    #[test]
415    fn test_parse_aranet2_reading_edge_values() {
416        // Test with edge case values
417        let data: [u8; 7] = [
418            0x00, 0x00, // temp = 0 (0°C)
419            0,    // humidity = 0
420            0,    // battery = 0
421            0,    // status = Error
422            0x00, 0x00, // interval = 0
423        ];
424
425        let reading = parse_aranet2_reading(&data).unwrap();
426        assert_eq!(reading.co2, 0);
427        assert!((reading.temperature - 0.0).abs() < 0.01);
428        assert_eq!(reading.humidity, 0);
429        assert_eq!(reading.battery, 0);
430        assert_eq!(reading.status, Status::Error);
431        assert_eq!(reading.interval, 0);
432    }
433
434    #[test]
435    fn test_parse_aranet2_reading_max_values() {
436        let data: [u8; 7] = [
437            0xFF, 0xFF, // temp = 65535
438            255,  // humidity = 255 (invalid but possible)
439            100,  // battery = 100
440            3,    // status = Red
441            0xFF, 0xFF, // interval = 65535
442        ];
443
444        let reading = parse_aranet2_reading(&data).unwrap();
445        assert!((reading.temperature - 3276.75).abs() < 0.01); // 65535/20
446        assert_eq!(reading.humidity, 255);
447        assert_eq!(reading.battery, 100);
448        assert_eq!(reading.status, Status::Red);
449        assert_eq!(reading.interval, 65535);
450    }
451
452    // --- Aranet4 parsing tests ---
453
454    #[test]
455    fn test_parse_aranet4_reading() {
456        // Full 13-byte Aranet4 reading
457        let data: [u8; 13] = [
458            0x20, 0x03, // CO2 = 800
459            0xC2, 0x01, // temp_raw = 450 (22.5°C)
460            0x94, 0x27, // pressure_raw = 10132 (1013.2 hPa)
461            45,   // humidity
462            85,   // battery
463            1,    // status = Green
464            0x2C, 0x01, // interval = 300
465            0x78, 0x00, // age = 120
466        ];
467
468        let reading = parse_aranet4_reading(&data).unwrap();
469        assert_eq!(reading.co2, 800);
470        assert!((reading.temperature - 22.5).abs() < 0.01);
471        assert!((reading.pressure - 1013.2).abs() < 0.1);
472        assert_eq!(reading.humidity, 45);
473        assert_eq!(reading.battery, 85);
474        assert_eq!(reading.status, Status::Green);
475        assert_eq!(reading.interval, 300);
476        assert_eq!(reading.age, 120);
477    }
478
479    #[test]
480    fn test_parse_aranet4_reading_high_co2() {
481        // High CO2 reading - red status
482        let data: [u8; 13] = [
483            0xD0, 0x07, // CO2 = 2000 ppm
484            0x90, 0x01, // temp_raw = 400 (20.0°C)
485            0x88, 0x27, // pressure_raw = 10120 (1012.0 hPa)
486            60,   // humidity
487            75,   // battery
488            3,    // status = Red
489            0x3C, 0x00, // interval = 60
490            0x1E, 0x00, // age = 30
491        ];
492
493        let reading = parse_aranet4_reading(&data).unwrap();
494        assert_eq!(reading.co2, 2000);
495        assert_eq!(reading.status, Status::Red);
496    }
497
498    #[test]
499    fn test_parse_aranet4_reading_insufficient_bytes() {
500        let data: [u8; 10] = [0; 10];
501
502        let result = parse_aranet4_reading(&data);
503        assert!(result.is_err());
504
505        let err = result.unwrap_err();
506        // The error message format changed: "Insufficient bytes: expected 13, got 10"
507        assert!(err.to_string().contains("expected 13"));
508        assert!(err.to_string().contains("got 10"));
509    }
510
511    // --- Aranet Radon parsing tests ---
512
513    #[test]
514    fn test_parse_aranet_radon_reading() {
515        // 15-byte extended reading format
516        let data: [u8; 15] = [
517            0x00, 0x00, // CO2 = 0 (not applicable for radon)
518            0xC2, 0x01, // temp_raw = 450 (22.5°C)
519            0x94, 0x27, // pressure_raw = 10132 (1013.2 hPa)
520            50,   // humidity
521            80,   // battery
522            1,    // status = Green
523            0x2C, 0x01, // interval = 300
524            0x3C, 0x00, // age = 60
525            0x64, 0x00, // radon = 100 Bq/m³
526        ];
527
528        let result = parse_aranet_radon_reading(&data).unwrap();
529        assert_eq!(result.reading.radon, Some(100));
530        assert!(result.reading.radiation_rate.is_none());
531        assert!((result.reading.temperature - 22.5).abs() < 0.01);
532        assert_eq!(result.reading.humidity, 50);
533    }
534
535    #[test]
536    fn test_parse_aranet_radon_reading_high_radon() {
537        let mut data: [u8; 15] = [0; 15];
538        // Set radon to high value: 500 Bq/m³
539        data[13] = 0xF4;
540        data[14] = 0x01; // 500 in LE
541
542        let result = parse_aranet_radon_reading(&data).unwrap();
543        assert_eq!(result.reading.radon, Some(500));
544    }
545
546    #[test]
547    fn test_parse_aranet_radon_reading_insufficient_bytes() {
548        let data: [u8; 12] = [0; 12];
549
550        let result = parse_aranet_radon_reading(&data);
551        assert!(result.is_err());
552        assert!(
553            result
554                .unwrap_err()
555                .to_string()
556                .contains("requires 15 bytes")
557        );
558    }
559
560    // --- Aranet Radon GATT parsing tests ---
561
562    #[test]
563    fn test_parse_aranet_radon_gatt() {
564        // GATT format: device_type(2) + interval(2) + age(2) + battery(1) + temp(2) + pressure(2) + humidity(2) + radon(4) + status(1)
565        let mut data: [u8; 18] = [0; 18];
566        // Bytes 0-1: device type (0x0003 for radon)
567        data[0] = 0x03;
568        data[1] = 0x00;
569        // Bytes 2-3: interval = 600 seconds
570        data[2] = 0x58;
571        data[3] = 0x02;
572        // Bytes 4-5: age = 120 seconds
573        data[4] = 0x78;
574        data[5] = 0x00;
575        // Byte 6: battery = 85%
576        data[6] = 85;
577        // Bytes 7-8: temp = 450 (22.5°C)
578        data[7] = 0xC2;
579        data[8] = 0x01;
580        // Bytes 9-10: pressure = 10132 (1013.2 hPa)
581        data[9] = 0x94;
582        data[10] = 0x27;
583        // Bytes 11-12: humidity_raw = 450 (45.0%)
584        data[11] = 0xC2;
585        data[12] = 0x01;
586        // Bytes 13-16: radon = 100 Bq/m³
587        data[13] = 0x64;
588        data[14] = 0x00;
589        data[15] = 0x00;
590        data[16] = 0x00;
591        // Byte 17: status = Green
592        data[17] = 1;
593
594        let reading = parse_aranet_radon_gatt(&data).unwrap();
595        assert_eq!(reading.battery, 85);
596        assert!((reading.temperature - 22.5).abs() < 0.01);
597        assert_eq!(reading.radon, Some(100)); // Radon stored in dedicated field
598        assert_eq!(reading.co2, 0); // CO2 is 0 for radon devices
599        assert_eq!(reading.status, Status::Green);
600        assert_eq!(reading.interval, 600);
601        assert_eq!(reading.age, 120);
602    }
603
604    #[test]
605    fn test_parse_aranet_radon_gatt_insufficient_bytes() {
606        let data: [u8; 15] = [0; 15];
607
608        let result = parse_aranet_radon_gatt(&data);
609        assert!(result.is_err());
610        assert!(
611            result
612                .unwrap_err()
613                .to_string()
614                .contains("at least 18 bytes")
615        );
616    }
617
618    #[test]
619    fn test_parse_aranet_radon_gatt_high_radon() {
620        // Test that high radon values are stored correctly in the u32 field
621        let mut data: [u8; 18] = [0; 18];
622        // Bytes 0-5: header (device type, interval, age)
623        data[0] = 0x03; // device type = radon
624        // Bytes 13-16: Radon = 100000
625        data[13] = 0xA0;
626        data[14] = 0x86;
627        data[15] = 0x01;
628        data[16] = 0x00; // 100000 in LE u32
629
630        let reading = parse_aranet_radon_gatt(&data).unwrap();
631        assert_eq!(reading.radon, Some(100000)); // Full u32 value preserved
632    }
633
634    // --- parse_reading_for_device tests ---
635
636    #[test]
637    fn test_parse_reading_for_device_aranet4() {
638        let data: [u8; 13] = [
639            0x20, 0x03, // CO2 = 800
640            0xC2, 0x01, // temp
641            0x94, 0x27, // pressure
642            45, 85, 1, // humidity, battery, status
643            0x2C, 0x01, // interval
644            0x78, 0x00, // age
645        ];
646
647        let reading = parse_reading_for_device(&data, DeviceType::Aranet4).unwrap();
648        assert_eq!(reading.co2, 800);
649    }
650
651    #[test]
652    fn test_parse_reading_for_device_aranet2() {
653        let data: [u8; 7] = [0xC2, 0x01, 55, 90, 1, 0x2C, 0x01];
654
655        let reading = parse_reading_for_device(&data, DeviceType::Aranet2).unwrap();
656        assert_eq!(reading.co2, 0); // Aranet2 doesn't have CO2
657        assert!((reading.temperature - 22.5).abs() < 0.01);
658    }
659
660    // --- ExtendedReading tests ---
661
662    #[test]
663    fn test_extended_reading_with_radon() {
664        let reading = CurrentReading {
665            co2: 0,
666            temperature: 22.5,
667            pressure: 1013.2,
668            humidity: 50,
669            battery: 80,
670            status: Status::Green,
671            interval: 300,
672            age: 60,
673            captured_at: None,
674            radon: Some(150),
675            radiation_rate: None,
676            radiation_total: None,
677            radon_avg_24h: None,
678            radon_avg_7d: None,
679            radon_avg_30d: None,
680        };
681
682        let extended = ExtendedReading {
683            reading,
684            radiation_duration: None,
685        };
686
687        assert_eq!(extended.reading.radon, Some(150));
688        assert!(extended.reading.radiation_rate.is_none());
689        assert!((extended.reading.temperature - 22.5).abs() < 0.01);
690    }
691
692    #[test]
693    fn test_extended_reading_with_radiation() {
694        let reading = CurrentReading {
695            co2: 0,
696            temperature: 20.0,
697            pressure: 1000.0,
698            humidity: 45,
699            battery: 90,
700            status: Status::Green,
701            interval: 60,
702            age: 30,
703            captured_at: None,
704            radon: None,
705            radiation_rate: Some(0.15),
706            radiation_total: Some(0.001),
707            radon_avg_24h: None,
708            radon_avg_7d: None,
709            radon_avg_30d: None,
710        };
711
712        let extended = ExtendedReading {
713            reading,
714            radiation_duration: Some(3600),
715        };
716
717        assert!(extended.reading.radon.is_none());
718        assert!((extended.reading.radiation_rate.unwrap() - 0.15).abs() < 0.001);
719        assert_eq!(extended.radiation_duration, Some(3600));
720    }
721
722    #[test]
723    fn test_extended_reading_debug() {
724        let reading = CurrentReading {
725            co2: 800,
726            temperature: 22.5,
727            pressure: 1013.2,
728            humidity: 50,
729            battery: 80,
730            status: Status::Green,
731            interval: 300,
732            age: 60,
733            captured_at: None,
734            radon: Some(100),
735            radiation_rate: None,
736            radiation_total: None,
737            radon_avg_24h: None,
738            radon_avg_7d: None,
739            radon_avg_30d: None,
740        };
741
742        let extended = ExtendedReading {
743            reading,
744            radiation_duration: None,
745        };
746
747        let debug_str = format!("{:?}", extended);
748        assert!(debug_str.contains("radon"));
749        assert!(debug_str.contains("100"));
750    }
751
752    #[test]
753    fn test_extended_reading_clone() {
754        let reading = CurrentReading {
755            co2: 800,
756            temperature: 22.5,
757            pressure: 1013.2,
758            humidity: 50,
759            battery: 80,
760            status: Status::Green,
761            interval: 300,
762            age: 60,
763            captured_at: None,
764            radon: Some(100),
765            radiation_rate: Some(0.1),
766            radiation_total: Some(0.001),
767            radon_avg_24h: None,
768            radon_avg_7d: None,
769            radon_avg_30d: None,
770        };
771
772        let extended = ExtendedReading {
773            reading,
774            radiation_duration: Some(3600),
775        };
776
777        let cloned = extended.clone();
778        assert_eq!(cloned.reading.radon, extended.reading.radon);
779        assert_eq!(
780            cloned.reading.radiation_rate,
781            extended.reading.radiation_rate
782        );
783        assert_eq!(cloned.reading.co2, extended.reading.co2);
784        assert_eq!(cloned.radiation_duration, extended.radiation_duration);
785    }
786
787    #[test]
788    fn test_parse_aranet_radiation_gatt() {
789        // 28 bytes: 2 unknown + 2 interval + 2 age + 1 battery + 4 dose_rate + 8 total_dose + 8 duration + 1 status
790        let data = [
791            0x00, 0x00, // Unknown bytes
792            0x3C, 0x00, // Interval = 60 seconds
793            0x1E, 0x00, // Age = 30 seconds
794            0x5A, // Battery = 90%
795            0xE8, 0x03, 0x00, 0x00, // Dose rate = 1000 nSv/h = 1.0 µSv/h
796            0x40, 0x42, 0x0F, 0x00, 0x00, 0x00, 0x00,
797            0x00, // Total dose = 1,000,000 nSv = 1.0 mSv
798            0x10, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Duration = 3600 seconds
799            0x01, // Status = Green
800        ];
801
802        let result = parse_aranet_radiation_gatt(&data).unwrap();
803        assert_eq!(result.reading.interval, 60);
804        assert_eq!(result.reading.age, 30);
805        assert_eq!(result.reading.battery, 90);
806        assert!((result.reading.radiation_rate.unwrap() - 1.0).abs() < 0.001);
807        assert!((result.reading.radiation_total.unwrap() - 1.0).abs() < 0.001);
808        assert_eq!(result.radiation_duration, Some(3600));
809        assert_eq!(result.reading.status, Status::Green);
810        assert!(result.reading.radon.is_none());
811    }
812
813    #[test]
814    fn test_parse_aranet_radiation_gatt_insufficient_bytes() {
815        let data = [0x00; 20]; // Only 20 bytes, need 28
816        let result = parse_aranet_radiation_gatt(&data);
817        assert!(result.is_err());
818        let err = result.unwrap_err();
819        assert!(err.to_string().contains("28 bytes"));
820    }
821
822    #[test]
823    fn test_parse_aranet_radiation_gatt_high_values() {
824        // Test with high radiation values
825        let data = [
826            0x00, 0x00, // Unknown bytes
827            0x2C, 0x01, // Interval = 300 seconds
828            0x0A, 0x00, // Age = 10 seconds
829            0x64, // Battery = 100%
830            0x10, 0x27, 0x00, 0x00, // Dose rate = 10,000 nSv/h = 10.0 µSv/h
831            0x00, 0xE1, 0xF5, 0x05, 0x00, 0x00, 0x00,
832            0x00, // Total dose = 100,000,000 nSv = 100.0 mSv
833            0x80, 0x51, 0x01, 0x00, 0x00, 0x00, 0x00,
834            0x00, // Duration = 86400 seconds (1 day)
835            0x02, // Status = Yellow
836        ];
837
838        let result = parse_aranet_radiation_gatt(&data).unwrap();
839        assert_eq!(result.reading.interval, 300);
840        assert!((result.reading.radiation_rate.unwrap() - 10.0).abs() < 0.001);
841        assert!((result.reading.radiation_total.unwrap() - 100.0).abs() < 0.001);
842        assert_eq!(result.radiation_duration, Some(86400));
843        assert_eq!(result.reading.status, Status::Yellow);
844    }
845}
846
847/// Property-based tests for BLE reading parsers.
848///
849/// These tests verify that all parsing functions are safe to call with any input,
850/// ensuring they never panic regardless of the byte sequence provided.
851///
852/// # Test Categories
853///
854/// ## Panic Safety Tests
855/// Each device type parser is tested with random byte sequences:
856/// - `parse_aranet4_never_panics`: Aranet4 CO2 sensor format
857/// - `parse_aranet2_never_panics`: Aranet2 temperature/humidity format
858/// - `parse_aranet_radon_never_panics`: Aranet Radon sensor format
859/// - `parse_aranet_radon_gatt_never_panics`: Aranet Radon GATT format
860/// - `parse_aranet_radiation_gatt_never_panics`: Aranet Radiation format
861/// - `parse_reading_for_device_never_panics`: Generic dispatcher
862///
863/// ## Valid Input Tests
864/// - `aranet4_valid_bytes_parse_correctly`: Structured Aranet4 data
865/// - `aranet2_valid_bytes_parse_correctly`: Structured Aranet2 data
866///
867/// # Running Tests
868///
869/// ```bash
870/// cargo test -p aranet-core proptests
871/// ```
872#[cfg(test)]
873mod proptests {
874    use super::*;
875    use proptest::prelude::*;
876
877    proptest! {
878        /// Parsing random bytes should never panic for any device type.
879        #[test]
880        fn parse_aranet4_never_panics(data: Vec<u8>) {
881            let _ = parse_aranet4_reading(&data);
882        }
883
884        #[test]
885        fn parse_aranet2_never_panics(data: Vec<u8>) {
886            let _ = parse_aranet2_reading(&data);
887        }
888
889        #[test]
890        fn parse_aranet_radon_never_panics(data: Vec<u8>) {
891            let _ = parse_aranet_radon_reading(&data);
892        }
893
894        #[test]
895        fn parse_aranet_radon_gatt_never_panics(data: Vec<u8>) {
896            let _ = parse_aranet_radon_gatt(&data);
897        }
898
899        #[test]
900        fn parse_aranet_radiation_gatt_never_panics(data: Vec<u8>) {
901            let _ = parse_aranet_radiation_gatt(&data);
902        }
903
904        /// parse_reading_for_device should never panic regardless of input.
905        #[test]
906        fn parse_reading_for_device_never_panics(
907            data: Vec<u8>,
908            device_type_byte in 0xF1u8..=0xF4u8,
909        ) {
910            if let Ok(device_type) = DeviceType::try_from(device_type_byte) {
911                let _ = parse_reading_for_device(&data, device_type);
912            }
913        }
914
915        /// Valid Aranet4 readings should round-trip correctly.
916        #[test]
917        fn aranet4_valid_bytes_parse_correctly(
918            co2 in 0u16..10000u16,
919            temp_raw in 0u16..2000u16,
920            pressure_raw in 8000u16..12000u16,
921            humidity in 0u8..100u8,
922            battery in 0u8..100u8,
923            status_byte in 0u8..4u8,
924            interval in 60u16..3600u16,
925            age in 0u16..3600u16,
926        ) {
927            let mut data = [0u8; 13];
928            data[0..2].copy_from_slice(&co2.to_le_bytes());
929            data[2..4].copy_from_slice(&temp_raw.to_le_bytes());
930            data[4..6].copy_from_slice(&pressure_raw.to_le_bytes());
931            data[6] = humidity;
932            data[7] = battery;
933            data[8] = status_byte;
934            data[9..11].copy_from_slice(&interval.to_le_bytes());
935            data[11..13].copy_from_slice(&age.to_le_bytes());
936
937            let result = parse_aranet4_reading(&data);
938            prop_assert!(result.is_ok());
939
940            let reading = result.unwrap();
941            prop_assert_eq!(reading.co2, co2);
942            prop_assert_eq!(reading.humidity, humidity);
943            prop_assert_eq!(reading.battery, battery);
944            prop_assert_eq!(reading.interval, interval);
945            prop_assert_eq!(reading.age, age);
946        }
947
948        /// Valid Aranet2 readings should parse correctly.
949        #[test]
950        fn aranet2_valid_bytes_parse_correctly(
951            temp_raw in 0u16..2000u16,
952            humidity in 0u8..100u8,
953            battery in 0u8..100u8,
954            status_byte in 0u8..4u8,
955            interval in 60u16..3600u16,
956        ) {
957            let mut data = [0u8; 7];
958            data[0..2].copy_from_slice(&temp_raw.to_le_bytes());
959            data[2] = humidity;
960            data[3] = battery;
961            data[4] = status_byte;
962            data[5..7].copy_from_slice(&interval.to_le_bytes());
963
964            let result = parse_aranet2_reading(&data);
965            prop_assert!(result.is_ok());
966
967            let reading = result.unwrap();
968            prop_assert_eq!(reading.co2, 0); // Aranet2 has no CO2
969            prop_assert_eq!(reading.humidity, humidity);
970            prop_assert_eq!(reading.battery, battery);
971        }
972    }
973}