Skip to main content

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::collections::HashMap;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::time::Duration;
9
10use btleplug::api::{Characteristic, Peripheral as _, WriteType};
11use btleplug::platform::{Adapter, Peripheral};
12use tokio::sync::RwLock;
13use tokio::time::timeout;
14use tracing::{debug, info, warn};
15use uuid::Uuid;
16
17use crate::error::{Error, Result};
18use crate::scan::{ScanOptions, find_device};
19use crate::traits::AranetDevice;
20use crate::util::{create_identifier, format_peripheral_id};
21use crate::uuid::{
22    BATTERY_LEVEL, BATTERY_SERVICE, CURRENT_READINGS_DETAIL, CURRENT_READINGS_DETAIL_ALT,
23    DEVICE_INFO_SERVICE, DEVICE_NAME, FIRMWARE_REVISION, GAP_SERVICE, HARDWARE_REVISION,
24    MANUFACTURER_NAME, MODEL_NUMBER, SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD,
25    SERIAL_NUMBER, SOFTWARE_REVISION,
26};
27use aranet_types::{CurrentReading, DeviceInfo, DeviceType};
28
29/// Represents a connected Aranet device.
30///
31/// # Note on Clone
32///
33/// This struct intentionally does not implement `Clone`. A `Device` represents
34/// an active BLE connection with associated state (services discovered, notification
35/// handlers, etc.). Cloning would create ambiguity about connection ownership and
36/// could lead to resource conflicts. If you need to share a device across multiple
37/// tasks, wrap it in `Arc<Device>`.
38///
39/// # Cleanup
40///
41/// You MUST call [`Device::disconnect`] before dropping the device to properly
42/// release BLE resources. If a Device is dropped without calling disconnect,
43/// a warning will be logged.
44pub struct Device {
45    /// The BLE adapter used for connection.
46    ///
47    /// This field is stored to keep the adapter alive for the lifetime of the
48    /// peripheral connection. The peripheral may hold internal references to
49    /// the adapter, and dropping the adapter could invalidate the connection.
50    #[allow(dead_code)]
51    adapter: Adapter,
52    /// The underlying BLE peripheral.
53    peripheral: Peripheral,
54    /// Cached device name.
55    name: Option<String>,
56    /// Device address or identifier (MAC address on Linux/Windows, UUID on macOS).
57    address: String,
58    /// Detected device type.
59    device_type: Option<DeviceType>,
60    /// Whether services have been discovered.
61    services_discovered: bool,
62    /// Cache of discovered characteristics by UUID for O(1) lookup.
63    /// Built after service discovery to avoid searching through services on each read.
64    characteristics_cache: RwLock<HashMap<Uuid, Characteristic>>,
65    /// Handles for spawned notification tasks (for cleanup).
66    notification_handles: tokio::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
67    /// Whether disconnect has been called (for Drop warning).
68    disconnected: AtomicBool,
69    /// Connection configuration (timeouts, etc.).
70    config: ConnectionConfig,
71}
72
73impl std::fmt::Debug for Device {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        // Provide a clean debug output that excludes internal BLE details
76        // (adapter, peripheral, notification_handles, characteristics_cache)
77        // which are not useful for debugging application logic.
78        f.debug_struct("Device")
79            .field("name", &self.name)
80            .field("address", &self.address)
81            .field("device_type", &self.device_type)
82            .field("services_discovered", &self.services_discovered)
83            .finish_non_exhaustive()
84    }
85}
86
87/// Default timeout for BLE characteristic read operations.
88const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
89
90/// Default timeout for BLE characteristic write operations.
91const DEFAULT_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
92
93/// Default timeout for BLE connection operations.
94const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
95
96/// Default timeout for service discovery.
97const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
98
99/// Default timeout for connection validation (keepalive check).
100const DEFAULT_VALIDATION_TIMEOUT: Duration = Duration::from_secs(3);
101
102/// Configuration for BLE connection timeouts and behavior.
103///
104/// Use this to customize timeout values for different environments.
105/// For example, increase timeouts in challenging RF environments
106/// (concrete walls, electromagnetic interference).
107///
108/// # Example
109///
110/// ```no_run
111/// use std::time::Duration;
112/// use aranet_core::device::ConnectionConfig;
113///
114/// // Create a config for challenging RF environments
115/// let config = ConnectionConfig::default()
116///     .connection_timeout(Duration::from_secs(20))
117///     .read_timeout(Duration::from_secs(15));
118/// ```
119#[derive(Debug, Clone)]
120pub struct ConnectionConfig {
121    /// Timeout for establishing a BLE connection.
122    pub connection_timeout: Duration,
123    /// Timeout for BLE read operations.
124    pub read_timeout: Duration,
125    /// Timeout for BLE write operations.
126    pub write_timeout: Duration,
127    /// Timeout for service discovery after connection.
128    pub discovery_timeout: Duration,
129    /// Timeout for connection validation (keepalive) checks.
130    pub validation_timeout: Duration,
131}
132
133impl Default for ConnectionConfig {
134    fn default() -> Self {
135        Self {
136            connection_timeout: DEFAULT_CONNECT_TIMEOUT,
137            read_timeout: DEFAULT_READ_TIMEOUT,
138            write_timeout: DEFAULT_WRITE_TIMEOUT,
139            discovery_timeout: DEFAULT_DISCOVERY_TIMEOUT,
140            validation_timeout: DEFAULT_VALIDATION_TIMEOUT,
141        }
142    }
143}
144
145impl ConnectionConfig {
146    /// Create a new connection config with default values.
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Create a config optimized for the current platform.
152    pub fn for_current_platform() -> Self {
153        let platform = crate::platform::PlatformConfig::for_current_platform();
154        Self {
155            connection_timeout: platform.recommended_connection_timeout,
156            read_timeout: platform.recommended_operation_timeout,
157            write_timeout: platform.recommended_operation_timeout,
158            discovery_timeout: platform.recommended_operation_timeout,
159            validation_timeout: DEFAULT_VALIDATION_TIMEOUT,
160        }
161    }
162
163    /// Create a config for challenging RF environments.
164    ///
165    /// Uses longer timeouts to accommodate signal interference,
166    /// thick walls, or long distances.
167    pub fn challenging_environment() -> Self {
168        Self {
169            connection_timeout: Duration::from_secs(90),
170            read_timeout: Duration::from_secs(30),
171            write_timeout: Duration::from_secs(15),
172            discovery_timeout: Duration::from_secs(30),
173            validation_timeout: Duration::from_secs(5),
174        }
175    }
176
177    /// Create a config for fast, reliable environments.
178    ///
179    /// Uses shorter timeouts for quicker failure detection
180    /// when devices are nearby with strong signals.
181    pub fn fast() -> Self {
182        Self {
183            connection_timeout: Duration::from_secs(8),
184            read_timeout: Duration::from_secs(5),
185            write_timeout: Duration::from_secs(5),
186            discovery_timeout: Duration::from_secs(5),
187            validation_timeout: Duration::from_secs(2),
188        }
189    }
190
191    /// Set the connection timeout.
192    #[must_use]
193    pub fn connection_timeout(mut self, timeout: Duration) -> Self {
194        self.connection_timeout = timeout;
195        self
196    }
197
198    /// Set the read timeout.
199    #[must_use]
200    pub fn read_timeout(mut self, timeout: Duration) -> Self {
201        self.read_timeout = timeout;
202        self
203    }
204
205    /// Set the write timeout.
206    #[must_use]
207    pub fn write_timeout(mut self, timeout: Duration) -> Self {
208        self.write_timeout = timeout;
209        self
210    }
211
212    /// Set the service discovery timeout.
213    #[must_use]
214    pub fn discovery_timeout(mut self, timeout: Duration) -> Self {
215        self.discovery_timeout = timeout;
216        self
217    }
218
219    /// Set the validation timeout.
220    #[must_use]
221    pub fn validation_timeout(mut self, timeout: Duration) -> Self {
222        self.validation_timeout = timeout;
223        self
224    }
225}
226
227/// Signal strength quality levels based on RSSI values.
228#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
229pub enum SignalQuality {
230    /// Signal too weak for reliable operation (< -85 dBm).
231    Poor,
232    /// Usable but may have issues (-85 to -75 dBm).
233    Fair,
234    /// Good signal strength (-75 to -60 dBm).
235    Good,
236    /// Excellent signal strength (> -60 dBm).
237    Excellent,
238}
239
240impl SignalQuality {
241    /// Determine signal quality from RSSI value in dBm.
242    ///
243    /// # Arguments
244    ///
245    /// * `rssi` - Signal strength in dBm (typically -30 to -100)
246    ///
247    /// # Returns
248    ///
249    /// The signal quality category.
250    pub fn from_rssi(rssi: i16) -> Self {
251        match rssi {
252            r if r > -60 => SignalQuality::Excellent,
253            r if r > -75 => SignalQuality::Good,
254            r if r > -85 => SignalQuality::Fair,
255            _ => SignalQuality::Poor,
256        }
257    }
258
259    /// Get a human-readable description of the signal quality.
260    pub fn description(&self) -> &'static str {
261        match self {
262            SignalQuality::Excellent => "Excellent signal",
263            SignalQuality::Good => "Good signal",
264            SignalQuality::Fair => "Fair signal - connection may be unstable",
265            SignalQuality::Poor => "Poor signal - consider moving closer",
266        }
267    }
268
269    /// Get recommended read delay for history downloads based on signal quality.
270    pub fn recommended_read_delay(&self) -> Duration {
271        match self {
272            SignalQuality::Excellent => Duration::from_millis(30),
273            SignalQuality::Good => Duration::from_millis(50),
274            SignalQuality::Fair => Duration::from_millis(100),
275            SignalQuality::Poor => Duration::from_millis(200),
276        }
277    }
278
279    /// Check if the signal is strong enough for reliable operations.
280    pub fn is_usable(&self) -> bool {
281        matches!(
282            self,
283            SignalQuality::Excellent | SignalQuality::Good | SignalQuality::Fair
284        )
285    }
286}
287
288impl Device {
289    /// Connect to an Aranet device by name or MAC address.
290    ///
291    /// # Example
292    ///
293    /// ```no_run
294    /// use aranet_core::device::Device;
295    ///
296    /// #[tokio::main]
297    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
298    ///     let device = Device::connect("Aranet4 12345").await?;
299    ///     println!("Connected to {:?}", device);
300    ///     Ok(())
301    /// }
302    /// ```
303    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
304    pub async fn connect(identifier: &str) -> Result<Self> {
305        Self::connect_with_config(identifier, ConnectionConfig::default()).await
306    }
307
308    /// Connect to an Aranet device with a custom scan timeout.
309    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier, timeout_secs = scan_timeout.as_secs()))]
310    pub async fn connect_with_timeout(identifier: &str, scan_timeout: Duration) -> Result<Self> {
311        let config = ConnectionConfig::default().connection_timeout(scan_timeout);
312        Self::connect_with_config(identifier, config).await
313    }
314
315    /// Connect to an Aranet device with full configuration.
316    ///
317    /// This is the most flexible connection method, allowing customization
318    /// of all timeout values.
319    ///
320    /// # Example
321    ///
322    /// ```no_run
323    /// use std::time::Duration;
324    /// use aranet_core::device::{Device, ConnectionConfig};
325    ///
326    /// #[tokio::main]
327    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
328    ///     // Use longer timeouts for challenging RF environment
329    ///     let config = ConnectionConfig::challenging_environment();
330    ///     let device = Device::connect_with_config("Aranet4 12345", config).await?;
331    ///     Ok(())
332    /// }
333    /// ```
334    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
335    pub async fn connect_with_config(identifier: &str, config: ConnectionConfig) -> Result<Self> {
336        let options = ScanOptions {
337            duration: config.connection_timeout,
338            filter_aranet_only: false, // We're looking for a specific device
339            use_service_filter: false,
340        };
341
342        // Try find_device first (uses default 5s scan), then with custom options
343        let (adapter, peripheral) = match find_device(identifier).await {
344            Ok(result) => result,
345            Err(_) => crate::scan::find_device_with_options(identifier, options).await?,
346        };
347
348        Self::from_peripheral_with_config(adapter, peripheral, config).await
349    }
350
351    /// Connect to a device using an existing BLE adapter.
352    ///
353    /// This avoids creating a new btleplug `Manager` (and D-Bus connection) on
354    /// every call.  Prefer this over [`connect_with_config`](Self::connect_with_config)
355    /// in long-running services that poll devices repeatedly.
356    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
357    pub async fn connect_with_adapter(
358        adapter: Adapter,
359        identifier: &str,
360        config: ConnectionConfig,
361    ) -> Result<Self> {
362        let options = ScanOptions {
363            duration: config.connection_timeout,
364            filter_aranet_only: false,
365            use_service_filter: false,
366        };
367
368        let peripheral = match crate::scan::find_device_with_adapter(
369            &adapter,
370            identifier,
371            ScanOptions::default(),
372        )
373        .await
374        {
375            Ok(p) => p,
376            Err(e) => {
377                debug!("Fast scan failed ({e}), retrying with extended options");
378                crate::scan::find_device_with_adapter(&adapter, identifier, options).await?
379            }
380        };
381
382        Self::from_peripheral_with_config(adapter, peripheral, config).await
383    }
384
385    /// Create a Device from an already-discovered peripheral.
386    #[tracing::instrument(level = "info", skip_all)]
387    pub async fn from_peripheral(adapter: Adapter, peripheral: Peripheral) -> Result<Self> {
388        Self::from_peripheral_with_config(adapter, peripheral, ConnectionConfig::default()).await
389    }
390
391    /// Create a Device from an already-discovered peripheral with custom timeout.
392    #[tracing::instrument(level = "info", skip_all, fields(timeout_secs = connect_timeout.as_secs()))]
393    pub async fn from_peripheral_with_timeout(
394        adapter: Adapter,
395        peripheral: Peripheral,
396        connect_timeout: Duration,
397    ) -> Result<Self> {
398        let config = ConnectionConfig::default().connection_timeout(connect_timeout);
399        Self::from_peripheral_with_config(adapter, peripheral, config).await
400    }
401
402    /// Create a Device from an already-discovered peripheral with full configuration.
403    #[tracing::instrument(level = "info", skip_all, fields(connect_timeout = ?config.connection_timeout))]
404    pub async fn from_peripheral_with_config(
405        adapter: Adapter,
406        peripheral: Peripheral,
407        config: ConnectionConfig,
408    ) -> Result<Self> {
409        // Let the BlueZ agent complete "Just Works" pairing for this device only.
410        #[cfg(target_os = "linux")]
411        crate::bluez_agent::allow_pairing(&peripheral.address().to_string());
412
413        // Connect to the device with timeout
414        info!("Connecting to device...");
415        timeout(config.connection_timeout, peripheral.connect())
416            .await
417            .map_err(|_| Error::Timeout {
418                operation: "connect to device".to_string(),
419                duration: config.connection_timeout,
420            })??;
421        info!("Connected!");
422
423        // Discover services with timeout
424        info!("Discovering services...");
425        timeout(config.discovery_timeout, peripheral.discover_services())
426            .await
427            .map_err(|_| Error::Timeout {
428                operation: "discover services".to_string(),
429                duration: config.discovery_timeout,
430            })??;
431
432        let mut services = peripheral.services();
433
434        // If service discovery returned nothing, BlueZ may have stale state
435        // from a previous failed connection (e.g., auth failure during GATT
436        // discovery). Disconnect, wait, and retry once with a clean connection.
437        if services.is_empty() {
438            warn!("Service discovery returned 0 services — retrying with fresh connection");
439            let _ = peripheral.disconnect().await;
440            tokio::time::sleep(Duration::from_secs(2)).await;
441
442            timeout(config.connection_timeout, peripheral.connect())
443                .await
444                .map_err(|_| Error::Timeout {
445                    operation: "reconnect to device".to_string(),
446                    duration: config.connection_timeout,
447                })??;
448
449            timeout(config.discovery_timeout, peripheral.discover_services())
450                .await
451                .map_err(|_| Error::Timeout {
452                    operation: "rediscover services".to_string(),
453                    duration: config.discovery_timeout,
454                })??;
455
456            services = peripheral.services();
457        }
458
459        debug!("Found {} services", services.len());
460
461        // Build characteristics cache for O(1) lookups
462        let mut characteristics_cache = HashMap::new();
463        for service in &services {
464            debug!("  Service: {}", service.uuid);
465            for char in &service.characteristics {
466                debug!("    Characteristic: {}", char.uuid);
467                characteristics_cache.insert(char.uuid, char.clone());
468            }
469        }
470        debug!(
471            "Cached {} characteristics for fast lookup",
472            characteristics_cache.len()
473        );
474
475        // Get device properties
476        let properties = peripheral.properties().await?;
477        let name = properties.as_ref().and_then(|p| p.local_name.clone());
478
479        // Get address - on macOS this may be 00:00:00:00:00:00, so we use peripheral ID as fallback
480        let address = properties
481            .as_ref()
482            .map(|p| create_identifier(&p.address.to_string(), &peripheral.id()))
483            .unwrap_or_else(|| format_peripheral_id(&peripheral.id()));
484
485        // Determine device type from name
486        let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
487
488        Ok(Self {
489            adapter,
490            peripheral,
491            name,
492            address,
493            device_type,
494            services_discovered: true,
495            characteristics_cache: RwLock::new(characteristics_cache),
496            notification_handles: tokio::sync::Mutex::new(Vec::new()),
497            disconnected: AtomicBool::new(false),
498            config,
499        })
500    }
501
502    /// Check if the device is connected (queries BLE stack state).
503    ///
504    /// Note: This only checks the BLE stack's connection state, which may be stale,
505    /// especially on macOS. For a more reliable check, use [`Self::validate_connection`].
506    pub async fn is_connected(&self) -> bool {
507        match self.peripheral.is_connected().await {
508            Ok(connected) => connected,
509            Err(e) => {
510                warn!("Failed to query connection state: {e}");
511                false
512            }
513        }
514    }
515
516    /// Validate the connection by performing a lightweight read operation.
517    ///
518    /// This is more reliable than `is_connected()` as it actively verifies
519    /// the connection is working. Uses battery level read as it's fast and
520    /// always available on Aranet devices.
521    ///
522    /// This method is useful for detecting "zombie connections" where the
523    /// BLE stack thinks it's connected but the device is actually out of range.
524    ///
525    /// # Returns
526    ///
527    /// `true` if the connection is active and responsive, `false` otherwise.
528    pub async fn validate_connection(&self) -> bool {
529        timeout(self.config.validation_timeout, self.read_battery())
530            .await
531            .map(|r| r.is_ok())
532            .unwrap_or(false)
533    }
534
535    /// Check if the connection is alive by performing a lightweight keepalive check.
536    ///
537    /// This is an alias for [`Self::validate_connection`] that better describes
538    /// the intent when used for connection health monitoring.
539    ///
540    /// # Example
541    ///
542    /// ```ignore
543    /// // In a health monitor loop
544    /// if !device.is_connection_alive().await {
545    ///     // Connection lost, need to reconnect
546    /// }
547    /// ```
548    pub async fn is_connection_alive(&self) -> bool {
549        self.validate_connection().await
550    }
551
552    /// Get the current connection configuration.
553    pub fn config(&self) -> &ConnectionConfig {
554        &self.config
555    }
556
557    /// Get the current signal quality based on RSSI.
558    ///
559    /// Returns `None` if RSSI cannot be read.
560    pub async fn signal_quality(&self) -> Option<SignalQuality> {
561        self.read_rssi().await.ok().map(SignalQuality::from_rssi)
562    }
563
564    /// Disconnect from the device.
565    ///
566    /// This will:
567    /// 1. Abort all active notification handlers
568    /// 2. Disconnect from the BLE peripheral
569    ///
570    /// **Important:** You MUST call this method before dropping the Device
571    /// to ensure proper cleanup of BLE resources.
572    #[tracing::instrument(level = "info", skip(self), fields(device_name = ?self.name))]
573    pub async fn disconnect(&self) -> Result<()> {
574        info!("Disconnecting from device...");
575        self.disconnected.store(true, Ordering::SeqCst);
576
577        // Abort all notification handlers
578        {
579            let mut handles = self.notification_handles.lock().await;
580            for handle in handles.drain(..) {
581                handle.abort();
582            }
583        }
584
585        self.peripheral.disconnect().await?;
586        Ok(())
587    }
588
589    /// Get the device name.
590    pub fn name(&self) -> Option<&str> {
591        self.name.as_deref()
592    }
593
594    /// Get the device address or identifier.
595    ///
596    /// On Linux and Windows, this returns the Bluetooth MAC address (e.g., "AA:BB:CC:DD:EE:FF").
597    /// On macOS, this returns a UUID identifier since MAC addresses are not exposed.
598    pub fn address(&self) -> &str {
599        &self.address
600    }
601
602    /// Get the detected device type.
603    pub fn device_type(&self) -> Option<DeviceType> {
604        self.device_type
605    }
606
607    /// Read the current RSSI (signal strength) of the connection.
608    ///
609    /// Returns the RSSI in dBm. More negative values indicate weaker signals.
610    /// Typical values range from -30 (strong) to -90 (weak).
611    pub async fn read_rssi(&self) -> Result<i16> {
612        let properties = self.peripheral.properties().await?;
613        properties
614            .and_then(|p| p.rssi)
615            .ok_or_else(|| Error::InvalidData("RSSI not available".to_string()))
616    }
617
618    /// Find a characteristic by UUID using the cached lookup table.
619    ///
620    /// Uses O(1) lookup from the characteristics cache built during service discovery.
621    /// Falls back to searching through services if the cache is empty (shouldn't happen
622    /// normally, but provides robustness).
623    async fn find_characteristic(&self, uuid: Uuid) -> Result<Characteristic> {
624        // Try cache first (O(1) lookup)
625        {
626            let cache = self.characteristics_cache.read().await;
627            if let Some(char) = cache.get(&uuid) {
628                return Ok(char.clone());
629            }
630
631            // If cache is populated but characteristic not found, it doesn't exist
632            if !cache.is_empty() {
633                return Err(Error::characteristic_not_found(
634                    uuid.to_string(),
635                    self.peripheral.services().len(),
636                ));
637            }
638        }
639
640        // Fallback: search services directly (shouldn't happen in normal operation)
641        warn!(
642            "Characteristics cache empty, falling back to service search for {}",
643            uuid
644        );
645        let services = self.peripheral.services();
646        let service_count = services.len();
647
648        // First try Aranet-specific services
649        for service in &services {
650            if service.uuid == SAF_TEHNIKA_SERVICE_NEW || service.uuid == SAF_TEHNIKA_SERVICE_OLD {
651                for char in &service.characteristics {
652                    if char.uuid == uuid {
653                        return Ok(char.clone());
654                    }
655                }
656            }
657        }
658
659        // Then try standard services (GAP, Device Info, Battery)
660        for service in &services {
661            if service.uuid == GAP_SERVICE
662                || service.uuid == DEVICE_INFO_SERVICE
663                || service.uuid == BATTERY_SERVICE
664            {
665                for char in &service.characteristics {
666                    if char.uuid == uuid {
667                        return Ok(char.clone());
668                    }
669                }
670            }
671        }
672
673        // Finally search all services
674        for service in &services {
675            for char in &service.characteristics {
676                if char.uuid == uuid {
677                    return Ok(char.clone());
678                }
679            }
680        }
681
682        Err(Error::characteristic_not_found(
683            uuid.to_string(),
684            service_count,
685        ))
686    }
687
688    /// Read a characteristic value by UUID.
689    ///
690    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
691    /// The timeout is controlled by [`ConnectionConfig::read_timeout`].
692    pub async fn read_characteristic(&self, uuid: Uuid) -> Result<Vec<u8>> {
693        let characteristic = self.find_characteristic(uuid).await?;
694        let data = timeout(
695            self.config.read_timeout,
696            self.peripheral.read(&characteristic),
697        )
698        .await
699        .map_err(|_| Error::Timeout {
700            operation: format!("read characteristic {}", uuid),
701            duration: self.config.read_timeout,
702        })??;
703        Ok(data)
704    }
705
706    /// Read a characteristic value with a custom timeout.
707    ///
708    /// Use this when you need a different timeout than the default,
709    /// for example when reading large data.
710    pub async fn read_characteristic_with_timeout(
711        &self,
712        uuid: Uuid,
713        read_timeout: Duration,
714    ) -> Result<Vec<u8>> {
715        let characteristic = self.find_characteristic(uuid).await?;
716        let data = timeout(read_timeout, self.peripheral.read(&characteristic))
717            .await
718            .map_err(|_| Error::Timeout {
719                operation: format!("read characteristic {}", uuid),
720                duration: read_timeout,
721            })??;
722        Ok(data)
723    }
724
725    /// Write a value to a characteristic.
726    ///
727    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
728    /// The timeout is controlled by [`ConnectionConfig::write_timeout`].
729    pub async fn write_characteristic(&self, uuid: Uuid, data: &[u8]) -> Result<()> {
730        let characteristic = self.find_characteristic(uuid).await?;
731        timeout(
732            self.config.write_timeout,
733            self.peripheral
734                .write(&characteristic, data, WriteType::WithResponse),
735        )
736        .await
737        .map_err(|_| Error::Timeout {
738            operation: format!("write characteristic {}", uuid),
739            duration: self.config.write_timeout,
740        })??;
741        Ok(())
742    }
743
744    /// Write a value to a characteristic with a custom timeout.
745    pub async fn write_characteristic_with_timeout(
746        &self,
747        uuid: Uuid,
748        data: &[u8],
749        write_timeout: Duration,
750    ) -> Result<()> {
751        let characteristic = self.find_characteristic(uuid).await?;
752        timeout(
753            write_timeout,
754            self.peripheral
755                .write(&characteristic, data, WriteType::WithResponse),
756        )
757        .await
758        .map_err(|_| Error::Timeout {
759            operation: format!("write characteristic {}", uuid),
760            duration: write_timeout,
761        })??;
762        Ok(())
763    }
764
765    /// Read current sensor measurements.
766    ///
767    /// Automatically selects the correct characteristic UUID based on device type:
768    /// - Aranet4 uses `f0cd3001`
769    /// - Aranet2, Radon, Radiation use `f0cd3003`
770    #[tracing::instrument(level = "debug", skip(self), fields(device_name = ?self.name, device_type = ?self.device_type))]
771    pub async fn read_current(&self) -> Result<CurrentReading> {
772        // Use the correct characteristic directly when device type is known,
773        // otherwise probe primary then fall back to alternative.
774        let data = match self.device_type {
775            Some(DeviceType::Aranet4) => self.read_characteristic(CURRENT_READINGS_DETAIL).await?,
776            Some(DeviceType::Aranet2 | DeviceType::AranetRadon | DeviceType::AranetRadiation) => {
777                self.read_characteristic(CURRENT_READINGS_DETAIL_ALT)
778                    .await?
779            }
780            None | Some(_) => {
781                // Unknown type: try primary first, fall back to alternative
782                match self.read_characteristic(CURRENT_READINGS_DETAIL).await {
783                    Ok(data) => data,
784                    Err(Error::CharacteristicNotFound { .. }) => {
785                        debug!("Primary reading characteristic not found, trying alternative");
786                        self.read_characteristic(CURRENT_READINGS_DETAIL_ALT)
787                            .await?
788                    }
789                    Err(e) => return Err(e),
790                }
791            }
792        };
793
794        // Parse based on device type.
795        let device_type = match self.device_type {
796            Some(dt) => dt,
797            None => {
798                warn!(
799                    "Device type unknown for {}; defaulting to Aranet4 — \
800                     readings may be incorrect if this is a different model",
801                    self.name().unwrap_or("unknown")
802                );
803                DeviceType::Aranet4
804            }
805        };
806        crate::readings::parse_reading_for_device(&data, device_type)
807    }
808
809    /// Read the battery level (0-100).
810    #[tracing::instrument(level = "debug", skip(self))]
811    pub async fn read_battery(&self) -> Result<u8> {
812        let data = self.read_characteristic(BATTERY_LEVEL).await?;
813        if data.is_empty() {
814            return Err(Error::InvalidData("Empty battery data".to_string()));
815        }
816        Ok(data[0])
817    }
818
819    /// Read device information.
820    ///
821    /// This method reads all device info characteristics in parallel for better performance.
822    #[tracing::instrument(level = "debug", skip(self))]
823    pub async fn read_device_info(&self) -> Result<DeviceInfo> {
824        fn read_string(data: Vec<u8>) -> String {
825            String::from_utf8(data)
826                .unwrap_or_default()
827                .trim_end_matches('\0')
828                .to_string()
829        }
830
831        // Read all characteristics in parallel for better performance
832        let (
833            name_result,
834            model_result,
835            serial_result,
836            firmware_result,
837            hardware_result,
838            software_result,
839            manufacturer_result,
840        ) = tokio::join!(
841            self.read_characteristic(DEVICE_NAME),
842            self.read_characteristic(MODEL_NUMBER),
843            self.read_characteristic(SERIAL_NUMBER),
844            self.read_characteristic(FIRMWARE_REVISION),
845            self.read_characteristic(HARDWARE_REVISION),
846            self.read_characteristic(SOFTWARE_REVISION),
847            self.read_characteristic(MANUFACTURER_NAME),
848        );
849
850        let name = name_result
851            .map(read_string)
852            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
853
854        let model = model_result.map(read_string).unwrap_or_default();
855        let serial = serial_result.map(read_string).unwrap_or_default();
856        let firmware = firmware_result.map(read_string).unwrap_or_default();
857        let hardware = hardware_result.map(read_string).unwrap_or_default();
858        let software = software_result.map(read_string).unwrap_or_default();
859        let manufacturer = manufacturer_result.map(read_string).unwrap_or_default();
860
861        Ok(DeviceInfo {
862            name,
863            model,
864            serial,
865            firmware,
866            hardware,
867            software,
868            manufacturer,
869        })
870    }
871
872    /// Read essential device information only.
873    ///
874    /// This is a faster alternative to [`Self::read_device_info`] that only reads
875    /// the most critical characteristics: name, serial number, and firmware version.
876    /// Use this for faster startup when full device info isn't needed immediately.
877    #[tracing::instrument(level = "debug", skip(self))]
878    pub async fn read_device_info_essential(&self) -> Result<DeviceInfo> {
879        fn read_string(data: Vec<u8>) -> String {
880            String::from_utf8(data)
881                .unwrap_or_default()
882                .trim_end_matches('\0')
883                .to_string()
884        }
885
886        // Only read the essential characteristics in parallel
887        let (name_result, serial_result, firmware_result) = tokio::join!(
888            self.read_characteristic(DEVICE_NAME),
889            self.read_characteristic(SERIAL_NUMBER),
890            self.read_characteristic(FIRMWARE_REVISION),
891        );
892
893        let name = name_result
894            .map(read_string)
895            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
896        let serial = serial_result.map(read_string).unwrap_or_default();
897        let firmware = firmware_result.map(read_string).unwrap_or_default();
898
899        Ok(DeviceInfo {
900            name,
901            model: String::new(),
902            serial,
903            firmware,
904            hardware: String::new(),
905            software: String::new(),
906            manufacturer: String::new(),
907        })
908    }
909
910    /// Subscribe to notifications on a characteristic.
911    ///
912    /// The callback will be invoked for each notification received.
913    /// The notification handler task is tracked and will be aborted when
914    /// `disconnect()` is called.
915    pub async fn subscribe_to_notifications<F>(&self, uuid: Uuid, callback: F) -> Result<()>
916    where
917        F: Fn(&[u8]) + Send + Sync + 'static,
918    {
919        let characteristic = self.find_characteristic(uuid).await?;
920
921        self.peripheral.subscribe(&characteristic).await?;
922
923        // Set up notification handler
924        let mut stream = self.peripheral.notifications().await?;
925        let char_uuid = characteristic.uuid;
926
927        let handle = tokio::spawn(async move {
928            use futures::StreamExt;
929            while let Some(notification) = stream.next().await {
930                if notification.uuid == char_uuid {
931                    callback(&notification.value);
932                }
933            }
934        });
935
936        // Store the handle for cleanup on disconnect
937        self.notification_handles.lock().await.push(handle);
938
939        Ok(())
940    }
941
942    /// Unsubscribe from notifications on a characteristic.
943    pub async fn unsubscribe_from_notifications(&self, uuid: Uuid) -> Result<()> {
944        let characteristic = self.find_characteristic(uuid).await?;
945        self.peripheral.unsubscribe(&characteristic).await?;
946        Ok(())
947    }
948
949    /// Get the number of cached characteristics.
950    ///
951    /// This is useful for debugging and testing to verify service discovery worked.
952    pub async fn cached_characteristic_count(&self) -> usize {
953        self.characteristics_cache.read().await.len()
954    }
955}
956
957// NOTE: Drop performs best-effort cleanup if disconnect() was not called.
958// The cleanup is spawned as a background task and may not complete during shutdown.
959// For reliable cleanup, callers SHOULD explicitly call `device.disconnect().await`
960// before dropping the Device.
961//
962// The cleanup behavior:
963// 1. Aborts all notification handlers (sync operation)
964// 2. Spawns an async task to disconnect the peripheral (best-effort)
965// 3. Logs a warning about the implicit cleanup
966//
967// For automatic cleanup, consider using `ReconnectingDevice` which manages the lifecycle.
968
969impl Drop for Device {
970    fn drop(&mut self) {
971        if !self.disconnected.load(Ordering::SeqCst) {
972            // Mark as disconnected to prevent double-cleanup
973            self.disconnected.store(true, Ordering::SeqCst);
974
975            // Log warning about implicit cleanup
976            warn!(
977                device_name = ?self.name,
978                device_address = %self.address,
979                "Device dropped without calling disconnect() - performing best-effort cleanup. \
980                 For reliable cleanup, call device.disconnect().await before dropping."
981            );
982
983            // Best-effort cleanup: abort notification handlers
984            // We can't use .await here, so we try_lock and abort synchronously
985            if let Ok(mut handles) = self.notification_handles.try_lock() {
986                for handle in handles.drain(..) {
987                    handle.abort();
988                }
989            }
990
991            // Spawn a best-effort cleanup task for the BLE disconnect
992            // This uses try_runtime to handle the case where the runtime is shutting down
993            let peripheral = self.peripheral.clone();
994            let address = self.address.clone();
995
996            // Try to spawn cleanup task - this may fail if runtime is shutting down
997            if let Ok(handle) = tokio::runtime::Handle::try_current() {
998                handle.spawn(async move {
999                    if let Err(e) = peripheral.disconnect().await {
1000                        debug!(
1001                            device_address = %address,
1002                            error = %e,
1003                            "Best-effort disconnect failed (device may already be disconnected)"
1004                        );
1005                    } else {
1006                        debug!(
1007                            device_address = %address,
1008                            "Best-effort disconnect completed"
1009                        );
1010                    }
1011                });
1012            }
1013        }
1014    }
1015}
1016
1017impl AranetDevice for Device {
1018    // --- Connection Management ---
1019
1020    async fn is_connected(&self) -> bool {
1021        Device::is_connected(self).await
1022    }
1023
1024    async fn disconnect(&self) -> Result<()> {
1025        Device::disconnect(self).await
1026    }
1027
1028    // --- Device Identity ---
1029
1030    fn name(&self) -> Option<&str> {
1031        Device::name(self)
1032    }
1033
1034    fn address(&self) -> &str {
1035        Device::address(self)
1036    }
1037
1038    fn device_type(&self) -> Option<DeviceType> {
1039        Device::device_type(self)
1040    }
1041
1042    // --- Current Readings ---
1043
1044    async fn read_current(&self) -> Result<CurrentReading> {
1045        Device::read_current(self).await
1046    }
1047
1048    async fn read_device_info(&self) -> Result<DeviceInfo> {
1049        Device::read_device_info(self).await
1050    }
1051
1052    async fn read_rssi(&self) -> Result<i16> {
1053        Device::read_rssi(self).await
1054    }
1055
1056    // --- Battery ---
1057
1058    async fn read_battery(&self) -> Result<u8> {
1059        Device::read_battery(self).await
1060    }
1061
1062    // --- History ---
1063
1064    async fn get_history_info(&self) -> Result<crate::history::HistoryInfo> {
1065        Device::get_history_info(self).await
1066    }
1067
1068    async fn download_history(&self) -> Result<Vec<aranet_types::HistoryRecord>> {
1069        Device::download_history(self).await
1070    }
1071
1072    async fn download_history_with_options(
1073        &self,
1074        options: crate::history::HistoryOptions,
1075    ) -> Result<Vec<aranet_types::HistoryRecord>> {
1076        Device::download_history_with_options(self, options).await
1077    }
1078
1079    // --- Settings ---
1080
1081    async fn get_interval(&self) -> Result<crate::settings::MeasurementInterval> {
1082        Device::get_interval(self).await
1083    }
1084
1085    async fn set_interval(&self, interval: crate::settings::MeasurementInterval) -> Result<()> {
1086        Device::set_interval(self, interval).await
1087    }
1088
1089    async fn get_calibration(&self) -> Result<crate::settings::CalibrationData> {
1090        Device::get_calibration(self).await
1091    }
1092}