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