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_i16_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 Aranet2 advertisement data.
198///
199/// Layout after the device-type byte is removed. It is the AranetRn+ layout with
200/// the radon and pressure slots unused (reference: Aranet4-Python
201/// `<xxxxxxxxHHHHBBBHHB` applied to the full payload):
202/// - byte 0: flags
203/// - bytes 1-8: basic info
204/// - bytes 9-10: temperature (u16 LE, ×0.05 °C)
205/// - bytes 11-12: unused
206/// - bytes 13-14: humidity (u16 LE, ×0.1 %)
207/// - byte 15: unused
208/// - byte 16: battery (%)
209/// - byte 17: status (bits 2-3 = temperature status)
210/// - bytes 18-19: interval (s)
211/// - bytes 20-21: age (s)
212/// - byte 22: counter (optional)
213fn parse_aranet2_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
214    if data.len() < 22 {
215        return Err(Error::InvalidData(format!(
216            "Aranet2 advertisement requires at least 22 bytes, got {}",
217            data.len()
218        )));
219    }
220
221    let flags = data[0];
222    let mut buf = &data[9..];
223    let temp_raw = buf.get_i16_le();
224    let _unused = buf.get_u16_le();
225    let humidity_raw = buf.get_u16_le();
226    let _unused = buf.get_u8();
227    let battery = buf.get_u8();
228    let status_raw = buf.get_u8();
229    // Status for Aranet2: bits[0:1] = humidity, bits[2:3] = temperature
230    let status = Status::from((status_raw >> 2) & 0x03);
231    let interval = buf.get_u16_le();
232    let age = buf.get_u16_le();
233    let counter = if !buf.is_empty() {
234        Some(buf.get_u8())
235    } else {
236        None
237    };
238
239    Ok(AdvertisementData {
240        device_type: DeviceType::Aranet2,
241        co2: None,
242        temperature: Some(temp_raw as f32 * 0.05),
243        pressure: None,
244        humidity: Some((humidity_raw as f32 * 0.1).clamp(0.0, 100.0) as u8),
245        battery,
246        status,
247        interval,
248        age,
249        radon: None,
250        radiation_dose_rate: None,
251        counter,
252        flags,
253    })
254}
255
256/// Parse Aranet Radon advertisement data (v2 format - actual device format).
257///
258/// Format (after device type byte removed, 23 bytes):
259/// Based on Python: `<xxxxxxxHHHHBBBHHB` (7 skip bytes, not 8)
260/// - bytes 0-6: Basic info (flags, version, etc.) - 7 bytes
261/// - bytes 7-8: Radon concentration (u16 LE, Bq/m³)
262/// - bytes 9-10: Temperature (u16 LE, *0.05 for °C)
263/// - bytes 11-12: Pressure (u16 LE, *0.1 for hPa)
264/// - bytes 13-14: Humidity (u16 LE, *0.1 for %)
265/// - byte 15: Unknown/reserved (u8) - skipped in Python decode
266/// - byte 16: Battery (u8)
267/// - byte 17: Status (u8)
268/// - bytes 18-19: Interval (u16 LE, seconds)
269/// - bytes 20-21: Age (u16 LE, seconds)
270/// - byte 22: Counter (u8)
271fn parse_aranet_radon_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
272    if data.len() < 22 {
273        return Err(Error::InvalidData(format!(
274            "Aranet Radon advertisement requires at least 22 bytes, got {}",
275            data.len()
276        )));
277    }
278
279    let flags = data[0];
280    // Skip to sensor data at offset 7 (7 bytes of basic info)
281    let mut buf = &data[7..];
282    let radon = buf.get_u16_le() as u32;
283    let temp_raw = buf.get_i16_le();
284    let pressure_raw = buf.get_u16_le();
285    let humidity_raw = buf.get_u16_le();
286    let _reserved = buf.get_u8(); // Unknown/reserved byte (skipped in Python)
287    let battery = buf.get_u8();
288    let status = Status::from(buf.get_u8());
289    let interval = buf.get_u16_le();
290    let age = buf.get_u16_le();
291    let counter = if !buf.is_empty() {
292        Some(buf.get_u8())
293    } else {
294        None
295    };
296
297    Ok(AdvertisementData {
298        device_type: DeviceType::AranetRadon,
299        co2: None,
300        temperature: Some(temp_raw as f32 * 0.05),
301        pressure: Some(pressure_raw as f32 * 0.1),
302        humidity: Some((humidity_raw as f32 * 0.1).clamp(0.0, 100.0) as u8),
303        battery,
304        status,
305        interval,
306        age,
307        radon: Some(radon),
308        radiation_dose_rate: None,
309        counter,
310        flags,
311    })
312}
313
314/// Parse Aranet Radiation advertisement data.
315///
316/// Layout after the device-type byte is removed (reference: Aranet4-Python
317/// `<xxxxxxIIHBBBHHB` applied to the full payload):
318/// - byte 0: flags
319/// - bytes 1-4: basic info
320/// - bytes 5-8: total dose (u32 LE, nSv)
321/// - bytes 9-12: measurement duration (u32 LE, s)
322/// - bytes 13-14: dose rate (u16 LE, nSv/h)
323/// - byte 15: unknown
324/// - byte 16: battery (%)
325/// - byte 17: status
326/// - bytes 18-19: interval (s)
327/// - bytes 20-21: age (s)
328/// - byte 22: counter (optional)
329fn parse_aranet_radiation_advertisement_v2(data: &[u8]) -> Result<AdvertisementData> {
330    if data.len() < 22 {
331        return Err(Error::InvalidData(format!(
332            "Aranet Radiation advertisement requires at least 22 bytes, got {}",
333            data.len()
334        )));
335    }
336
337    let flags = data[0];
338    let mut buf = &data[5..];
339    let _radiation_total = buf.get_u32_le(); // nSv total dose
340    let _radiation_duration = buf.get_u32_le(); // seconds
341    let radiation_rate_nsv = buf.get_u16_le(); // nSv/h
342    let _unknown = buf.get_u8();
343    let battery = buf.get_u8();
344    let status = Status::from(buf.get_u8());
345    let interval = buf.get_u16_le();
346    let age = buf.get_u16_le();
347    let counter = if !buf.is_empty() {
348        Some(buf.get_u8())
349    } else {
350        None
351    };
352
353    // Same unit handling as the GATT reading: nSv/h → µSv/h
354    let dose_rate_usv = radiation_rate_nsv as f32 / 1000.0;
355
356    Ok(AdvertisementData {
357        device_type: DeviceType::AranetRadiation,
358        co2: None,
359        temperature: None,
360        pressure: None,
361        humidity: None,
362        battery,
363        status,
364        interval,
365        age,
366        radon: None,
367        radiation_dose_rate: Some(dose_rate_usv),
368        counter,
369        flags,
370    })
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_parse_aranet4_advertisement() {
379        // Aranet4 v2 format: 22 bytes, no device type prefix
380        // Flags byte has bit 5 set (0x20) for Smart Home integration
381        let data: [u8; 22] = [
382            0x22, // flags (bit 5 = integrations enabled)
383            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, 0x01, // basic info (7 bytes)
384            0x20, 0x03, // CO2 = 800
385            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
386            0x94, 0x27, // pressure_raw = 10132 (10132 * 0.1 = 1013.2 hPa)
387            45,   // humidity
388            85,   // battery
389            1,    // status = Green
390            0x2C, 0x01, // interval = 300
391            0x78, 0x00, // age = 120
392            5,    // counter
393        ];
394
395        let result = parse_advertisement(&data).unwrap();
396        assert_eq!(result.device_type, DeviceType::Aranet4);
397        assert_eq!(result.co2, Some(800));
398        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
399        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
400        assert_eq!(result.humidity, Some(45));
401        assert_eq!(result.battery, 85);
402        assert_eq!(result.status, Status::Green);
403        assert_eq!(result.interval, 300);
404        assert_eq!(result.age, 120);
405    }
406
407    #[test]
408    fn test_parse_aranet2_advertisement() {
409        // Aranet2 v2 format: device type 0x01, then 19+ bytes
410        // Flags byte has bit 5 set (0x20) for Smart Home integration
411        let data: [u8; 24] = [
412            0x01, // device type = Aranet2
413            0x20, // flags (bit 5 = integrations enabled)
414            0x13, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, // basic info (8 bytes)
415            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
416            0x00, 0x00, // unused
417            0xC2, 0x01, // humidity_raw = 450 (450 * 0.1 = 45%)
418            0x00, // unused
419            85,   // battery
420            0x04, // status flags: bits[2:3] = 01 = Green (temperature status)
421            0x2C, 0x01, // interval = 300
422            0x3C, 0x00, // age = 60
423            0x07, // counter
424        ];
425
426        let result = parse_advertisement(&data).unwrap();
427        assert_eq!(result.device_type, DeviceType::Aranet2);
428        assert!(result.co2.is_none());
429        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
430        assert_eq!(result.humidity, Some(45));
431        assert_eq!(result.battery, 85);
432        assert_eq!(result.status, Status::Green);
433    }
434
435    /// Real Aranet2 advertisement (manufacturer data after the 0x0702 company ID),
436    /// from Home Assistant's aranet integration test fixtures.
437    /// The device reported 24.8 °C, 52.4 % RH, battery 79 %, 60 s interval.
438    const ARANET2_CAPTURE: [u8; 24] = [
439        0x01, 0x21, 0x04, 0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x01, 0x00, 0x00, 0x0c,
440        0x02, 0x00, 0x4f, 0x00, 0x3c, 0x00, 0x01, 0x00, 0x80,
441    ];
442
443    #[test]
444    fn test_parse_aranet2_real_capture() {
445        let result = parse_advertisement(&ARANET2_CAPTURE).unwrap();
446        assert_eq!(result.device_type, DeviceType::Aranet2);
447        assert!(
448            (result.temperature.unwrap() - 24.8).abs() < 0.01,
449            "temperature was {:?}",
450            result.temperature
451        );
452        assert_eq!(result.humidity, Some(52));
453        assert_eq!(result.battery, 79);
454        assert_eq!(result.interval, 60);
455        assert_eq!(result.age, 1);
456        assert_eq!(result.counter, Some(0x80));
457    }
458
459    #[test]
460    fn test_parse_aranet_radon_advertisement() {
461        // Aranet Radon v2 format: device type 0x03, then 23 bytes
462        // Format: <xxxxxxxHHHHBBBHHB (7 skip, 4xH, 3xB, 2xH, 1xB)
463        // Flags byte has bit 5 set (0x20) for Smart Home integration
464        let data: [u8; 24] = [
465            0x03, // device type = Aranet Radon
466            0x21, // flags (bit 5 = integrations enabled)
467            0x00, 0x0C, 0x01, 0x00, 0x00, 0x00, // basic info (6 bytes, total 7 with flags)
468            0x51, 0x00, // radon = 81 Bq/m³
469            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
470            0x94, 0x27, // pressure_raw = 10132 (10132 * 0.1 = 1013.2 hPa)
471            0xC2, 0x01, // humidity_raw = 450 (450 * 0.1 = 45%)
472            0x00, // reserved byte (skipped in Python decode)
473            85,   // battery
474            1,    // status = Green
475            0x2C, 0x01, // interval = 300
476            0x3C, 0x00, // age = 60
477            5,    // counter
478        ];
479
480        let result = parse_advertisement(&data).unwrap();
481        assert_eq!(result.device_type, DeviceType::AranetRadon);
482        assert!(result.co2.is_none());
483        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
484        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
485        assert_eq!(result.humidity, Some(45));
486        assert_eq!(result.radon, Some(81));
487        assert_eq!(result.battery, 85);
488        assert_eq!(result.status, Status::Green);
489    }
490
491    #[test]
492    fn test_parse_empty_data() {
493        let result = parse_advertisement(&[]);
494        assert!(result.is_err());
495        assert!(result.unwrap_err().to_string().contains("empty"));
496    }
497
498    #[test]
499    fn test_parse_unknown_device_type() {
500        // Unknown device type byte (not 0x01, 0x02, or 0x03)
501        // and not Aranet4 length (7 or 22 bytes)
502        let data: [u8; 16] = [0xFF; 16];
503        let result = parse_advertisement(&data);
504        assert!(result.is_err());
505        let err_msg = result.unwrap_err().to_string();
506        assert!(
507            err_msg.contains("Unknown device type byte"),
508            "Expected unknown device type error, got: {}",
509            err_msg
510        );
511    }
512
513    #[test]
514    fn test_parse_aranet4_insufficient_bytes() {
515        // Aranet4 is detected by length (7 or 22 bytes)
516        // 10 bytes is not a valid Aranet4 length, so it will try to parse as other device
517        // But 0x22 is not a valid device type, so it will fail
518        let data: [u8; 10] = [0x22; 10];
519        let result = parse_advertisement(&data);
520        assert!(result.is_err());
521        let err_msg = result.unwrap_err().to_string();
522        assert!(
523            err_msg.contains("Unknown device type byte"),
524            "Expected unknown device type error, got: {}",
525            err_msg
526        );
527    }
528
529    /// Real Aranet Radiation advertisement (Home Assistant fixtures).
530    /// Total 11616 nSv over 366600 s ≈ 114 nSv/h average; current rate 110 nSv/h.
531    const RADIATION_CAPTURE: [u8; 24] = [
532        0x02, 0x21, 0x26, 0x04, 0x01, 0x00, 0x60, 0x2d, 0x00, 0x00, 0x08, 0x98, 0x05, 0x00, 0x6e,
533        0x00, 0x00, 0x64, 0x00, 0x2c, 0x01, 0xfd, 0x00, 0xc7,
534    ];
535
536    #[test]
537    fn test_parse_aranet_radiation_real_capture() {
538        let result = parse_advertisement(&RADIATION_CAPTURE).unwrap();
539        assert_eq!(result.device_type, DeviceType::AranetRadiation);
540        let rate = result.radiation_dose_rate.unwrap();
541        assert!((rate - 0.11).abs() < 0.001, "dose rate was {rate} µSv/h");
542        assert_eq!(result.battery, 100);
543        assert_eq!(result.interval, 300);
544        assert_eq!(result.age, 253);
545        assert_eq!(result.counter, Some(0xc7));
546    }
547
548    #[test]
549    fn test_parse_aranet_radiation_advertisement() {
550        // Aranet Radiation v2 format: device type 0x02, then 19+ bytes
551        // Flags byte has bit 5 set (0x20) for Smart Home integration
552        // Note: Using 23 bytes to avoid triggering Aranet4 detection (which uses 7 or 22 bytes)
553        let data: [u8; 24] = [
554            0x02, // device type = Radiation
555            0x20, // flags (bit 5 = integrations enabled)
556            0x13, 0x04, 0x01, 0x00, // basic info (4 bytes)
557            0x00, 0x00, 0x00, 0x00, // radiation total (u32, nSv)
558            0x00, 0x00, 0x00, 0x00, // radiation duration (u32, s)
559            0xE8, 0x03, // radiation rate = 1000 nSv/h = 1.0 µSv/h
560            0x00, // unknown
561            85,   // battery
562            1,    // status = Green
563            0x2C, 0x01, // interval = 300
564            0x3C, 0x00, // age = 60
565            5,    // counter
566        ];
567
568        let result = parse_advertisement(&data).unwrap();
569        assert_eq!(result.device_type, DeviceType::AranetRadiation);
570        assert!(result.co2.is_none());
571        assert!(result.temperature.is_none());
572        assert!(result.radon.is_none());
573        assert!((result.radiation_dose_rate.unwrap() - 1.0).abs() < 0.001);
574        assert_eq!(result.battery, 85);
575        assert_eq!(result.status, Status::Green);
576        assert_eq!(result.interval, 300);
577        assert_eq!(result.age, 60);
578    }
579
580    #[test]
581    fn test_parse_aranet_radiation_insufficient_bytes() {
582        // Device type 0x02 but not enough bytes
583        let data: [u8; 10] = [0x02, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
584        let result = parse_advertisement(&data);
585        assert!(result.is_err());
586        let err_msg = result.unwrap_err().to_string();
587        assert!(
588            err_msg.contains("requires at least 22 bytes"),
589            "Expected insufficient bytes error, got: {}",
590            err_msg
591        );
592    }
593
594    #[test]
595    fn test_parse_smart_home_not_enabled() {
596        // Aranet4 format (22 bytes) but bit 5 not set in flags
597        let data: [u8; 22] = [
598            0x00, // flags (bit 5 NOT set - integrations disabled)
599            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, 0x01, // basic info
600            0x20, 0x03, // CO2
601            0xC2, 0x01, // temp
602            0x94, 0x27, // pressure
603            45, 85, 1, // humidity, battery, status
604            0x2C, 0x01, // interval
605            0x78, 0x00, // age
606            5,    // counter
607        ];
608
609        let result = parse_advertisement(&data);
610        assert!(result.is_err());
611        let err_msg = result.unwrap_err().to_string();
612        assert!(
613            err_msg.contains("Smart Home integration is not enabled"),
614            "Expected Smart Home error, got: {}",
615            err_msg
616        );
617    }
618}
619
620/// Property-based tests for BLE advertisement parsing.
621///
622/// These tests verify that advertisement parsing is safe with any input,
623/// including malformed or random data that might be received from BLE scans.
624///
625/// # Test Categories
626///
627/// ## Panic Safety Tests
628/// - `parse_advertisement_never_panics`: Any random bytes
629/// - `parse_aranet4_advertisement_never_panics`: 22-byte sequences
630/// - `parse_aranet2_advertisement_never_panics`: Aranet2 device type
631/// - `parse_aranet_radon_advertisement_never_panics`: Radon device type
632/// - `parse_aranet_radiation_advertisement_never_panics`: Radiation device type
633///
634/// # Running Tests
635///
636/// ```bash
637/// cargo test -p aranet-core advertisement::proptests
638/// ```
639#[cfg(test)]
640mod proptests {
641    use super::*;
642    use proptest::prelude::*;
643
644    proptest! {
645        /// Parsing random advertisement bytes should never panic.
646        /// It may return an error, but should always be safe.
647        #[test]
648        fn parse_advertisement_never_panics(data: Vec<u8>) {
649            let _ = parse_advertisement(&data);
650        }
651
652        /// Parsing with valid Aranet4 length (22 bytes) should not panic.
653        #[test]
654        fn parse_aranet4_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 22)) {
655            let _ = parse_advertisement(&data);
656        }
657
658        /// Parsing with Aranet2 format (device type 0x01) should not panic.
659        #[test]
660        fn parse_aranet2_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 19..=30)) {
661            let mut modified = data.clone();
662            if !modified.is_empty() {
663                modified[0] = 0x01; // Set device type to Aranet2
664            }
665            let _ = parse_advertisement(&modified);
666        }
667
668        /// Parsing with Aranet Radon format should not panic.
669        #[test]
670        fn parse_aranet_radon_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 23..=30)) {
671            let mut modified = data.clone();
672            if !modified.is_empty() {
673                modified[0] = 0x03; // Set device type to Radon
674            }
675            let _ = parse_advertisement(&modified);
676        }
677
678        /// Parsing with Aranet Radiation format should not panic.
679        #[test]
680        fn parse_aranet_radiation_advertisement_never_panics(data in proptest::collection::vec(any::<u8>(), 19..=30)) {
681            let mut modified = data.clone();
682            if !modified.is_empty() {
683                modified[0] = 0x02; // Set device type to Radiation
684            }
685            let _ = parse_advertisement(&modified);
686        }
687    }
688}