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}