Skip to main content

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