Skip to main content

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    ///
137    /// Note: This method does not verify the write succeeded. For verified
138    /// writes, use [`set_interval_verified`].
139    pub async fn set_interval(&self, interval: MeasurementInterval) -> Result<()> {
140        info!("Setting measurement interval to {:?}", interval);
141
142        // Command format: 0x90 XX (XX = interval in minutes)
143        let minutes = match interval {
144            MeasurementInterval::OneMinute => 0x01,
145            MeasurementInterval::TwoMinutes => 0x02,
146            MeasurementInterval::FiveMinutes => 0x05,
147            MeasurementInterval::TenMinutes => 0x0A,
148        };
149
150        let cmd = [0x90, minutes];
151        self.write_characteristic(COMMAND, &cmd).await?;
152
153        Ok(())
154    }
155
156    /// Set the measurement interval with verification.
157    ///
158    /// This method writes the new interval and then reads it back to verify
159    /// the change was applied successfully. Use this for critical settings
160    /// changes where confirmation is needed.
161    ///
162    /// # Errors
163    ///
164    /// Returns `Error::WriteFailed` if the read-back value doesn't match
165    /// the requested interval.
166    pub async fn set_interval_verified(&self, interval: MeasurementInterval) -> Result<()> {
167        self.set_interval(interval).await?;
168
169        // Small delay to allow the device to process the command
170        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
171
172        let actual = self.get_interval().await?;
173        if actual != interval {
174            return Err(Error::WriteFailed {
175                uuid: COMMAND.to_string(),
176                reason: format!(
177                    "Interval verification failed: expected {:?}, got {:?}",
178                    interval, actual
179                ),
180            });
181        }
182
183        info!("Measurement interval verified: {:?}", interval);
184        Ok(())
185    }
186
187    /// Enable or disable Smart Home integration.
188    ///
189    /// When enabled, the device advertises sensor data that can be read
190    /// without connecting (passive scanning).
191    ///
192    /// Note: This method does not verify the write succeeded. For verified
193    /// writes, use [`set_smart_home_verified`].
194    pub async fn set_smart_home(&self, enabled: bool) -> Result<()> {
195        info!("Setting Smart Home integration to {}", enabled);
196
197        // Command format: 0x91 XX (XX = 00 disabled, 01 enabled)
198        let cmd = [0x91, if enabled { 0x01 } else { 0x00 }];
199        self.write_characteristic(COMMAND, &cmd).await?;
200
201        Ok(())
202    }
203
204    /// Enable or disable Smart Home integration with verification.
205    ///
206    /// This method writes the setting and then reads it back to verify
207    /// the change was applied successfully.
208    ///
209    /// # Errors
210    ///
211    /// Returns `Error::WriteFailed` if the read-back value doesn't match
212    /// the requested setting.
213    pub async fn set_smart_home_verified(&self, enabled: bool) -> Result<()> {
214        self.set_smart_home(enabled).await?;
215
216        // Small delay to allow the device to process the command
217        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
218
219        let settings = self.get_settings().await?;
220        if settings.smart_home_enabled != enabled {
221            return Err(Error::WriteFailed {
222                uuid: COMMAND.to_string(),
223                reason: format!(
224                    "Smart Home verification failed: expected {}, got {}",
225                    enabled, settings.smart_home_enabled
226                ),
227            });
228        }
229
230        info!("Smart Home integration verified: {}", enabled);
231        Ok(())
232    }
233
234    /// Set the Bluetooth range.
235    ///
236    /// Note: This method does not verify the write succeeded. For verified
237    /// writes, use [`set_bluetooth_range_verified`].
238    pub async fn set_bluetooth_range(&self, range: BluetoothRange) -> Result<()> {
239        info!("Setting Bluetooth range to {:?}", range);
240
241        // Command format: 0x92 XX (XX = 00 standard, 01 extended)
242        let cmd = [0x92, range as u8];
243        self.write_characteristic(COMMAND, &cmd).await?;
244
245        Ok(())
246    }
247
248    /// Set the Bluetooth range with verification.
249    ///
250    /// This method writes the setting and then reads it back to verify
251    /// the change was applied successfully.
252    ///
253    /// # Errors
254    ///
255    /// Returns `Error::WriteFailed` if the read-back value doesn't match
256    /// the requested setting.
257    pub async fn set_bluetooth_range_verified(&self, range: BluetoothRange) -> Result<()> {
258        self.set_bluetooth_range(range).await?;
259
260        // Small delay to allow the device to process the command
261        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
262
263        let settings = self.get_settings().await?;
264        if settings.bluetooth_range != range {
265            return Err(Error::WriteFailed {
266                uuid: COMMAND.to_string(),
267                reason: format!(
268                    "Bluetooth range verification failed: expected {:?}, got {:?}",
269                    range, settings.bluetooth_range
270                ),
271            });
272        }
273
274        info!("Bluetooth range verified: {:?}", range);
275        Ok(())
276    }
277
278    /// Read calibration data from the device.
279    pub async fn get_calibration(&self) -> Result<CalibrationData> {
280        let raw = self.read_characteristic(CALIBRATION).await?;
281
282        // Parse CO2 offset if available (typically at offset 2-3)
283        let co2_offset = if raw.len() >= 4 {
284            Some(i16::from_le_bytes([raw[2], raw[3]]))
285        } else {
286            None
287        };
288
289        Ok(CalibrationData { raw, co2_offset })
290    }
291
292    /// Read device settings from the SENSOR_STATE characteristic.
293    ///
294    /// This reads the device configuration including:
295    /// - Smart Home integration status
296    /// - Bluetooth range setting
297    /// - Temperature display unit
298    /// - Radon display unit (for Aranet Radon devices)
299    /// - Buzzer settings
300    /// - Calibration settings
301    pub async fn get_settings(&self) -> Result<DeviceSettings> {
302        let data = self.read_characteristic(SENSOR_STATE).await?;
303
304        if data.len() < 3 {
305            return Err(Error::InvalidData(
306                "Sensor state data too short".to_string(),
307            ));
308        }
309
310        debug!("Sensor state raw: {:02x?} (len={})", data, data.len());
311
312        // Parse the sensor state bytes according to the Aranet protocol:
313        // byte[0] = device type (0xF1=Aranet4, 0xF2=Aranet2, 0xF3=Radon, 0xF4=Radiation)
314        // byte[1] = configuration flags 'c'
315        // byte[2] = options flags 'o'
316        let device_type_byte = data[0];
317        let config_flags = data[1];
318        let option_flags = data[2];
319
320        let is_aranet4 = device_type_byte == 0xF1;
321        let is_aranet_radon = device_type_byte == 0xF3;
322        let is_aranet_radiation = device_type_byte == 0xF4;
323
324        // Parse configuration flags (byte 1):
325        // bit 0: buzzer enabled
326        // bit 5: temperature unit (0=Fahrenheit, 1=Celsius)
327        // bit 7: varies by device (Aranet4=auto calibration, Radon=Bq/pCi)
328        let buzzer_enabled = (config_flags & 0x01) != 0;
329        let temp_bit = (config_flags >> 5) & 0x01;
330        let bit7 = (config_flags >> 7) & 0x01;
331
332        // Temperature unit: bit 5 = 1 means Celsius, 0 means Fahrenheit
333        // Note: Aranet Radiation doesn't have temperature, defaults to Celsius
334        let temperature_unit = if is_aranet_radiation || temp_bit == 1 {
335            TemperatureUnit::Celsius
336        } else {
337            TemperatureUnit::Fahrenheit
338        };
339
340        // Radon unit: for Aranet Radon, bit 7 = 1 means Bq/m³, 0 means pCi/L
341        let radon_unit = if is_aranet_radon {
342            if bit7 == 1 {
343                RadonUnit::BqM3
344            } else {
345                RadonUnit::PciL
346            }
347        } else {
348            RadonUnit::BqM3 // Default for non-radon devices
349        };
350
351        // Auto calibration enabled (Aranet4 only)
352        let auto_calibration_enabled = is_aranet4 && bit7 == 1;
353
354        // Parse option flags (byte 2):
355        // bit 1: bluetooth range (0=normal/standard, 1=extended)
356        // bit 7: smart home integration enabled
357        let bluetooth_range = if (option_flags >> 1) & 0x01 == 1 {
358            BluetoothRange::Extended
359        } else {
360            BluetoothRange::Standard
361        };
362
363        let smart_home_enabled = (option_flags >> 7) & 0x01 == 1;
364
365        debug!(
366            "Parsed settings: smart_home={}, bt_range={:?}, temp_unit={:?}, radon_unit={:?}",
367            smart_home_enabled, bluetooth_range, temperature_unit, radon_unit
368        );
369
370        Ok(DeviceSettings {
371            smart_home_enabled,
372            bluetooth_range,
373            temperature_unit,
374            radon_unit,
375            buzzer_enabled,
376            auto_calibration_enabled,
377        })
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_interval_from_seconds() {
387        assert_eq!(
388            MeasurementInterval::from_seconds(60),
389            Some(MeasurementInterval::OneMinute)
390        );
391        assert_eq!(
392            MeasurementInterval::from_seconds(120),
393            Some(MeasurementInterval::TwoMinutes)
394        );
395        assert_eq!(
396            MeasurementInterval::from_seconds(300),
397            Some(MeasurementInterval::FiveMinutes)
398        );
399        assert_eq!(
400            MeasurementInterval::from_seconds(600),
401            Some(MeasurementInterval::TenMinutes)
402        );
403        assert_eq!(MeasurementInterval::from_seconds(100), None);
404    }
405
406    #[test]
407    fn test_interval_from_minutes() {
408        assert_eq!(
409            MeasurementInterval::from_minutes(1),
410            Some(MeasurementInterval::OneMinute)
411        );
412        assert_eq!(
413            MeasurementInterval::from_minutes(2),
414            Some(MeasurementInterval::TwoMinutes)
415        );
416        assert_eq!(
417            MeasurementInterval::from_minutes(5),
418            Some(MeasurementInterval::FiveMinutes)
419        );
420        assert_eq!(
421            MeasurementInterval::from_minutes(10),
422            Some(MeasurementInterval::TenMinutes)
423        );
424        assert_eq!(MeasurementInterval::from_minutes(3), None);
425    }
426
427    #[test]
428    fn test_interval_as_seconds() {
429        assert_eq!(MeasurementInterval::OneMinute.as_seconds(), 60);
430        assert_eq!(MeasurementInterval::TwoMinutes.as_seconds(), 120);
431        assert_eq!(MeasurementInterval::FiveMinutes.as_seconds(), 300);
432        assert_eq!(MeasurementInterval::TenMinutes.as_seconds(), 600);
433    }
434}