aranet_core/
advertisement.rs

1//! BLE advertisement data parsing for passive monitoring.
2//!
3//! This module provides functionality to parse sensor data directly from
4//! Bluetooth advertisements without requiring a connection. This enables
5//! monitoring multiple devices simultaneously with lower power consumption.
6//!
7//! # Requirements
8//!
9//! For advertisement data to be available, Smart Home integration must be
10//! enabled on the Aranet device (see [`Device::set_smart_home`](crate::device::Device::set_smart_home)).
11
12use bytes::Buf;
13use serde::{Deserialize, Serialize};
14
15use aranet_types::{DeviceType, Status};
16
17use crate::error::{Error, Result};
18
19/// Parsed sensor data from a BLE advertisement.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AdvertisementData {
22    /// Device type detected from advertisement.
23    pub device_type: DeviceType,
24    /// CO2 concentration in ppm (Aranet4 only).
25    pub co2: Option<u16>,
26    /// Temperature in degrees Celsius.
27    pub temperature: Option<f32>,
28    /// Atmospheric pressure in hPa.
29    pub pressure: Option<f32>,
30    /// Relative humidity percentage (0-100).
31    pub humidity: Option<u8>,
32    /// Battery level percentage (0-100).
33    pub battery: u8,
34    /// CO2 status indicator.
35    pub status: Status,
36    /// Measurement interval in seconds.
37    pub interval: u16,
38    /// Age of reading in seconds since last measurement.
39    pub age: u16,
40    /// Radon concentration in Bq/m³ (Aranet Radon only).
41    pub radon: Option<u32>,
42    /// Radiation dose rate in µSv/h (Aranet Radiation only).
43    pub radiation_dose_rate: Option<f32>,
44    /// Advertisement counter (increments with each new reading).
45    pub counter: Option<u8>,
46    /// Raw manufacturer data flags.
47    pub flags: u8,
48}
49
50/// Parse advertisement data from raw manufacturer data bytes.
51///
52/// The manufacturer data should be from manufacturer ID 0x0702 (SAF Tehnika).
53///
54/// # Arguments
55///
56/// * `data` - Raw manufacturer data bytes (excluding the manufacturer ID)
57///
58/// # Returns
59///
60/// Parsed advertisement data or an error if the data is invalid.
61pub fn parse_advertisement(data: &[u8]) -> Result<AdvertisementData> {
62    parse_advertisement_with_name(data, None)
63}
64
65/// Parse advertisement data with optional device name for better detection.
66///
67/// The device name helps distinguish Aranet4 from other device types since
68/// Aranet4 advertisements don't include a device type prefix byte.
69pub fn parse_advertisement_with_name(data: &[u8], name: Option<&str>) -> Result<AdvertisementData> {
70    if data.is_empty() {
71        return Err(Error::InvalidData(
72            "Advertisement data is empty".to_string(),
73        ));
74    }
75
76    // Aranet advertisement format detection:
77    // - Aranet4: NO device type byte prefix, detect by name or length (7 or 22 bytes)
78    // - Aranet2: First byte = 0x01
79    // - Aranet Radiation: First byte = 0x02
80    // - Aranet Radon: First byte = 0x03
81    //
82    // The data structure is:
83    // - Bytes 0-3: Basic info (flags, version)
84    // - Bit 5 of flags (byte 0): Smart Home integrations enabled
85    // - Remaining bytes: Sensor measurements (if integrations enabled)
86
87    let is_aranet4_by_name = name.map(|n| n.starts_with("Aranet4")).unwrap_or(false);
88    let is_aranet4_by_len = data.len() == 7 || data.len() == 22;
89
90    let (device_type, sensor_data) = if is_aranet4_by_name || is_aranet4_by_len {
91        // Aranet4: prepend virtual 0x00 device type byte
92        (DeviceType::Aranet4, data)
93    } else {
94        // Other devices have the device type as first byte
95        let device_type = match data[0] {
96            0x01 => DeviceType::Aranet2,
97            0x02 => DeviceType::AranetRadiation,
98            0x03 => DeviceType::AranetRadon,
99            other => {
100                return Err(Error::InvalidData(format!(
101                    "Unknown device type byte: 0x{:02X}. Expected 0x01 (Aranet2), \
102                     0x02 (Radiation), or 0x03 (Radon). Data length: {} bytes.",
103                    other,
104                    data.len()
105                )));
106            }
107        };
108        (device_type, &data[1..])
109    };
110
111    // Check if Smart Home integrations are enabled (bit 5 of flags byte)
112    if sensor_data.is_empty() {
113        return Err(Error::InvalidData(
114            "Advertisement data too short for basic info".to_string(),
115        ));
116    }
117
118    let flags = sensor_data[0];
119    let integrations_enabled = (flags & (1 << 5)) != 0;
120
121    if !integrations_enabled {
122        return Err(Error::InvalidData(
123            "Smart Home integration is not enabled on this device. \
124             To enable: go to device Settings > Smart Home > Enable."
125                .to_string(),
126        ));
127    }
128
129    match device_type {
130        DeviceType::Aranet4 => parse_aranet4_advertisement_v2(sensor_data),
131        DeviceType::Aranet2 => parse_aranet2_advertisement_v2(sensor_data),
132        DeviceType::AranetRadon => parse_aranet_radon_advertisement_v2(sensor_data),
133        DeviceType::AranetRadiation => parse_aranet_radiation_advertisement_v2(sensor_data),
134        _ => Err(Error::InvalidData(format!(
135            "Unsupported device type for advertisement parsing: {:?}",
136            device_type
137        ))),
138    }
139}
140
141/// Parse Aranet4 advertisement data (v2 format - actual device format).
142///
143/// Format (22 bytes, no device type prefix):
144/// - bytes 0-7: Basic info (flags, version, etc.)
145/// - bytes 8-9: CO2 (u16 LE)
146/// - bytes 10-11: Temperature (u16 LE, *0.05 for °C)
147/// - bytes 12-13: Pressure (u16 LE, *0.1 for hPa)
148/// - byte 14: Humidity (u8)
149/// - byte 15: Battery (u8)
150/// - byte 16: Status (u8)
151/// - bytes 17-18: Interval (u16 LE, seconds)
152/// - bytes 19-20: Age (u16 LE, seconds)
153/// - byte 21: Counter (u8)
154fn parse_aranet4_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
155    // Minimum 22 bytes for full Aranet4 advertisement
156    if data.len() < 22 {
157        return Err(Error::InvalidData(format!(
158            "Aranet4 advertisement requires 22 bytes, got {}",
159            data.len()
160        )));
161    }
162
163    let flags = data[0];
164    // Skip to sensor data at offset 8
165    let mut buf = &data[8..];
166    let co2 = buf.get_u16_le();
167    let temp_raw = buf.get_u16_le();
168    let pressure_raw = buf.get_u16_le();
169    let humidity = buf.get_u8();
170    let battery = buf.get_u8();
171    let status = Status::from(buf.get_u8());
172    let interval = buf.get_u16_le();
173    let age = buf.get_u16_le();
174    let counter = if !buf.is_empty() {
175        Some(buf.get_u8())
176    } else {
177        None
178    };
179
180    Ok(AdvertisementData {
181        device_type: DeviceType::Aranet4,
182        co2: Some(co2),
183        temperature: Some(temp_raw as f32 * 0.05),
184        pressure: Some(pressure_raw as f32 * 0.1),
185        humidity: Some(humidity),
186        battery,
187        status,
188        interval,
189        age,
190        radon: None,
191        radiation_dose_rate: None,
192        counter,
193        flags,
194    })
195}
196
197/// Parse Aranet4 advertisement data (legacy format for tests).
198#[allow(dead_code)]
199fn parse_aranet4_advertisement(data: &[u8]) -> Result<AdvertisementData> {
200    if data.len() < 16 {
201        return Err(Error::InvalidData(format!(
202            "Aranet4 advertisement requires 16 bytes, got {}",
203            data.len()
204        )));
205    }
206
207    let mut buf = &data[1..]; // Skip device type byte
208    let flags = buf.get_u8();
209    let co2 = buf.get_u16_le();
210    let temp_raw = buf.get_u16_le();
211    let pressure_raw = buf.get_u16_le();
212    let humidity = buf.get_u8();
213    let battery = buf.get_u8();
214    let status = Status::from(buf.get_u8());
215    let interval = buf.get_u16_le();
216    let age = buf.get_u16_le();
217    let counter = buf.get_u8();
218
219    Ok(AdvertisementData {
220        device_type: DeviceType::Aranet4,
221        co2: Some(co2),
222        temperature: Some(temp_raw as f32 / 20.0),
223        pressure: Some(pressure_raw as f32 / 10.0),
224        humidity: Some(humidity),
225        battery,
226        status,
227        interval,
228        age,
229        radon: None,
230        radiation_dose_rate: None,
231        counter: Some(counter),
232        flags,
233    })
234}
235
236/// Parse Aranet2 advertisement data (v2 format - actual device format).
237///
238/// Format (after device type byte removed, 19+ bytes):
239/// - bytes 0-7: Basic info (flags, version, etc.)
240/// - bytes 8-9: Temperature (u16 LE, *0.05 for °C)
241/// - bytes 10-11: unused
242/// - bytes 12-13: Humidity (u16 LE, *0.1 for %)
243/// - byte 14: Battery (u8)
244/// - byte 15: Status (u8)
245/// - bytes 16-17: Interval (u16 LE, seconds)
246/// - bytes 18-19: Age (u16 LE, seconds)
247/// - byte 20: Counter (u8)
248fn parse_aranet2_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
249    if data.len() < 19 {
250        return Err(Error::InvalidData(format!(
251            "Aranet2 advertisement requires at least 19 bytes, got {}",
252            data.len()
253        )));
254    }
255
256    let flags = data[0];
257    // Skip to sensor data at offset 7
258    let mut buf = &data[7..];
259    let temp_raw = buf.get_u16_le();
260    let _unused = buf.get_u16_le();
261    let humidity_raw = buf.get_u16_le();
262    let battery = buf.get_u8();
263    let status_raw = buf.get_u8();
264    // Status for Aranet2 encodes both temp and humidity status
265    let status = Status::from(status_raw & 0x03);
266    let interval = buf.get_u16_le();
267    let age = buf.get_u16_le();
268    let counter = if !buf.is_empty() {
269        Some(buf.get_u8())
270    } else {
271        None
272    };
273
274    Ok(AdvertisementData {
275        device_type: DeviceType::Aranet2,
276        co2: None,
277        temperature: Some(temp_raw as f32 * 0.05),
278        pressure: None,
279        humidity: Some((humidity_raw as f32 * 0.1).min(255.0) as u8),
280        battery,
281        status,
282        interval,
283        age,
284        radon: None,
285        radiation_dose_rate: None,
286        counter,
287        flags,
288    })
289}
290
291/// Parse Aranet Radon advertisement data (v2 format - actual device format).
292///
293/// Format (after device type byte removed, 23 bytes):
294/// Based on Python: `<xxxxxxxHHHHBBBHHB` (7 skip bytes, not 8)
295/// - bytes 0-6: Basic info (flags, version, etc.) - 7 bytes
296/// - bytes 7-8: Radon concentration (u16 LE, Bq/m³)
297/// - bytes 9-10: Temperature (u16 LE, *0.05 for °C)
298/// - bytes 11-12: Pressure (u16 LE, *0.1 for hPa)
299/// - bytes 13-14: Humidity (u16 LE, *0.1 for %)
300/// - byte 15: Unknown/reserved (u8) - skipped in Python decode
301/// - byte 16: Battery (u8)
302/// - byte 17: Status (u8)
303/// - bytes 18-19: Interval (u16 LE, seconds)
304/// - bytes 20-21: Age (u16 LE, seconds)
305/// - byte 22: Counter (u8)
306fn parse_aranet_radon_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
307    if data.len() < 22 {
308        return Err(Error::InvalidData(format!(
309            "Aranet Radon advertisement requires at least 22 bytes, got {}",
310            data.len()
311        )));
312    }
313
314    let flags = data[0];
315    // Skip to sensor data at offset 7 (7 bytes of basic info)
316    let mut buf = &data[7..];
317    let radon = buf.get_u16_le() as u32;
318    let temp_raw = buf.get_u16_le();
319    let pressure_raw = buf.get_u16_le();
320    let humidity_raw = buf.get_u16_le();
321    let _reserved = buf.get_u8(); // Unknown/reserved byte (skipped in Python)
322    let battery = buf.get_u8();
323    let status = Status::from(buf.get_u8());
324    let interval = buf.get_u16_le();
325    let age = buf.get_u16_le();
326    let counter = if !buf.is_empty() {
327        Some(buf.get_u8())
328    } else {
329        None
330    };
331
332    Ok(AdvertisementData {
333        device_type: DeviceType::AranetRadon,
334        co2: None,
335        temperature: Some(temp_raw as f32 * 0.05),
336        pressure: Some(pressure_raw as f32 * 0.1),
337        humidity: Some((humidity_raw as f32 * 0.1).min(255.0) as u8),
338        battery,
339        status,
340        interval,
341        age,
342        radon: Some(radon),
343        radiation_dose_rate: None,
344        counter,
345        flags,
346    })
347}
348
349/// Parse Aranet Radiation advertisement data (v2 format - actual device format).
350///
351/// Format (after device type byte removed, 19+ bytes):
352/// - bytes 0-5: Basic info (flags, version, etc.)
353/// - bytes 6-9: Radiation total (u32 LE, nSv)
354/// - bytes 10-13: Radiation duration (u32 LE, seconds)
355/// - bytes 14-15: Radiation rate (u16 LE, *10 for nSv/h)
356/// - byte 16: Battery (u8)
357/// - byte 17: Status (u8)
358/// - bytes 18-19: Interval (u16 LE, seconds)
359/// - bytes 20-21: Age (u16 LE, seconds)
360/// - byte 22: Counter (u8)
361fn parse_aranet_radiation_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
362    // Need at least 21 bytes: 5 header + 4 total + 4 duration + 2 rate + 1 battery + 1 status + 2 interval + 2 age
363    if data.len() < 21 {
364        return Err(Error::InvalidData(format!(
365            "Aranet Radiation advertisement requires at least 21 bytes, got {}",
366            data.len()
367        )));
368    }
369
370    let flags = data[0];
371    // Skip to sensor data at offset 5
372    let mut buf = &data[5..];
373    let _radiation_total = buf.get_u32_le(); // nSv total dose
374    let _radiation_duration = buf.get_u32_le(); // seconds
375    let radiation_rate_raw = buf.get_u16_le(); // *10 for nSv/h
376    let battery = buf.get_u8();
377    let status = Status::from(buf.get_u8());
378    let interval = buf.get_u16_le();
379    let age = buf.get_u16_le();
380    let counter = if !buf.is_empty() {
381        Some(buf.get_u8())
382    } else {
383        None
384    };
385
386    // Convert from nSv/h * 10 to µSv/h
387    let dose_rate_usv = (radiation_rate_raw as f32 * 10.0) / 1000.0;
388
389    Ok(AdvertisementData {
390        device_type: DeviceType::AranetRadiation,
391        co2: None,
392        temperature: None,
393        pressure: None,
394        humidity: None,
395        battery,
396        status,
397        interval,
398        age,
399        radon: None,
400        radiation_dose_rate: Some(dose_rate_usv),
401        counter,
402        flags,
403    })
404}
405
406/// Parse Aranet2 advertisement data (legacy format for tests).
407#[allow(dead_code)]
408fn parse_aranet2_advertisement(data: &[u8]) -> Result<AdvertisementData> {
409    if data.len() < 12 {
410        return Err(Error::InvalidData(format!(
411            "Aranet2 advertisement requires at least 12 bytes, got {}",
412            data.len()
413        )));
414    }
415
416    let mut buf = &data[1..];
417    let flags = buf.get_u8();
418    let temp_raw = buf.get_u16_le();
419    let humidity_raw = buf.get_u16_le();
420    let battery = buf.get_u8();
421    let status = Status::from(buf.get_u8());
422    let interval = buf.get_u16_le();
423    let age = buf.get_u16_le();
424
425    Ok(AdvertisementData {
426        device_type: DeviceType::Aranet2,
427        co2: None,
428        temperature: Some(temp_raw as f32 / 20.0),
429        pressure: None,
430        humidity: Some((humidity_raw / 10).min(255) as u8),
431        battery,
432        status,
433        interval,
434        age,
435        radon: None,
436        radiation_dose_rate: None,
437        counter: None,
438        flags,
439    })
440}
441
442/// Parse Aranet Radon advertisement data (legacy format for tests).
443#[allow(dead_code)]
444fn parse_aranet_radon_advertisement(data: &[u8]) -> Result<AdvertisementData> {
445    if data.len() < 18 {
446        return Err(Error::InvalidData(format!(
447            "Aranet Radon advertisement requires at least 18 bytes, got {}",
448            data.len()
449        )));
450    }
451
452    let mut buf = &data[1..];
453    let flags = buf.get_u8();
454    let temp_raw = buf.get_u16_le();
455    let pressure_raw = buf.get_u16_le();
456    let humidity_raw = buf.get_u16_le();
457    let battery = buf.get_u8();
458    let status = Status::from(buf.get_u8());
459    let interval = buf.get_u16_le();
460    let age = buf.get_u16_le();
461    let radon = buf.get_u32_le();
462
463    Ok(AdvertisementData {
464        device_type: DeviceType::AranetRadon,
465        co2: None,
466        temperature: Some(temp_raw as f32 / 20.0),
467        pressure: Some(pressure_raw as f32 / 10.0),
468        humidity: Some((humidity_raw / 10).min(255) as u8),
469        battery,
470        status,
471        interval,
472        age,
473        radon: Some(radon),
474        radiation_dose_rate: None,
475        counter: None,
476        flags,
477    })
478}
479
480/// Parse Aranet Radiation advertisement data (legacy format for tests).
481#[allow(dead_code)]
482fn parse_aranet_radiation_advertisement(data: &[u8]) -> Result<AdvertisementData> {
483    if data.len() < 16 {
484        return Err(Error::InvalidData(format!(
485            "Aranet Radiation advertisement requires at least 16 bytes, got {}",
486            data.len()
487        )));
488    }
489
490    let mut buf = &data[1..];
491    let flags = buf.get_u8();
492    let battery = buf.get_u8();
493    let status = Status::from(buf.get_u8());
494    let interval = buf.get_u16_le();
495    let age = buf.get_u16_le();
496    // Dose rate is in nSv/h, convert to µSv/h
497    let dose_rate_nsv = buf.get_u32_le();
498    let dose_rate_usv = dose_rate_nsv as f32 / 1000.0;
499
500    Ok(AdvertisementData {
501        device_type: DeviceType::AranetRadiation,
502        co2: None,
503        temperature: None,
504        pressure: None,
505        humidity: None,
506        battery,
507        status,
508        interval,
509        age,
510        radon: None,
511        radiation_dose_rate: Some(dose_rate_usv),
512        counter: None,
513        flags,
514    })
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn test_parse_aranet4_advertisement() {
523        // Aranet4 v2 format: 22 bytes, no device type prefix
524        // Flags byte has bit 5 set (0x20) for Smart Home integration
525        let data: [u8; 22] = [
526            0x22, // flags (bit 5 = integrations enabled)
527            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, 0x01, // basic info (7 bytes)
528            0x20, 0x03, // CO2 = 800
529            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
530            0x94, 0x27, // pressure_raw = 10132 (10132 * 0.1 = 1013.2 hPa)
531            45,   // humidity
532            85,   // battery
533            1,    // status = Green
534            0x2C, 0x01, // interval = 300
535            0x78, 0x00, // age = 120
536            5,    // counter
537        ];
538
539        let result = parse_advertisement(&data).unwrap();
540        assert_eq!(result.device_type, DeviceType::Aranet4);
541        assert_eq!(result.co2, Some(800));
542        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
543        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
544        assert_eq!(result.humidity, Some(45));
545        assert_eq!(result.battery, 85);
546        assert_eq!(result.status, Status::Green);
547        assert_eq!(result.interval, 300);
548        assert_eq!(result.age, 120);
549    }
550
551    #[test]
552    fn test_parse_aranet2_advertisement() {
553        // Aranet2 v2 format: device type 0x01, then 19+ bytes
554        // Flags byte has bit 5 set (0x20) for Smart Home integration
555        let data: [u8; 20] = [
556            0x01, // device type = Aranet2
557            0x20, // flags (bit 5 = integrations enabled)
558            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, // basic info (6 bytes)
559            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
560            0x00, 0x00, // unused
561            0xC2, 0x01, // humidity_raw = 450 (450 * 0.1 = 45%)
562            85,   // battery
563            1,    // status = Green
564            0x2C, 0x01, // interval = 300
565            0x3C, 0x00, // age = 60
566        ];
567
568        let result = parse_advertisement(&data).unwrap();
569        assert_eq!(result.device_type, DeviceType::Aranet2);
570        assert!(result.co2.is_none());
571        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
572        assert_eq!(result.humidity, Some(45));
573        assert_eq!(result.battery, 85);
574    }
575
576    #[test]
577    fn test_parse_aranet_radon_advertisement() {
578        // Aranet Radon v2 format: device type 0x03, then 23 bytes
579        // Format: <xxxxxxxHHHHBBBHHB (7 skip, 4xH, 3xB, 2xH, 1xB)
580        // Flags byte has bit 5 set (0x20) for Smart Home integration
581        let data: [u8; 24] = [
582            0x03, // device type = Aranet Radon
583            0x21, // flags (bit 5 = integrations enabled)
584            0x00, 0x0C, 0x01, 0x00, 0x00, 0x00, // basic info (6 bytes, total 7 with flags)
585            0x51, 0x00, // radon = 81 Bq/m³
586            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
587            0x94, 0x27, // pressure_raw = 10132 (10132 * 0.1 = 1013.2 hPa)
588            0xC2, 0x01, // humidity_raw = 450 (450 * 0.1 = 45%)
589            0x00, // reserved byte (skipped in Python decode)
590            85,   // battery
591            1,    // status = Green
592            0x2C, 0x01, // interval = 300
593            0x3C, 0x00, // age = 60
594            5,    // counter
595        ];
596
597        let result = parse_advertisement(&data).unwrap();
598        assert_eq!(result.device_type, DeviceType::AranetRadon);
599        assert!(result.co2.is_none());
600        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
601        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
602        assert_eq!(result.humidity, Some(45));
603        assert_eq!(result.radon, Some(81));
604        assert_eq!(result.battery, 85);
605        assert_eq!(result.status, Status::Green);
606    }
607
608    #[test]
609    fn test_parse_empty_data() {
610        let result = parse_advertisement(&[]);
611        assert!(result.is_err());
612        assert!(result.unwrap_err().to_string().contains("empty"));
613    }
614
615    #[test]
616    fn test_parse_unknown_device_type() {
617        // Unknown device type byte (not 0x01, 0x02, or 0x03)
618        // and not Aranet4 length (7 or 22 bytes)
619        let data: [u8; 16] = [0xFF; 16];
620        let result = parse_advertisement(&data);
621        assert!(result.is_err());
622        let err_msg = result.unwrap_err().to_string();
623        assert!(
624            err_msg.contains("Unknown device type byte"),
625            "Expected unknown device type error, got: {}",
626            err_msg
627        );
628    }
629
630    #[test]
631    fn test_parse_aranet4_insufficient_bytes() {
632        // Aranet4 is detected by length (7 or 22 bytes)
633        // 10 bytes is not a valid Aranet4 length, so it will try to parse as other device
634        // But 0x22 is not a valid device type, so it will fail
635        let data: [u8; 10] = [0x22; 10];
636        let result = parse_advertisement(&data);
637        assert!(result.is_err());
638        let err_msg = result.unwrap_err().to_string();
639        assert!(
640            err_msg.contains("Unknown device type byte"),
641            "Expected unknown device type error, got: {}",
642            err_msg
643        );
644    }
645
646    #[test]
647    fn test_parse_aranet_radiation_advertisement() {
648        // Aranet Radiation v2 format: device type 0x02, then 19+ bytes
649        // Flags byte has bit 5 set (0x20) for Smart Home integration
650        // Note: Using 23 bytes to avoid triggering Aranet4 detection (which uses 7 or 22 bytes)
651        let data: [u8; 23] = [
652            0x02, // device type = Radiation
653            0x20, // flags (bit 5 = integrations enabled)
654            0x13, 0x04, 0x01, 0x00, // basic info (4 bytes)
655            0x00, 0x00, 0x00, 0x00, // radiation total (u32)
656            0x00, 0x00, 0x00, 0x00, // radiation duration (u32)
657            0x64, 0x00, // radiation rate = 100 (*10 = 1000 nSv/h = 1.0 µSv/h)
658            85,   // battery
659            1,    // status = Green
660            0x2C, 0x01, // interval = 300
661            0x3C, 0x00, // age = 60
662            5,    // counter
663        ];
664
665        let result = parse_advertisement(&data).unwrap();
666        assert_eq!(result.device_type, DeviceType::AranetRadiation);
667        assert!(result.co2.is_none());
668        assert!(result.temperature.is_none());
669        assert!(result.radon.is_none());
670        assert!((result.radiation_dose_rate.unwrap() - 1.0).abs() < 0.001);
671        assert_eq!(result.battery, 85);
672        assert_eq!(result.status, Status::Green);
673        assert_eq!(result.interval, 300);
674        assert_eq!(result.age, 60);
675    }
676
677    #[test]
678    fn test_parse_aranet_radiation_insufficient_bytes() {
679        // Device type 0x02 but not enough bytes
680        let data: [u8; 10] = [0x02, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
681        let result = parse_advertisement(&data);
682        assert!(result.is_err());
683        let err_msg = result.unwrap_err().to_string();
684        assert!(
685            err_msg.contains("requires at least 21 bytes"),
686            "Expected insufficient bytes error, got: {}",
687            err_msg
688        );
689    }
690
691    #[test]
692    fn test_parse_smart_home_not_enabled() {
693        // Aranet4 format (22 bytes) but bit 5 not set in flags
694        let data: [u8; 22] = [
695            0x00, // flags (bit 5 NOT set - integrations disabled)
696            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, 0x01, // basic info
697            0x20, 0x03, // CO2
698            0xC2, 0x01, // temp
699            0x94, 0x27, // pressure
700            45, 85, 1, // humidity, battery, status
701            0x2C, 0x01, // interval
702            0x78, 0x00, // age
703            5,    // counter
704        ];
705
706        let result = parse_advertisement(&data);
707        assert!(result.is_err());
708        let err_msg = result.unwrap_err().to_string();
709        assert!(
710            err_msg.contains("Smart Home integration is not enabled"),
711            "Expected Smart Home error, got: {}",
712            err_msg
713        );
714    }
715}
716
717/// Property-based tests for BLE advertisement parsing.
718///
719/// These tests verify that advertisement parsing is safe with any input,
720/// including malformed or random data that might be received from BLE scans.
721///
722/// # Test Categories
723///
724/// ## Panic Safety Tests
725/// - `parse_advertisement_never_panics`: Any random bytes
726/// - `parse_aranet4_advertisement_never_panics`: 22-byte sequences
727/// - `parse_aranet2_advertisement_never_panics`: Aranet2 device type
728/// - `parse_aranet_radon_advertisement_never_panics`: Radon device type
729/// - `parse_aranet_radiation_advertisement_never_panics`: Radiation device type
730///
731/// # Running Tests
732///
733/// ```bash
734/// cargo test -p aranet-core advertisement::proptests
735/// ```
736#[cfg(test)]
737mod proptests {
738    use super::*;
739    use proptest::prelude::*;
740
741    proptest! {
742        /// Parsing random advertisement bytes should never panic.
743        /// It may return an error, but should always be safe.
744        #[test]
745        fn parse_advertisement_never_panics(data: Vec<u8>) {
746            let _ = parse_advertisement(&data);
747        }
748
749        /// Parsing with valid Aranet4 length (22 bytes) should not panic.
750        #[test]
751        fn parse_aranet4_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 22)) {
752            let _ = parse_advertisement(&data);
753        }
754
755        /// Parsing with Aranet2 format (device type 0x01) should not panic.
756        #[test]
757        fn parse_aranet2_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 19..=30)) {
758            let mut modified = data.clone();
759            if !modified.is_empty() {
760                modified[0] = 0x01; // Set device type to Aranet2
761            }
762            let _ = parse_advertisement(&modified);
763        }
764
765        /// Parsing with Aranet Radon format should not panic.
766        #[test]
767        fn parse_aranet_radon_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 23..=30)) {
768            let mut modified = data.clone();
769            if !modified.is_empty() {
770                modified[0] = 0x03; // Set device type to Radon
771            }
772            let _ = parse_advertisement(&modified);
773        }
774
775        /// Parsing with Aranet Radiation format should not panic.
776        #[test]
777        fn parse_aranet_radiation_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 19..=30)) {
778            let mut modified = data.clone();
779            if !modified.is_empty() {
780                modified[0] = 0x02; // Set device type to Radiation
781            }
782            let _ = parse_advertisement(&modified);
783        }
784    }
785}