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    if data.len() < 19 {
363        return Err(Error::InvalidData(format!(
364            "Aranet Radiation advertisement requires at least 19 bytes, got {}",
365            data.len()
366        )));
367    }
368
369    let flags = data[0];
370    // Skip to sensor data at offset 5
371    let mut buf = &data[5..];
372    let _radiation_total = buf.get_u32_le(); // nSv total dose
373    let _radiation_duration = buf.get_u32_le(); // seconds
374    let radiation_rate_raw = buf.get_u16_le(); // *10 for nSv/h
375    let battery = buf.get_u8();
376    let status = Status::from(buf.get_u8());
377    let interval = buf.get_u16_le();
378    let age = buf.get_u16_le();
379    let counter = if !buf.is_empty() {
380        Some(buf.get_u8())
381    } else {
382        None
383    };
384
385    // Convert from nSv/h * 10 to µSv/h
386    let dose_rate_usv = (radiation_rate_raw as f32 * 10.0) / 1000.0;
387
388    Ok(AdvertisementData {
389        device_type: DeviceType::AranetRadiation,
390        co2: None,
391        temperature: None,
392        pressure: None,
393        humidity: None,
394        battery,
395        status,
396        interval,
397        age,
398        radon: None,
399        radiation_dose_rate: Some(dose_rate_usv),
400        counter,
401        flags,
402    })
403}
404
405/// Parse Aranet2 advertisement data (legacy format for tests).
406#[allow(dead_code)]
407fn parse_aranet2_advertisement(data: &[u8]) -> Result<AdvertisementData> {
408    if data.len() < 12 {
409        return Err(Error::InvalidData(format!(
410            "Aranet2 advertisement requires at least 12 bytes, got {}",
411            data.len()
412        )));
413    }
414
415    let mut buf = &data[1..];
416    let flags = buf.get_u8();
417    let temp_raw = buf.get_u16_le();
418    let humidity_raw = buf.get_u16_le();
419    let battery = buf.get_u8();
420    let status = Status::from(buf.get_u8());
421    let interval = buf.get_u16_le();
422    let age = buf.get_u16_le();
423
424    Ok(AdvertisementData {
425        device_type: DeviceType::Aranet2,
426        co2: None,
427        temperature: Some(temp_raw as f32 / 20.0),
428        pressure: None,
429        humidity: Some((humidity_raw / 10).min(255) as u8),
430        battery,
431        status,
432        interval,
433        age,
434        radon: None,
435        radiation_dose_rate: None,
436        counter: None,
437        flags,
438    })
439}
440
441/// Parse Aranet Radon advertisement data (legacy format for tests).
442#[allow(dead_code)]
443fn parse_aranet_radon_advertisement(data: &[u8]) -> Result<AdvertisementData> {
444    if data.len() < 18 {
445        return Err(Error::InvalidData(format!(
446            "Aranet Radon advertisement requires at least 18 bytes, got {}",
447            data.len()
448        )));
449    }
450
451    let mut buf = &data[1..];
452    let flags = buf.get_u8();
453    let temp_raw = buf.get_u16_le();
454    let pressure_raw = buf.get_u16_le();
455    let humidity_raw = buf.get_u16_le();
456    let battery = buf.get_u8();
457    let status = Status::from(buf.get_u8());
458    let interval = buf.get_u16_le();
459    let age = buf.get_u16_le();
460    let radon = buf.get_u32_le();
461
462    Ok(AdvertisementData {
463        device_type: DeviceType::AranetRadon,
464        co2: None,
465        temperature: Some(temp_raw as f32 / 20.0),
466        pressure: Some(pressure_raw as f32 / 10.0),
467        humidity: Some((humidity_raw / 10).min(255) as u8),
468        battery,
469        status,
470        interval,
471        age,
472        radon: Some(radon),
473        radiation_dose_rate: None,
474        counter: None,
475        flags,
476    })
477}
478
479/// Parse Aranet Radiation advertisement data (legacy format for tests).
480#[allow(dead_code)]
481fn parse_aranet_radiation_advertisement(data: &[u8]) -> Result<AdvertisementData> {
482    if data.len() < 16 {
483        return Err(Error::InvalidData(format!(
484            "Aranet Radiation advertisement requires at least 16 bytes, got {}",
485            data.len()
486        )));
487    }
488
489    let mut buf = &data[1..];
490    let flags = buf.get_u8();
491    let battery = buf.get_u8();
492    let status = Status::from(buf.get_u8());
493    let interval = buf.get_u16_le();
494    let age = buf.get_u16_le();
495    // Dose rate is in nSv/h, convert to µSv/h
496    let dose_rate_nsv = buf.get_u32_le();
497    let dose_rate_usv = dose_rate_nsv as f32 / 1000.0;
498
499    Ok(AdvertisementData {
500        device_type: DeviceType::AranetRadiation,
501        co2: None,
502        temperature: None,
503        pressure: None,
504        humidity: None,
505        battery,
506        status,
507        interval,
508        age,
509        radon: None,
510        radiation_dose_rate: Some(dose_rate_usv),
511        counter: None,
512        flags,
513    })
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn test_parse_aranet4_advertisement() {
522        // Aranet4 v2 format: 22 bytes, no device type prefix
523        // Flags byte has bit 5 set (0x20) for Smart Home integration
524        let data: [u8; 22] = [
525            0x22, // flags (bit 5 = integrations enabled)
526            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, 0x01, // basic info (7 bytes)
527            0x20, 0x03, // CO2 = 800
528            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
529            0x94, 0x27, // pressure_raw = 10132 (10132 * 0.1 = 1013.2 hPa)
530            45,   // humidity
531            85,   // battery
532            1,    // status = Green
533            0x2C, 0x01, // interval = 300
534            0x78, 0x00, // age = 120
535            5,    // counter
536        ];
537
538        let result = parse_advertisement(&data).unwrap();
539        assert_eq!(result.device_type, DeviceType::Aranet4);
540        assert_eq!(result.co2, Some(800));
541        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
542        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
543        assert_eq!(result.humidity, Some(45));
544        assert_eq!(result.battery, 85);
545        assert_eq!(result.status, Status::Green);
546        assert_eq!(result.interval, 300);
547        assert_eq!(result.age, 120);
548    }
549
550    #[test]
551    fn test_parse_aranet2_advertisement() {
552        // Aranet2 v2 format: device type 0x01, then 19+ bytes
553        // Flags byte has bit 5 set (0x20) for Smart Home integration
554        let data: [u8; 20] = [
555            0x01, // device type = Aranet2
556            0x20, // flags (bit 5 = integrations enabled)
557            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, // basic info (6 bytes)
558            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
559            0x00, 0x00, // unused
560            0xC2, 0x01, // humidity_raw = 450 (450 * 0.1 = 45%)
561            85,   // battery
562            1,    // status = Green
563            0x2C, 0x01, // interval = 300
564            0x3C, 0x00, // age = 60
565        ];
566
567        let result = parse_advertisement(&data).unwrap();
568        assert_eq!(result.device_type, DeviceType::Aranet2);
569        assert!(result.co2.is_none());
570        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
571        assert_eq!(result.humidity, Some(45));
572        assert_eq!(result.battery, 85);
573    }
574
575    #[test]
576    fn test_parse_aranet_radon_advertisement() {
577        // Aranet Radon v2 format: device type 0x03, then 23 bytes
578        // Format: <xxxxxxxHHHHBBBHHB (7 skip, 4xH, 3xB, 2xH, 1xB)
579        // Flags byte has bit 5 set (0x20) for Smart Home integration
580        let data: [u8; 24] = [
581            0x03, // device type = Aranet Radon
582            0x21, // flags (bit 5 = integrations enabled)
583            0x00, 0x0C, 0x01, 0x00, 0x00, 0x00, // basic info (6 bytes, total 7 with flags)
584            0x51, 0x00, // radon = 81 Bq/m³
585            0xC2, 0x01, // temp_raw = 450 (450 * 0.05 = 22.5°C)
586            0x94, 0x27, // pressure_raw = 10132 (10132 * 0.1 = 1013.2 hPa)
587            0xC2, 0x01, // humidity_raw = 450 (450 * 0.1 = 45%)
588            0x00, // reserved byte (skipped in Python decode)
589            85,   // battery
590            1,    // status = Green
591            0x2C, 0x01, // interval = 300
592            0x3C, 0x00, // age = 60
593            5,    // counter
594        ];
595
596        let result = parse_advertisement(&data).unwrap();
597        assert_eq!(result.device_type, DeviceType::AranetRadon);
598        assert!(result.co2.is_none());
599        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
600        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
601        assert_eq!(result.humidity, Some(45));
602        assert_eq!(result.radon, Some(81));
603        assert_eq!(result.battery, 85);
604        assert_eq!(result.status, Status::Green);
605    }
606
607    #[test]
608    fn test_parse_empty_data() {
609        let result = parse_advertisement(&[]);
610        assert!(result.is_err());
611        assert!(result.unwrap_err().to_string().contains("empty"));
612    }
613
614    #[test]
615    fn test_parse_unknown_device_type() {
616        // Unknown device type byte (not 0x01, 0x02, or 0x03)
617        // and not Aranet4 length (7 or 22 bytes)
618        let data: [u8; 16] = [0xFF; 16];
619        let result = parse_advertisement(&data);
620        assert!(result.is_err());
621        let err_msg = result.unwrap_err().to_string();
622        assert!(
623            err_msg.contains("Unknown device type byte"),
624            "Expected unknown device type error, got: {}",
625            err_msg
626        );
627    }
628
629    #[test]
630    fn test_parse_aranet4_insufficient_bytes() {
631        // Aranet4 is detected by length (7 or 22 bytes)
632        // 10 bytes is not a valid Aranet4 length, so it will try to parse as other device
633        // But 0x22 is not a valid device type, so it will fail
634        let data: [u8; 10] = [0x22; 10];
635        let result = parse_advertisement(&data);
636        assert!(result.is_err());
637        let err_msg = result.unwrap_err().to_string();
638        assert!(
639            err_msg.contains("Unknown device type byte"),
640            "Expected unknown device type error, got: {}",
641            err_msg
642        );
643    }
644
645    #[test]
646    fn test_parse_aranet_radiation_advertisement() {
647        // Aranet Radiation v2 format: device type 0x02, then 19+ bytes
648        // Flags byte has bit 5 set (0x20) for Smart Home integration
649        // Note: Using 23 bytes to avoid triggering Aranet4 detection (which uses 7 or 22 bytes)
650        let data: [u8; 23] = [
651            0x02, // device type = Radiation
652            0x20, // flags (bit 5 = integrations enabled)
653            0x13, 0x04, 0x01, 0x00, // basic info (4 bytes)
654            0x00, 0x00, 0x00, 0x00, // radiation total (u32)
655            0x00, 0x00, 0x00, 0x00, // radiation duration (u32)
656            0x64, 0x00, // radiation rate = 100 (*10 = 1000 nSv/h = 1.0 µSv/h)
657            85,   // battery
658            1,    // status = Green
659            0x2C, 0x01, // interval = 300
660            0x3C, 0x00, // age = 60
661            5,    // counter
662        ];
663
664        let result = parse_advertisement(&data).unwrap();
665        assert_eq!(result.device_type, DeviceType::AranetRadiation);
666        assert!(result.co2.is_none());
667        assert!(result.temperature.is_none());
668        assert!(result.radon.is_none());
669        assert!((result.radiation_dose_rate.unwrap() - 1.0).abs() < 0.001);
670        assert_eq!(result.battery, 85);
671        assert_eq!(result.status, Status::Green);
672        assert_eq!(result.interval, 300);
673        assert_eq!(result.age, 60);
674    }
675
676    #[test]
677    fn test_parse_aranet_radiation_insufficient_bytes() {
678        // Device type 0x02 but not enough bytes
679        let data: [u8; 10] = [0x02, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00];
680        let result = parse_advertisement(&data);
681        assert!(result.is_err());
682        let err_msg = result.unwrap_err().to_string();
683        assert!(
684            err_msg.contains("requires at least 19 bytes"),
685            "Expected insufficient bytes error, got: {}",
686            err_msg
687        );
688    }
689
690    #[test]
691    fn test_parse_smart_home_not_enabled() {
692        // Aranet4 format (22 bytes) but bit 5 not set in flags
693        let data: [u8; 22] = [
694            0x00, // flags (bit 5 NOT set - integrations disabled)
695            0x13, 0x04, 0x01, 0x00, 0x0E, 0x0F, 0x01, // basic info
696            0x20, 0x03, // CO2
697            0xC2, 0x01, // temp
698            0x94, 0x27, // pressure
699            45, 85, 1, // humidity, battery, status
700            0x2C, 0x01, // interval
701            0x78, 0x00, // age
702            5,    // counter
703        ];
704
705        let result = parse_advertisement(&data);
706        assert!(result.is_err());
707        let err_msg = result.unwrap_err().to_string();
708        assert!(
709            err_msg.contains("Smart Home integration is not enabled"),
710            "Expected Smart Home error, got: {}",
711            err_msg
712        );
713    }
714}