Skip to main content

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
6use std::time::Duration;
7
8use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
9use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
10use tokio::sync::RwLock;
11use tokio::time::sleep;
12use tracing::{debug, info, warn};
13
14/// Cached BLE manager — avoids creating a new D-Bus connection on every call.
15///
16/// Using `RwLock<Option<Manager>>` instead of `OnceCell` so the manager can
17/// be re-created if the underlying D-Bus connection dies (e.g., dbus-daemon
18/// restart, adapter reset).
19static MANAGER: RwLock<Option<Manager>> = RwLock::const_new(None);
20
21/// Get or create the shared BLE manager.
22async fn shared_manager() -> Result<Manager> {
23    // Fast path: read lock to return existing manager.
24    {
25        let guard = MANAGER.read().await;
26        if let Some(m) = guard.as_ref() {
27            return Ok(m.clone());
28        }
29    }
30    // Slow path: create a new manager under write lock.
31    let mut guard = MANAGER.write().await;
32    // Double-check after acquiring write lock.
33    if let Some(m) = guard.as_ref() {
34        return Ok(m.clone());
35    }
36    let m = Manager::new().await?;
37    *guard = Some(m.clone());
38    Ok(m)
39}
40
41/// Reset the cached manager, forcing the next call to create a fresh one.
42///
43/// Call this when the D-Bus connection appears to be dead (e.g., adapter
44/// enumeration fails with a connection error).
45async fn reset_manager() {
46    let mut guard = MANAGER.write().await;
47    if guard.take().is_some() {
48        warn!("BLE manager reset — next operation will create a new D-Bus connection");
49    }
50}
51
52use crate::error::{Error, Result};
53use crate::util::{create_identifier, format_peripheral_id};
54use crate::uuid::{MANUFACTURER_ID, SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD};
55use aranet_types::DeviceType;
56
57/// Progress update for device finding operations.
58#[derive(Debug, Clone)]
59pub enum FindProgress {
60    /// Found device in cache, no scan needed.
61    CacheHit,
62    /// Starting scan attempt.
63    ScanAttempt {
64        /// Current attempt number (1-based).
65        attempt: u32,
66        /// Total number of attempts.
67        total: u32,
68        /// Duration of this scan attempt.
69        duration_secs: u64,
70    },
71    /// Device found on specific attempt.
72    Found { attempt: u32 },
73    /// Attempt failed, will retry.
74    RetryNeeded { attempt: u32 },
75}
76
77/// Callback type for progress updates during device finding.
78pub type ProgressCallback = Box<dyn Fn(FindProgress) + Send + Sync>;
79
80/// Information about a discovered Aranet device.
81#[derive(Debug, Clone)]
82pub struct DiscoveredDevice {
83    /// The device name (e.g., "Aranet4 12345").
84    pub name: Option<String>,
85    /// The peripheral ID for connecting.
86    pub id: PeripheralId,
87    /// The BLE address as a string (may be zeros on macOS, use `id` instead).
88    pub address: String,
89    /// A connection identifier (peripheral ID on macOS, address on other platforms).
90    pub identifier: String,
91    /// RSSI signal strength.
92    pub rssi: Option<i16>,
93    /// Device type if detected from advertisement.
94    pub device_type: Option<DeviceType>,
95    /// Whether the device is connectable.
96    pub is_aranet: bool,
97    /// Raw manufacturer data from advertisement (if available).
98    pub manufacturer_data: Option<Vec<u8>>,
99}
100
101/// Options for scanning.
102#[derive(Debug, Clone)]
103pub struct ScanOptions {
104    /// How long to scan for devices.
105    pub duration: Duration,
106    /// Only return devices that appear to be Aranet devices.
107    pub filter_aranet_only: bool,
108    /// Use targeted BLE scan filter for Aranet service UUIDs.
109    /// This reduces noise from non-Aranet devices but may not work on all platforms.
110    pub use_service_filter: bool,
111}
112
113impl Default for ScanOptions {
114    fn default() -> Self {
115        Self {
116            duration: Duration::from_secs(5),
117            filter_aranet_only: true,
118            // Default to false for maximum compatibility - service filtering
119            // may not work on all platforms/adapters
120            use_service_filter: false,
121        }
122    }
123}
124
125impl ScanOptions {
126    /// Create new scan options with defaults.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// Set the scan duration.
132    pub fn duration(mut self, duration: Duration) -> Self {
133        self.duration = duration;
134        self
135    }
136
137    /// Set scan duration in seconds.
138    pub fn duration_secs(mut self, secs: u64) -> Self {
139        self.duration = Duration::from_secs(secs);
140        self
141    }
142
143    /// Set whether to filter for Aranet devices only.
144    pub fn filter_aranet_only(mut self, filter: bool) -> Self {
145        self.filter_aranet_only = filter;
146        self
147    }
148
149    /// Scan for all BLE devices, not just Aranet.
150    pub fn all_devices(self) -> Self {
151        self.filter_aranet_only(false)
152    }
153
154    /// Enable or disable BLE service UUID filtering.
155    ///
156    /// When enabled, the BLE scan will filter for Aranet service UUIDs at the
157    /// adapter level, reducing noise from non-Aranet devices. This may not
158    /// work on all platforms or with all BLE adapters.
159    ///
160    /// Default: `false` (for maximum compatibility)
161    pub fn use_service_filter(mut self, enable: bool) -> Self {
162        self.use_service_filter = enable;
163        self
164    }
165
166    /// Create optimized scan options for finding Aranet devices quickly.
167    ///
168    /// Uses service UUID filtering if available and a shorter scan duration.
169    pub fn optimized() -> Self {
170        Self {
171            duration: Duration::from_secs(3),
172            filter_aranet_only: true,
173            use_service_filter: true,
174        }
175    }
176}
177
178/// Adapter reused by [`get_adapter`] on macOS.
179///
180/// On CoreBluetooth every `Manager::adapters()` call starts a new
181/// `CBCentralManager` on its own OS thread that never exits, so creating an
182/// adapter per connection leaks a thread per poll. Other platforms create
183/// adapters cheaply and stay uncached, so a dead D-Bus connection can still
184/// be recovered by `reset_manager`.
185///
186/// The adapter is created on [`run_on_process_runtime`], because btleplug runs
187/// its event loop (device discovery, and each peripheral's notification task)
188/// on the runtime that creates it. Created on a caller's runtime, it would stop
189/// seeing devices, with no error, once that runtime shut down, which happens
190/// after every `#[tokio::test]` and in any program that builds a runtime per
191/// call.
192static ADAPTER: RwLock<Option<Adapter>> = RwLock::const_new(None);
193
194/// Runtime for tasks that must outlive the caller's runtime. It is created on
195/// first use and runs one worker thread for the rest of the process.
196static PROCESS_RUNTIME: std::sync::Mutex<Option<tokio::runtime::Runtime>> =
197    std::sync::Mutex::new(None);
198
199/// Run `future` to completion on [`PROCESS_RUNTIME`]. Tasks it spawns keep
200/// running after the caller's runtime shuts down.
201async fn run_on_process_runtime<F>(future: F) -> Result<F::Output>
202where
203    F: std::future::Future + Send + 'static,
204    F::Output: Send + 'static,
205{
206    let handle = {
207        let mut guard = PROCESS_RUNTIME.lock().unwrap_or_else(|e| e.into_inner());
208        match guard.as_ref() {
209            Some(runtime) => runtime.handle().clone(),
210            None => {
211                let runtime = tokio::runtime::Builder::new_multi_thread()
212                    .worker_threads(1)
213                    .thread_name("aranet-ble")
214                    .enable_all()
215                    .build()?;
216                let handle = runtime.handle().clone();
217                *guard = Some(runtime);
218                handle
219            }
220        }
221    };
222    let output = handle.spawn(future).await.map_err(std::io::Error::from)?;
223    Ok(output)
224}
225
226/// Get the first available Bluetooth adapter.
227///
228/// On macOS the adapter is created once and shared by every caller in the
229/// process, whichever tokio runtime they use. Its background tasks run on a
230/// dedicated one-thread runtime that lives as long as the process, and a new
231/// adapter replaces it if its CoreBluetooth thread stops.
232pub async fn get_adapter() -> Result<Adapter> {
233    if cfg!(target_os = "macos") {
234        cached_adapter().await
235    } else {
236        create_adapter().await
237    }
238}
239
240async fn cached_adapter() -> Result<Adapter> {
241    let cached = ADAPTER.read().await.clone();
242    if let Some(adapter) = cached
243        && adapter_thread_is_running(&adapter).await
244    {
245        return Ok(adapter);
246    }
247    let mut guard = ADAPTER.write().await;
248    if let Some(adapter) = guard.as_ref() {
249        if adapter_thread_is_running(adapter).await {
250            return Ok(adapter.clone());
251        }
252        warn!("CoreBluetooth adapter thread has stopped; creating a new adapter");
253    }
254    let adapter = run_on_process_runtime(create_adapter()).await??;
255    *guard = Some(adapter.clone());
256    Ok(adapter)
257}
258
259/// Whether the cached adapter's CoreBluetooth thread still takes requests.
260///
261/// btleplug's CoreBluetooth thread can panic (for example when services are
262/// discovered after a connect has timed out). Every later request on that
263/// adapter then fails with "Channel closed", so it has to be replaced. A reply
264/// that is merely slow keeps the adapter: only a closed channel proves the
265/// thread is gone.
266async fn adapter_thread_is_running(adapter: &Adapter) -> bool {
267    let adapter = adapter.clone();
268    // Run on the process runtime so the timeout works even if the caller's
269    // runtime has no time driver.
270    let state = run_on_process_runtime(async move {
271        tokio::time::timeout(Duration::from_secs(2), adapter.adapter_state()).await
272    })
273    .await;
274    match state {
275        Ok(Ok(Err(e))) => {
276            debug!("Cached Bluetooth adapter is unusable: {e}");
277            false
278        }
279        _ => true,
280    }
281}
282
283async fn create_adapter() -> Result<Adapter> {
284    use crate::error::DeviceNotFoundReason;
285
286    // On Linux, register a BlueZ agent to handle authentication during service
287    // discovery. Without this, BlueZ hangs when it encounters characteristics
288    // that require authentication (e.g., Battery Level on Aranet devices).
289    #[cfg(target_os = "linux")]
290    crate::bluez_agent::ensure_agent();
291
292    let manager = shared_manager().await?;
293    let adapters = match manager.adapters().await {
294        Ok(a) => a,
295        Err(e) => {
296            // The D-Bus connection may have died — reset the cached manager
297            // so the next call creates a fresh connection.
298            reset_manager().await;
299            return Err(e.into());
300        }
301    };
302
303    adapters
304        .into_iter()
305        .next()
306        .ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
307}
308
309/// Scan for Aranet devices in range.
310///
311/// Returns a list of discovered devices, or an error if the scan failed.
312/// An empty list indicates no devices were found (not an error).
313///
314/// # Errors
315///
316/// Returns an error if:
317/// - No Bluetooth adapter is available
318/// - Bluetooth is not enabled
319/// - The scan could not be started or stopped
320pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
321    scan_with_options(ScanOptions::default()).await
322}
323
324/// Scan for devices with custom options.
325pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
326    let adapter = get_adapter().await?;
327    scan_with_adapter(&adapter, options).await
328}
329
330/// Scan for devices with retry logic for flaky Bluetooth environments.
331///
332/// This function will retry the scan up to `max_retries` times if:
333/// - The scan fails due to a Bluetooth error
334/// - No devices are found (when `retry_on_empty` is true)
335///
336/// A delay is applied between retries, starting at 500ms and doubling each attempt.
337///
338/// # Arguments
339///
340/// * `options` - Scan options
341/// * `max_retries` - Maximum number of retry attempts
342/// * `retry_on_empty` - Whether to retry if no devices are found
343///
344/// # Example
345///
346/// ```ignore
347/// use aranet_core::scan::{ScanOptions, scan_with_retry};
348///
349/// // Retry up to 3 times, including when no devices found
350/// let devices = scan_with_retry(ScanOptions::default(), 3, true).await?;
351/// ```
352pub async fn scan_with_retry(
353    options: ScanOptions,
354    max_retries: u32,
355    retry_on_empty: bool,
356) -> Result<Vec<DiscoveredDevice>> {
357    let mut attempt = 0;
358    let mut delay = Duration::from_millis(500);
359
360    loop {
361        match scan_with_options(options.clone()).await {
362            Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
363                attempt += 1;
364                warn!(
365                    "No devices found, retrying ({}/{})...",
366                    attempt, max_retries
367                );
368                sleep(delay).await;
369                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
370            }
371            Ok(devices) => return Ok(devices),
372            Err(e) if attempt < max_retries => {
373                attempt += 1;
374                warn!(
375                    "Scan failed ({}), retrying ({}/{})...",
376                    e, attempt, max_retries
377                );
378                sleep(delay).await;
379                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
380            }
381            Err(e) => return Err(e),
382        }
383    }
384}
385
386/// Scan for devices using a specific adapter.
387pub async fn scan_with_adapter(
388    adapter: &Adapter,
389    options: ScanOptions,
390) -> Result<Vec<DiscoveredDevice>> {
391    info!(
392        "Starting BLE scan for {} seconds (service_filter={})...",
393        options.duration.as_secs(),
394        options.use_service_filter
395    );
396
397    // Create scan filter - optionally filter for Aranet service UUIDs
398    let scan_filter = if options.use_service_filter {
399        ScanFilter {
400            services: vec![SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD],
401        }
402    } else {
403        ScanFilter::default()
404    };
405
406    // Start scanning
407    adapter.start_scan(scan_filter).await?;
408
409    // Wait for the scan duration
410    sleep(options.duration).await;
411
412    // Stop scanning
413    adapter.stop_scan().await?;
414
415    // Get discovered peripherals
416    let peripherals = adapter.peripherals().await?;
417    let mut discovered = Vec::new();
418
419    for peripheral in peripherals {
420        match process_peripheral(&peripheral, options.filter_aranet_only).await {
421            Ok(Some(device)) => {
422                info!("Found Aranet device: {:?}", device.name);
423                discovered.push(device);
424            }
425            Ok(None) => {
426                // Not an Aranet device or filtered out
427            }
428            Err(e) => {
429                debug!("Error processing peripheral: {}", e);
430            }
431        }
432    }
433
434    info!("Scan complete. Found {} device(s)", discovered.len());
435    Ok(discovered)
436}
437
438/// Process a peripheral and determine if it's an Aranet device.
439async fn process_peripheral(
440    peripheral: &Peripheral,
441    filter_aranet_only: bool,
442) -> Result<Option<DiscoveredDevice>> {
443    let properties = peripheral.properties().await?;
444    let properties = match properties {
445        Some(p) => p,
446        None => return Ok(None),
447    };
448
449    let id = peripheral.id();
450    let address = properties.address.to_string();
451    let name = properties.local_name.clone();
452    let rssi = properties.rssi;
453
454    // Check if this is an Aranet device
455    let is_aranet = is_aranet_device(&properties);
456
457    if filter_aranet_only && !is_aranet {
458        return Ok(None);
459    }
460
461    // Try to determine device type from name
462    let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
463
464    // Get manufacturer data if available
465    let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
466
467    // Create identifier: use peripheral ID string on macOS (where address is 00:00:00:00:00:00)
468    // On other platforms, use the address
469    let identifier = create_identifier(&address, &id);
470
471    Ok(Some(DiscoveredDevice {
472        name,
473        id,
474        address,
475        identifier,
476        rssi,
477        device_type,
478        is_aranet,
479        manufacturer_data,
480    }))
481}
482
483/// Check if a peripheral is an Aranet device based on its properties.
484fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
485    // Check manufacturer data for Aranet manufacturer ID
486    if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
487        return true;
488    }
489
490    // Check service UUIDs for Aranet services
491    for service_uuid in properties.service_data.keys() {
492        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
493            return true;
494        }
495    }
496
497    // Check advertised services
498    for service_uuid in &properties.services {
499        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
500            return true;
501        }
502    }
503
504    // Check device name for Aranet
505    if let Some(name) = &properties.local_name {
506        let name_lower = name.to_lowercase();
507        if name_lower.contains("aranet") {
508            return true;
509        }
510    }
511
512    false
513}
514
515/// Find a specific device by name or address.
516pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
517    find_device_with_options(identifier, ScanOptions::default()).await
518}
519
520/// Find a specific device by name or address with custom options.
521///
522/// This function uses a retry strategy to improve reliability:
523/// 1. First checks if the device is already known (cached from previous scans)
524/// 2. Performs up to 3 scan attempts with increasing durations
525///
526/// This helps with BLE reliability issues where devices may not appear
527/// on every scan due to advertisement timing.
528pub async fn find_device_with_options(
529    identifier: &str,
530    options: ScanOptions,
531) -> Result<(Adapter, Peripheral)> {
532    find_device_with_progress(identifier, options, None).await
533}
534
535/// Find a specific device using a pre-existing adapter.
536///
537/// This avoids creating a new btleplug `Manager` (and D-Bus connection) on
538/// every call.  The caller is responsible for keeping the `Adapter` alive.
539pub async fn find_device_with_adapter(
540    adapter: &Adapter,
541    identifier: &str,
542    options: ScanOptions,
543) -> Result<Peripheral> {
544    find_device_with_adapter_progress(adapter, identifier, options, None).await
545}
546
547/// Find a specific device using a pre-existing adapter, with progress callback.
548pub async fn find_device_with_adapter_progress(
549    adapter: &Adapter,
550    identifier: &str,
551    options: ScanOptions,
552    progress: Option<ProgressCallback>,
553) -> Result<Peripheral> {
554    let identifier_lower = identifier.to_lowercase();
555
556    info!("Looking for device: {}", identifier);
557
558    if let Some(peripheral) = find_peripheral_by_identifier(adapter, &identifier_lower).await? {
559        info!("Found device in cache (no scan needed)");
560        if let Some(ref cb) = progress {
561            cb(FindProgress::CacheHit);
562        }
563        return Ok(peripheral);
564    }
565
566    let max_attempts: u32 = 3;
567    let base_duration = options.duration.as_millis() as u64 / 2;
568    let base_duration = Duration::from_millis(base_duration.max(2000));
569
570    for attempt in 1..=max_attempts {
571        let scan_duration = base_duration * attempt;
572        let duration_secs = scan_duration.as_secs();
573
574        info!(
575            "Scan attempt {}/{} ({}s)...",
576            attempt, max_attempts, duration_secs
577        );
578
579        if let Some(ref cb) = progress {
580            cb(FindProgress::ScanAttempt {
581                attempt,
582                total: max_attempts,
583                duration_secs,
584            });
585        }
586
587        adapter.start_scan(ScanFilter::default()).await?;
588        sleep(scan_duration).await;
589        adapter.stop_scan().await?;
590
591        if let Some(peripheral) = find_peripheral_by_identifier(adapter, &identifier_lower).await? {
592            info!("Found device on attempt {}", attempt);
593            if let Some(ref cb) = progress {
594                cb(FindProgress::Found { attempt });
595            }
596            return Ok(peripheral);
597        }
598
599        if attempt < max_attempts {
600            warn!("Device not found, retrying...");
601            if let Some(ref cb) = progress {
602                cb(FindProgress::RetryNeeded { attempt });
603            }
604        }
605    }
606
607    warn!(
608        "Device not found after {} attempts: {}",
609        max_attempts, identifier
610    );
611    Err(Error::device_not_found(identifier))
612}
613
614/// Find a specific device with progress callback for UI feedback.
615///
616/// The progress callback is called with updates about the search progress,
617/// including cache hits, scan attempts, and retry information.
618pub async fn find_device_with_progress(
619    identifier: &str,
620    options: ScanOptions,
621    progress: Option<ProgressCallback>,
622) -> Result<(Adapter, Peripheral)> {
623    let adapter = get_adapter().await?;
624    let peripheral =
625        find_device_with_adapter_progress(&adapter, identifier, options, progress).await?;
626    Ok((adapter, peripheral))
627}
628
629/// Search through known peripherals to find one matching the identifier.
630async fn find_peripheral_by_identifier(
631    adapter: &Adapter,
632    identifier_lower: &str,
633) -> Result<Option<Peripheral>> {
634    let peripherals = adapter.peripherals().await?;
635
636    for peripheral in peripherals {
637        if let Ok(Some(props)) = peripheral.properties().await {
638            let address = props.address.to_string().to_lowercase();
639            let peripheral_id = format_peripheral_id(&peripheral.id()).to_lowercase();
640
641            // Check peripheral ID match (macOS uses UUIDs)
642            if peripheral_id.contains(identifier_lower) {
643                debug!("Matched by peripheral ID: {}", peripheral_id);
644                return Ok(Some(peripheral));
645            }
646
647            // Check address match (Linux/Windows use MAC addresses)
648            if address != "00:00:00:00:00:00"
649                && (address == identifier_lower
650                    || address.replace(':', "") == identifier_lower.replace(':', ""))
651            {
652                debug!("Matched by address: {}", address);
653                return Ok(Some(peripheral));
654            }
655
656            // Check name match (partial match supported)
657            if let Some(name) = &props.local_name
658                && name.to_lowercase().contains(identifier_lower)
659            {
660                debug!("Matched by name: {}", name);
661                return Ok(Some(peripheral));
662            }
663        }
664    }
665
666    Ok(None)
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    // ==================== ScanOptions Tests ====================
674
675    #[test]
676    fn test_scan_options_default() {
677        let options = ScanOptions::default();
678        assert_eq!(options.duration, Duration::from_secs(5));
679        assert!(options.filter_aranet_only);
680    }
681
682    #[test]
683    fn test_scan_options_new() {
684        let options = ScanOptions::new();
685        assert_eq!(options.duration, Duration::from_secs(5));
686        assert!(options.filter_aranet_only);
687    }
688
689    #[test]
690    fn test_scan_options_duration() {
691        let options = ScanOptions::new().duration(Duration::from_secs(10));
692        assert_eq!(options.duration, Duration::from_secs(10));
693    }
694
695    #[test]
696    fn test_scan_options_duration_secs() {
697        let options = ScanOptions::new().duration_secs(15);
698        assert_eq!(options.duration, Duration::from_secs(15));
699    }
700
701    #[test]
702    fn test_scan_options_filter_aranet_only() {
703        let options = ScanOptions::new().filter_aranet_only(false);
704        assert!(!options.filter_aranet_only);
705
706        let options = ScanOptions::new().filter_aranet_only(true);
707        assert!(options.filter_aranet_only);
708    }
709
710    #[test]
711    fn test_scan_options_all_devices() {
712        let options = ScanOptions::new().all_devices();
713        assert!(!options.filter_aranet_only);
714    }
715
716    #[test]
717    fn test_scan_options_chaining() {
718        let options = ScanOptions::new()
719            .duration_secs(20)
720            .filter_aranet_only(false);
721
722        assert_eq!(options.duration, Duration::from_secs(20));
723        assert!(!options.filter_aranet_only);
724    }
725
726    #[test]
727    fn test_scan_options_clone() {
728        let options1 = ScanOptions::new().duration_secs(8);
729        let options2 = options1.clone();
730
731        assert_eq!(options1.duration, options2.duration);
732        assert_eq!(options1.filter_aranet_only, options2.filter_aranet_only);
733    }
734
735    #[test]
736    fn test_scan_options_debug() {
737        let options = ScanOptions::new();
738        let debug = format!("{:?}", options);
739        assert!(debug.contains("ScanOptions"));
740        assert!(debug.contains("duration"));
741        assert!(debug.contains("filter_aranet_only"));
742    }
743
744    // ==================== FindProgress Tests ====================
745
746    #[test]
747    fn test_find_progress_cache_hit() {
748        let progress = FindProgress::CacheHit;
749        let debug = format!("{:?}", progress);
750        assert!(debug.contains("CacheHit"));
751    }
752
753    #[test]
754    fn test_find_progress_scan_attempt() {
755        let progress = FindProgress::ScanAttempt {
756            attempt: 2,
757            total: 3,
758            duration_secs: 5,
759        };
760
761        if let FindProgress::ScanAttempt {
762            attempt,
763            total,
764            duration_secs,
765        } = progress
766        {
767            assert_eq!(attempt, 2);
768            assert_eq!(total, 3);
769            assert_eq!(duration_secs, 5);
770        } else {
771            panic!("Expected ScanAttempt variant");
772        }
773    }
774
775    #[test]
776    fn test_find_progress_found() {
777        let progress = FindProgress::Found { attempt: 1 };
778        assert!(matches!(progress, FindProgress::Found { attempt: 1 }));
779    }
780
781    #[test]
782    fn test_find_progress_retry_needed() {
783        let progress = FindProgress::RetryNeeded { attempt: 2 };
784        assert!(matches!(progress, FindProgress::RetryNeeded { attempt: 2 }));
785    }
786
787    #[test]
788    fn test_find_progress_clone() {
789        let progress1 = FindProgress::ScanAttempt {
790            attempt: 1,
791            total: 3,
792            duration_secs: 4,
793        };
794        let progress2 = progress1.clone();
795
796        assert!(matches!(
797            (&progress1, &progress2),
798            (
799                FindProgress::ScanAttempt {
800                    attempt: 1,
801                    total: 3,
802                    duration_secs: 4,
803                },
804                FindProgress::ScanAttempt {
805                    attempt: 1,
806                    total: 3,
807                    duration_secs: 4,
808                },
809            )
810        ));
811    }
812
813    // ==================== Process Runtime Tests ====================
814
815    #[test]
816    fn test_tasks_spawned_on_process_runtime_outlive_the_caller_runtime() {
817        // btleplug spawns the adapter's event loop from inside adapter creation;
818        // that task must keep running after the caller's runtime shuts down.
819        let (tx, rx) = std::sync::mpsc::channel();
820        let caller = tokio::runtime::Builder::new_current_thread()
821            .enable_all()
822            .build()
823            .unwrap();
824        caller
825            .block_on(run_on_process_runtime(async move {
826                tokio::spawn(async move {
827                    tokio::time::sleep(Duration::from_millis(50)).await;
828                    tx.send(()).unwrap();
829                });
830            }))
831            .unwrap();
832        drop(caller);
833
834        rx.recv_timeout(Duration::from_secs(5))
835            .expect("task spawned on the process runtime died with the caller's runtime");
836    }
837
838    // ==================== DiscoveredDevice Tests ====================
839    // Note: DiscoveredDevice tests are removed because PeripheralId from btleplug
840    // has platform-specific implementations that cannot be easily mocked in tests.
841    // - macOS: PeripheralId wraps a UUID
842    // - Linux: PeripheralId wraps bluez_async::DeviceId (not directly accessible)
843    // - Windows: PeripheralId wraps a u64
844    //
845    // The DiscoveredDevice struct derives Clone and Debug, so these traits are
846    // guaranteed to work correctly by the compiler.
847}