aranet_core/
settings.rs

1//! Device settings read/write.
2//!
3//! This module provides functionality to read and modify device
4//! settings on Aranet sensors.
5
6use tracing::{debug, info};
7
8use crate::device::Device;
9use crate::error::{Error, Result};
10use crate::uuid::{CALIBRATION, COMMAND, READ_INTERVAL, SENSOR_STATE};
11
12/// Measurement interval options.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[repr(u8)]
15pub enum MeasurementInterval {
16    /// 1 minute interval.
17    OneMinute = 0x01,
18    /// 2 minute interval.
19    TwoMinutes = 0x02,
20    /// 5 minute interval.
21    FiveMinutes = 0x05,
22    /// 10 minute interval.
23    TenMinutes = 0x0A,
24}
25
26impl MeasurementInterval {
27    /// Get the interval in seconds.
28    pub fn as_seconds(&self) -> u16 {
29        match self {
30            MeasurementInterval::OneMinute => 60,
31            MeasurementInterval::TwoMinutes => 120,
32            MeasurementInterval::FiveMinutes => 300,
33            MeasurementInterval::TenMinutes => 600,
34        }
35    }
36
37    /// Try to create from seconds value.
38    pub fn from_seconds(seconds: u16) -> Option<Self> {
39        match seconds {
40            60 => Some(MeasurementInterval::OneMinute),
41            120 => Some(MeasurementInterval::TwoMinutes),
42            300 => Some(MeasurementInterval::FiveMinutes),
43            600 => Some(MeasurementInterval::TenMinutes),
44            _ => None,
45        }
46    }
47
48    /// Try to create from minutes value.
49    pub fn from_minutes(minutes: u8) -> Option<Self> {
50        match minutes {
51            1 => Some(MeasurementInterval::OneMinute),
52            2 => Some(MeasurementInterval::TwoMinutes),
53            5 => Some(MeasurementInterval::FiveMinutes),
54            10 => Some(MeasurementInterval::TenMinutes),
55            _ => None,
56        }
57    }
58}
59
60/// Bluetooth range options.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62#[repr(u8)]
63pub enum BluetoothRange {
64    /// Standard range.
65    #[default]
66    Standard = 0x00,
67    /// Extended range.
68    Extended = 0x01,
69}
70
71/// Temperature display unit.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
73pub enum TemperatureUnit {
74    /// Celsius (default for most devices).
75    #[default]
76    Celsius,
77    /// Fahrenheit.
78    Fahrenheit,
79}
80
81/// Radon display unit.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum RadonUnit {
84    /// Becquerels per cubic meter (default).
85    #[default]
86    BqM3,
87    /// PicoCuries per liter.
88    PciL,
89}
90
91/// Device settings read from the SENSOR_STATE characteristic.
92#[derive(Debug, Clone, Default)]
93pub struct DeviceSettings {
94    /// Smart Home integration enabled.
95    pub smart_home_enabled: bool,
96    /// Bluetooth range setting.
97    pub bluetooth_range: BluetoothRange,
98    /// Temperature display unit.
99    pub temperature_unit: TemperatureUnit,
100    /// Radon display unit (only relevant for Aranet Radon).
101    pub radon_unit: RadonUnit,
102    /// Whether buzzer is enabled.
103    pub buzzer_enabled: bool,
104    /// Whether automatic calibration is enabled (Aranet4 only).
105    pub auto_calibration_enabled: bool,
106}
107
108/// Calibration data from the device.
109#[derive(Debug, Clone, Default)]
110pub struct CalibrationData {
111    /// Raw calibration bytes.
112    pub raw: Vec<u8>,
113    /// CO2 calibration offset (if available).
114    pub co2_offset: Option<i16>,
115}
116
117impl Device {
118    /// Get the current measurement interval.
119    pub async fn get_interval(&self) -> Result<MeasurementInterval> {
120        let data = self.read_characteristic(READ_INTERVAL).await?;
121
122        if data.len() < 2 {
123            return Err(Error::InvalidData("Invalid interval data".to_string()));
124        }
125
126        let seconds = u16::from_le_bytes([data[0], data[1]]);
127
128        MeasurementInterval::from_seconds(seconds)
129            .ok_or_else(|| Error::InvalidData(format!("Unknown interval: {} seconds", seconds)))
130    }
131
132    /// Set the measurement interval.
133    ///
134    /// The device will start using the new interval after the current
135    /// measurement cycle completes.
136    pub async fn set_interval(&self, interval: MeasurementInterval) -> Result<()> {
137        info!("Setting measurement interval to {:?}", interval);
138
139        // Command format: 0x90 XX (XX = interval in minutes)
140        let minutes = match interval {
141            MeasurementInterval::OneMinute => 0x01,
142            MeasurementInterval::TwoMinutes => 0x02,
143            MeasurementInterval::FiveMinutes => 0x05,
144            MeasurementInterval::TenMinutes => 0x0A,
145        };
146
147        let cmd = [0x90, minutes];
148        self.write_characteristic(COMMAND, &cmd).await?;
149
150        Ok(())
151    }
152
153    /// Enable or disable Smart Home integration.
154    ///
155    /// When enabled, the device advertises sensor data that can be read
156    /// without connecting (passive scanning).
157    pub async fn set_smart_home(&self, enabled: bool) -> Result<()> {
158        info!("Setting Smart Home integration to {}", enabled);
159
160        // Command format: 0x91 XX (XX = 00 disabled, 01 enabled)
161        let cmd = [0x91, if enabled { 0x01 } else { 0x00 }];
162        self.write_characteristic(COMMAND, &cmd).await?;
163
164        Ok(())
165    }
166
167    /// Set the Bluetooth range.
168    pub async fn set_bluetooth_range(&self, range: BluetoothRange) -> Result<()> {
169        info!("Setting Bluetooth range to {:?}", range);
170
171        // Command format: 0x92 XX (XX = 00 standard, 01 extended)
172        let cmd = [0x92, range as u8];
173        self.write_characteristic(COMMAND, &cmd).await?;
174
175        Ok(())
176    }
177
178    /// Read calibration data from the device.
179    pub async fn get_calibration(&self) -> Result<CalibrationData> {
180        let raw = self.read_characteristic(CALIBRATION).await?;
181
182        // Parse CO2 offset if available (typically at offset 2-3)
183        let co2_offset = if raw.len() >= 4 {
184            Some(i16::from_le_bytes([raw[2], raw[3]]))
185        } else {
186            None
187        };
188
189        Ok(CalibrationData { raw, co2_offset })
190    }
191
192    /// Read device settings from the SENSOR_STATE characteristic.
193    ///
194    /// This reads the device configuration including:
195    /// - Smart Home integration status
196    /// - Bluetooth range setting
197    /// - Temperature display unit
198    /// - Radon display unit (for Aranet Radon devices)
199    /// - Buzzer settings
200    /// - Calibration settings
201    pub async fn get_settings(&self) -> Result<DeviceSettings> {
202        let data = self.read_characteristic(SENSOR_STATE).await?;
203
204        if data.len() < 3 {
205            return Err(Error::InvalidData(
206                "Sensor state data too short".to_string(),
207            ));
208        }
209
210        debug!("Sensor state raw: {:02x?} (len={})", data, data.len());
211
212        // Parse the sensor state bytes according to the Aranet protocol:
213        // byte[0] = device type (0xF1=Aranet4, 0xF2=Aranet2, 0xF3=Radon, 0xF4=Radiation)
214        // byte[1] = configuration flags 'c'
215        // byte[2] = options flags 'o'
216        let device_type_byte = data[0];
217        let config_flags = data[1];
218        let option_flags = data[2];
219
220        let is_aranet4 = device_type_byte == 0xF1;
221        let is_aranet_radon = device_type_byte == 0xF3;
222        let is_aranet_radiation = device_type_byte == 0xF4;
223
224        // Parse configuration flags (byte 1):
225        // bit 0: buzzer enabled
226        // bit 5: temperature unit (0=Fahrenheit, 1=Celsius)
227        // bit 7: varies by device (Aranet4=auto calibration, Radon=Bq/pCi)
228        let buzzer_enabled = (config_flags & 0x01) != 0;
229        let temp_bit = (config_flags >> 5) & 0x01;
230        let bit7 = (config_flags >> 7) & 0x01;
231
232        // Temperature unit: bit 5 = 1 means Celsius, 0 means Fahrenheit
233        // Note: Aranet Radiation doesn't have temperature, defaults to Celsius
234        let temperature_unit = if is_aranet_radiation || temp_bit == 1 {
235            TemperatureUnit::Celsius
236        } else {
237            TemperatureUnit::Fahrenheit
238        };
239
240        // Radon unit: for Aranet Radon, bit 7 = 1 means Bq/m³, 0 means pCi/L
241        let radon_unit = if is_aranet_radon {
242            if bit7 == 1 {
243                RadonUnit::BqM3
244            } else {
245                RadonUnit::PciL
246            }
247        } else {
248            RadonUnit::BqM3 // Default for non-radon devices
249        };
250
251        // Auto calibration enabled (Aranet4 only)
252        let auto_calibration_enabled = is_aranet4 && bit7 == 1;
253
254        // Parse option flags (byte 2):
255        // bit 1: bluetooth range (0=normal/standard, 1=extended)
256        // bit 7: smart home integration enabled
257        let bluetooth_range = if (option_flags >> 1) & 0x01 == 1 {
258            BluetoothRange::Extended
259        } else {
260            BluetoothRange::Standard
261        };
262
263        let smart_home_enabled = (option_flags >> 7) & 0x01 == 1;
264
265        debug!(
266            "Parsed settings: smart_home={}, bt_range={:?}, temp_unit={:?}, radon_unit={:?}",
267            smart_home_enabled, bluetooth_range, temperature_unit, radon_unit
268        );
269
270        Ok(DeviceSettings {
271            smart_home_enabled,
272            bluetooth_range,
273            temperature_unit,
274            radon_unit,
275            buzzer_enabled,
276            auto_calibration_enabled,
277        })
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_interval_from_seconds() {
287        assert_eq!(
288            MeasurementInterval::from_seconds(60),
289            Some(MeasurementInterval::OneMinute)
290        );
291        assert_eq!(
292            MeasurementInterval::from_seconds(120),
293            Some(MeasurementInterval::TwoMinutes)
294        );
295        assert_eq!(
296            MeasurementInterval::from_seconds(300),
297            Some(MeasurementInterval::FiveMinutes)
298        );
299        assert_eq!(
300            MeasurementInterval::from_seconds(600),
301            Some(MeasurementInterval::TenMinutes)
302        );
303        assert_eq!(MeasurementInterval::from_seconds(100), None);
304    }
305
306    #[test]
307    fn test_interval_from_minutes() {
308        assert_eq!(
309            MeasurementInterval::from_minutes(1),
310            Some(MeasurementInterval::OneMinute)
311        );
312        assert_eq!(
313            MeasurementInterval::from_minutes(2),
314            Some(MeasurementInterval::TwoMinutes)
315        );
316        assert_eq!(
317            MeasurementInterval::from_minutes(5),
318            Some(MeasurementInterval::FiveMinutes)
319        );
320        assert_eq!(
321            MeasurementInterval::from_minutes(10),
322            Some(MeasurementInterval::TenMinutes)
323        );
324        assert_eq!(MeasurementInterval::from_minutes(3), None);
325    }
326
327    #[test]
328    fn test_interval_as_seconds() {
329        assert_eq!(MeasurementInterval::OneMinute.as_seconds(), 60);
330        assert_eq!(MeasurementInterval::TwoMinutes.as_seconds(), 120);
331        assert_eq!(MeasurementInterval::FiveMinutes.as_seconds(), 300);
332        assert_eq!(MeasurementInterval::TenMinutes.as_seconds(), 600);
333    }
334}