aranet_core/
device.rs

1//! Aranet device connection and communication.
2//!
3//! This module provides the main interface for connecting to and
4//! communicating with Aranet sensors over Bluetooth Low Energy.
5
6use std::time::Duration;
7
8use async_trait::async_trait;
9use btleplug::api::{Characteristic, Peripheral as _, WriteType};
10use btleplug::platform::{Adapter, Peripheral};
11use tokio::time::timeout;
12use tracing::{debug, info};
13use uuid::Uuid;
14
15use crate::error::{Error, Result};
16use crate::scan::{ScanOptions, find_device};
17use crate::traits::AranetDevice;
18use crate::util::{create_identifier, format_peripheral_id};
19use crate::uuid::{
20    BATTERY_LEVEL, BATTERY_SERVICE, CURRENT_READINGS_DETAIL, CURRENT_READINGS_DETAIL_ALT,
21    DEVICE_INFO_SERVICE, DEVICE_NAME, FIRMWARE_REVISION, GAP_SERVICE, HARDWARE_REVISION,
22    MANUFACTURER_NAME, MODEL_NUMBER, SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD,
23    SERIAL_NUMBER, SOFTWARE_REVISION,
24};
25use aranet_types::{CurrentReading, DeviceInfo, DeviceType};
26
27/// Represents a connected Aranet device.
28///
29/// # Note on Clone
30///
31/// This struct intentionally does not implement `Clone`. A `Device` represents
32/// an active BLE connection with associated state (services discovered, notification
33/// handlers, etc.). Cloning would create ambiguity about connection ownership and
34/// could lead to resource conflicts. If you need to share a device across multiple
35/// tasks, wrap it in `Arc<Device>`.
36pub struct Device {
37    /// The BLE adapter used for connection.
38    ///
39    /// This field is stored to keep the adapter alive for the lifetime of the
40    /// peripheral connection. The peripheral may hold internal references to
41    /// the adapter, and dropping the adapter could invalidate the connection.
42    #[allow(dead_code)]
43    adapter: Adapter,
44    /// The underlying BLE peripheral.
45    peripheral: Peripheral,
46    /// Cached device name.
47    name: Option<String>,
48    /// Device address or identifier (MAC address on Linux/Windows, UUID on macOS).
49    address: String,
50    /// Detected device type.
51    device_type: Option<DeviceType>,
52    /// Whether services have been discovered.
53    services_discovered: bool,
54    /// Handles for spawned notification tasks (for cleanup).
55    notification_handles: tokio::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
56}
57
58impl std::fmt::Debug for Device {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        // Provide a clean debug output that excludes internal BLE details
61        // (adapter, peripheral, notification_handles) which are not useful
62        // for debugging application logic and may expose implementation details.
63        f.debug_struct("Device")
64            .field("name", &self.name)
65            .field("address", &self.address)
66            .field("device_type", &self.device_type)
67            .field("services_discovered", &self.services_discovered)
68            .finish_non_exhaustive()
69    }
70}
71
72/// Default timeout for BLE characteristic read operations.
73const READ_TIMEOUT: Duration = Duration::from_secs(10);
74
75/// Default timeout for BLE characteristic write operations.
76const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
77
78impl Device {
79    /// Connect to an Aranet device by name or MAC address.
80    ///
81    /// # Example
82    ///
83    /// ```no_run
84    /// use aranet_core::device::Device;
85    ///
86    /// #[tokio::main]
87    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
88    ///     let device = Device::connect("Aranet4 12345").await?;
89    ///     println!("Connected to {:?}", device);
90    ///     Ok(())
91    /// }
92    /// ```
93    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
94    pub async fn connect(identifier: &str) -> Result<Self> {
95        Self::connect_with_timeout(identifier, Duration::from_secs(15)).await
96    }
97
98    /// Connect to an Aranet device with a custom scan timeout.
99    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier, timeout_secs = timeout.as_secs()))]
100    pub async fn connect_with_timeout(identifier: &str, timeout: Duration) -> Result<Self> {
101        let options = ScanOptions {
102            duration: timeout,
103            filter_aranet_only: false, // We're looking for a specific device
104        };
105
106        // Try find_device first (uses default 5s scan), then with custom options
107        let (adapter, peripheral) = match find_device(identifier).await {
108            Ok(result) => result,
109            Err(_) => crate::scan::find_device_with_options(identifier, options).await?,
110        };
111
112        Self::from_peripheral(adapter, peripheral).await
113    }
114
115    /// Create a Device from an already-discovered peripheral.
116    #[tracing::instrument(level = "info", skip_all)]
117    pub async fn from_peripheral(adapter: Adapter, peripheral: Peripheral) -> Result<Self> {
118        // Connect to the device
119        info!("Connecting to device...");
120        peripheral.connect().await?;
121        info!("Connected!");
122
123        // Discover services
124        info!("Discovering services...");
125        peripheral.discover_services().await?;
126
127        let services = peripheral.services();
128        debug!("Found {} services", services.len());
129        for service in &services {
130            debug!("  Service: {}", service.uuid);
131            for char in &service.characteristics {
132                debug!("    Characteristic: {}", char.uuid);
133            }
134        }
135
136        // Get device properties
137        let properties = peripheral.properties().await?;
138        let name = properties.as_ref().and_then(|p| p.local_name.clone());
139
140        // Get address - on macOS this may be 00:00:00:00:00:00, so we use peripheral ID as fallback
141        let address = properties
142            .as_ref()
143            .map(|p| create_identifier(&p.address.to_string(), &peripheral.id()))
144            .unwrap_or_else(|| format_peripheral_id(&peripheral.id()));
145
146        // Determine device type from name
147        let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
148
149        Ok(Self {
150            adapter,
151            peripheral,
152            name,
153            address,
154            device_type,
155            services_discovered: true,
156            notification_handles: tokio::sync::Mutex::new(Vec::new()),
157        })
158    }
159
160    /// Check if the device is connected.
161    pub async fn is_connected(&self) -> bool {
162        self.peripheral.is_connected().await.unwrap_or(false)
163    }
164
165    /// Disconnect from the device.
166    ///
167    /// This will:
168    /// 1. Abort all active notification handlers
169    /// 2. Disconnect from the BLE peripheral
170    ///
171    /// **Important:** You MUST call this method before dropping the Device
172    /// to ensure proper cleanup of BLE resources.
173    #[tracing::instrument(level = "info", skip(self), fields(device_name = ?self.name))]
174    pub async fn disconnect(&self) -> Result<()> {
175        info!("Disconnecting from device...");
176
177        // Abort all notification handlers
178        {
179            let mut handles = self.notification_handles.lock().await;
180            for handle in handles.drain(..) {
181                handle.abort();
182            }
183        }
184
185        self.peripheral.disconnect().await?;
186        Ok(())
187    }
188
189    /// Get the device name.
190    pub fn name(&self) -> Option<&str> {
191        self.name.as_deref()
192    }
193
194    /// Get the device address or identifier.
195    ///
196    /// On Linux and Windows, this returns the Bluetooth MAC address (e.g., "AA:BB:CC:DD:EE:FF").
197    /// On macOS, this returns a UUID identifier since MAC addresses are not exposed.
198    pub fn address(&self) -> &str {
199        &self.address
200    }
201
202    /// Get the detected device type.
203    pub fn device_type(&self) -> Option<DeviceType> {
204        self.device_type
205    }
206
207    /// Read the current RSSI (signal strength) of the connection.
208    ///
209    /// Returns the RSSI in dBm. More negative values indicate weaker signals.
210    /// Typical values range from -30 (strong) to -90 (weak).
211    pub async fn read_rssi(&self) -> Result<i16> {
212        let properties = self.peripheral.properties().await?;
213        properties
214            .and_then(|p| p.rssi)
215            .ok_or_else(|| Error::InvalidData("RSSI not available".to_string()))
216    }
217
218    /// Find a characteristic by UUID, searching through known Aranet services.
219    fn find_characteristic(&self, uuid: Uuid) -> Result<Characteristic> {
220        let services = self.peripheral.services();
221        let service_count = services.len();
222
223        // First try Aranet-specific services
224        for service in &services {
225            if service.uuid == SAF_TEHNIKA_SERVICE_NEW || service.uuid == SAF_TEHNIKA_SERVICE_OLD {
226                for char in &service.characteristics {
227                    if char.uuid == uuid {
228                        return Ok(char.clone());
229                    }
230                }
231            }
232        }
233
234        // Then try standard services (GAP, Device Info, Battery)
235        for service in &services {
236            if service.uuid == GAP_SERVICE
237                || service.uuid == DEVICE_INFO_SERVICE
238                || service.uuid == BATTERY_SERVICE
239            {
240                for char in &service.characteristics {
241                    if char.uuid == uuid {
242                        return Ok(char.clone());
243                    }
244                }
245            }
246        }
247
248        // Finally search all services
249        for service in &services {
250            for char in &service.characteristics {
251                if char.uuid == uuid {
252                    return Ok(char.clone());
253                }
254            }
255        }
256
257        Err(Error::characteristic_not_found(
258            uuid.to_string(),
259            service_count,
260        ))
261    }
262
263    /// Read a characteristic value by UUID.
264    ///
265    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
266    /// The default timeout is 10 seconds.
267    pub async fn read_characteristic(&self, uuid: Uuid) -> Result<Vec<u8>> {
268        let characteristic = self.find_characteristic(uuid)?;
269        let data = timeout(READ_TIMEOUT, self.peripheral.read(&characteristic))
270            .await
271            .map_err(|_| Error::Timeout {
272                operation: format!("read characteristic {}", uuid),
273                duration: READ_TIMEOUT,
274            })??;
275        Ok(data)
276    }
277
278    /// Write a value to a characteristic.
279    ///
280    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
281    /// The default timeout is 10 seconds.
282    pub async fn write_characteristic(&self, uuid: Uuid, data: &[u8]) -> Result<()> {
283        let characteristic = self.find_characteristic(uuid)?;
284        timeout(
285            WRITE_TIMEOUT,
286            self.peripheral
287                .write(&characteristic, data, WriteType::WithResponse),
288        )
289        .await
290        .map_err(|_| Error::Timeout {
291            operation: format!("write characteristic {}", uuid),
292            duration: WRITE_TIMEOUT,
293        })??;
294        Ok(())
295    }
296
297    /// Read current sensor measurements.
298    ///
299    /// Automatically selects the correct characteristic UUID based on device type:
300    /// - Aranet4 uses `f0cd3001`
301    /// - Aranet2, Radon, Radiation use `f0cd3003`
302    #[tracing::instrument(level = "debug", skip(self), fields(device_name = ?self.name, device_type = ?self.device_type))]
303    pub async fn read_current(&self) -> Result<CurrentReading> {
304        // Try primary characteristic first (Aranet4)
305        let data = match self.read_characteristic(CURRENT_READINGS_DETAIL).await {
306            Ok(data) => data,
307            Err(Error::CharacteristicNotFound { .. }) => {
308                // Try alternative characteristic (Aranet2/Radon/Radiation)
309                debug!("Primary reading characteristic not found, trying alternative");
310                self.read_characteristic(CURRENT_READINGS_DETAIL_ALT)
311                    .await?
312            }
313            Err(e) => return Err(e),
314        };
315
316        // Parse based on device type
317        match self.device_type {
318            Some(DeviceType::Aranet4) | None => {
319                // Default to Aranet4 parsing
320                Ok(CurrentReading::from_bytes(&data)?)
321            }
322            Some(DeviceType::Aranet2) => crate::readings::parse_aranet2_reading(&data),
323            Some(DeviceType::AranetRadon) => crate::readings::parse_aranet_radon_gatt(&data),
324            Some(DeviceType::AranetRadiation) => {
325                // Use dedicated radiation parser that extracts dose rate, total dose, and duration
326                crate::readings::parse_aranet_radiation_gatt(&data).map(|ext| ext.reading)
327            }
328            // Handle future device types - default to Aranet4 parsing
329            Some(_) => Ok(CurrentReading::from_bytes(&data)?),
330        }
331    }
332
333    /// Read the battery level (0-100).
334    #[tracing::instrument(level = "debug", skip(self))]
335    pub async fn read_battery(&self) -> Result<u8> {
336        let data = self.read_characteristic(BATTERY_LEVEL).await?;
337        if data.is_empty() {
338            return Err(Error::InvalidData("Empty battery data".to_string()));
339        }
340        Ok(data[0])
341    }
342
343    /// Read device information.
344    ///
345    /// This method reads all device info characteristics in parallel for better performance.
346    #[tracing::instrument(level = "debug", skip(self))]
347    pub async fn read_device_info(&self) -> Result<DeviceInfo> {
348        fn read_string(data: Vec<u8>) -> String {
349            String::from_utf8(data)
350                .unwrap_or_default()
351                .trim_end_matches('\0')
352                .to_string()
353        }
354
355        // Read all characteristics in parallel for better performance
356        let (
357            name_result,
358            model_result,
359            serial_result,
360            firmware_result,
361            hardware_result,
362            software_result,
363            manufacturer_result,
364        ) = tokio::join!(
365            self.read_characteristic(DEVICE_NAME),
366            self.read_characteristic(MODEL_NUMBER),
367            self.read_characteristic(SERIAL_NUMBER),
368            self.read_characteristic(FIRMWARE_REVISION),
369            self.read_characteristic(HARDWARE_REVISION),
370            self.read_characteristic(SOFTWARE_REVISION),
371            self.read_characteristic(MANUFACTURER_NAME),
372        );
373
374        let name = name_result
375            .map(read_string)
376            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
377
378        let model = model_result.map(read_string).unwrap_or_default();
379        let serial = serial_result.map(read_string).unwrap_or_default();
380        let firmware = firmware_result.map(read_string).unwrap_or_default();
381        let hardware = hardware_result.map(read_string).unwrap_or_default();
382        let software = software_result.map(read_string).unwrap_or_default();
383        let manufacturer = manufacturer_result.map(read_string).unwrap_or_default();
384
385        Ok(DeviceInfo {
386            name,
387            model,
388            serial,
389            firmware,
390            hardware,
391            software,
392            manufacturer,
393        })
394    }
395
396    /// Subscribe to notifications on a characteristic.
397    ///
398    /// The callback will be invoked for each notification received.
399    /// The notification handler task is tracked and will be aborted when
400    /// `disconnect()` is called.
401    pub async fn subscribe_to_notifications<F>(&self, uuid: Uuid, callback: F) -> Result<()>
402    where
403        F: Fn(&[u8]) + Send + Sync + 'static,
404    {
405        let characteristic = self.find_characteristic(uuid)?;
406
407        self.peripheral.subscribe(&characteristic).await?;
408
409        // Set up notification handler
410        let mut stream = self.peripheral.notifications().await?;
411        let char_uuid = characteristic.uuid;
412
413        let handle = tokio::spawn(async move {
414            use futures::StreamExt;
415            while let Some(notification) = stream.next().await {
416                if notification.uuid == char_uuid {
417                    callback(&notification.value);
418                }
419            }
420        });
421
422        // Store the handle for cleanup on disconnect
423        self.notification_handles.lock().await.push(handle);
424
425        Ok(())
426    }
427
428    /// Unsubscribe from notifications on a characteristic.
429    pub async fn unsubscribe_from_notifications(&self, uuid: Uuid) -> Result<()> {
430        let characteristic = self.find_characteristic(uuid)?;
431        self.peripheral.unsubscribe(&characteristic).await?;
432        Ok(())
433    }
434}
435
436// NOTE: We intentionally do NOT implement Drop for Device.
437//
438// The previous implementation spawned a thread and used `futures::executor::block_on`
439// which can panic if called from within an async runtime. This is problematic because:
440// 1. Device is typically used in async contexts
441// 2. Spawning threads in Drop is unpredictable and can cause issues during shutdown
442// 3. Cleanup should be explicit, not implicit
443//
444// Callers MUST explicitly call `device.disconnect().await` before dropping the Device.
445// For automatic cleanup, consider using `ReconnectingDevice` which manages the lifecycle.
446
447#[async_trait]
448impl AranetDevice for Device {
449    // --- Connection Management ---
450
451    async fn is_connected(&self) -> bool {
452        Device::is_connected(self).await
453    }
454
455    async fn disconnect(&self) -> Result<()> {
456        Device::disconnect(self).await
457    }
458
459    // --- Device Identity ---
460
461    fn name(&self) -> Option<&str> {
462        Device::name(self)
463    }
464
465    fn address(&self) -> &str {
466        Device::address(self)
467    }
468
469    fn device_type(&self) -> Option<DeviceType> {
470        Device::device_type(self)
471    }
472
473    // --- Current Readings ---
474
475    async fn read_current(&self) -> Result<CurrentReading> {
476        Device::read_current(self).await
477    }
478
479    async fn read_device_info(&self) -> Result<DeviceInfo> {
480        Device::read_device_info(self).await
481    }
482
483    async fn read_rssi(&self) -> Result<i16> {
484        Device::read_rssi(self).await
485    }
486
487    // --- Battery ---
488
489    async fn read_battery(&self) -> Result<u8> {
490        Device::read_battery(self).await
491    }
492
493    // --- History ---
494
495    async fn get_history_info(&self) -> Result<crate::history::HistoryInfo> {
496        Device::get_history_info(self).await
497    }
498
499    async fn download_history(&self) -> Result<Vec<aranet_types::HistoryRecord>> {
500        Device::download_history(self).await
501    }
502
503    async fn download_history_with_options(
504        &self,
505        options: crate::history::HistoryOptions,
506    ) -> Result<Vec<aranet_types::HistoryRecord>> {
507        Device::download_history_with_options(self, options).await
508    }
509
510    // --- Settings ---
511
512    async fn get_interval(&self) -> Result<crate::settings::MeasurementInterval> {
513        Device::get_interval(self).await
514    }
515
516    async fn set_interval(&self, interval: crate::settings::MeasurementInterval) -> Result<()> {
517        Device::set_interval(self, interval).await
518    }
519
520    async fn get_calibration(&self) -> Result<crate::settings::CalibrationData> {
521        Device::get_calibration(self).await
522    }
523}