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        // Connect to the device with timeout
410        info!("Connecting to device...");
411        timeout(config.connection_timeout, peripheral.connect())
412            .await
413            .map_err(|_| Error::Timeout {
414                operation: "connect to device".to_string(),
415                duration: config.connection_timeout,
416            })??;
417        info!("Connected!");
418
419        // Discover services with timeout
420        info!("Discovering services...");
421        timeout(config.discovery_timeout, peripheral.discover_services())
422            .await
423            .map_err(|_| Error::Timeout {
424                operation: "discover services".to_string(),
425                duration: config.discovery_timeout,
426            })??;
427
428        let mut services = peripheral.services();
429
430        // If service discovery returned nothing, BlueZ may have stale state
431        // from a previous failed connection (e.g., auth failure during GATT
432        // discovery). Disconnect, wait, and retry once with a clean connection.
433        if services.is_empty() {
434            warn!("Service discovery returned 0 services — retrying with fresh connection");
435            let _ = peripheral.disconnect().await;
436            tokio::time::sleep(Duration::from_secs(2)).await;
437
438            timeout(config.connection_timeout, peripheral.connect())
439                .await
440                .map_err(|_| Error::Timeout {
441                    operation: "reconnect to device".to_string(),
442                    duration: config.connection_timeout,
443                })??;
444
445            timeout(config.discovery_timeout, peripheral.discover_services())
446                .await
447                .map_err(|_| Error::Timeout {
448                    operation: "rediscover services".to_string(),
449                    duration: config.discovery_timeout,
450                })??;
451
452            services = peripheral.services();
453        }
454
455        debug!("Found {} services", services.len());
456
457        // Build characteristics cache for O(1) lookups
458        let mut characteristics_cache = HashMap::new();
459        for service in &services {
460            debug!("  Service: {}", service.uuid);
461            for char in &service.characteristics {
462                debug!("    Characteristic: {}", char.uuid);
463                characteristics_cache.insert(char.uuid, char.clone());
464            }
465        }
466        debug!(
467            "Cached {} characteristics for fast lookup",
468            characteristics_cache.len()
469        );
470
471        // Get device properties
472        let properties = peripheral.properties().await?;
473        let name = properties.as_ref().and_then(|p| p.local_name.clone());
474
475        // Get address - on macOS this may be 00:00:00:00:00:00, so we use peripheral ID as fallback
476        let address = properties
477            .as_ref()
478            .map(|p| create_identifier(&p.address.to_string(), &peripheral.id()))
479            .unwrap_or_else(|| format_peripheral_id(&peripheral.id()));
480
481        // Determine device type from name
482        let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
483
484        Ok(Self {
485            adapter,
486            peripheral,
487            name,
488            address,
489            device_type,
490            services_discovered: true,
491            characteristics_cache: RwLock::new(characteristics_cache),
492            notification_handles: tokio::sync::Mutex::new(Vec::new()),
493            disconnected: AtomicBool::new(false),
494            config,
495        })
496    }
497
498    /// Check if the device is connected (queries BLE stack state).
499    ///
500    /// Note: This only checks the BLE stack's connection state, which may be stale,
501    /// especially on macOS. For a more reliable check, use [`Self::validate_connection`].
502    pub async fn is_connected(&self) -> bool {
503        match self.peripheral.is_connected().await {
504            Ok(connected) => connected,
505            Err(e) => {
506                warn!("Failed to query connection state: {e}");
507                false
508            }
509        }
510    }
511
512    /// Validate the connection by performing a lightweight read operation.
513    ///
514    /// This is more reliable than `is_connected()` as it actively verifies
515    /// the connection is working. Uses battery level read as it's fast and
516    /// always available on Aranet devices.
517    ///
518    /// This method is useful for detecting "zombie connections" where the
519    /// BLE stack thinks it's connected but the device is actually out of range.
520    ///
521    /// # Returns
522    ///
523    /// `true` if the connection is active and responsive, `false` otherwise.
524    pub async fn validate_connection(&self) -> bool {
525        timeout(self.config.validation_timeout, self.read_battery())
526            .await
527            .map(|r| r.is_ok())
528            .unwrap_or(false)
529    }
530
531    /// Check if the connection is alive by performing a lightweight keepalive check.
532    ///
533    /// This is an alias for [`Self::validate_connection`] that better describes
534    /// the intent when used for connection health monitoring.
535    ///
536    /// # Example
537    ///
538    /// ```ignore
539    /// // In a health monitor loop
540    /// if !device.is_connection_alive().await {
541    ///     // Connection lost, need to reconnect
542    /// }
543    /// ```
544    pub async fn is_connection_alive(&self) -> bool {
545        self.validate_connection().await
546    }
547
548    /// Get the current connection configuration.
549    pub fn config(&self) -> &ConnectionConfig {
550        &self.config
551    }
552
553    /// Get the current signal quality based on RSSI.
554    ///
555    /// Returns `None` if RSSI cannot be read.
556    pub async fn signal_quality(&self) -> Option<SignalQuality> {
557        self.read_rssi().await.ok().map(SignalQuality::from_rssi)
558    }
559
560    /// Disconnect from the device.
561    ///
562    /// This will:
563    /// 1. Abort all active notification handlers
564    /// 2. Disconnect from the BLE peripheral
565    ///
566    /// **Important:** You MUST call this method before dropping the Device
567    /// to ensure proper cleanup of BLE resources.
568    #[tracing::instrument(level = "info", skip(self), fields(device_name = ?self.name))]
569    pub async fn disconnect(&self) -> Result<()> {
570        info!("Disconnecting from device...");
571        self.disconnected.store(true, Ordering::SeqCst);
572
573        // Abort all notification handlers
574        {
575            let mut handles = self.notification_handles.lock().await;
576            for handle in handles.drain(..) {
577                handle.abort();
578            }
579        }
580
581        self.peripheral.disconnect().await?;
582        Ok(())
583    }
584
585    /// Get the device name.
586    pub fn name(&self) -> Option<&str> {
587        self.name.as_deref()
588    }
589
590    /// Get the device address or identifier.
591    ///
592    /// On Linux and Windows, this returns the Bluetooth MAC address (e.g., "AA:BB:CC:DD:EE:FF").
593    /// On macOS, this returns a UUID identifier since MAC addresses are not exposed.
594    pub fn address(&self) -> &str {
595        &self.address
596    }
597
598    /// Get the detected device type.
599    pub fn device_type(&self) -> Option<DeviceType> {
600        self.device_type
601    }
602
603    /// Read the current RSSI (signal strength) of the connection.
604    ///
605    /// Returns the RSSI in dBm. More negative values indicate weaker signals.
606    /// Typical values range from -30 (strong) to -90 (weak).
607    pub async fn read_rssi(&self) -> Result<i16> {
608        let properties = self.peripheral.properties().await?;
609        properties
610            .and_then(|p| p.rssi)
611            .ok_or_else(|| Error::InvalidData("RSSI not available".to_string()))
612    }
613
614    /// Find a characteristic by UUID using the cached lookup table.
615    ///
616    /// Uses O(1) lookup from the characteristics cache built during service discovery.
617    /// Falls back to searching through services if the cache is empty (shouldn't happen
618    /// normally, but provides robustness).
619    async fn find_characteristic(&self, uuid: Uuid) -> Result<Characteristic> {
620        // Try cache first (O(1) lookup)
621        {
622            let cache = self.characteristics_cache.read().await;
623            if let Some(char) = cache.get(&uuid) {
624                return Ok(char.clone());
625            }
626
627            // If cache is populated but characteristic not found, it doesn't exist
628            if !cache.is_empty() {
629                return Err(Error::characteristic_not_found(
630                    uuid.to_string(),
631                    self.peripheral.services().len(),
632                ));
633            }
634        }
635
636        // Fallback: search services directly (shouldn't happen in normal operation)
637        warn!(
638            "Characteristics cache empty, falling back to service search for {}",
639            uuid
640        );
641        let services = self.peripheral.services();
642        let service_count = services.len();
643
644        // First try Aranet-specific services
645        for service in &services {
646            if service.uuid == SAF_TEHNIKA_SERVICE_NEW || service.uuid == SAF_TEHNIKA_SERVICE_OLD {
647                for char in &service.characteristics {
648                    if char.uuid == uuid {
649                        return Ok(char.clone());
650                    }
651                }
652            }
653        }
654
655        // Then try standard services (GAP, Device Info, Battery)
656        for service in &services {
657            if service.uuid == GAP_SERVICE
658                || service.uuid == DEVICE_INFO_SERVICE
659                || service.uuid == BATTERY_SERVICE
660            {
661                for char in &service.characteristics {
662                    if char.uuid == uuid {
663                        return Ok(char.clone());
664                    }
665                }
666            }
667        }
668
669        // Finally search all services
670        for service in &services {
671            for char in &service.characteristics {
672                if char.uuid == uuid {
673                    return Ok(char.clone());
674                }
675            }
676        }
677
678        Err(Error::characteristic_not_found(
679            uuid.to_string(),
680            service_count,
681        ))
682    }
683
684    /// Read a characteristic value by UUID.
685    ///
686    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
687    /// The timeout is controlled by [`ConnectionConfig::read_timeout`].
688    pub async fn read_characteristic(&self, uuid: Uuid) -> Result<Vec<u8>> {
689        let characteristic = self.find_characteristic(uuid).await?;
690        let data = timeout(
691            self.config.read_timeout,
692            self.peripheral.read(&characteristic),
693        )
694        .await
695        .map_err(|_| Error::Timeout {
696            operation: format!("read characteristic {}", uuid),
697            duration: self.config.read_timeout,
698        })??;
699        Ok(data)
700    }
701
702    /// Read a characteristic value with a custom timeout.
703    ///
704    /// Use this when you need a different timeout than the default,
705    /// for example when reading large data.
706    pub async fn read_characteristic_with_timeout(
707        &self,
708        uuid: Uuid,
709        read_timeout: Duration,
710    ) -> Result<Vec<u8>> {
711        let characteristic = self.find_characteristic(uuid).await?;
712        let data = timeout(read_timeout, self.peripheral.read(&characteristic))
713            .await
714            .map_err(|_| Error::Timeout {
715                operation: format!("read characteristic {}", uuid),
716                duration: read_timeout,
717            })??;
718        Ok(data)
719    }
720
721    /// Write a value to a characteristic.
722    ///
723    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
724    /// The timeout is controlled by [`ConnectionConfig::write_timeout`].
725    pub async fn write_characteristic(&self, uuid: Uuid, data: &[u8]) -> Result<()> {
726        let characteristic = self.find_characteristic(uuid).await?;
727        timeout(
728            self.config.write_timeout,
729            self.peripheral
730                .write(&characteristic, data, WriteType::WithResponse),
731        )
732        .await
733        .map_err(|_| Error::Timeout {
734            operation: format!("write characteristic {}", uuid),
735            duration: self.config.write_timeout,
736        })??;
737        Ok(())
738    }
739
740    /// Write a value to a characteristic with a custom timeout.
741    pub async fn write_characteristic_with_timeout(
742        &self,
743        uuid: Uuid,
744        data: &[u8],
745        write_timeout: Duration,
746    ) -> Result<()> {
747        let characteristic = self.find_characteristic(uuid).await?;
748        timeout(
749            write_timeout,
750            self.peripheral
751                .write(&characteristic, data, WriteType::WithResponse),
752        )
753        .await
754        .map_err(|_| Error::Timeout {
755            operation: format!("write characteristic {}", uuid),
756            duration: write_timeout,
757        })??;
758        Ok(())
759    }
760
761    /// Read current sensor measurements.
762    ///
763    /// Automatically selects the correct characteristic UUID based on device type:
764    /// - Aranet4 uses `f0cd3001`
765    /// - Aranet2, Radon, Radiation use `f0cd3003`
766    #[tracing::instrument(level = "debug", skip(self), fields(device_name = ?self.name, device_type = ?self.device_type))]
767    pub async fn read_current(&self) -> Result<CurrentReading> {
768        // Use the correct characteristic directly when device type is known,
769        // otherwise probe primary then fall back to alternative.
770        let data = match self.device_type {
771            Some(DeviceType::Aranet4) => self.read_characteristic(CURRENT_READINGS_DETAIL).await?,
772            Some(DeviceType::Aranet2 | DeviceType::AranetRadon | DeviceType::AranetRadiation) => {
773                self.read_characteristic(CURRENT_READINGS_DETAIL_ALT)
774                    .await?
775            }
776            None | Some(_) => {
777                // Unknown type: try primary first, fall back to alternative
778                match self.read_characteristic(CURRENT_READINGS_DETAIL).await {
779                    Ok(data) => data,
780                    Err(Error::CharacteristicNotFound { .. }) => {
781                        debug!("Primary reading characteristic not found, trying alternative");
782                        self.read_characteristic(CURRENT_READINGS_DETAIL_ALT)
783                            .await?
784                    }
785                    Err(e) => return Err(e),
786                }
787            }
788        };
789
790        // Parse based on device type.
791        let device_type = match self.device_type {
792            Some(dt) => dt,
793            None => {
794                warn!(
795                    "Device type unknown for {}; defaulting to Aranet4 — \
796                     readings may be incorrect if this is a different model",
797                    self.name().unwrap_or("unknown")
798                );
799                DeviceType::Aranet4
800            }
801        };
802        crate::readings::parse_reading_for_device(&data, device_type)
803    }
804
805    /// Read the battery level (0-100).
806    #[tracing::instrument(level = "debug", skip(self))]
807    pub async fn read_battery(&self) -> Result<u8> {
808        let data = self.read_characteristic(BATTERY_LEVEL).await?;
809        if data.is_empty() {
810            return Err(Error::InvalidData("Empty battery data".to_string()));
811        }
812        Ok(data[0])
813    }
814
815    /// Read device information.
816    ///
817    /// This method reads all device info characteristics in parallel for better performance.
818    #[tracing::instrument(level = "debug", skip(self))]
819    pub async fn read_device_info(&self) -> Result<DeviceInfo> {
820        fn read_string(data: Vec<u8>) -> String {
821            String::from_utf8(data)
822                .unwrap_or_default()
823                .trim_end_matches('\0')
824                .to_string()
825        }
826
827        // Read all characteristics in parallel for better performance
828        let (
829            name_result,
830            model_result,
831            serial_result,
832            firmware_result,
833            hardware_result,
834            software_result,
835            manufacturer_result,
836        ) = tokio::join!(
837            self.read_characteristic(DEVICE_NAME),
838            self.read_characteristic(MODEL_NUMBER),
839            self.read_characteristic(SERIAL_NUMBER),
840            self.read_characteristic(FIRMWARE_REVISION),
841            self.read_characteristic(HARDWARE_REVISION),
842            self.read_characteristic(SOFTWARE_REVISION),
843            self.read_characteristic(MANUFACTURER_NAME),
844        );
845
846        let name = name_result
847            .map(read_string)
848            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
849
850        let model = model_result.map(read_string).unwrap_or_default();
851        let serial = serial_result.map(read_string).unwrap_or_default();
852        let firmware = firmware_result.map(read_string).unwrap_or_default();
853        let hardware = hardware_result.map(read_string).unwrap_or_default();
854        let software = software_result.map(read_string).unwrap_or_default();
855        let manufacturer = manufacturer_result.map(read_string).unwrap_or_default();
856
857        Ok(DeviceInfo {
858            name,
859            model,
860            serial,
861            firmware,
862            hardware,
863            software,
864            manufacturer,
865        })
866    }
867
868    /// Read essential device information only.
869    ///
870    /// This is a faster alternative to [`Self::read_device_info`] that only reads
871    /// the most critical characteristics: name, serial number, and firmware version.
872    /// Use this for faster startup when full device info isn't needed immediately.
873    #[tracing::instrument(level = "debug", skip(self))]
874    pub async fn read_device_info_essential(&self) -> Result<DeviceInfo> {
875        fn read_string(data: Vec<u8>) -> String {
876            String::from_utf8(data)
877                .unwrap_or_default()
878                .trim_end_matches('\0')
879                .to_string()
880        }
881
882        // Only read the essential characteristics in parallel
883        let (name_result, serial_result, firmware_result) = tokio::join!(
884            self.read_characteristic(DEVICE_NAME),
885            self.read_characteristic(SERIAL_NUMBER),
886            self.read_characteristic(FIRMWARE_REVISION),
887        );
888
889        let name = name_result
890            .map(read_string)
891            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
892        let serial = serial_result.map(read_string).unwrap_or_default();
893        let firmware = firmware_result.map(read_string).unwrap_or_default();
894
895        Ok(DeviceInfo {
896            name,
897            model: String::new(),
898            serial,
899            firmware,
900            hardware: String::new(),
901            software: String::new(),
902            manufacturer: String::new(),
903        })
904    }
905
906    /// Subscribe to notifications on a characteristic.
907    ///
908    /// The callback will be invoked for each notification received.
909    /// The notification handler task is tracked and will be aborted when
910    /// `disconnect()` is called.
911    pub async fn subscribe_to_notifications<F>(&self, uuid: Uuid, callback: F) -> Result<()>
912    where
913        F: Fn(&[u8]) + Send + Sync + 'static,
914    {
915        let characteristic = self.find_characteristic(uuid).await?;
916
917        self.peripheral.subscribe(&characteristic).await?;
918
919        // Set up notification handler
920        let mut stream = self.peripheral.notifications().await?;
921        let char_uuid = characteristic.uuid;
922
923        let handle = tokio::spawn(async move {
924            use futures::StreamExt;
925            while let Some(notification) = stream.next().await {
926                if notification.uuid == char_uuid {
927                    callback(&notification.value);
928                }
929            }
930        });
931
932        // Store the handle for cleanup on disconnect
933        self.notification_handles.lock().await.push(handle);
934
935        Ok(())
936    }
937
938    /// Unsubscribe from notifications on a characteristic.
939    pub async fn unsubscribe_from_notifications(&self, uuid: Uuid) -> Result<()> {
940        let characteristic = self.find_characteristic(uuid).await?;
941        self.peripheral.unsubscribe(&characteristic).await?;
942        Ok(())
943    }
944
945    /// Get the number of cached characteristics.
946    ///
947    /// This is useful for debugging and testing to verify service discovery worked.
948    pub async fn cached_characteristic_count(&self) -> usize {
949        self.characteristics_cache.read().await.len()
950    }
951}
952
953// NOTE: Drop performs best-effort cleanup if disconnect() was not called.
954// The cleanup is spawned as a background task and may not complete during shutdown.
955// For reliable cleanup, callers SHOULD explicitly call `device.disconnect().await`
956// before dropping the Device.
957//
958// The cleanup behavior:
959// 1. Aborts all notification handlers (sync operation)
960// 2. Spawns an async task to disconnect the peripheral (best-effort)
961// 3. Logs a warning about the implicit cleanup
962//
963// For automatic cleanup, consider using `ReconnectingDevice` which manages the lifecycle.
964
965impl Drop for Device {
966    fn drop(&mut self) {
967        if !self.disconnected.load(Ordering::SeqCst) {
968            // Mark as disconnected to prevent double-cleanup
969            self.disconnected.store(true, Ordering::SeqCst);
970
971            // Log warning about implicit cleanup
972            warn!(
973                device_name = ?self.name,
974                device_address = %self.address,
975                "Device dropped without calling disconnect() - performing best-effort cleanup. \
976                 For reliable cleanup, call device.disconnect().await before dropping."
977            );
978
979            // Best-effort cleanup: abort notification handlers
980            // We can't use .await here, so we try_lock and abort synchronously
981            if let Ok(mut handles) = self.notification_handles.try_lock() {
982                for handle in handles.drain(..) {
983                    handle.abort();
984                }
985            }
986
987            // Spawn a best-effort cleanup task for the BLE disconnect
988            // This uses try_runtime to handle the case where the runtime is shutting down
989            let peripheral = self.peripheral.clone();
990            let address = self.address.clone();
991
992            // Try to spawn cleanup task - this may fail if runtime is shutting down
993            if let Ok(handle) = tokio::runtime::Handle::try_current() {
994                handle.spawn(async move {
995                    if let Err(e) = peripheral.disconnect().await {
996                        debug!(
997                            device_address = %address,
998                            error = %e,
999                            "Best-effort disconnect failed (device may already be disconnected)"
1000                        );
1001                    } else {
1002                        debug!(
1003                            device_address = %address,
1004                            "Best-effort disconnect completed"
1005                        );
1006                    }
1007                });
1008            }
1009        }
1010    }
1011}
1012
1013impl AranetDevice for Device {
1014    // --- Connection Management ---
1015
1016    async fn is_connected(&self) -> bool {
1017        Device::is_connected(self).await
1018    }
1019
1020    async fn disconnect(&self) -> Result<()> {
1021        Device::disconnect(self).await
1022    }
1023
1024    // --- Device Identity ---
1025
1026    fn name(&self) -> Option<&str> {
1027        Device::name(self)
1028    }
1029
1030    fn address(&self) -> &str {
1031        Device::address(self)
1032    }
1033
1034    fn device_type(&self) -> Option<DeviceType> {
1035        Device::device_type(self)
1036    }
1037
1038    // --- Current Readings ---
1039
1040    async fn read_current(&self) -> Result<CurrentReading> {
1041        Device::read_current(self).await
1042    }
1043
1044    async fn read_device_info(&self) -> Result<DeviceInfo> {
1045        Device::read_device_info(self).await
1046    }
1047
1048    async fn read_rssi(&self) -> Result<i16> {
1049        Device::read_rssi(self).await
1050    }
1051
1052    // --- Battery ---
1053
1054    async fn read_battery(&self) -> Result<u8> {
1055        Device::read_battery(self).await
1056    }
1057
1058    // --- History ---
1059
1060    async fn get_history_info(&self) -> Result<crate::history::HistoryInfo> {
1061        Device::get_history_info(self).await
1062    }
1063
1064    async fn download_history(&self) -> Result<Vec<aranet_types::HistoryRecord>> {
1065        Device::download_history(self).await
1066    }
1067
1068    async fn download_history_with_options(
1069        &self,
1070        options: crate::history::HistoryOptions,
1071    ) -> Result<Vec<aranet_types::HistoryRecord>> {
1072        Device::download_history_with_options(self, options).await
1073    }
1074
1075    // --- Settings ---
1076
1077    async fn get_interval(&self) -> Result<crate::settings::MeasurementInterval> {
1078        Device::get_interval(self).await
1079    }
1080
1081    async fn set_interval(&self, interval: crate::settings::MeasurementInterval) -> Result<()> {
1082        Device::set_interval(self, interval).await
1083    }
1084
1085    async fn get_calibration(&self) -> Result<crate::settings::CalibrationData> {
1086        Device::get_calibration(self).await
1087    }
1088}