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;
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.
44///
45/// # Timeouts
46///
47/// With default settings, [`Device::connect`] gives up after about 30 s of scanning
48/// on a device that the adapter doesn't already know and that isn't advertising. A
49/// device that the adapter still lists (on Linux a paired device, or one seen in the
50/// last 30 s or so; on macOS one that this process found in an earlier scan and
51/// hasn't disconnected from since) is connected to without scanning. If it has gone,
52/// the connect fails within about 20 s (15 s connecting, up to 5 s to confirm the
53/// disconnect). A device that is found by scanning but refuses the connection fails
54/// after about 50 s (30 s scanning, 15 s connecting, up to 5 s to confirm the
55/// disconnect). One that connects slowly and then stops answering fails after at
56/// most about 70 s (adding up to 10 s each for service discovery and for reading its
57/// properties), and the rare retry after an empty service discovery can take about
58/// 102 s. On Linux, service discovery can take up to 20 s instead of 10 s while BlueZ
59/// is still discovering a device it has just connected (a first connection to an
60/// Aranet4, for example), so those two cases can take about 80 s and 122 s there. A
61/// search can also wait for another scan in the same process to finish. Use
62/// [`Device::connect_with_scan_options`] to change the scan time and
63/// [`ConnectionConfig`] to change the connect timeouts.
64///
65/// On Linux, a connection to a sensor that BlueZ doesn't list as paired pairs
66/// it first, which can add up to `connection_timeout` plus the wait for
67/// BlueZ's service discovery (`discovery_timeout`, but at least 20 s), plus
68/// 5 s: 40 s by default. A sensor that refused to pair is connected without
69/// pairing for 10 minutes.
70pub struct Device {
71    /// The BLE adapter used for connection.
72    ///
73    /// This field is stored to keep the adapter alive for the lifetime of the
74    /// peripheral connection. The peripheral may hold internal references to
75    /// the adapter, and dropping the adapter could invalidate the connection.
76    #[allow(dead_code)]
77    adapter: Adapter,
78    /// The underlying BLE peripheral.
79    peripheral: Peripheral,
80    /// Cached device name.
81    name: Option<String>,
82    /// Device address or identifier (MAC address on Linux/Windows, UUID on macOS).
83    address: String,
84    /// Detected device type.
85    device_type: Option<DeviceType>,
86    /// Whether services have been discovered.
87    services_discovered: bool,
88    /// Cache of discovered characteristics by UUID for O(1) lookup.
89    /// Built after service discovery to avoid searching through services on each read.
90    characteristics_cache: RwLock<HashMap<Uuid, Characteristic>>,
91    /// The notification task of each subscribed characteristic.
92    notification_tasks: crate::link::NotificationTasks,
93    /// Whether disconnect has been called (for Drop warning).
94    disconnected: AtomicBool,
95    /// Connection configuration (timeouts, etc.).
96    config: ConnectionConfig,
97}
98
99impl std::fmt::Debug for Device {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        // Provide a clean debug output that excludes internal BLE details
102        // (adapter, peripheral, notification_tasks, characteristics_cache)
103        // which are not useful for debugging application logic.
104        f.debug_struct("Device")
105            .field("name", &self.name)
106            .field("address", &self.address)
107            .field("device_type", &self.device_type)
108            .field("services_discovered", &self.services_discovered)
109            .finish_non_exhaustive()
110    }
111}
112
113/// Default timeout for BLE characteristic read operations.
114const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
115
116/// Default timeout for BLE characteristic write operations.
117const DEFAULT_WRITE_TIMEOUT: Duration = Duration::from_secs(10);
118
119/// Default timeout for BLE connection operations.
120const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
121
122/// Default timeout for service discovery.
123const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
124
125/// Default timeout for connection validation (keepalive check).
126const DEFAULT_VALIDATION_TIMEOUT: Duration = Duration::from_secs(3);
127
128/// Scan duration of the one device search that `connect`, `connect_with_timeout`,
129/// `connect_with_config` and `connect_with_adapter` run: scans of 5, 10 and 15 s.
130const CONNECT_SCAN_DURATION: Duration = Duration::from_secs(10);
131
132/// Current-readings characteristics to try, in order, for a device type.
133///
134/// Connection checks read them too, instead of Battery Level (0x2A19), which
135/// needs pairing.
136fn readings_characteristics(device_type: Option<DeviceType>) -> &'static [Uuid] {
137    match device_type {
138        Some(DeviceType::Aranet4) => &[CURRENT_READINGS_DETAIL],
139        Some(DeviceType::Aranet2 | DeviceType::AranetRadon | DeviceType::AranetRadiation) => {
140            &[CURRENT_READINGS_DETAIL_ALT]
141        }
142        // Unknown, or a type newer than this crate (`DeviceType` is
143        // `#[non_exhaustive]`): try the Aranet4 characteristic first.
144        None | Some(_) => &[CURRENT_READINGS_DETAIL, CURRENT_READINGS_DETAIL_ALT],
145    }
146}
147
148/// Reads the first of `candidates` that the device has, using `read`.
149///
150/// Moves to the next candidate only when `read` returns `CharacteristicNotFound`.
151/// Any other error, such as a timeout or a lost link, is returned at once.
152async fn read_first_available<F, Fut>(candidates: &[Uuid], mut read: F) -> Result<Vec<u8>>
153where
154    F: FnMut(Uuid) -> Fut,
155    Fut: Future<Output = Result<Vec<u8>>>,
156{
157    let Some((&last, earlier)) = candidates.split_last() else {
158        return Err(Error::Unsupported(
159            "no current-readings characteristic for this device type".into(),
160        ));
161    };
162    for &uuid in earlier {
163        match read(uuid).await {
164            Ok(data) => return Ok(data),
165            Err(Error::CharacteristicNotFound { .. }) => {
166                debug!("Reading characteristic {uuid} not found, trying the next one");
167            }
168            Err(e) => return Err(e),
169        }
170    }
171    read(last).await
172}
173
174/// Configuration for BLE connection timeouts and behavior.
175///
176/// Use this to customize timeout values for different environments.
177/// For example, increase timeouts in challenging RF environments
178/// (concrete walls, electromagnetic interference).
179///
180/// # Example
181///
182/// ```no_run
183/// use std::time::Duration;
184/// use aranet_core::device::ConnectionConfig;
185///
186/// // Create a config for challenging RF environments
187/// let config = ConnectionConfig::default()
188///     .connection_timeout(Duration::from_secs(20))
189///     .read_timeout(Duration::from_secs(15));
190/// ```
191#[derive(Debug, Clone)]
192pub struct ConnectionConfig {
193    /// Timeout for establishing a BLE connection.
194    pub connection_timeout: Duration,
195    /// Timeout for BLE read operations.
196    pub read_timeout: Duration,
197    /// Timeout for BLE write operations.
198    pub write_timeout: Duration,
199    /// Timeout for service discovery after connection.
200    ///
201    /// On Linux, when BlueZ is still discovering the services of a device it
202    /// has just connected (a first connection to a sensor with many
203    /// characteristics, such as an Aranet4), a connect waits for BlueZ to
204    /// finish for up to this long, but at least 20 s.
205    pub discovery_timeout: Duration,
206    /// Timeout for connection validation (keepalive) checks.
207    pub validation_timeout: Duration,
208}
209
210impl Default for ConnectionConfig {
211    fn default() -> Self {
212        Self {
213            connection_timeout: DEFAULT_CONNECT_TIMEOUT,
214            read_timeout: DEFAULT_READ_TIMEOUT,
215            write_timeout: DEFAULT_WRITE_TIMEOUT,
216            discovery_timeout: DEFAULT_DISCOVERY_TIMEOUT,
217            validation_timeout: DEFAULT_VALIDATION_TIMEOUT,
218        }
219    }
220}
221
222impl ConnectionConfig {
223    /// Create a new connection config with default values.
224    pub fn new() -> Self {
225        Self::default()
226    }
227
228    /// Create a config optimized for the current platform.
229    pub fn for_current_platform() -> Self {
230        let platform = crate::platform::PlatformConfig::for_current_platform();
231        Self {
232            connection_timeout: platform.recommended_connection_timeout,
233            read_timeout: platform.recommended_operation_timeout,
234            write_timeout: platform.recommended_operation_timeout,
235            discovery_timeout: platform.recommended_operation_timeout,
236            validation_timeout: DEFAULT_VALIDATION_TIMEOUT,
237        }
238    }
239
240    /// Create a config for challenging RF environments.
241    ///
242    /// Uses longer timeouts to accommodate signal interference,
243    /// thick walls, or long distances.
244    ///
245    /// These are the connection's timeouts only: [`Device::connect_with_config`]
246    /// still searches with scans of 5, 10 and 15 s (up to about 30 s).
247    /// To search longer for a weak sensor, use [`Device::connect_with_scan_options`]
248    /// with a longer `ScanOptions::duration`.
249    pub fn challenging_environment() -> Self {
250        Self {
251            connection_timeout: Duration::from_secs(90),
252            read_timeout: Duration::from_secs(30),
253            write_timeout: Duration::from_secs(15),
254            discovery_timeout: Duration::from_secs(30),
255            validation_timeout: Duration::from_secs(5),
256        }
257    }
258
259    /// Create a config for fast, reliable environments.
260    ///
261    /// Uses shorter timeouts for quicker failure detection
262    /// when devices are nearby with strong signals.
263    pub fn fast() -> Self {
264        Self {
265            connection_timeout: Duration::from_secs(8),
266            read_timeout: Duration::from_secs(5),
267            write_timeout: Duration::from_secs(5),
268            discovery_timeout: Duration::from_secs(5),
269            validation_timeout: Duration::from_secs(2),
270        }
271    }
272
273    /// Set the connection timeout.
274    #[must_use]
275    pub fn connection_timeout(mut self, timeout: Duration) -> Self {
276        self.connection_timeout = timeout;
277        self
278    }
279
280    /// Set the read timeout.
281    #[must_use]
282    pub fn read_timeout(mut self, timeout: Duration) -> Self {
283        self.read_timeout = timeout;
284        self
285    }
286
287    /// Set the write timeout.
288    #[must_use]
289    pub fn write_timeout(mut self, timeout: Duration) -> Self {
290        self.write_timeout = timeout;
291        self
292    }
293
294    /// Set the service discovery timeout.
295    #[must_use]
296    pub fn discovery_timeout(mut self, timeout: Duration) -> Self {
297        self.discovery_timeout = timeout;
298        self
299    }
300
301    /// Set the validation timeout.
302    #[must_use]
303    pub fn validation_timeout(mut self, timeout: Duration) -> Self {
304        self.validation_timeout = timeout;
305        self
306    }
307}
308
309/// Signal strength quality levels based on RSSI values.
310#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
311pub enum SignalQuality {
312    /// Signal too weak for reliable operation (< -85 dBm).
313    Poor,
314    /// Usable but may have issues (-85 to -75 dBm).
315    Fair,
316    /// Good signal strength (-75 to -60 dBm).
317    Good,
318    /// Excellent signal strength (> -60 dBm).
319    Excellent,
320}
321
322impl SignalQuality {
323    /// Determine signal quality from RSSI value in dBm.
324    ///
325    /// # Arguments
326    ///
327    /// * `rssi` - Signal strength in dBm (typically -30 to -100)
328    ///
329    /// # Returns
330    ///
331    /// The signal quality category.
332    pub fn from_rssi(rssi: i16) -> Self {
333        match rssi {
334            r if r > -60 => SignalQuality::Excellent,
335            r if r > -75 => SignalQuality::Good,
336            r if r > -85 => SignalQuality::Fair,
337            _ => SignalQuality::Poor,
338        }
339    }
340
341    /// Get a human-readable description of the signal quality.
342    pub fn description(&self) -> &'static str {
343        match self {
344            SignalQuality::Excellent => "Excellent signal",
345            SignalQuality::Good => "Good signal",
346            SignalQuality::Fair => "Fair signal - connection may be unstable",
347            SignalQuality::Poor => "Poor signal - consider moving closer",
348        }
349    }
350
351    /// Get recommended read delay for history downloads based on signal quality.
352    pub fn recommended_read_delay(&self) -> Duration {
353        match self {
354            SignalQuality::Excellent => Duration::from_millis(30),
355            SignalQuality::Good => Duration::from_millis(50),
356            SignalQuality::Fair => Duration::from_millis(100),
357            SignalQuality::Poor => Duration::from_millis(200),
358        }
359    }
360
361    /// Check if the signal is strong enough for reliable operations.
362    pub fn is_usable(&self) -> bool {
363        matches!(
364            self,
365            SignalQuality::Excellent | SignalQuality::Good | SignalQuality::Fair
366        )
367    }
368}
369
370impl Device {
371    /// Connect to an Aranet device by its address, identifier or full name.
372    ///
373    /// `identifier` must match exactly, as [`find_device`](crate::scan::find_device)
374    /// describes (case is ignored): a name must be the device's whole name, not part
375    /// of it. On macOS, where Bluetooth doesn't expose MAC addresses, use the
376    /// device's identifier (a CoreBluetooth UUID) or its name.
377    ///
378    /// The device is searched for with scans of 5, 10 and 15 s (up to about 30 s) and
379    /// connected with the default [`ConnectionConfig`]. See the "Timeouts" section of
380    /// [`Device`] for how long that can take.
381    ///
382    /// # Errors
383    ///
384    /// - [`Error::InvalidConfig`] if `identifier` is empty or blank, before
385    ///   Bluetooth is used.
386    /// - [`Error::DeviceNotFound`] with
387    ///   [`DeviceNotFoundReason::NoAdapter`](crate::error::DeviceNotFoundReason::NoAdapter)
388    ///   if there is no Bluetooth adapter.
389    /// - [`Error::DeviceNotFound`] if no nearby device matches `identifier` exactly,
390    ///   or several do; its reason says which, as
391    ///   [`find_device`](crate::scan::find_device) describes.
392    /// - Otherwise, an error from the Bluetooth adapter or the connection, such as
393    ///   [`Error::Timeout`] when a connect step runs out of time.
394    ///
395    /// # Example
396    ///
397    /// ```no_run
398    /// use aranet_core::device::Device;
399    ///
400    /// #[tokio::main]
401    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
402    ///     let device = Device::connect("Aranet4 12345").await?;
403    ///     println!("Connected to {:?}", device);
404    ///     Ok(())
405    /// }
406    /// ```
407    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
408    pub async fn connect(identifier: &str) -> Result<Self> {
409        Self::connect_with_config(identifier, ConnectionConfig::default()).await
410    }
411
412    /// Connect with a custom connection timeout.
413    ///
414    /// `identifier` must match exactly, and errors are returned, as
415    /// [`Device::connect`] describes.
416    ///
417    /// The device search uses scans of 5, 10 and 15 s (up to about 30 s).
418    /// `timeout` replaces only the connect timeout (`connection_timeout`); every other
419    /// timeout keeps its default. Use [`Device::connect_with_scan_options`] to change
420    /// the scan time as well.
421    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier, timeout_secs = timeout.as_secs()))]
422    pub async fn connect_with_timeout(identifier: &str, timeout: Duration) -> Result<Self> {
423        Self::connect_with_scan_options(
424            identifier,
425            ScanOptions::default().duration(CONNECT_SCAN_DURATION),
426            ConnectionConfig::default().connection_timeout(timeout),
427        )
428        .await
429    }
430
431    /// Connect to an Aranet device with full configuration.
432    ///
433    /// `identifier` must match exactly, and errors are returned, as
434    /// [`Device::connect`] describes.
435    ///
436    /// `config` sets every timeout of the connection itself. The device is searched for
437    /// with scans of 5, 10 and 15 s (up to about 30 s); use
438    /// [`Device::connect_with_scan_options`] to change the scan time too.
439    ///
440    /// # Example
441    ///
442    /// ```no_run
443    /// use std::time::Duration;
444    /// use aranet_core::device::{Device, ConnectionConfig};
445    ///
446    /// #[tokio::main]
447    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
448    ///     // Longer connect timeouts for a challenging RF environment. They don't
449    ///     // lengthen the search for the device.
450    ///     let config = ConnectionConfig::challenging_environment();
451    ///     let device = Device::connect_with_config("Aranet4 12345", config).await?;
452    ///     Ok(())
453    /// }
454    /// ```
455    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
456    pub async fn connect_with_config(identifier: &str, config: ConnectionConfig) -> Result<Self> {
457        Self::connect_with_scan_options(
458            identifier,
459            ScanOptions::default().duration(CONNECT_SCAN_DURATION),
460            config,
461        )
462        .await
463    }
464
465    /// Find `identifier` with `scan`, then connect with `config`.
466    ///
467    /// `identifier` must match exactly, and errors are returned, as
468    /// [`Device::connect`] describes.
469    ///
470    /// The search makes up to three scans of `scan.duration / 2`, `scan.duration` and
471    /// `1.5 × scan.duration` (at least 2, 4 and 6 s), so it takes up to about
472    /// 3 × `scan.duration`, plus any wait for another scan in the same process.
473    /// Only `scan.duration` is used: as in
474    /// [`find_device_with_options`](crate::scan::find_device_with_options), the search
475    /// ignores `scan`'s filter flags. On macOS every scan asks the Bluetooth stack
476    /// only for devices that advertise an Aranet service, as every current Aranet
477    /// sensor does.
478    /// A device that the adapter already knows from an earlier scan is used without
479    /// scanning. `config` sets the timeouts of the connection itself; see the
480    /// "Timeouts" section of [`Device`].
481    ///
482    /// # Example
483    ///
484    /// ```no_run
485    /// use std::time::Duration;
486    /// use aranet_core::device::{ConnectionConfig, Device};
487    /// use aranet_core::scan::ScanOptions;
488    ///
489    /// #[tokio::main]
490    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
491    ///     // Scan for 10, 20 and 30 s (up to about 60 s, twice the default
492    ///     // search), then allow 30 s to connect.
493    ///     let scan = ScanOptions::default().duration(Duration::from_secs(20));
494    ///     let config = ConnectionConfig::default().connection_timeout(Duration::from_secs(30));
495    ///     let device = Device::connect_with_scan_options("Aranet4 12345", scan, config).await?;
496    ///     device.disconnect().await?;
497    ///     Ok(())
498    /// }
499    /// ```
500    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier, scan_secs = scan.duration.as_secs()))]
501    pub async fn connect_with_scan_options(
502        identifier: &str,
503        scan: ScanOptions,
504        config: ConnectionConfig,
505    ) -> Result<Self> {
506        let (adapter, peripheral) = crate::scan::find_device_with_options(identifier, scan).await?;
507        Self::from_peripheral_with_config(adapter, peripheral, config).await
508    }
509
510    /// Connect to a device using an existing BLE adapter.
511    ///
512    /// This avoids creating a new btleplug `Manager` (and D-Bus connection) on
513    /// every call.  Prefer this over [`connect_with_config`](Self::connect_with_config)
514    /// in long-running services that poll devices repeatedly.
515    ///
516    /// Like [`connect_with_config`](Self::connect_with_config), it searches once with
517    /// scans of 5, 10 and 15 s (up to about 30 s), and `config` sets the timeouts of
518    /// the connection itself.
519    ///
520    /// `identifier` must match exactly, and errors are returned, as
521    /// [`Device::connect`] describes.
522    #[tracing::instrument(level = "info", skip_all, fields(identifier = %identifier))]
523    pub async fn connect_with_adapter(
524        adapter: Adapter,
525        identifier: &str,
526        config: ConnectionConfig,
527    ) -> Result<Self> {
528        let peripheral = crate::scan::find_device_with_adapter(
529            &adapter,
530            identifier,
531            ScanOptions::default().duration(CONNECT_SCAN_DURATION),
532        )
533        .await?;
534        Self::from_peripheral_with_config(adapter, peripheral, config).await
535    }
536
537    /// Create a Device from an already-discovered peripheral.
538    #[tracing::instrument(level = "info", skip_all)]
539    pub async fn from_peripheral(adapter: Adapter, peripheral: Peripheral) -> Result<Self> {
540        Self::from_peripheral_with_config(adapter, peripheral, ConnectionConfig::default()).await
541    }
542
543    /// Create a Device from an already-discovered peripheral with custom timeout.
544    #[tracing::instrument(level = "info", skip_all, fields(timeout_secs = connect_timeout.as_secs()))]
545    pub async fn from_peripheral_with_timeout(
546        adapter: Adapter,
547        peripheral: Peripheral,
548        connect_timeout: Duration,
549    ) -> Result<Self> {
550        let config = ConnectionConfig::default().connection_timeout(connect_timeout);
551        Self::from_peripheral_with_config(adapter, peripheral, config).await
552    }
553
554    /// Create a Device from an already-discovered peripheral with full configuration.
555    ///
556    /// If the connect fails or times out, the peripheral is disconnected before
557    /// the error is returned. If this future is dropped before it finishes (a
558    /// caller's timeout, a cancelled task), the peripheral is disconnected in
559    /// the background; that is best effort if the process is exiting. Either
560    /// way the disconnect waits at most 5 s for the Bluetooth stack to confirm
561    /// it.
562    ///
563    /// On Linux, if BlueZ doesn't list the sensor as paired, this pairs it first
564    /// (BlueZ's `Device1.Pair`, through an agent that exists only for that
565    /// pairing and approves only this sensor), which takes at most
566    /// `connection_timeout` plus the wait for BlueZ's service discovery
567    /// (`discovery_timeout`, but at least 20 s), plus 5 s. If pairing fails, a
568    /// warning with the `bluetoothctl` commands that fix it is logged and the
569    /// connection goes ahead unpaired. BlueZ then asks for pairing itself, so on
570    /// a host without a Bluetooth agent this connection's reads can time out. A
571    /// sensor that refused to pair isn't asked again for 10 minutes; after any
572    /// other failure, the next connection pairs again.
573    #[tracing::instrument(level = "info", skip_all, fields(connect_timeout = ?config.connection_timeout))]
574    pub async fn from_peripheral_with_config(
575        adapter: Adapter,
576        peripheral: Peripheral,
577        config: ConnectionConfig,
578    ) -> Result<Self> {
579        let cleanup = crate::link::cleanup_runtime().ok_or_else(|| {
580            Error::Io(std::io::Error::other(
581                "no tokio runtime available for Bluetooth cleanup",
582            ))
583        })?;
584
585        let crate::link::OpenLink {
586            pending,
587            services,
588            properties,
589        } = crate::link::connect(&peripheral, &config, &cleanup).await?;
590
591        // Build characteristics cache for O(1) lookups
592        let mut characteristics_cache = HashMap::new();
593        for service in &services {
594            debug!("  Service: {}", service.uuid);
595            for char in &service.characteristics {
596                debug!("    Characteristic: {}", char.uuid);
597                characteristics_cache.insert(char.uuid, char.clone());
598            }
599        }
600        debug!(
601            "Cached {} characteristics for fast lookup",
602            characteristics_cache.len()
603        );
604
605        let name = properties.as_ref().and_then(|p| p.local_name.clone());
606
607        // Get address - on macOS this may be 00:00:00:00:00:00, so we use peripheral ID as fallback
608        let address = properties
609            .as_ref()
610            .map(|p| create_identifier(&p.address.to_string(), &peripheral.id()))
611            .unwrap_or_else(|| format_peripheral_id(&peripheral.id()));
612
613        // Determine device type from name
614        let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
615
616        let device = Self {
617            adapter,
618            peripheral,
619            name,
620            address,
621            device_type,
622            services_discovered: true,
623            characteristics_cache: RwLock::new(characteristics_cache),
624            notification_tasks: crate::link::NotificationTasks::default(),
625            disconnected: AtomicBool::new(false),
626            config,
627        };
628        // Nothing between `connect` returning and here awaits, so the link is
629        // never left without an owner.
630        pending.disarm();
631        Ok(device)
632    }
633
634    /// Check if the device is connected (queries BLE stack state).
635    ///
636    /// Returns `false` if the Bluetooth stack reports an error or doesn't answer
637    /// within the connection's `validation_timeout` (3 s by default, set with
638    /// [`ConnectionConfig::validation_timeout`]). On macOS the stack stops
639    /// answering once the sensor has dropped the connection.
640    ///
641    /// Note: This only checks the BLE stack's connection state, which may be stale,
642    /// especially on macOS. For a more reliable check, use [`Self::validate_connection`].
643    pub async fn is_connected(&self) -> bool {
644        crate::link::is_connected(&self.peripheral, self.config.validation_timeout).await
645    }
646
647    /// Validate the connection by reading the current measurements.
648    ///
649    /// This reads the characteristic that [`Self::read_current`] uses, so the
650    /// check needs no pairing that a reading doesn't need (unpaired Aranet2 and
651    /// AranetRn+ sensors answer it) and never starts a pairing that a reading
652    /// wouldn't. It fails only if the read fails or takes longer than the
653    /// connection's `validation_timeout` (3 s by default, set with
654    /// [`ConnectionConfig::validation_timeout`]).
655    ///
656    /// This is more reliable than `is_connected()` as it actively verifies
657    /// the connection is working. It detects "zombie connections", where the
658    /// BLE stack thinks it's connected but the device is actually out of range.
659    ///
660    /// # Returns
661    ///
662    /// `true` if the connection is active and responsive, `false` otherwise.
663    pub async fn validate_connection(&self) -> bool {
664        matches!(
665            timeout(self.config.validation_timeout, self.read_current_bytes()).await,
666            Ok(Ok(_))
667        )
668    }
669
670    /// Check if the connection is alive by reading the current measurements.
671    ///
672    /// This is an alias for [`Self::validate_connection`] that better describes
673    /// the intent when used for connection health monitoring. Like it, it needs
674    /// no pairing that a reading doesn't need, and fails only if the read fails
675    /// or takes longer than the connection's `validation_timeout`.
676    ///
677    /// # Example
678    ///
679    /// ```ignore
680    /// // In a health monitor loop
681    /// if !device.is_connection_alive().await {
682    ///     // Connection lost, need to reconnect
683    /// }
684    /// ```
685    pub async fn is_connection_alive(&self) -> bool {
686        self.validate_connection().await
687    }
688
689    /// Get the current connection configuration.
690    pub fn config(&self) -> &ConnectionConfig {
691        &self.config
692    }
693
694    /// Get the current signal quality based on RSSI.
695    ///
696    /// Returns `None` if RSSI cannot be read.
697    pub async fn signal_quality(&self) -> Option<SignalQuality> {
698        self.read_rssi().await.ok().map(SignalQuality::from_rssi)
699    }
700
701    /// Disconnect from the device.
702    ///
703    /// This will:
704    /// 1. Abort all active notification handlers
705    /// 2. Disconnect from the BLE peripheral
706    ///
707    /// Returns [`Error::Timeout`] if the Bluetooth stack doesn't confirm the
708    /// disconnect within 5 s. The disconnect runs as a background task on
709    /// aranet-core's own runtime, or on the caller's runtime if aranet-core's
710    /// can't be started, so it completes even if this future is dropped, for
711    /// example by a caller's timeout. Returns [`Error::Io`] if there is no
712    /// runtime to run it on at all, or if the task panicked or its runtime
713    /// shut down before it finished.
714    ///
715    /// **Important:** You MUST call this method before dropping the Device
716    /// to ensure proper cleanup of BLE resources.
717    #[tracing::instrument(level = "info", skip(self), fields(device_name = ?self.name))]
718    pub async fn disconnect(&self) -> Result<()> {
719        info!("Disconnecting from device...");
720        self.disconnected.store(true, Ordering::SeqCst);
721
722        // Stop every notification callback.
723        self.notification_tasks.abort_all();
724
725        // `cleanup_runtime` is `None` only if aranet-core's runtime can't be
726        // started and this future isn't polled on a tokio runtime either.
727        // The time limit needs a tokio timer, so give up instead of panicking.
728        let runtime = crate::link::cleanup_runtime().ok_or_else(|| {
729            Error::Io(std::io::Error::other(
730                "no tokio runtime available to disconnect from the device",
731            ))
732        })?;
733        crate::link::disconnect_detached(&self.peripheral, &runtime).await
734    }
735
736    /// Get the device name.
737    pub fn name(&self) -> Option<&str> {
738        self.name.as_deref()
739    }
740
741    /// Get the device address or identifier.
742    ///
743    /// On Linux and Windows, this returns the Bluetooth MAC address (e.g., "AA:BB:CC:DD:EE:FF").
744    /// On macOS, this returns a UUID identifier since MAC addresses are not exposed.
745    pub fn address(&self) -> &str {
746        &self.address
747    }
748
749    /// Get the detected device type.
750    pub fn device_type(&self) -> Option<DeviceType> {
751        self.device_type
752    }
753
754    /// Read the current RSSI (signal strength) of the connection.
755    ///
756    /// Returns the RSSI in dBm. More negative values indicate weaker signals.
757    /// Typical values range from -30 (strong) to -90 (weak).
758    ///
759    /// Returns [`Error::Timeout`] if the Bluetooth stack doesn't answer within
760    /// the connection's `read_timeout` (10 s by default, set with
761    /// [`ConnectionConfig::read_timeout`]).
762    pub async fn read_rssi(&self) -> Result<i16> {
763        let properties = crate::link::bounded(
764            "read device properties",
765            self.config.read_timeout,
766            self.peripheral.properties(),
767        )
768        .await?;
769        properties
770            .and_then(|p| p.rssi)
771            .ok_or_else(|| Error::InvalidData("RSSI not available".to_string()))
772    }
773
774    /// Find a characteristic by UUID using the cached lookup table.
775    ///
776    /// Uses O(1) lookup from the characteristics cache built during service discovery.
777    /// Falls back to searching through services if the cache is empty (shouldn't happen
778    /// normally, but provides robustness).
779    async fn find_characteristic(&self, uuid: Uuid) -> Result<Characteristic> {
780        // Try cache first (O(1) lookup)
781        {
782            let cache = self.characteristics_cache.read().await;
783            if let Some(char) = cache.get(&uuid) {
784                return Ok(char.clone());
785            }
786
787            // If cache is populated but characteristic not found, it doesn't exist
788            if !cache.is_empty() {
789                return Err(Error::characteristic_not_found(
790                    uuid.to_string(),
791                    self.peripheral.services().len(),
792                ));
793            }
794        }
795
796        // Fallback: search services directly (shouldn't happen in normal operation)
797        warn!(
798            "Characteristics cache empty, falling back to service search for {}",
799            uuid
800        );
801        let services = self.peripheral.services();
802        let service_count = services.len();
803
804        // First try Aranet-specific services
805        for service in &services {
806            if service.uuid == SAF_TEHNIKA_SERVICE_NEW || service.uuid == SAF_TEHNIKA_SERVICE_OLD {
807                for char in &service.characteristics {
808                    if char.uuid == uuid {
809                        return Ok(char.clone());
810                    }
811                }
812            }
813        }
814
815        // Then try standard services (GAP, Device Info, Battery)
816        for service in &services {
817            if service.uuid == GAP_SERVICE
818                || service.uuid == DEVICE_INFO_SERVICE
819                || service.uuid == BATTERY_SERVICE
820            {
821                for char in &service.characteristics {
822                    if char.uuid == uuid {
823                        return Ok(char.clone());
824                    }
825                }
826            }
827        }
828
829        // Finally search all services
830        for service in &services {
831            for char in &service.characteristics {
832                if char.uuid == uuid {
833                    return Ok(char.clone());
834                }
835            }
836        }
837
838        Err(Error::characteristic_not_found(
839            uuid.to_string(),
840            service_count,
841        ))
842    }
843
844    /// Read a characteristic value by UUID.
845    ///
846    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
847    /// The timeout is controlled by [`ConnectionConfig::read_timeout`].
848    pub async fn read_characteristic(&self, uuid: Uuid) -> Result<Vec<u8>> {
849        let characteristic = self.find_characteristic(uuid).await?;
850        let data = timeout(
851            self.config.read_timeout,
852            self.peripheral.read(&characteristic),
853        )
854        .await
855        .map_err(|_| Error::Timeout {
856            operation: format!("read characteristic {}", uuid),
857            duration: self.config.read_timeout,
858        })??;
859        Ok(data)
860    }
861
862    /// Read a characteristic value with a custom timeout.
863    ///
864    /// Use this when you need a different timeout than the default,
865    /// for example when reading large data.
866    pub async fn read_characteristic_with_timeout(
867        &self,
868        uuid: Uuid,
869        read_timeout: Duration,
870    ) -> Result<Vec<u8>> {
871        let characteristic = self.find_characteristic(uuid).await?;
872        let data = timeout(read_timeout, self.peripheral.read(&characteristic))
873            .await
874            .map_err(|_| Error::Timeout {
875                operation: format!("read characteristic {}", uuid),
876                duration: read_timeout,
877            })??;
878        Ok(data)
879    }
880
881    /// Write a value to a characteristic.
882    ///
883    /// This method includes a timeout to prevent indefinite hangs on BLE operations.
884    /// The timeout is controlled by [`ConnectionConfig::write_timeout`].
885    pub async fn write_characteristic(&self, uuid: Uuid, data: &[u8]) -> Result<()> {
886        let characteristic = self.find_characteristic(uuid).await?;
887        timeout(
888            self.config.write_timeout,
889            self.peripheral
890                .write(&characteristic, data, WriteType::WithResponse),
891        )
892        .await
893        .map_err(|_| Error::Timeout {
894            operation: format!("write characteristic {}", uuid),
895            duration: self.config.write_timeout,
896        })??;
897        Ok(())
898    }
899
900    /// Write a value to a characteristic with a custom timeout.
901    pub async fn write_characteristic_with_timeout(
902        &self,
903        uuid: Uuid,
904        data: &[u8],
905        write_timeout: Duration,
906    ) -> Result<()> {
907        let characteristic = self.find_characteristic(uuid).await?;
908        timeout(
909            write_timeout,
910            self.peripheral
911                .write(&characteristic, data, WriteType::WithResponse),
912        )
913        .await
914        .map_err(|_| Error::Timeout {
915            operation: format!("write characteristic {}", uuid),
916            duration: write_timeout,
917        })??;
918        Ok(())
919    }
920
921    /// Raw current-readings bytes; on CharacteristicNotFound tries the next candidate.
922    ///
923    /// Reads the characteristics from `readings_characteristics` in order, as
924    /// `read_first_available` describes.
925    async fn read_current_bytes(&self) -> Result<Vec<u8>> {
926        read_first_available(readings_characteristics(self.device_type), |uuid| {
927            self.read_characteristic(uuid)
928        })
929        .await
930    }
931
932    /// Read current sensor measurements.
933    ///
934    /// Automatically selects the correct characteristic UUID based on device type:
935    /// - Aranet4 uses `f0cd3001`
936    /// - Aranet2, Radon, Radiation use `f0cd3003`
937    /// - an unknown type tries `f0cd3001`, then `f0cd3003` if the device doesn't have it
938    #[tracing::instrument(level = "debug", skip(self), fields(device_name = ?self.name, device_type = ?self.device_type))]
939    pub async fn read_current(&self) -> Result<CurrentReading> {
940        let data = self.read_current_bytes().await?;
941
942        // Parse based on device type.
943        let device_type = match self.device_type {
944            Some(dt) => dt,
945            None => {
946                warn!(
947                    "Device type unknown for {}; defaulting to Aranet4 — \
948                     readings may be incorrect if this is a different model",
949                    self.name().unwrap_or("unknown")
950                );
951                DeviceType::Aranet4
952            }
953        };
954        crate::readings::parse_reading_for_device(&data, device_type)
955    }
956
957    /// Read the battery level (0-100).
958    #[tracing::instrument(level = "debug", skip(self))]
959    pub async fn read_battery(&self) -> Result<u8> {
960        let data = self.read_characteristic(BATTERY_LEVEL).await?;
961        if data.is_empty() {
962            return Err(Error::InvalidData("Empty battery data".to_string()));
963        }
964        Ok(data[0])
965    }
966
967    /// Read device information.
968    ///
969    /// This method reads all device info characteristics in parallel for better performance.
970    #[tracing::instrument(level = "debug", skip(self))]
971    pub async fn read_device_info(&self) -> Result<DeviceInfo> {
972        fn read_string(data: Vec<u8>) -> String {
973            String::from_utf8(data)
974                .unwrap_or_default()
975                .trim_end_matches('\0')
976                .to_string()
977        }
978
979        // Read all characteristics in parallel for better performance
980        let (
981            name_result,
982            model_result,
983            serial_result,
984            firmware_result,
985            hardware_result,
986            software_result,
987            manufacturer_result,
988        ) = tokio::join!(
989            self.read_characteristic(DEVICE_NAME),
990            self.read_characteristic(MODEL_NUMBER),
991            self.read_characteristic(SERIAL_NUMBER),
992            self.read_characteristic(FIRMWARE_REVISION),
993            self.read_characteristic(HARDWARE_REVISION),
994            self.read_characteristic(SOFTWARE_REVISION),
995            self.read_characteristic(MANUFACTURER_NAME),
996        );
997
998        let name = name_result
999            .map(read_string)
1000            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
1001
1002        let model = model_result.map(read_string).unwrap_or_default();
1003        let serial = serial_result.map(read_string).unwrap_or_default();
1004        let firmware = firmware_result.map(read_string).unwrap_or_default();
1005        let hardware = hardware_result.map(read_string).unwrap_or_default();
1006        let software = software_result.map(read_string).unwrap_or_default();
1007        let manufacturer = manufacturer_result.map(read_string).unwrap_or_default();
1008
1009        Ok(DeviceInfo {
1010            name,
1011            model,
1012            serial,
1013            firmware,
1014            hardware,
1015            software,
1016            manufacturer,
1017        })
1018    }
1019
1020    /// Read essential device information only.
1021    ///
1022    /// This is a faster alternative to [`Self::read_device_info`] that only reads
1023    /// the most critical characteristics: name, serial number, and firmware version.
1024    /// Use this for faster startup when full device info isn't needed immediately.
1025    #[tracing::instrument(level = "debug", skip(self))]
1026    pub async fn read_device_info_essential(&self) -> Result<DeviceInfo> {
1027        fn read_string(data: Vec<u8>) -> String {
1028            String::from_utf8(data)
1029                .unwrap_or_default()
1030                .trim_end_matches('\0')
1031                .to_string()
1032        }
1033
1034        // Only read the essential characteristics in parallel
1035        let (name_result, serial_result, firmware_result) = tokio::join!(
1036            self.read_characteristic(DEVICE_NAME),
1037            self.read_characteristic(SERIAL_NUMBER),
1038            self.read_characteristic(FIRMWARE_REVISION),
1039        );
1040
1041        let name = name_result
1042            .map(read_string)
1043            .unwrap_or_else(|_| self.name.clone().unwrap_or_default());
1044        let serial = serial_result.map(read_string).unwrap_or_default();
1045        let firmware = firmware_result.map(read_string).unwrap_or_default();
1046
1047        Ok(DeviceInfo {
1048            name,
1049            model: String::new(),
1050            serial,
1051            firmware,
1052            hardware: String::new(),
1053            software: String::new(),
1054            manufacturer: String::new(),
1055        })
1056    }
1057
1058    /// Subscribe to notifications on a characteristic.
1059    ///
1060    /// The callback is called on a background task with the value of each
1061    /// notification the characteristic sends, until
1062    /// [`unsubscribe_from_notifications`](Self::unsubscribe_from_notifications)
1063    /// or [`disconnect`](Self::disconnect) is called or the device is dropped.
1064    ///
1065    /// Subscribing to the same characteristic again replaces its callback. If
1066    /// subscribing fails, the previous callback keeps running.
1067    ///
1068    /// Opening the notification stream and enabling notifications on the
1069    /// device each give up with [`Error::Timeout`] after the connection's
1070    /// `write_timeout` (set with [`ConnectionConfig::write_timeout`]).
1071    pub async fn subscribe_to_notifications<F>(&self, uuid: Uuid, callback: F) -> Result<()>
1072    where
1073        F: Fn(&[u8]) + Send + Sync + 'static,
1074    {
1075        let characteristic = self.find_characteristic(uuid).await?;
1076        self.notification_tasks
1077            .subscribe(
1078                &self.peripheral,
1079                &characteristic,
1080                self.config.write_timeout,
1081                callback,
1082            )
1083            .await
1084    }
1085
1086    /// Unsubscribe from notifications on a characteristic.
1087    ///
1088    /// Stops the characteristic's callback first, then tells the device to
1089    /// stop sending notifications. Once this returns, the callback is not
1090    /// called again, even if the device call fails.
1091    ///
1092    /// Waiting for the callback to stop and the device call each give up
1093    /// after the connection's `write_timeout` (set with
1094    /// [`ConnectionConfig::write_timeout`]); the device call then fails with
1095    /// [`Error::Timeout`].
1096    pub async fn unsubscribe_from_notifications(&self, uuid: Uuid) -> Result<()> {
1097        let characteristic = self.find_characteristic(uuid).await?;
1098        self.notification_tasks
1099            .unsubscribe(&self.peripheral, &characteristic, self.config.write_timeout)
1100            .await
1101    }
1102
1103    /// Get the number of cached characteristics.
1104    ///
1105    /// This is useful for debugging and testing to verify service discovery worked.
1106    pub async fn cached_characteristic_count(&self) -> usize {
1107        self.characteristics_cache.read().await.len()
1108    }
1109}
1110
1111// NOTE: Drop performs best-effort cleanup if disconnect() was not called.
1112// The disconnect is spawned on aranet-core's own runtime (`aranet-ble`) when it
1113// can be started, so it runs even if the caller's runtime is shutting down, and
1114// it gives up after 5 s if the Bluetooth stack never confirms. It can still be
1115// cut short when the process exits. For reliable cleanup, callers SHOULD
1116// explicitly call `device.disconnect().await` before dropping the Device.
1117//
1118// The cleanup behavior:
1119// 1. Aborts all notification handlers (sync operation)
1120// 2. Spawns a time-limited disconnect of the peripheral on aranet-core's runtime (best-effort)
1121// 3. Logs a warning about the implicit cleanup
1122//
1123// For automatic cleanup, consider using `ReconnectingDevice` which manages the lifecycle.
1124
1125impl Drop for Device {
1126    fn drop(&mut self) {
1127        if !self.disconnected.load(Ordering::SeqCst) {
1128            // Mark as disconnected to prevent double-cleanup
1129            self.disconnected.store(true, Ordering::SeqCst);
1130
1131            // Log warning about implicit cleanup
1132            warn!(
1133                device_name = ?self.name,
1134                device_address = %self.address,
1135                "Device dropped without calling disconnect() - performing best-effort cleanup. \
1136                 For reliable cleanup, call device.disconnect().await before dropping."
1137            );
1138
1139            // Abort every notification task. The task map's lock is never
1140            // held across an await, so unlike the old `try_lock` this is
1141            // never skipped.
1142            self.notification_tasks.abort_all();
1143
1144            // Spawn a best-effort, time-limited disconnect, on aranet-core's own
1145            // runtime when it can be started, so it outlives the caller's runtime
1146            let peripheral = self.peripheral.clone();
1147            let address = self.address.clone();
1148
1149            if let Some(runtime) = crate::link::cleanup_runtime() {
1150                runtime.spawn(async move {
1151                    match crate::link::disconnect(&peripheral).await {
1152                        Ok(()) => debug!(
1153                            device_address = %address,
1154                            "Best-effort disconnect completed"
1155                        ),
1156                        Err(e) => debug!(
1157                            device_address = %address,
1158                            error = %e,
1159                            "Best-effort disconnect failed (device may already be disconnected)"
1160                        ),
1161                    }
1162                });
1163            }
1164        }
1165    }
1166}
1167
1168impl AranetDevice for Device {
1169    // --- Connection Management ---
1170
1171    async fn is_connected(&self) -> bool {
1172        Device::is_connected(self).await
1173    }
1174
1175    async fn disconnect(&self) -> Result<()> {
1176        Device::disconnect(self).await
1177    }
1178
1179    // --- Device Identity ---
1180
1181    fn name(&self) -> Option<&str> {
1182        Device::name(self)
1183    }
1184
1185    fn address(&self) -> &str {
1186        Device::address(self)
1187    }
1188
1189    fn device_type(&self) -> Option<DeviceType> {
1190        Device::device_type(self)
1191    }
1192
1193    // --- Current Readings ---
1194
1195    async fn read_current(&self) -> Result<CurrentReading> {
1196        Device::read_current(self).await
1197    }
1198
1199    async fn read_device_info(&self) -> Result<DeviceInfo> {
1200        Device::read_device_info(self).await
1201    }
1202
1203    async fn read_rssi(&self) -> Result<i16> {
1204        Device::read_rssi(self).await
1205    }
1206
1207    // --- Battery ---
1208
1209    async fn read_battery(&self) -> Result<u8> {
1210        Device::read_battery(self).await
1211    }
1212
1213    // --- History ---
1214
1215    async fn get_history_info(&self) -> Result<crate::history::HistoryInfo> {
1216        Device::get_history_info(self).await
1217    }
1218
1219    async fn download_history(&self) -> Result<Vec<aranet_types::HistoryRecord>> {
1220        Device::download_history(self).await
1221    }
1222
1223    async fn download_history_with_options(
1224        &self,
1225        options: crate::history::HistoryOptions,
1226    ) -> Result<Vec<aranet_types::HistoryRecord>> {
1227        Device::download_history_with_options(self, options).await
1228    }
1229
1230    // --- Settings ---
1231
1232    async fn get_interval(&self) -> Result<crate::settings::MeasurementInterval> {
1233        Device::get_interval(self).await
1234    }
1235
1236    async fn set_interval(&self, interval: crate::settings::MeasurementInterval) -> Result<()> {
1237        Device::set_interval(self, interval).await
1238    }
1239
1240    async fn get_calibration(&self) -> Result<crate::settings::CalibrationData> {
1241        Device::get_calibration(self).await
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248
1249    use futures::FutureExt;
1250
1251    #[test]
1252    fn readings_characteristics_follow_the_device_type() {
1253        assert_eq!(
1254            readings_characteristics(Some(DeviceType::Aranet4)),
1255            &[CURRENT_READINGS_DETAIL]
1256        );
1257        for device_type in [
1258            DeviceType::Aranet2,
1259            DeviceType::AranetRadon,
1260            DeviceType::AranetRadiation,
1261        ] {
1262            assert_eq!(
1263                readings_characteristics(Some(device_type)),
1264                &[CURRENT_READINGS_DETAIL_ALT],
1265                "{device_type:?}"
1266            );
1267        }
1268        // Unknown type: the Aranet4 characteristic first, as `read_current` always did.
1269        assert_eq!(
1270            readings_characteristics(None),
1271            &[CURRENT_READINGS_DETAIL, CURRENT_READINGS_DETAIL_ALT]
1272        );
1273
1274        // Battery Level (0x2A19) needs pairing, so a connection check must never read it.
1275        for device_type in [
1276            None,
1277            Some(DeviceType::Aranet4),
1278            Some(DeviceType::Aranet2),
1279            Some(DeviceType::AranetRadon),
1280            Some(DeviceType::AranetRadiation),
1281        ] {
1282            assert!(
1283                !readings_characteristics(device_type).contains(&BATTERY_LEVEL),
1284                "{device_type:?} reads Battery Level"
1285            );
1286        }
1287    }
1288
1289    /// Runs `read_first_available` over `candidates` with a reader that gives
1290    /// the scripted `answers` in order. Returns the result and the
1291    /// characteristics the reader was asked for.
1292    fn read_scripted(
1293        candidates: &[Uuid],
1294        answers: Vec<Result<Vec<u8>>>,
1295    ) -> (Result<Vec<u8>>, Vec<Uuid>) {
1296        let mut answers = answers.into_iter();
1297        let mut asked = Vec::new();
1298        let result = read_first_available(candidates, |uuid| {
1299            asked.push(uuid);
1300            std::future::ready(answers.next().expect("read more often than scripted"))
1301        })
1302        .now_or_never()
1303        .expect("scripted reads finish at once");
1304        (result, asked)
1305    }
1306
1307    fn not_found(uuid: Uuid) -> Error {
1308        Error::characteristic_not_found(uuid.to_string(), 6)
1309    }
1310
1311    #[test]
1312    fn a_missing_characteristic_moves_on_to_the_next_candidate() {
1313        let both = [CURRENT_READINGS_DETAIL, CURRENT_READINGS_DETAIL_ALT];
1314
1315        let (result, asked) = read_scripted(&both, vec![Ok(vec![1])]);
1316        assert_eq!(result.unwrap(), [1]);
1317        assert_eq!(asked, [CURRENT_READINGS_DETAIL]);
1318
1319        let (result, asked) = read_scripted(
1320            &both,
1321            vec![Err(not_found(CURRENT_READINGS_DETAIL)), Ok(vec![2])],
1322        );
1323        assert_eq!(result.unwrap(), [2]);
1324        assert_eq!(asked, both);
1325    }
1326
1327    #[test]
1328    fn any_other_error_ends_the_search() {
1329        let (result, asked) = read_scripted(
1330            &[CURRENT_READINGS_DETAIL, CURRENT_READINGS_DETAIL_ALT],
1331            vec![Err(Error::timeout(
1332                "read characteristic f0cd3001",
1333                Duration::from_secs(10),
1334            ))],
1335        );
1336        assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
1337        assert_eq!(asked, [CURRENT_READINGS_DETAIL]);
1338    }
1339
1340    #[test]
1341    fn the_readings_read_never_asks_for_battery_level() {
1342        for device_type in [
1343            None,
1344            Some(DeviceType::Aranet4),
1345            Some(DeviceType::Aranet2),
1346            Some(DeviceType::AranetRadon),
1347            Some(DeviceType::AranetRadiation),
1348        ] {
1349            let candidates = readings_characteristics(device_type);
1350            let answers = candidates
1351                .iter()
1352                .map(|&uuid| Err(not_found(uuid)))
1353                .collect();
1354            let (result, asked) = read_scripted(candidates, answers);
1355            assert!(
1356                matches!(result, Err(Error::CharacteristicNotFound { .. })),
1357                "{device_type:?}: {result:?}"
1358            );
1359            assert_eq!(asked, candidates, "{device_type:?}");
1360            assert!(!asked.contains(&BATTERY_LEVEL), "{device_type:?}");
1361        }
1362    }
1363}