aranet_core/
scan.rs

1//! Device discovery and scanning.
2//!
3//! This module provides functionality to scan for Aranet devices
4//! using Bluetooth Low Energy.
5//!
6//! Scans in one process run one at a time: each scan window takes a
7//! process-wide permit and releases it only after the scan has stopped. A
8//! window runs on aranet-core's background runtime and stops as soon as its
9//! caller is dropped.
10
11use std::ops::ControlFlow;
12use std::sync::{Arc, LazyLock};
13use std::time::Duration;
14
15use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
16use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
17use tokio::runtime::Handle;
18use tokio::sync::RwLock;
19use tokio::time::sleep;
20use tokio_util::sync::CancellationToken;
21use tracing::{debug, info, warn};
22
23/// Cached BLE manager — avoids creating a new D-Bus connection on every call.
24///
25/// Using `RwLock<Option<Manager>>` instead of `OnceCell` so the manager can
26/// be re-created if the underlying D-Bus connection dies (e.g., dbus-daemon
27/// restart, adapter reset).
28static MANAGER: RwLock<Option<Manager>> = RwLock::const_new(None);
29
30/// Get or create the shared BLE manager.
31async fn shared_manager() -> Result<Manager> {
32    // Fast path: read lock to return existing manager.
33    {
34        let guard = MANAGER.read().await;
35        if let Some(m) = guard.as_ref() {
36            return Ok(m.clone());
37        }
38    }
39    // Slow path: create a new manager under write lock.
40    let mut guard = MANAGER.write().await;
41    // Double-check after acquiring write lock.
42    if let Some(m) = guard.as_ref() {
43        return Ok(m.clone());
44    }
45    // bluez-async spawns the D-Bus connection's only I/O task on the runtime
46    // that creates the manager. On the caller's runtime that task would die when
47    // the runtime shuts down, and every later call on the cached manager would
48    // wait out the 30 s D-Bus timeout and fail.
49    let m = crate::runtime::run(Manager::new()).await??;
50    *guard = Some(m.clone());
51    Ok(m)
52}
53
54/// Reset the cached manager, forcing the next call to create a fresh one.
55///
56/// Call this when the D-Bus connection appears to be dead (e.g., adapter
57/// enumeration fails with a connection error).
58async fn reset_manager() {
59    let mut guard = MANAGER.write().await;
60    if guard.take().is_some() {
61        warn!("BLE manager reset — next operation will create a new D-Bus connection");
62    }
63}
64
65use crate::error::{Error, Result};
66use crate::util::create_identifier;
67use crate::uuid::{MANUFACTURER_ID, SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD};
68use aranet_types::DeviceType;
69
70/// Progress update for device finding operations.
71#[derive(Debug, Clone)]
72pub enum FindProgress {
73    /// Found the device among the devices the adapter already knows, without
74    /// scanning for it: before the first scan, or after waiting for another
75    /// search's scan.
76    CacheHit,
77    /// Starting scan attempt.
78    ScanAttempt {
79        /// Current attempt number (1-based).
80        attempt: u32,
81        /// Total number of attempts.
82        total: u32,
83        /// Duration of this scan attempt.
84        duration_secs: u64,
85    },
86    /// Device found on specific attempt.
87    Found { attempt: u32 },
88    /// Attempt failed, will retry.
89    RetryNeeded { attempt: u32 },
90}
91
92/// Callback type for progress updates during device finding.
93pub type ProgressCallback = Box<dyn Fn(FindProgress) + Send + Sync>;
94
95/// Information about a discovered Aranet device.
96#[derive(Debug, Clone)]
97pub struct DiscoveredDevice {
98    /// The device name (e.g., "Aranet4 12345").
99    pub name: Option<String>,
100    /// The peripheral ID for connecting.
101    pub id: PeripheralId,
102    /// The BLE address as a string (may be zeros on macOS, use `id` instead).
103    pub address: String,
104    /// A connection identifier (peripheral ID on macOS, address on other platforms).
105    pub identifier: String,
106    /// RSSI signal strength.
107    pub rssi: Option<i16>,
108    /// Device type if detected from advertisement.
109    pub device_type: Option<DeviceType>,
110    /// Whether the device is connectable.
111    pub is_aranet: bool,
112    /// Raw manufacturer data from advertisement (if available).
113    pub manufacturer_data: Option<Vec<u8>>,
114}
115
116/// Options for scanning.
117#[derive(Debug, Clone)]
118pub struct ScanOptions {
119    /// How long to scan for devices.
120    pub duration: Duration,
121    /// Only return devices that appear to be Aranet devices.
122    pub filter_aranet_only: bool,
123    /// Use targeted BLE scan filter for Aranet service UUIDs.
124    /// This reduces noise from non-Aranet devices but may not work on all platforms.
125    pub use_service_filter: bool,
126}
127
128impl Default for ScanOptions {
129    fn default() -> Self {
130        Self {
131            duration: Duration::from_secs(5),
132            filter_aranet_only: true,
133            // Default to false for maximum compatibility - service filtering
134            // may not work on all platforms/adapters
135            use_service_filter: false,
136        }
137    }
138}
139
140impl ScanOptions {
141    /// Create new scan options with defaults.
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Set the scan duration.
147    pub fn duration(mut self, duration: Duration) -> Self {
148        self.duration = duration;
149        self
150    }
151
152    /// Set scan duration in seconds.
153    pub fn duration_secs(mut self, secs: u64) -> Self {
154        self.duration = Duration::from_secs(secs);
155        self
156    }
157
158    /// Set whether to filter for Aranet devices only.
159    pub fn filter_aranet_only(mut self, filter: bool) -> Self {
160        self.filter_aranet_only = filter;
161        self
162    }
163
164    /// Scan for all BLE devices, not just Aranet.
165    pub fn all_devices(self) -> Self {
166        self.filter_aranet_only(false)
167    }
168
169    /// Enable or disable BLE service UUID filtering.
170    ///
171    /// When enabled, the BLE scan will filter for Aranet service UUIDs at the
172    /// adapter level, reducing noise from non-Aranet devices. This may not
173    /// work on all platforms or with all BLE adapters.
174    ///
175    /// Default: `false` (for maximum compatibility)
176    pub fn use_service_filter(mut self, enable: bool) -> Self {
177        self.use_service_filter = enable;
178        self
179    }
180
181    /// Create optimized scan options for finding Aranet devices quickly.
182    ///
183    /// Uses service UUID filtering if available and a shorter scan duration.
184    pub fn optimized() -> Self {
185        Self {
186            duration: Duration::from_secs(3),
187            filter_aranet_only: true,
188            use_service_filter: true,
189        }
190    }
191}
192
193/// Adapter reused by [`get_adapter`] on macOS.
194///
195/// On CoreBluetooth every `Manager::adapters()` call starts a new
196/// `CBCentralManager` on its own OS thread that never exits, so creating an
197/// adapter per connection leaks a thread per poll. Other platforms create
198/// adapters cheaply and stay uncached, so a dead D-Bus connection can still
199/// be recovered by `reset_manager`.
200///
201/// The adapter is created on aranet-core's background runtime (`crate::runtime`),
202/// because btleplug runs its event loop (device discovery, and each peripheral's
203/// notification task) on the runtime that creates it. Created on a caller's
204/// runtime, it would stop seeing devices, with no error, once that runtime shut
205/// down, which happens after every `#[tokio::test]` and in any program that
206/// builds a runtime per call.
207static ADAPTER: RwLock<Option<Adapter>> = RwLock::const_new(None);
208
209/// Get the first available Bluetooth adapter.
210///
211/// The adapter, and the Bluetooth manager behind it, are created on aranet-core's
212/// background runtime (thread `aranet-ble`), so they keep working after the caller's
213/// tokio runtime shuts down. On macOS the adapter is also created once, shared by
214/// every caller in the process, and replaced if its CoreBluetooth thread stops.
215pub async fn get_adapter() -> Result<Adapter> {
216    if cfg!(target_os = "macos") {
217        cached_adapter().await
218    } else {
219        crate::runtime::run(create_adapter()).await?
220    }
221}
222
223async fn cached_adapter() -> Result<Adapter> {
224    let cached = ADAPTER.read().await.clone();
225    if let Some(adapter) = cached
226        && adapter_thread_is_running(&adapter).await
227    {
228        return Ok(adapter);
229    }
230    let mut guard = ADAPTER.write().await;
231    if let Some(adapter) = guard.as_ref() {
232        if adapter_thread_is_running(adapter).await {
233            return Ok(adapter.clone());
234        }
235        warn!("CoreBluetooth adapter thread has stopped; creating a new adapter");
236    }
237    let adapter = crate::runtime::run(create_adapter()).await??;
238    *guard = Some(adapter.clone());
239    Ok(adapter)
240}
241
242/// Whether the cached adapter's CoreBluetooth thread still takes requests.
243///
244/// btleplug's CoreBluetooth thread can panic (for example when services are
245/// discovered after a connect has timed out). Every later request on that
246/// adapter then fails with "Channel closed", so it has to be replaced. A reply
247/// that is merely slow keeps the adapter: only a closed channel proves the
248/// thread is gone.
249async fn adapter_thread_is_running(adapter: &Adapter) -> bool {
250    let adapter = adapter.clone();
251    // Run on aranet-core's runtime so the timeout works even if the caller's
252    // runtime has no time driver.
253    let state = crate::runtime::run(async move {
254        tokio::time::timeout(Duration::from_secs(2), adapter.adapter_state()).await
255    })
256    .await;
257    match state {
258        Ok(Ok(Err(e))) => {
259            debug!("Cached Bluetooth adapter is unusable: {e}");
260            false
261        }
262        _ => true,
263    }
264}
265
266async fn create_adapter() -> Result<Adapter> {
267    use crate::error::DeviceNotFoundReason;
268
269    let manager = shared_manager().await?;
270    let adapters = match manager.adapters().await {
271        Ok(a) => a,
272        Err(e) => {
273            // The D-Bus connection may have died — reset the cached manager
274            // so the next call creates a fresh connection.
275            reset_manager().await;
276            return Err(e.into());
277        }
278    };
279
280    adapters
281        .into_iter()
282        .next()
283        .ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
284}
285
286/// Starts and stops a Bluetooth scan: btleplug's [`Adapter`] in production, a
287/// fake in the tests.
288pub(crate) trait ScanControl: Clone + Send + Sync + 'static {
289    /// Start scanning. BlueZ fails with `org.bluez.Error.InProgress` if this
290    /// process is already scanning.
291    fn start(&self, filter: ScanFilter) -> impl Future<Output = Result<()>> + Send;
292
293    /// Stop scanning.
294    fn stop(&self) -> impl Future<Output = Result<()>> + Send;
295}
296
297impl ScanControl for Adapter {
298    async fn start(&self, filter: ScanFilter) -> Result<()> {
299        Central::start_scan(self, filter).await?;
300        Ok(())
301    }
302
303    async fn stop(&self) -> Result<()> {
304        Central::stop_scan(self).await?;
305        Ok(())
306    }
307}
308
309/// Lets one scan window run at a time. Whoever holds its [`ScanPermit`] may scan.
310///
311/// A scan is process-wide: BlueZ gives each D-Bus client (this whole process)
312/// one discovery session, and on macOS the adapter from `get_adapter` is one
313/// `CBCentralManager` shared by the whole process, so one caller's stop ends
314/// every caller's scan. tokio's mutex hands the permit out in the order it was
315/// asked for.
316#[derive(Clone, Default)]
317pub(crate) struct ScanLock(Arc<tokio::sync::Mutex<()>>);
318
319impl ScanLock {
320    /// Wait for the permit.
321    pub(crate) async fn acquire(&self) -> ScanPermit {
322        ScanPermit {
323            _guard: Arc::clone(&self.0).lock_owned().await,
324        }
325    }
326}
327
328/// Permission to scan, from [`ScanLock::acquire`]. The next caller can scan once
329/// it is dropped, so it must be held until the scan has stopped.
330///
331/// While holding a permit, never wait for another lock or another scan: only
332/// start, stop, sleep and read the adapter's known peripherals. Callers may hold
333/// their own locks while they wait for the permit, because its holder never
334/// waits for them.
335pub(crate) struct ScanPermit {
336    _guard: tokio::sync::OwnedMutexGuard<()>,
337}
338
339static SCAN_LOCK: LazyLock<ScanLock> = LazyLock::new(ScanLock::default);
340
341/// The [`ScanLock`] that every scan in this process takes. See [`ScanPermit`]
342/// for what its holder may wait for.
343pub(crate) fn scan_lock() -> &'static ScanLock {
344    &SCAN_LOCK
345}
346
347/// One scan window: start, wait `duration`, stop.
348///
349/// The window runs as a task on `runtime`, so it finishes even if this future is
350/// dropped (a timeout, an aborted task, or a caller whose runtime shuts down).
351/// Dropping the future ends the wait early, but the scan is still stopped, and
352/// `permit` is released only once `stop` has returned, so the next window can't
353/// start while this one is still stopping.
354async fn scan_window<S: ScanControl>(
355    runtime: &Handle,
356    scanner: &S,
357    permit: ScanPermit,
358    filter: ScanFilter,
359    duration: Duration,
360) -> Result<()> {
361    let scanner = scanner.clone();
362    let cancel = CancellationToken::new();
363    // Dropping the caller's future drops this guard, which ends the window early.
364    let _end_early_on_drop = cancel.clone().drop_guard();
365    let window = runtime.spawn(async move {
366        let _permit = permit; // released only after stop has returned
367        if cancel.is_cancelled() {
368            return Ok(());
369        }
370        scanner.start(filter).await?; // a failed start has nothing to stop
371        let _ = cancel.run_until_cancelled(sleep(duration)).await;
372        let stopped = scanner.stop().await;
373        if let Err(e) = &stopped {
374            // Nobody may be awaiting this task any more.
375            warn!("Failed to stop the Bluetooth scan: {e}");
376        }
377        stopped
378    });
379    window.await.map_err(std::io::Error::from)?
380}
381
382/// Scan with `scanner` (the adapter, in production) for `duration`, holding
383/// `permit` (from [`scan_lock`]) until the scan has stopped. The window runs on
384/// aranet-core's runtime.
385pub(crate) async fn run_scan<S: ScanControl>(
386    scanner: &S,
387    permit: ScanPermit,
388    filter: ScanFilter,
389    duration: Duration,
390) -> Result<()> {
391    scan_window(
392        &crate::runtime::handle()?,
393        scanner,
394        permit,
395        filter,
396        duration,
397    )
398    .await
399}
400
401/// Scan for Aranet devices in range.
402///
403/// Returns a list of discovered devices, or an error if the scan failed.
404/// An empty list indicates no devices were found (not an error).
405///
406/// # Errors
407///
408/// Returns an error if:
409/// - No Bluetooth adapter is available
410/// - Bluetooth is not enabled
411/// - The scan could not be started or stopped
412pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
413    scan_with_options(ScanOptions::default()).await
414}
415
416/// Scan for devices with custom options.
417pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
418    let adapter = get_adapter().await?;
419    scan_with_adapter(&adapter, options).await
420}
421
422/// Scan for devices with retry logic for flaky Bluetooth environments.
423///
424/// This function will retry the scan up to `max_retries` times if:
425/// - The scan fails due to a Bluetooth error
426/// - No devices are found (when `retry_on_empty` is true)
427///
428/// A delay is applied between retries, starting at 500ms and doubling each attempt.
429///
430/// # Arguments
431///
432/// * `options` - Scan options
433/// * `max_retries` - Maximum number of retry attempts
434/// * `retry_on_empty` - Whether to retry if no devices are found
435///
436/// # Example
437///
438/// ```ignore
439/// use aranet_core::scan::{ScanOptions, scan_with_retry};
440///
441/// // Retry up to 3 times, including when no devices found
442/// let devices = scan_with_retry(ScanOptions::default(), 3, true).await?;
443/// ```
444pub async fn scan_with_retry(
445    options: ScanOptions,
446    max_retries: u32,
447    retry_on_empty: bool,
448) -> Result<Vec<DiscoveredDevice>> {
449    let mut attempt = 0;
450    let mut delay = Duration::from_millis(500);
451
452    loop {
453        match scan_with_options(options.clone()).await {
454            Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
455                attempt += 1;
456                warn!(
457                    "No devices found, retrying ({}/{})...",
458                    attempt, max_retries
459                );
460                sleep(delay).await;
461                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
462            }
463            Ok(devices) => return Ok(devices),
464            Err(e) if attempt < max_retries => {
465                attempt += 1;
466                warn!(
467                    "Scan failed ({}), retrying ({}/{})...",
468                    e, attempt, max_retries
469                );
470                sleep(delay).await;
471                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
472            }
473            Err(e) => return Err(e),
474        }
475    }
476}
477
478/// Scan for devices using a specific adapter.
479pub async fn scan_with_adapter(
480    adapter: &Adapter,
481    options: ScanOptions,
482) -> Result<Vec<DiscoveredDevice>> {
483    info!(
484        "Starting BLE scan for {} seconds (service_filter={})...",
485        options.duration.as_secs(),
486        options.use_service_filter
487    );
488
489    // Create scan filter - optionally filter for Aranet service UUIDs
490    let scan_filter = if options.use_service_filter {
491        aranet_service_filter()
492    } else {
493        ScanFilter::default()
494    };
495
496    let permit = scan_lock().acquire().await;
497    run_scan(adapter, permit, scan_filter, options.duration).await?;
498
499    // Get discovered peripherals
500    let peripherals = adapter.peripherals().await?;
501    let mut discovered = Vec::new();
502
503    for peripheral in peripherals {
504        match process_peripheral(&peripheral, options.filter_aranet_only).await {
505            Ok(Some(device)) => {
506                info!("Found Aranet device: {:?}", device.name);
507                discovered.push(device);
508            }
509            Ok(None) => {
510                // Not an Aranet device or filtered out
511            }
512            Err(e) => {
513                debug!("Error processing peripheral: {}", e);
514            }
515        }
516    }
517
518    info!("Scan complete. Found {} device(s)", discovered.len());
519    Ok(discovered)
520}
521
522/// Process a peripheral and determine if it's an Aranet device.
523async fn process_peripheral(
524    peripheral: &Peripheral,
525    filter_aranet_only: bool,
526) -> Result<Option<DiscoveredDevice>> {
527    let properties = peripheral.properties().await?;
528    let properties = match properties {
529        Some(p) => p,
530        None => return Ok(None),
531    };
532
533    let id = peripheral.id();
534    let address = properties.address.to_string();
535    let name = properties.local_name.clone();
536    let rssi = properties.rssi;
537
538    // Check if this is an Aranet device
539    let is_aranet = is_aranet_device(&properties);
540
541    if filter_aranet_only && !is_aranet {
542        return Ok(None);
543    }
544
545    // Try to determine device type from name
546    let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
547
548    // Get manufacturer data if available
549    let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
550
551    // Create identifier: use peripheral ID string on macOS (where address is 00:00:00:00:00:00)
552    // On other platforms, use the address
553    let identifier = create_identifier(&address, &id);
554
555    Ok(Some(DiscoveredDevice {
556        name,
557        id,
558        address,
559        identifier,
560        rssi,
561        device_type,
562        is_aranet,
563        manufacturer_data,
564    }))
565}
566
567/// Check if a peripheral is an Aranet device based on its properties.
568fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
569    // Check manufacturer data for Aranet manufacturer ID
570    if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
571        return true;
572    }
573
574    // Check service UUIDs for Aranet services
575    for service_uuid in properties.service_data.keys() {
576        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
577            return true;
578        }
579    }
580
581    // Check advertised services
582    for service_uuid in &properties.services {
583        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
584            return true;
585        }
586    }
587
588    // Check device name for Aranet
589    if let Some(name) = &properties.local_name {
590        let name_lower = name.to_lowercase();
591        if name_lower.contains("aranet") {
592            return true;
593        }
594    }
595
596    false
597}
598
599/// A scan filter that asks the Bluetooth stack only for devices that advertise
600/// an Aranet service.
601fn aranet_service_filter() -> ScanFilter {
602    ScanFilter {
603        services: vec![SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD],
604    }
605}
606
607/// Whether every scan window of a device search asks the Bluetooth stack only for
608/// devices that advertise an Aranet service (see `search_filter`). Only on macOS:
609/// btleplug's CoreBluetooth backend keeps every device a scan reports for the
610/// rest of the process and leaks a little memory for every advertisement it
611/// receives, so unfiltered searches near many Bluetooth devices make a
612/// long-running program grow by 1-2 MB an hour. Leaving only a search's last
613/// window unfiltered wasn't enough: in a 12 h macOS soak, the 8.5% of the
614/// service's polls whose search reached that window added about 106 kB each
615/// (about 80% of the growth, against about 2 kB for a search that ended
616/// earlier), and a sensor out of range runs it on every poll, about 6 MB an hour.
617///
618/// The cost: a macOS search doesn't find a sensor that advertises neither Aranet
619/// service UUID. All four current Aranet sensor types advertise 0xFCE0; older
620/// firmware is untested. `aranet scan`, `scan_with_options` and the passive
621/// monitor still see every device. Linux and Windows searches ask for every
622/// device.
623///
624/// Workaround for btleplug 0.11.8
625/// (<https://github.com/deviceplug/btleplug/issues/494>); re-check when
626/// upgrading btleplug.
627const FILTER_SEARCH_WINDOWS: bool = cfg!(target_os = "macos");
628
629/// The scan filter of every window of a device search: with `aranet_only`, it
630/// asks only for devices that advertise an Aranet service; without it, for every
631/// device.
632fn search_filter(aranet_only: bool) -> ScanFilter {
633    if aranet_only {
634        aranet_service_filter()
635    } else {
636        ScanFilter::default()
637    }
638}
639
640/// What a device search does after its scan window `attempt` (counted from 1) of
641/// `max_attempts` found no device that `query` names, where `similar` are the
642/// names that contain the query (from `Search::Missing`): `Continue` to scan
643/// again, or `Break` with the error the search fails with.
644///
645/// Similar names end the search at once with `NoExactMatch`, so a query that is
646/// part of a name, the commonest mistake, fails after the first window that
647/// leaves such names known instead of after the last window. Scanning on could
648/// still find a device that the query names only if no window has heard that
649/// device yet and its whole name is part of the name of one that has been
650/// heard. Without similar names the search scans again until its last window,
651/// then fails with `NotFound`.
652fn after_missed_scan(
653    query: &str,
654    similar: Vec<String>,
655    attempt: u32,
656    max_attempts: u32,
657) -> ControlFlow<Error> {
658    use crate::error::DeviceNotFoundReason;
659
660    if !similar.is_empty() {
661        return ControlFlow::Break(Error::DeviceNotFound(DeviceNotFoundReason::NoExactMatch {
662            identifier: query.to_string(),
663            similar,
664        }));
665    }
666    if attempt < max_attempts {
667        return ControlFlow::Continue(());
668    }
669    ControlFlow::Break(Error::device_not_found(query))
670}
671
672/// Find a device by its address, identifier or full name.
673///
674/// `identifier` is trimmed and case is ignored, but otherwise it must match one
675/// of these exactly:
676/// - the device's [`DiscoveredDevice::identifier`]: the MAC address on Linux
677///   and Windows, the CoreBluetooth UUID on macOS;
678/// - btleplug's device ID as [`DiscoveredDevice::id`] displays it
679///   (`hci0/dev_AA_BB_CC_DD_EE_FF` on Linux);
680/// - the MAC address, with or without colons;
681/// - the whole advertised name. On macOS a name shown as
682///   `"Kitchen [Aranet4 1A2B3]"` also matches `Kitchen` or `Aranet4 1A2B3`.
683///
684/// An address or identifier match wins over a name match.
685///
686/// The device is searched for as [`find_device_with_options`] describes, with the
687/// default [`ScanOptions`].
688///
689/// # Errors
690///
691/// - [`Error::InvalidConfig`] if `identifier` is empty or blank, before
692///   Bluetooth is used.
693/// - [`Error::DeviceNotFound`] with
694///   [`DeviceNotFoundReason::NoAdapter`](crate::error::DeviceNotFoundReason::NoAdapter)
695///   if there is no Bluetooth adapter.
696/// - [`Error::DeviceNotFound`] with
697///   [`DeviceNotFoundReason::Ambiguous`](crate::error::DeviceNotFoundReason::Ambiguous)
698///   at once if several nearby devices match;
699///   [`DeviceNotFoundReason::NoExactMatch`](crate::error::DeviceNotFoundReason::NoExactMatch)
700///   if none matches but some Aranet device names contain `identifier`, after
701///   the first scan that ends that way rather than after the last; and
702///   [`DeviceNotFoundReason::NotFound`](crate::error::DeviceNotFoundReason::NotFound)
703///   otherwise, after the last scan.
704/// - [`Error::Bluetooth`] if the adapter fails.
705pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
706    find_device_with_options(identifier, ScanOptions::default()).await
707}
708
709/// Find a specific device by name or address with custom options.
710///
711/// `identifier` must match exactly, as [`find_device`] describes.
712///
713/// This function uses a retry strategy to improve reliability:
714/// 1. First checks if the device is already known (cached from previous scans)
715/// 2. Performs up to 3 scan attempts with increasing durations
716///
717/// This helps with BLE reliability issues where devices may not appear
718/// on every scan due to advertisement timing.
719///
720/// Only `options.duration` is used: the search ignores the filter flags of
721/// `options`. On macOS every scan attempt asks the Bluetooth stack only for
722/// devices that advertise an Aranet service, as every current Aranet sensor does.
723/// On other platforms every attempt asks for every device.
724pub async fn find_device_with_options(
725    identifier: &str,
726    options: ScanOptions,
727) -> Result<(Adapter, Peripheral)> {
728    find_device_with_progress(identifier, options, None).await
729}
730
731/// Find a specific device using a pre-existing adapter.
732///
733/// `identifier` must match exactly, as [`find_device`] describes, and the device
734/// is searched for as [`find_device_with_options`] describes.
735///
736/// This avoids creating a new btleplug `Manager` (and D-Bus connection) on
737/// every call.  The caller is responsible for keeping the `Adapter` alive.
738pub async fn find_device_with_adapter(
739    adapter: &Adapter,
740    identifier: &str,
741    options: ScanOptions,
742) -> Result<Peripheral> {
743    find_device_with_adapter_progress(adapter, identifier, options, None).await
744}
745
746/// Find a specific device using a pre-existing adapter, with progress callback.
747///
748/// `identifier` must match exactly, as [`find_device`] describes, and the device
749/// is searched for as [`find_device_with_options`] describes.
750pub async fn find_device_with_adapter_progress(
751    adapter: &Adapter,
752    identifier: &str,
753    options: ScanOptions,
754    progress: Option<ProgressCallback>,
755) -> Result<Peripheral> {
756    let query = parse_query(identifier)?;
757
758    info!("Looking for device: {}", identifier);
759
760    if let Search::Found(peripheral) = search_known_peripherals(adapter, query).await? {
761        info!("Found device in cache (no scan needed)");
762        if let Some(ref cb) = progress {
763            cb(FindProgress::CacheHit);
764        }
765        return Ok(peripheral);
766    }
767
768    let max_attempts: u32 = 3;
769    let base_duration = options.duration.as_millis() as u64 / 2;
770    let base_duration = Duration::from_millis(base_duration.max(2000));
771
772    // Ends after `max_attempts` windows at the latest: `after_missed_scan`
773    // stops the search after the last one.
774    let mut attempt = 0;
775    loop {
776        attempt += 1;
777        let scan_duration = base_duration * attempt;
778        let duration_secs = scan_duration.as_secs();
779
780        let permit = scan_lock().acquire().await;
781        // Another search may have scanned while this one waited for the permit.
782        if let Search::Found(peripheral) = search_known_peripherals(adapter, query).await? {
783            info!("Found device while waiting to scan");
784            if let Some(ref cb) = progress {
785                cb(FindProgress::CacheHit);
786            }
787            return Ok(peripheral);
788        }
789
790        info!(
791            "Scan attempt {}/{} ({}s)...",
792            attempt, max_attempts, duration_secs
793        );
794        let filter = search_filter(FILTER_SEARCH_WINDOWS);
795        debug!(
796            "Scan attempt {}/{} ({}s, {})",
797            attempt,
798            max_attempts,
799            duration_secs,
800            if filter.services.is_empty() {
801                "all devices"
802            } else {
803                "Aranet sensors only"
804            }
805        );
806
807        if let Some(ref cb) = progress {
808            cb(FindProgress::ScanAttempt {
809                attempt,
810                total: max_attempts,
811                duration_secs,
812            });
813        }
814
815        run_scan(adapter, permit, filter, scan_duration).await?;
816
817        let similar = match search_known_peripherals(adapter, query).await? {
818            Search::Found(peripheral) => {
819                info!("Found device on attempt {}", attempt);
820                if let Some(ref cb) = progress {
821                    cb(FindProgress::Found { attempt });
822                }
823                return Ok(peripheral);
824            }
825            Search::Missing { similar } => similar,
826        };
827
828        match after_missed_scan(query, similar, attempt, max_attempts) {
829            ControlFlow::Break(error) => {
830                warn!(
831                    "Device not found after {} of {} attempts: {}",
832                    attempt, max_attempts, identifier
833                );
834                return Err(error);
835            }
836            ControlFlow::Continue(()) => {
837                warn!("Device not found, retrying...");
838                if let Some(ref cb) = progress {
839                    cb(FindProgress::RetryNeeded { attempt });
840                }
841            }
842        }
843    }
844}
845
846/// Find a specific device with progress callback for UI feedback.
847///
848/// `identifier` must match exactly, as [`find_device`] describes, and the device
849/// is searched for as [`find_device_with_options`] describes.
850///
851/// The progress callback is called with updates about the search progress,
852/// including cache hits, scan attempts, and retry information.
853pub async fn find_device_with_progress(
854    identifier: &str,
855    options: ScanOptions,
856    progress: Option<ProgressCallback>,
857) -> Result<(Adapter, Peripheral)> {
858    // Reject an empty identifier before Bluetooth is touched.
859    parse_query(identifier)?;
860    let adapter = get_adapter().await?;
861    let peripheral =
862        find_device_with_adapter_progress(&adapter, identifier, options, progress).await?;
863    Ok((adapter, peripheral))
864}
865
866/// The address CoreBluetooth reports for every peripheral. It identifies
867/// nothing, so a query never matches it.
868const UNKNOWN_ADDRESS: &str = "00:00:00:00:00:00";
869
870/// What a device lookup knows about one peripheral the adapter has seen.
871#[derive(Debug, Clone, PartialEq, Eq)]
872struct KnownPeripheral {
873    /// What `aranet scan` prints (`create_identifier`): the MAC address on
874    /// Linux and Windows, the CoreBluetooth UUID on macOS.
875    identifier: String,
876    /// btleplug's device ID in the form the TUI and GUI store
877    /// (`peripheral.id().to_string()`): `hci0/dev_AA_BB_CC_DD_EE_FF` on Linux,
878    /// the UUID on macOS, the MAC address on Windows.
879    peripheral_id: String,
880    /// The Bluetooth address; `00:00:00:00:00:00` on macOS.
881    address: String,
882    /// The advertised name.
883    name: Option<String>,
884}
885
886/// The peripherals a query picks, as indices into the slice given to `lookup`.
887#[derive(Debug, PartialEq, Eq)]
888enum Lookup {
889    /// Exactly one peripheral matches.
890    Found(usize),
891    /// Several peripherals match, in ascending order.
892    Ambiguous(Vec<usize>),
893    /// Nothing matches. `similar` are the peripherals whose name contains both
894    /// the query and `aranet`, ordered by name and then identifier.
895    NotFound { similar: Vec<usize> },
896}
897
898/// Trims `identifier`. An identifier that is empty after trimming is
899/// `Error::InvalidConfig("device identifier is empty")`.
900fn parse_query(identifier: &str) -> Result<&str> {
901    let query = identifier.trim();
902    if query.is_empty() {
903        return Err(Error::invalid_config("device identifier is empty"));
904    }
905    Ok(query)
906}
907
908/// Pick the peripheral that `query` names, ignoring case. `query` comes from
909/// `parse_query`, so it is trimmed and not empty.
910///
911/// The rules, in order. A later rule is used only when the earlier ones match
912/// nothing, so a device's own address beats a device named like it:
913/// 1. an identifier: the one `aranet scan` prints, btleplug's device ID (the
914///    `hci0/dev_…` form on Linux), or the Bluetooth address with or without
915///    colons, never `00:00:00:00:00:00`;
916/// 2. the whole advertised name, or either half of CoreBluetooth's combined
917///    `"<GAP name> [<advertised name>]"` (btleplug 0.11.8,
918///    `corebluetooth/internal.rs:577-585`).
919///
920/// One match is `Found` and several are `Ambiguous`. With no match, `NotFound`
921/// lists the peripherals whose name contains both `query` and `aranet`, so a
922/// short query never lists every phone and headset nearby. The answer depends
923/// only on which peripherals are known, never on their order.
924fn lookup(query: &str, known: &[KnownPeripheral]) -> Lookup {
925    let query = query.to_lowercase();
926    let bare_query = query.replace(':', "");
927
928    let by_identifier = matching(known, |peripheral| {
929        peripheral.identifier.to_lowercase() == query
930            || peripheral.peripheral_id.to_lowercase() == query
931            || (peripheral.address != UNKNOWN_ADDRESS
932                && peripheral.address.to_lowercase().replace(':', "") == bare_query)
933    });
934    if let Some(found) = decide(by_identifier) {
935        return found;
936    }
937
938    let by_name = matching(known, |peripheral| {
939        peripheral
940            .name
941            .as_deref()
942            .is_some_and(|name| name_matches(name, &query))
943    });
944    if let Some(found) = decide(by_name) {
945        return found;
946    }
947
948    let mut similar = matching(known, |peripheral| {
949        peripheral.name.as_deref().is_some_and(|name| {
950            let name = name.to_lowercase();
951            name.contains("aranet") && name.contains(&query)
952        })
953    });
954    similar.sort_by_key(|&index| (&known[index].name, &known[index].identifier));
955    Lookup::NotFound { similar }
956}
957
958/// Indices of the peripherals that satisfy `predicate`, in ascending order.
959fn matching(known: &[KnownPeripheral], predicate: impl Fn(&KnownPeripheral) -> bool) -> Vec<usize> {
960    known
961        .iter()
962        .enumerate()
963        .filter_map(|(index, peripheral)| predicate(peripheral).then_some(index))
964        .collect()
965}
966
967/// `Found` for one index, `Ambiguous` for several, `None` for none.
968fn decide(indices: Vec<usize>) -> Option<Lookup> {
969    match indices.len() {
970        0 => None,
971        1 => Some(Lookup::Found(indices[0])),
972        _ => Some(Lookup::Ambiguous(indices)),
973    }
974}
975
976/// Whether the advertised `name` is `query` (lower case): the whole name, or
977/// either half of CoreBluetooth's `"<GAP name> [<advertised name>]"`.
978fn name_matches(name: &str, query: &str) -> bool {
979    let name = name.trim().to_lowercase();
980    name == query
981        || name
982            .strip_suffix(']')
983            .and_then(|combined| combined.rsplit_once(" ["))
984            .is_some_and(|(gap, advertised)| gap.trim() == query || advertised.trim() == query)
985}
986
987/// What a search of the known peripherals found.
988#[derive(Debug, PartialEq, Eq)]
989enum Search<T> {
990    /// The one peripheral the query names.
991    Found(T),
992    /// Nothing matches; `similar` are the names that contain the query,
993    /// trimmed, sorted and without duplicates.
994    Missing { similar: Vec<String> },
995}
996
997/// `lookup`'s answer for `query`, with `Found` holding an index into `known`.
998///
999/// An ambiguous query is an error,
1000/// `Error::DeviceNotFound(DeviceNotFoundReason::Ambiguous { .. })`, whose
1001/// candidates are `"<name or 'unnamed'> (<identifier>)"`, sorted: scanning
1002/// again can't make it less ambiguous, so the find loop returns it at once.
1003fn resolve(query: &str, known: &[KnownPeripheral]) -> Result<Search<usize>> {
1004    use crate::error::DeviceNotFoundReason;
1005
1006    match lookup(query, known) {
1007        Lookup::Found(index) => Ok(Search::Found(index)),
1008        Lookup::Ambiguous(indices) => {
1009            let mut candidates: Vec<String> = indices
1010                .iter()
1011                .map(|&index| {
1012                    let device = &known[index];
1013                    let name = device.name.as_deref().unwrap_or("unnamed");
1014                    format!("{name} ({})", device.identifier)
1015                })
1016                .collect();
1017            candidates.sort();
1018            Err(Error::DeviceNotFound(DeviceNotFoundReason::Ambiguous {
1019                identifier: query.to_string(),
1020                candidates,
1021            }))
1022        }
1023        Lookup::NotFound { similar } => {
1024            let mut names: Vec<String> = similar
1025                .iter()
1026                .filter_map(|&index| known[index].name.as_deref())
1027                .map(|name| name.trim().to_string())
1028                .collect();
1029            names.sort();
1030            names.dedup();
1031            Ok(Search::Missing { similar: names })
1032        }
1033    }
1034}
1035
1036/// Look `query` up among the peripherals that `adapter` already knows, as
1037/// `resolve` decides.
1038async fn search_known_peripherals(adapter: &Adapter, query: &str) -> Result<Search<Peripheral>> {
1039    let mut peripherals = Vec::new();
1040    let mut known = Vec::new();
1041    for peripheral in adapter.peripherals().await? {
1042        if let Ok(Some(props)) = peripheral.properties().await {
1043            let id = peripheral.id();
1044            let address = props.address.to_string();
1045            known.push(KnownPeripheral {
1046                identifier: create_identifier(&address, &id),
1047                peripheral_id: id.to_string(),
1048                address,
1049                name: props.local_name,
1050            });
1051            peripherals.push(peripheral);
1052        }
1053    }
1054
1055    match resolve(query, &known)? {
1056        Search::Found(index) => {
1057            let device = &known[index];
1058            debug!("Matched {:?} ({})", device.name, device.identifier);
1059            Ok(Search::Found(peripherals.swap_remove(index)))
1060        }
1061        Search::Missing { similar } => Ok(Search::Missing { similar }),
1062    }
1063}
1064
1065#[cfg(test)]
1066mod tests {
1067    use super::*;
1068
1069    use std::sync::atomic::{AtomicBool, Ordering};
1070
1071    use futures::FutureExt;
1072
1073    use crate::error::DeviceNotFoundReason;
1074    use crate::test_support::within;
1075
1076    // ==================== ScanOptions Tests ====================
1077
1078    #[test]
1079    fn test_scan_options_default() {
1080        let options = ScanOptions::default();
1081        assert_eq!(options.duration, Duration::from_secs(5));
1082        assert!(options.filter_aranet_only);
1083    }
1084
1085    #[test]
1086    fn test_scan_options_new() {
1087        let options = ScanOptions::new();
1088        assert_eq!(options.duration, Duration::from_secs(5));
1089        assert!(options.filter_aranet_only);
1090    }
1091
1092    #[test]
1093    fn test_scan_options_duration() {
1094        let options = ScanOptions::new().duration(Duration::from_secs(10));
1095        assert_eq!(options.duration, Duration::from_secs(10));
1096    }
1097
1098    #[test]
1099    fn test_scan_options_duration_secs() {
1100        let options = ScanOptions::new().duration_secs(15);
1101        assert_eq!(options.duration, Duration::from_secs(15));
1102    }
1103
1104    #[test]
1105    fn test_scan_options_filter_aranet_only() {
1106        let options = ScanOptions::new().filter_aranet_only(false);
1107        assert!(!options.filter_aranet_only);
1108
1109        let options = ScanOptions::new().filter_aranet_only(true);
1110        assert!(options.filter_aranet_only);
1111    }
1112
1113    #[test]
1114    fn test_scan_options_all_devices() {
1115        let options = ScanOptions::new().all_devices();
1116        assert!(!options.filter_aranet_only);
1117    }
1118
1119    #[test]
1120    fn test_scan_options_chaining() {
1121        let options = ScanOptions::new()
1122            .duration_secs(20)
1123            .filter_aranet_only(false);
1124
1125        assert_eq!(options.duration, Duration::from_secs(20));
1126        assert!(!options.filter_aranet_only);
1127    }
1128
1129    #[test]
1130    fn test_scan_options_clone() {
1131        let options1 = ScanOptions::new().duration_secs(8);
1132        let options2 = options1.clone();
1133
1134        assert_eq!(options1.duration, options2.duration);
1135        assert_eq!(options1.filter_aranet_only, options2.filter_aranet_only);
1136    }
1137
1138    #[test]
1139    fn test_scan_options_debug() {
1140        let options = ScanOptions::new();
1141        let debug = format!("{:?}", options);
1142        assert!(debug.contains("ScanOptions"));
1143        assert!(debug.contains("duration"));
1144        assert!(debug.contains("filter_aranet_only"));
1145    }
1146
1147    // ==================== FindProgress Tests ====================
1148
1149    #[test]
1150    fn test_find_progress_cache_hit() {
1151        let progress = FindProgress::CacheHit;
1152        let debug = format!("{:?}", progress);
1153        assert!(debug.contains("CacheHit"));
1154    }
1155
1156    #[test]
1157    fn test_find_progress_scan_attempt() {
1158        let progress = FindProgress::ScanAttempt {
1159            attempt: 2,
1160            total: 3,
1161            duration_secs: 5,
1162        };
1163
1164        if let FindProgress::ScanAttempt {
1165            attempt,
1166            total,
1167            duration_secs,
1168        } = progress
1169        {
1170            assert_eq!(attempt, 2);
1171            assert_eq!(total, 3);
1172            assert_eq!(duration_secs, 5);
1173        } else {
1174            panic!("Expected ScanAttempt variant");
1175        }
1176    }
1177
1178    #[test]
1179    fn test_find_progress_found() {
1180        let progress = FindProgress::Found { attempt: 1 };
1181        assert!(matches!(progress, FindProgress::Found { attempt: 1 }));
1182    }
1183
1184    #[test]
1185    fn test_find_progress_retry_needed() {
1186        let progress = FindProgress::RetryNeeded { attempt: 2 };
1187        assert!(matches!(progress, FindProgress::RetryNeeded { attempt: 2 }));
1188    }
1189
1190    #[test]
1191    fn test_find_progress_clone() {
1192        let progress1 = FindProgress::ScanAttempt {
1193            attempt: 1,
1194            total: 3,
1195            duration_secs: 4,
1196        };
1197        let progress2 = progress1.clone();
1198
1199        assert!(matches!(
1200            (&progress1, &progress2),
1201            (
1202                FindProgress::ScanAttempt {
1203                    attempt: 1,
1204                    total: 3,
1205                    duration_secs: 4,
1206                },
1207                FindProgress::ScanAttempt {
1208                    attempt: 1,
1209                    total: 3,
1210                    duration_secs: 4,
1211                },
1212            )
1213        ));
1214    }
1215
1216    // ==================== Bluetooth Manager Tests ====================
1217
1218    /// bluez-async spawns the D-Bus connection's only I/O task on the runtime
1219    /// that creates the manager, and `shared_manager` caches the manager for the
1220    /// whole process, so that task has to outlive the runtime that created it.
1221    /// Needs a system bus but not BlueZ: an error reply (no `org.bluez` on the
1222    /// bus) still proves the connection works; only a missing reply fails.
1223    ///
1224    /// To run it on a Linux host:
1225    /// `cargo test --locked -p aranet-core --lib manager_still_answers -- --ignored`.
1226    /// In a Debian container, start a throwaway system bus first:
1227    /// `apt-get install dbus; mkdir -p /run/dbus; dbus-daemon --system --fork`.
1228    #[cfg(target_os = "linux")]
1229    #[test]
1230    #[ignore = "needs a system D-Bus (BlueZ not required)"]
1231    fn manager_still_answers_after_its_first_runtime_shuts_down() {
1232        let runtime = || {
1233            tokio::runtime::Builder::new_current_thread()
1234                .enable_all()
1235                .build()
1236                .unwrap()
1237        };
1238
1239        let first = runtime();
1240        let manager = first.block_on(shared_manager()).expect("manager");
1241        drop(first);
1242
1243        let second = runtime();
1244        let answered = second.block_on(async {
1245            tokio::time::timeout(Duration::from_secs(5), manager.adapters()).await
1246        });
1247        assert!(
1248            answered.is_ok(),
1249            "the manager's D-Bus connection died with the runtime that created it"
1250        );
1251    }
1252
1253    // ==================== Scan Window Tests ====================
1254
1255    /// Longest any scan-window test may take on the paused clock.
1256    const TEST_LIMIT: Duration = Duration::from_secs(600);
1257
1258    fn secs(n: u64) -> Duration {
1259        Duration::from_secs(n)
1260    }
1261
1262    /// A scanner that behaves like BlueZ: one discovery session per D-Bus
1263    /// client, so a second `start` before `stop` fails with InProgress. It logs
1264    /// when each start and stop happened, measured from its creation.
1265    #[derive(Clone)]
1266    struct FakeScanner {
1267        started_at: tokio::time::Instant,
1268        log: Arc<std::sync::Mutex<Vec<(Duration, &'static str)>>>,
1269        scanning: Arc<AtomicBool>,
1270        fail_start: bool,
1271        /// How long `stop` takes to end the session: StopDiscovery is a D-Bus
1272        /// round trip on BlueZ.
1273        stop_latency: Duration,
1274        on_stop: Option<std::sync::mpsc::Sender<()>>,
1275    }
1276
1277    impl FakeScanner {
1278        fn new() -> Self {
1279            Self {
1280                started_at: tokio::time::Instant::now(),
1281                log: Arc::default(),
1282                scanning: Arc::default(),
1283                fail_start: false,
1284                stop_latency: Duration::ZERO,
1285                on_stop: None,
1286            }
1287        }
1288
1289        fn record(&self, event: &'static str) {
1290            self.log
1291                .lock()
1292                .unwrap()
1293                .push((self.started_at.elapsed(), event));
1294        }
1295
1296        fn log(&self) -> Vec<(Duration, &'static str)> {
1297            self.log.lock().unwrap().clone()
1298        }
1299
1300        fn is_scanning(&self) -> bool {
1301            self.scanning.load(Ordering::SeqCst)
1302        }
1303    }
1304
1305    impl ScanControl for FakeScanner {
1306        async fn start(&self, _filter: ScanFilter) -> Result<()> {
1307            if self.fail_start {
1308                return Err(Error::InvalidData("start failed".into()));
1309            }
1310            if self.scanning.swap(true, Ordering::SeqCst) {
1311                return Err(Error::InvalidData("org.bluez.Error.InProgress".into()));
1312            }
1313            self.record("start");
1314            Ok(())
1315        }
1316
1317        async fn stop(&self) -> Result<()> {
1318            self.record("stop");
1319            sleep(self.stop_latency).await;
1320            self.scanning.store(false, Ordering::SeqCst);
1321            if let Some(on_stop) = &self.on_stop {
1322                let _ = on_stop.send(());
1323            }
1324            Ok(())
1325        }
1326    }
1327
1328    #[tokio::test(start_paused = true)]
1329    async fn scan_window_stops_the_scan_when_the_window_ends() {
1330        within(TEST_LIMIT, async {
1331            let rt = tokio::runtime::Handle::current();
1332            let lock = ScanLock::default();
1333            let scanner = FakeScanner::new();
1334
1335            let permit = lock.acquire().await;
1336            scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(5))
1337                .await
1338                .unwrap();
1339
1340            assert_eq!(scanner.log(), [(secs(0), "start"), (secs(5), "stop")]);
1341            assert!(!scanner.is_scanning());
1342        })
1343        .await;
1344    }
1345
1346    #[tokio::test(start_paused = true)]
1347    async fn dropping_the_caller_stops_the_scan_immediately() {
1348        within(TEST_LIMIT, async {
1349            let rt = tokio::runtime::Handle::current();
1350            let lock = ScanLock::default();
1351            let scanner = FakeScanner::new();
1352
1353            let permit = lock.acquire().await;
1354            let window = scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(5));
1355            assert!(tokio::time::timeout(secs(1), window).await.is_err());
1356            tokio::time::sleep(Duration::from_millis(10)).await;
1357
1358            assert_eq!(scanner.log(), [(secs(0), "start"), (secs(1), "stop")]);
1359            assert!(!scanner.is_scanning());
1360        })
1361        .await;
1362    }
1363
1364    #[tokio::test(start_paused = true)]
1365    async fn aborting_the_task_stops_the_scan() {
1366        within(TEST_LIMIT, async {
1367            let rt = tokio::runtime::Handle::current();
1368            let lock = ScanLock::default();
1369            let scanner = FakeScanner::new();
1370
1371            // A service reload or stop aborts its tasks like this (`abort_all`).
1372            let task = tokio::spawn({
1373                let scanner = scanner.clone();
1374                let lock = lock.clone();
1375                async move {
1376                    let permit = lock.acquire().await;
1377                    scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(5)).await
1378                }
1379            });
1380            tokio::time::sleep(secs(1)).await;
1381            task.abort();
1382            assert!(task.await.unwrap_err().is_cancelled());
1383            tokio::time::sleep(Duration::from_millis(10)).await;
1384
1385            assert_eq!(scanner.log(), [(secs(0), "start"), (secs(1), "stop")]);
1386            assert!(!scanner.is_scanning());
1387        })
1388        .await;
1389    }
1390
1391    #[tokio::test(start_paused = true)]
1392    async fn failed_start_releases_the_permit_without_stopping() {
1393        within(TEST_LIMIT, async {
1394            let rt = tokio::runtime::Handle::current();
1395            let lock = ScanLock::default();
1396            let scanner = FakeScanner {
1397                fail_start: true,
1398                ..FakeScanner::new()
1399            };
1400
1401            let permit = lock.acquire().await;
1402            let result = scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(5)).await;
1403
1404            assert!(matches!(result, Err(Error::InvalidData(ref m)) if m == "start failed"));
1405            assert!(
1406                scanner.log().is_empty(),
1407                "a scan that never started must not be stopped"
1408            );
1409            within(secs(1), lock.acquire()).await;
1410        })
1411        .await;
1412    }
1413
1414    #[test]
1415    fn scan_stops_after_the_callers_runtime_shuts_down() {
1416        let (tx, rx) = std::sync::mpsc::channel();
1417        let caller = tokio::runtime::Builder::new_current_thread()
1418            .enable_all()
1419            .build()
1420            .unwrap();
1421
1422        caller.block_on(async {
1423            let lock = ScanLock::default();
1424            let scanner = FakeScanner {
1425                on_stop: Some(tx),
1426                ..FakeScanner::new()
1427            };
1428            let permit = lock.acquire().await;
1429            // Through `run_scan`, which picks the runtime the window runs on.
1430            let window = run_scan(&scanner, permit, ScanFilter::default(), secs(10));
1431            let started = async {
1432                while !scanner.is_scanning() {
1433                    tokio::time::sleep(Duration::from_millis(1)).await;
1434                }
1435            };
1436            // Give up on the window once its scan is running, however late the
1437            // aranet-ble thread gets to it.
1438            tokio::select! {
1439                result = window => panic!("the 10 s window ended early: {result:?}"),
1440                started = tokio::time::timeout(secs(5), started) => {
1441                    started.expect("the scan never started");
1442                }
1443            }
1444        });
1445        drop(caller);
1446
1447        rx.recv_timeout(secs(5))
1448            .expect("scan never stopped after the caller's runtime shut down");
1449    }
1450
1451    #[tokio::test(start_paused = true)]
1452    async fn concurrent_scans_run_one_after_another() {
1453        within(TEST_LIMIT, async {
1454            let rt = tokio::runtime::Handle::current();
1455            let lock = ScanLock::default();
1456            let scanner = FakeScanner {
1457                stop_latency: Duration::from_millis(100),
1458                ..FakeScanner::new()
1459            };
1460            let scan = || async {
1461                let permit = lock.acquire().await;
1462                scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(2)).await
1463            };
1464
1465            let (first, second) = tokio::join!(scan(), scan());
1466
1467            first.expect("first scan");
1468            second.expect("second scan should wait for the first instead of failing");
1469            assert_eq!(
1470                scanner.log(),
1471                [
1472                    (secs(0), "start"),
1473                    (secs(2), "stop"),
1474                    (Duration::from_millis(2100), "start"),
1475                    (Duration::from_millis(4100), "stop"),
1476                ]
1477            );
1478        })
1479        .await;
1480    }
1481
1482    #[tokio::test(start_paused = true)]
1483    async fn next_scan_waits_until_a_cancelled_scan_has_stopped() {
1484        within(TEST_LIMIT, async {
1485            let rt = tokio::runtime::Handle::current();
1486            let lock = ScanLock::default();
1487            let scanner = FakeScanner::new();
1488
1489            let first = tokio::time::timeout(secs(1), async {
1490                let permit = lock.acquire().await;
1491                scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(5)).await
1492            });
1493            let second = async {
1494                tokio::task::yield_now().await;
1495                let permit = lock.acquire().await;
1496                scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(2)).await
1497            };
1498            let (first, second) = tokio::join!(first, second);
1499
1500            assert!(first.is_err(), "the first scan should have been cancelled");
1501            second.expect("the second scan should start after the first has stopped");
1502            assert_eq!(
1503                scanner.log(),
1504                [
1505                    (secs(0), "start"),
1506                    (secs(1), "stop"),
1507                    (secs(1), "start"),
1508                    (secs(3), "stop"),
1509                ]
1510            );
1511        })
1512        .await;
1513    }
1514
1515    #[tokio::test(start_paused = true)]
1516    async fn cancelling_while_waiting_for_the_permit_never_starts_a_scan() {
1517        within(TEST_LIMIT, async {
1518            let rt = tokio::runtime::Handle::current();
1519            let lock = ScanLock::default();
1520            let scanner = FakeScanner::new();
1521
1522            let permit = lock.acquire().await;
1523            let first = tokio::spawn({
1524                let (rt, scanner) = (rt.clone(), scanner.clone());
1525                async move {
1526                    scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(5)).await
1527                }
1528            });
1529            let second = tokio::time::timeout(secs(1), async {
1530                let permit = lock.acquire().await;
1531                scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(1)).await
1532            });
1533            assert!(
1534                second.await.is_err(),
1535                "the second scan should still be waiting for the permit"
1536            );
1537            tokio::time::sleep(secs(6)).await;
1538            first.await.unwrap().unwrap();
1539            assert_eq!(scanner.log(), [(secs(0), "start"), (secs(5), "stop")]);
1540
1541            // A caller dropped after taking the permit, before its window task
1542            // first ran: `now_or_never` polls the window once, then drops it.
1543            let permit = lock.acquire().await;
1544            let window = scan_window(&rt, &scanner, permit, ScanFilter::default(), secs(1));
1545            assert!(window.now_or_never().is_none());
1546            tokio::time::sleep(Duration::from_millis(10)).await;
1547            assert_eq!(scanner.log(), [(secs(0), "start"), (secs(5), "stop")]);
1548            within(secs(1), lock.acquire()).await;
1549        })
1550        .await;
1551    }
1552
1553    // ==================== Search Filter Tests ====================
1554
1555    /// The filters that the windows of a search with `windows` scan windows use,
1556    /// in order: the search asks `search_filter` once per window.
1557    fn search_filters(windows: u32, aranet_only: bool) -> Vec<ScanFilter> {
1558        (1..=windows).map(|_| search_filter(aranet_only)).collect()
1559    }
1560
1561    #[test]
1562    fn search_filter_asks_only_for_aranet_sensors_in_every_window() {
1563        let aranet = ScanFilter {
1564            services: vec![SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD],
1565        };
1566        for windows in 1..=3 {
1567            assert_eq!(
1568                search_filters(windows, true),
1569                vec![aranet.clone(); windows as usize],
1570                "{windows}-window search"
1571            );
1572        }
1573    }
1574
1575    #[test]
1576    fn search_filter_asks_for_every_device_when_not_filtering() {
1577        for windows in 1..=3 {
1578            assert_eq!(
1579                search_filters(windows, false),
1580                vec![ScanFilter::default(); windows as usize],
1581                "{windows}-window search"
1582            );
1583        }
1584    }
1585
1586    // ==================== Missed Scan Tests ====================
1587
1588    #[test]
1589    fn a_search_fails_after_the_first_scan_that_finds_only_similar_names() {
1590        // A partial name such as `-d Aranet4` fails as soon as a scan window
1591        // ends with names that contain it, not after the last window.
1592        for attempt in 1..=3 {
1593            let similar = vec!["Aranet4 12345".to_string(), "Aranet4 1ABCD".to_string()];
1594            match after_missed_scan("Aranet4", similar, attempt, 3) {
1595                ControlFlow::Break(Error::DeviceNotFound(DeviceNotFoundReason::NoExactMatch {
1596                    identifier,
1597                    similar,
1598                })) => {
1599                    assert_eq!(identifier, "Aranet4", "attempt {attempt}");
1600                    assert_eq!(
1601                        similar,
1602                        ["Aranet4 12345", "Aranet4 1ABCD"],
1603                        "attempt {attempt}"
1604                    );
1605                }
1606                other => panic!("attempt {attempt} of 3: {other:?}"),
1607            }
1608        }
1609    }
1610
1611    #[test]
1612    fn a_search_without_similar_names_scans_until_its_last_attempt() {
1613        for attempt in 1..3 {
1614            let decision = after_missed_scan("Aranet4 12345", Vec::new(), attempt, 3);
1615            assert!(
1616                matches!(decision, ControlFlow::Continue(())),
1617                "attempt {attempt} of 3: {decision:?}"
1618            );
1619        }
1620        match after_missed_scan("Aranet4 12345", Vec::new(), 3, 3) {
1621            ControlFlow::Break(Error::DeviceNotFound(DeviceNotFoundReason::NotFound {
1622                identifier,
1623            })) => assert_eq!(identifier, "Aranet4 12345"),
1624            other => panic!("attempt 3 of 3: {other:?}"),
1625        }
1626    }
1627
1628    // ==================== Device Lookup Tests ====================
1629
1630    /// CoreBluetooth UUIDs as `aranet scan` prints them on macOS. The first two
1631    /// are those of a real Aranet2 and AranetRn+; the other two are made up.
1632    const UUID_1: &str = "1f8893bf-9f7e-02b4-ef4a-7718f4f5d4be";
1633    const UUID_2: &str = "387c18c7-299f-cc32-d01c-6cf29a8d3ca5";
1634    const UUID_3: &str = "5b0e4c1d-7a3f-4e2b-9c6d-8f1a2b3c4d5e";
1635    const UUID_4: &str = "c4a7e2d9-3b1f-4c8e-a6d5-2f9b8e7c1a04";
1636
1637    fn known(
1638        identifier: &str,
1639        peripheral_id: &str,
1640        address: &str,
1641        name: Option<&str>,
1642    ) -> KnownPeripheral {
1643        KnownPeripheral {
1644            identifier: identifier.to_string(),
1645            peripheral_id: peripheral_id.to_string(),
1646            address: address.to_string(),
1647            name: name.map(str::to_string),
1648        }
1649    }
1650
1651    /// A peripheral as CoreBluetooth reports it: a UUID and no address.
1652    fn on_macos(uuid: &str, name: &str) -> KnownPeripheral {
1653        known(uuid, uuid, UNKNOWN_ADDRESS, Some(name))
1654    }
1655
1656    /// A peripheral as BlueZ reports it: its address, and the device ID
1657    /// `hci0/dev_AA_BB_…` that btleplug's `PeripheralId` displays.
1658    fn on_linux(address: &str, name: &str) -> KnownPeripheral {
1659        let device_id = format!("hci0/dev_{}", address.replace(':', "_"));
1660        known(address, &device_id, address, Some(name))
1661    }
1662
1663    /// `lookup`'s answer as its kind and the identifiers it picked, so answers
1664    /// for the same peripherals in different orders can be compared.
1665    fn outcome(query: &str, known: &[KnownPeripheral]) -> (&'static str, Vec<String>) {
1666        let (kind, indices) = match lookup(query, known) {
1667            Lookup::Found(index) => ("found", vec![index]),
1668            Lookup::Ambiguous(indices) => ("ambiguous", indices),
1669            Lookup::NotFound { similar } => ("not found", similar),
1670        };
1671        let mut identifiers: Vec<String> = indices
1672            .iter()
1673            .map(|&index| known[index].identifier.clone())
1674            .collect();
1675        if kind == "ambiguous" {
1676            // In slice order, which depends on the shuffle.
1677            identifiers.sort();
1678        }
1679        (kind, identifiers)
1680    }
1681
1682    /// The candidates of the `Ambiguous` error that `resolve` returns.
1683    fn candidates(query: &str, known: &[KnownPeripheral]) -> Vec<String> {
1684        match resolve(query, known) {
1685            Err(Error::DeviceNotFound(crate::error::DeviceNotFoundReason::Ambiguous {
1686                identifier,
1687                candidates,
1688            })) => {
1689                assert_eq!(identifier, query);
1690                candidates
1691            }
1692            other => panic!("{query:?} is not ambiguous: {other:?}"),
1693        }
1694    }
1695
1696    #[test]
1697    fn lookup_does_not_match_part_of_a_name() {
1698        let devices = [
1699            on_macos(UUID_1, "Aranet4 12345"),
1700            on_macos(UUID_2, "Aranet4 1ABCD"),
1701        ];
1702        assert_eq!(
1703            lookup("Aranet4 1", &devices),
1704            Lookup::NotFound {
1705                similar: vec![0, 1]
1706            }
1707        );
1708    }
1709
1710    #[test]
1711    fn lookup_does_not_match_part_of_a_uuid() {
1712        let devices = [on_macos(UUID_1, "Aranet2 2751B")];
1713        for query in ["4", "1f8893bf"] {
1714            assert_eq!(
1715                lookup(query, &devices),
1716                Lookup::NotFound { similar: vec![] },
1717                "{query:?}"
1718            );
1719        }
1720    }
1721
1722    #[test]
1723    fn lookup_ignores_linux_object_path_fragments() {
1724        let devices = [on_linux("AA:BB:CC:DD:EE:FF", "Aranet4 12345")];
1725        for query in ["hci0", "dev"] {
1726            assert_eq!(
1727                lookup(query, &devices),
1728                Lookup::NotFound { similar: vec![] },
1729                "{query:?}"
1730            );
1731        }
1732    }
1733
1734    #[test]
1735    fn lookup_matches_the_bluez_device_id_display_form() {
1736        // The TUI and GUI keep `DiscoveredDevice::id.to_string()` as a device's
1737        // ID, store it in the database and connect with it later.
1738        let devices = [
1739            on_linux("11:22:33:44:55:66", "Aranet2 2751B"),
1740            on_linux("AA:BB:CC:DD:EE:FF", "Aranet4 12345"),
1741        ];
1742        for query in ["hci0/dev_AA_BB_CC_DD_EE_FF", "hci0/dev_aa_bb_cc_dd_ee_ff"] {
1743            assert_eq!(lookup(query, &devices), Lookup::Found(1), "{query:?}");
1744        }
1745    }
1746
1747    #[test]
1748    fn lookup_matches_the_full_name_ignoring_case_and_whitespace() {
1749        let devices = [
1750            on_macos(UUID_2, "AranetRn+ 306B8"),
1751            on_macos(UUID_1, "Aranet2 2751B"),
1752        ];
1753        let query = parse_query(" aranet2 2751b ").unwrap();
1754        assert_eq!(lookup(query, &devices), Lookup::Found(1));
1755    }
1756
1757    #[test]
1758    fn lookup_matches_a_uuid_in_any_case() {
1759        let devices = [
1760            on_macos(UUID_2, "AranetRn+ 306B8"),
1761            on_macos(UUID_1, "Aranet2 2751B"),
1762        ];
1763        assert_eq!(
1764            lookup("1F8893BF-9F7E-02B4-EF4A-7718F4F5D4BE", &devices),
1765            Lookup::Found(1)
1766        );
1767    }
1768
1769    #[test]
1770    fn lookup_matches_an_address_with_or_without_colons() {
1771        let devices = [
1772            on_linux("11:22:33:44:55:66", "Aranet2 2751B"),
1773            on_linux("AA:BB:CC:DD:EE:FF", "Aranet4 12345"),
1774        ];
1775        for query in ["aa:bb:cc:dd:ee:ff", "AABBCCDDEEFF"] {
1776            assert_eq!(lookup(query, &devices), Lookup::Found(1), "{query:?}");
1777        }
1778    }
1779
1780    #[test]
1781    fn lookup_never_matches_the_all_zero_address() {
1782        let devices = [on_macos(UUID_1, "Aranet2 2751B")];
1783        for query in ["00:00:00:00:00:00", "000000000000"] {
1784            assert_eq!(
1785                lookup(query, &devices),
1786                Lookup::NotFound { similar: vec![] },
1787                "{query:?}"
1788            );
1789        }
1790    }
1791
1792    #[test]
1793    fn lookup_matches_either_half_of_a_corebluetooth_combined_name() {
1794        let devices = [
1795            on_macos(UUID_2, "AranetRn+ 306B8"),
1796            on_macos(UUID_1, "Kitchen [Aranet4 1A2B3]"),
1797        ];
1798        for query in ["Aranet4 1A2B3", "kitchen", "Kitchen [Aranet4 1A2B3]"] {
1799            assert_eq!(lookup(query, &devices), Lookup::Found(1), "{query:?}");
1800        }
1801    }
1802
1803    #[test]
1804    fn lookup_prefers_an_identifier_match_over_a_name_match() {
1805        let devices = [
1806            on_macos(UUID_1, "AA:BB:CC:DD:EE:FF"),
1807            on_linux("AA:BB:CC:DD:EE:FF", "Aranet4 12345"),
1808        ];
1809        assert_eq!(lookup("aa:bb:cc:dd:ee:ff", &devices), Lookup::Found(1));
1810    }
1811
1812    #[test]
1813    fn lookup_reports_duplicate_names_as_ambiguous() {
1814        let devices = [
1815            on_macos(UUID_1, "Aranet4 12345"),
1816            on_macos(UUID_2, "Aranet4 12345"),
1817        ];
1818        assert_eq!(
1819            lookup("Aranet4 12345", &devices),
1820            Lookup::Ambiguous(vec![0, 1])
1821        );
1822    }
1823
1824    #[test]
1825    fn lookup_suggests_only_aranet_names() {
1826        // "Standing desk" contains the query too, but it isn't an Aranet.
1827        let devices = [
1828            on_macos(UUID_1, "Aranet4 12345"),
1829            on_macos(UUID_2, "Standing desk"),
1830            on_macos(UUID_3, "Aranet2 2751B"),
1831        ];
1832        assert_eq!(
1833            lookup("an", &devices),
1834            Lookup::NotFound {
1835                similar: vec![2, 0]
1836            }
1837        );
1838    }
1839
1840    /// Every order of the indices `0..n`.
1841    fn orders(n: usize) -> Vec<Vec<usize>> {
1842        if n == 0 {
1843            return vec![Vec::new()];
1844        }
1845        let mut all = Vec::new();
1846        for shorter in orders(n - 1) {
1847            for at in 0..n {
1848                let mut order = shorter.clone();
1849                order.insert(at, n - 1);
1850                all.push(order);
1851            }
1852        }
1853        all
1854    }
1855
1856    #[test]
1857    fn lookup_result_is_independent_of_order() {
1858        let devices = [
1859            on_macos(UUID_1, "Aranet4 12345"),
1860            on_macos(UUID_2, "Aranet4 1ABCD"),
1861            on_macos(UUID_3, "Aranet2 2751B"),
1862            // A second sensor with the first one's name.
1863            on_macos(UUID_4, "Aranet4 12345"),
1864        ];
1865        let cases = [
1866            ("Aranet4 1", "not found", vec![UUID_1, UUID_4, UUID_2]),
1867            ("aranet2 2751b", "found", vec![UUID_3]),
1868            ("Aranet4", "not found", vec![UUID_1, UUID_4, UUID_2]),
1869            ("Aranet4 12345", "ambiguous", vec![UUID_1, UUID_4]),
1870        ];
1871        for (query, kind, identifiers) in cases {
1872            let outcomes: Vec<(&str, Vec<String>)> = orders(devices.len())
1873                .iter()
1874                .map(|order| {
1875                    let shuffled: Vec<KnownPeripheral> =
1876                        order.iter().map(|&index| devices[index].clone()).collect();
1877                    outcome(query, &shuffled)
1878                })
1879                .collect();
1880            assert!(
1881                outcomes.iter().all(|answer| *answer == outcomes[0]),
1882                "the answer for {query:?} depends on the order: {outcomes:?}"
1883            );
1884            assert_eq!(outcomes[0].0, kind, "{query:?}");
1885            assert_eq!(outcomes[0].1, identifiers, "{query:?}");
1886        }
1887    }
1888
1889    #[test]
1890    fn resolve_lists_candidates_and_similar_names_sorted() {
1891        // Two sensors share a name; the one listed second sorts first.
1892        let same_name = [
1893            on_macos(UUID_2, "Aranet4 12345"),
1894            on_macos(UUID_1, "Aranet4 12345"),
1895        ];
1896        assert_eq!(
1897            candidates("Aranet4 12345", &same_name),
1898            [
1899                format!("Aranet4 12345 ({UUID_1})"),
1900                format!("Aranet4 12345 ({UUID_2})"),
1901            ]
1902        );
1903
1904        // Two entries with one address, the first without a name.
1905        let one_address = [
1906            known(
1907                "AA:BB:CC:DD:EE:FF",
1908                "hci1/dev_AA_BB_CC_DD_EE_FF",
1909                "AA:BB:CC:DD:EE:FF",
1910                None,
1911            ),
1912            on_linux("AA:BB:CC:DD:EE:FF", "Aranet4 12345"),
1913        ];
1914        assert_eq!(
1915            candidates("aa:bb:cc:dd:ee:ff", &one_address),
1916            [
1917                "Aranet4 12345 (AA:BB:CC:DD:EE:FF)",
1918                "unnamed (AA:BB:CC:DD:EE:FF)",
1919            ]
1920        );
1921
1922        // Similar names come back trimmed, sorted and without duplicates.
1923        let similar = [
1924            on_macos(UUID_1, "Aranet4 1ABCD "),
1925            on_macos(UUID_2, "Aranet4 12345"),
1926            on_macos(UUID_3, "Aranet4 1ABCD"),
1927        ];
1928        assert_eq!(
1929            resolve("aranet4 1", &similar).unwrap(),
1930            Search::Missing {
1931                similar: vec!["Aranet4 12345".to_string(), "Aranet4 1ABCD".to_string()]
1932            }
1933        );
1934    }
1935
1936    #[test]
1937    fn parse_query_rejects_empty_and_blank_identifiers() {
1938        for identifier in ["", "   ", "\t\n"] {
1939            assert!(
1940                matches!(parse_query(identifier), Err(Error::InvalidConfig(_))),
1941                "{identifier:?}"
1942            );
1943        }
1944        assert_eq!(parse_query(" x ").unwrap(), "x");
1945    }
1946
1947    #[tokio::test]
1948    async fn find_device_rejects_an_empty_identifier_without_bluetooth() {
1949        // The real clock, unlike the other async tests here. Both calls must
1950        // return before they touch Bluetooth. If one reached the Bluetooth
1951        // stack, a paused clock would jump to the limit while the call waited
1952        // on the `aranet-ble` thread, and the failure would show a timeout
1953        // instead of the stack's own error. A correct call never waits, so the
1954        // 5 s limit matters only then.
1955        let found = within(Duration::from_secs(5), find_device("")).await;
1956        assert!(
1957            matches!(found, Err(Error::InvalidConfig(_))),
1958            "{:?}",
1959            found.err()
1960        );
1961
1962        let connected = within(Duration::from_secs(5), crate::device::Device::connect("  ")).await;
1963        assert!(
1964            matches!(connected, Err(Error::InvalidConfig(_))),
1965            "{:?}",
1966            connected.err()
1967        );
1968    }
1969
1970    // ==================== DiscoveredDevice Tests ====================
1971    // Note: DiscoveredDevice tests are removed because PeripheralId from btleplug
1972    // has platform-specific implementations that cannot be easily mocked in tests.
1973    // - macOS: PeripheralId wraps a UUID
1974    // - Linux: PeripheralId wraps bluez_async::DeviceId (not directly accessible)
1975    // - Windows: PeripheralId wraps a u64
1976    //
1977    // The DiscoveredDevice struct derives Clone and Debug, so these traits are
1978    // guaranteed to work correctly by the compiler.
1979}