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    if data.is_empty() {
63        return Err(Error::InvalidData(
64            "Advertisement data is empty".to_string(),
65        ));
66    }
67
68    // First byte is device type
69    let device_type = match data[0] {
70        0xF1 => DeviceType::Aranet4,
71        0xF2 => DeviceType::Aranet2,
72        0xF3 => DeviceType::AranetRadon,
73        0xF4 => DeviceType::AranetRadiation,
74        other => {
75            return Err(Error::InvalidData(format!(
76                "Unknown device type: 0x{:02X}",
77                other
78            )));
79        }
80    };
81
82    match device_type {
83        DeviceType::Aranet4 => parse_aranet4_advertisement(data),
84        DeviceType::Aranet2 => parse_aranet2_advertisement(data),
85        DeviceType::AranetRadon => parse_aranet_radon_advertisement(data),
86        DeviceType::AranetRadiation => parse_aranet_radiation_advertisement(data),
87        // Handle future device types - return error for unknown types
88        _ => Err(Error::InvalidData(format!(
89            "Unsupported device type for advertisement parsing: {:?}",
90            device_type
91        ))),
92    }
93}
94
95/// Parse Aranet4 advertisement data.
96///
97/// Format (16 bytes):
98/// - byte 0: Type (0xF1)
99/// - byte 1: Flags
100/// - bytes 2-3: CO2 (u16 LE)
101/// - bytes 4-5: Temperature (u16 LE, /20 for °C)
102/// - bytes 6-7: Pressure (u16 LE, /10 for hPa)
103/// - byte 8: Humidity (u8)
104/// - byte 9: Battery (u8)
105/// - byte 10: Status (u8)
106/// - bytes 11-12: Interval (u16 LE, seconds)
107/// - bytes 13-14: Age (u16 LE, seconds)
108/// - byte 15: Counter (u8)
109fn parse_aranet4_advertisement(data: &[u8]) -> Result<AdvertisementData> {
110    if data.len() < 16 {
111        return Err(Error::InvalidData(format!(
112            "Aranet4 advertisement requires 16 bytes, got {}",
113            data.len()
114        )));
115    }
116
117    let mut buf = &data[1..]; // Skip device type byte
118    let flags = buf.get_u8();
119    let co2 = buf.get_u16_le();
120    let temp_raw = buf.get_u16_le();
121    let pressure_raw = buf.get_u16_le();
122    let humidity = buf.get_u8();
123    let battery = buf.get_u8();
124    let status = Status::from(buf.get_u8());
125    let interval = buf.get_u16_le();
126    let age = buf.get_u16_le();
127    let counter = buf.get_u8();
128
129    Ok(AdvertisementData {
130        device_type: DeviceType::Aranet4,
131        co2: Some(co2),
132        temperature: Some(temp_raw as f32 / 20.0),
133        pressure: Some(pressure_raw as f32 / 10.0),
134        humidity: Some(humidity),
135        battery,
136        status,
137        interval,
138        age,
139        radon: None,
140        radiation_dose_rate: None,
141        counter: Some(counter),
142        flags,
143    })
144}
145
146/// Parse Aranet2 advertisement data.
147fn parse_aranet2_advertisement(data: &[u8]) -> Result<AdvertisementData> {
148    if data.len() < 12 {
149        return Err(Error::InvalidData(format!(
150            "Aranet2 advertisement requires at least 12 bytes, got {}",
151            data.len()
152        )));
153    }
154
155    let mut buf = &data[1..];
156    let flags = buf.get_u8();
157    let temp_raw = buf.get_u16_le();
158    let humidity_raw = buf.get_u16_le();
159    let battery = buf.get_u8();
160    let status = Status::from(buf.get_u8());
161    let interval = buf.get_u16_le();
162    let age = buf.get_u16_le();
163
164    Ok(AdvertisementData {
165        device_type: DeviceType::Aranet2,
166        co2: None,
167        temperature: Some(temp_raw as f32 / 20.0),
168        pressure: None,
169        humidity: Some((humidity_raw / 10).min(255) as u8),
170        battery,
171        status,
172        interval,
173        age,
174        radon: None,
175        radiation_dose_rate: None,
176        counter: None,
177        flags,
178    })
179}
180
181/// Parse Aranet Radon advertisement data.
182fn parse_aranet_radon_advertisement(data: &[u8]) -> Result<AdvertisementData> {
183    if data.len() < 18 {
184        return Err(Error::InvalidData(format!(
185            "Aranet Radon advertisement requires at least 18 bytes, got {}",
186            data.len()
187        )));
188    }
189
190    let mut buf = &data[1..];
191    let flags = buf.get_u8();
192    let temp_raw = buf.get_u16_le();
193    let pressure_raw = buf.get_u16_le();
194    let humidity_raw = buf.get_u16_le();
195    let battery = buf.get_u8();
196    let status = Status::from(buf.get_u8());
197    let interval = buf.get_u16_le();
198    let age = buf.get_u16_le();
199    let radon = buf.get_u32_le();
200
201    Ok(AdvertisementData {
202        device_type: DeviceType::AranetRadon,
203        co2: None,
204        temperature: Some(temp_raw as f32 / 20.0),
205        pressure: Some(pressure_raw as f32 / 10.0),
206        humidity: Some((humidity_raw / 10).min(255) as u8),
207        battery,
208        status,
209        interval,
210        age,
211        radon: Some(radon),
212        radiation_dose_rate: None,
213        counter: None,
214        flags,
215    })
216}
217
218/// Parse Aranet Radiation advertisement data.
219fn parse_aranet_radiation_advertisement(data: &[u8]) -> Result<AdvertisementData> {
220    if data.len() < 16 {
221        return Err(Error::InvalidData(format!(
222            "Aranet Radiation advertisement requires at least 16 bytes, got {}",
223            data.len()
224        )));
225    }
226
227    let mut buf = &data[1..];
228    let flags = buf.get_u8();
229    let battery = buf.get_u8();
230    let status = Status::from(buf.get_u8());
231    let interval = buf.get_u16_le();
232    let age = buf.get_u16_le();
233    // Dose rate is in nSv/h, convert to µSv/h
234    let dose_rate_nsv = buf.get_u32_le();
235    let dose_rate_usv = dose_rate_nsv as f32 / 1000.0;
236
237    Ok(AdvertisementData {
238        device_type: DeviceType::AranetRadiation,
239        co2: None,
240        temperature: None,
241        pressure: None,
242        humidity: None,
243        battery,
244        status,
245        interval,
246        age,
247        radon: None,
248        radiation_dose_rate: Some(dose_rate_usv),
249        counter: None,
250        flags,
251    })
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_parse_aranet4_advertisement() {
260        let data: [u8; 16] = [
261            0xF1, // device type
262            0x00, // flags
263            0x20, 0x03, // CO2 = 800
264            0xC2, 0x01, // temp_raw = 450 (22.5°C)
265            0x94, 0x27, // pressure_raw = 10132 (1013.2 hPa)
266            45,   // humidity
267            85,   // battery
268            1,    // status = Green
269            0x2C, 0x01, // interval = 300
270            0x78, 0x00, // age = 120
271            5,    // counter
272        ];
273
274        let result = parse_advertisement(&data).unwrap();
275        assert_eq!(result.device_type, DeviceType::Aranet4);
276        assert_eq!(result.co2, Some(800));
277        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
278        assert!((result.pressure.unwrap() - 1013.2).abs() < 0.1);
279        assert_eq!(result.humidity, Some(45));
280        assert_eq!(result.battery, 85);
281        assert_eq!(result.status, Status::Green);
282        assert_eq!(result.interval, 300);
283        assert_eq!(result.age, 120);
284        assert_eq!(result.counter, Some(5));
285    }
286
287    #[test]
288    fn test_parse_aranet2_advertisement() {
289        let data: [u8; 12] = [
290            0xF2, // device type
291            0x00, // flags
292            0xC2, 0x01, // temp_raw = 450 (22.5°C)
293            0xC2, 0x01, // humidity_raw = 450 (45%)
294            85,   // battery
295            1,    // status = Green
296            0x2C, 0x01, // interval = 300
297            0x3C, 0x00, // age = 60
298        ];
299
300        let result = parse_advertisement(&data).unwrap();
301        assert_eq!(result.device_type, DeviceType::Aranet2);
302        assert!(result.co2.is_none());
303        assert!((result.temperature.unwrap() - 22.5).abs() < 0.01);
304        assert_eq!(result.humidity, Some(45));
305        assert_eq!(result.battery, 85);
306    }
307
308    #[test]
309    fn test_parse_empty_data() {
310        let result = parse_advertisement(&[]);
311        assert!(result.is_err());
312        assert!(result.unwrap_err().to_string().contains("empty"));
313    }
314
315    #[test]
316    fn test_parse_unknown_device_type() {
317        let data: [u8; 16] = [0xFF; 16];
318        let result = parse_advertisement(&data);
319        assert!(result.is_err());
320        assert!(
321            result
322                .unwrap_err()
323                .to_string()
324                .contains("Unknown device type")
325        );
326    }
327
328    #[test]
329    fn test_parse_aranet4_insufficient_bytes() {
330        let data: [u8; 10] = [0xF1; 10];
331        let result = parse_advertisement(&data);
332        assert!(result.is_err());
333        assert!(
334            result
335                .unwrap_err()
336                .to_string()
337                .contains("requires 16 bytes")
338        );
339    }
340
341    #[test]
342    fn test_parse_aranet_radiation_advertisement() {
343        let data: [u8; 16] = [
344            0xF4, // device type = Radiation
345            0x00, // flags
346            85,   // battery
347            1,    // status = Green
348            0x2C, 0x01, // interval = 300
349            0x3C, 0x00, // age = 60
350            0xE8, 0x03, 0x00, 0x00, // dose rate = 1000 nSv/h = 1.0 µSv/h
351            0x00, 0x00, 0x00, 0x00, // padding
352        ];
353
354        let result = parse_advertisement(&data).unwrap();
355        assert_eq!(result.device_type, DeviceType::AranetRadiation);
356        assert!(result.co2.is_none());
357        assert!(result.temperature.is_none());
358        assert!(result.radon.is_none());
359        assert!((result.radiation_dose_rate.unwrap() - 1.0).abs() < 0.001);
360        assert_eq!(result.battery, 85);
361        assert_eq!(result.status, Status::Green);
362        assert_eq!(result.interval, 300);
363        assert_eq!(result.age, 60);
364    }
365
366    #[test]
367    fn test_parse_aranet_radiation_insufficient_bytes() {
368        let data: [u8; 10] = [0xF4; 10];
369        let result = parse_advertisement(&data);
370        assert!(result.is_err());
371        assert!(result.unwrap_err().to_string().contains("16 bytes"));
372    }
373}