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/// Get the first available Bluetooth adapter.
179pub async fn get_adapter() -> Result<Adapter> {
180    use crate::error::DeviceNotFoundReason;
181
182    // On Linux, register a BlueZ agent to handle authentication during service
183    // discovery. Without this, BlueZ hangs when it encounters characteristics
184    // that require authentication (e.g., Battery Level on Aranet devices).
185    #[cfg(target_os = "linux")]
186    crate::bluez_agent::ensure_agent();
187
188    let manager = shared_manager().await?;
189    let adapters = match manager.adapters().await {
190        Ok(a) => a,
191        Err(e) => {
192            // The D-Bus connection may have died — reset the cached manager
193            // so the next call creates a fresh connection.
194            reset_manager().await;
195            return Err(e.into());
196        }
197    };
198
199    adapters
200        .into_iter()
201        .next()
202        .ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
203}
204
205/// Scan for Aranet devices in range.
206///
207/// Returns a list of discovered devices, or an error if the scan failed.
208/// An empty list indicates no devices were found (not an error).
209///
210/// # Errors
211///
212/// Returns an error if:
213/// - No Bluetooth adapter is available
214/// - Bluetooth is not enabled
215/// - The scan could not be started or stopped
216pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
217    scan_with_options(ScanOptions::default()).await
218}
219
220/// Scan for devices with custom options.
221pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
222    let adapter = get_adapter().await?;
223    scan_with_adapter(&adapter, options).await
224}
225
226/// Scan for devices with retry logic for flaky Bluetooth environments.
227///
228/// This function will retry the scan up to `max_retries` times if:
229/// - The scan fails due to a Bluetooth error
230/// - No devices are found (when `retry_on_empty` is true)
231///
232/// A delay is applied between retries, starting at 500ms and doubling each attempt.
233///
234/// # Arguments
235///
236/// * `options` - Scan options
237/// * `max_retries` - Maximum number of retry attempts
238/// * `retry_on_empty` - Whether to retry if no devices are found
239///
240/// # Example
241///
242/// ```ignore
243/// use aranet_core::scan::{ScanOptions, scan_with_retry};
244///
245/// // Retry up to 3 times, including when no devices found
246/// let devices = scan_with_retry(ScanOptions::default(), 3, true).await?;
247/// ```
248pub async fn scan_with_retry(
249    options: ScanOptions,
250    max_retries: u32,
251    retry_on_empty: bool,
252) -> Result<Vec<DiscoveredDevice>> {
253    let mut attempt = 0;
254    let mut delay = Duration::from_millis(500);
255
256    loop {
257        match scan_with_options(options.clone()).await {
258            Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
259                attempt += 1;
260                warn!(
261                    "No devices found, retrying ({}/{})...",
262                    attempt, max_retries
263                );
264                sleep(delay).await;
265                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
266            }
267            Ok(devices) => return Ok(devices),
268            Err(e) if attempt < max_retries => {
269                attempt += 1;
270                warn!(
271                    "Scan failed ({}), retrying ({}/{})...",
272                    e, attempt, max_retries
273                );
274                sleep(delay).await;
275                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
276            }
277            Err(e) => return Err(e),
278        }
279    }
280}
281
282/// Scan for devices using a specific adapter.
283pub async fn scan_with_adapter(
284    adapter: &Adapter,
285    options: ScanOptions,
286) -> Result<Vec<DiscoveredDevice>> {
287    info!(
288        "Starting BLE scan for {} seconds (service_filter={})...",
289        options.duration.as_secs(),
290        options.use_service_filter
291    );
292
293    // Create scan filter - optionally filter for Aranet service UUIDs
294    let scan_filter = if options.use_service_filter {
295        ScanFilter {
296            services: vec![SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD],
297        }
298    } else {
299        ScanFilter::default()
300    };
301
302    // Start scanning
303    adapter.start_scan(scan_filter).await?;
304
305    // Wait for the scan duration
306    sleep(options.duration).await;
307
308    // Stop scanning
309    adapter.stop_scan().await?;
310
311    // Get discovered peripherals
312    let peripherals = adapter.peripherals().await?;
313    let mut discovered = Vec::new();
314
315    for peripheral in peripherals {
316        match process_peripheral(&peripheral, options.filter_aranet_only).await {
317            Ok(Some(device)) => {
318                info!("Found Aranet device: {:?}", device.name);
319                discovered.push(device);
320            }
321            Ok(None) => {
322                // Not an Aranet device or filtered out
323            }
324            Err(e) => {
325                debug!("Error processing peripheral: {}", e);
326            }
327        }
328    }
329
330    info!("Scan complete. Found {} device(s)", discovered.len());
331    Ok(discovered)
332}
333
334/// Process a peripheral and determine if it's an Aranet device.
335async fn process_peripheral(
336    peripheral: &Peripheral,
337    filter_aranet_only: bool,
338) -> Result<Option<DiscoveredDevice>> {
339    let properties = peripheral.properties().await?;
340    let properties = match properties {
341        Some(p) => p,
342        None => return Ok(None),
343    };
344
345    let id = peripheral.id();
346    let address = properties.address.to_string();
347    let name = properties.local_name.clone();
348    let rssi = properties.rssi;
349
350    // Check if this is an Aranet device
351    let is_aranet = is_aranet_device(&properties);
352
353    if filter_aranet_only && !is_aranet {
354        return Ok(None);
355    }
356
357    // Try to determine device type from name
358    let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
359
360    // Get manufacturer data if available
361    let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
362
363    // Create identifier: use peripheral ID string on macOS (where address is 00:00:00:00:00:00)
364    // On other platforms, use the address
365    let identifier = create_identifier(&address, &id);
366
367    Ok(Some(DiscoveredDevice {
368        name,
369        id,
370        address,
371        identifier,
372        rssi,
373        device_type,
374        is_aranet,
375        manufacturer_data,
376    }))
377}
378
379/// Check if a peripheral is an Aranet device based on its properties.
380fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
381    // Check manufacturer data for Aranet manufacturer ID
382    if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
383        return true;
384    }
385
386    // Check service UUIDs for Aranet services
387    for service_uuid in properties.service_data.keys() {
388        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
389            return true;
390        }
391    }
392
393    // Check advertised services
394    for service_uuid in &properties.services {
395        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
396            return true;
397        }
398    }
399
400    // Check device name for Aranet
401    if let Some(name) = &properties.local_name {
402        let name_lower = name.to_lowercase();
403        if name_lower.contains("aranet") {
404            return true;
405        }
406    }
407
408    false
409}
410
411/// Find a specific device by name or address.
412pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
413    find_device_with_options(identifier, ScanOptions::default()).await
414}
415
416/// Find a specific device by name or address with custom options.
417///
418/// This function uses a retry strategy to improve reliability:
419/// 1. First checks if the device is already known (cached from previous scans)
420/// 2. Performs up to 3 scan attempts with increasing durations
421///
422/// This helps with BLE reliability issues where devices may not appear
423/// on every scan due to advertisement timing.
424pub async fn find_device_with_options(
425    identifier: &str,
426    options: ScanOptions,
427) -> Result<(Adapter, Peripheral)> {
428    find_device_with_progress(identifier, options, None).await
429}
430
431/// Find a specific device using a pre-existing adapter.
432///
433/// This avoids creating a new btleplug `Manager` (and D-Bus connection) on
434/// every call.  The caller is responsible for keeping the `Adapter` alive.
435pub async fn find_device_with_adapter(
436    adapter: &Adapter,
437    identifier: &str,
438    options: ScanOptions,
439) -> Result<Peripheral> {
440    find_device_with_adapter_progress(adapter, identifier, options, None).await
441}
442
443/// Find a specific device using a pre-existing adapter, with progress callback.
444pub async fn find_device_with_adapter_progress(
445    adapter: &Adapter,
446    identifier: &str,
447    options: ScanOptions,
448    progress: Option<ProgressCallback>,
449) -> Result<Peripheral> {
450    let identifier_lower = identifier.to_lowercase();
451
452    info!("Looking for device: {}", identifier);
453
454    if let Some(peripheral) = find_peripheral_by_identifier(adapter, &identifier_lower).await? {
455        info!("Found device in cache (no scan needed)");
456        if let Some(ref cb) = progress {
457            cb(FindProgress::CacheHit);
458        }
459        return Ok(peripheral);
460    }
461
462    let max_attempts: u32 = 3;
463    let base_duration = options.duration.as_millis() as u64 / 2;
464    let base_duration = Duration::from_millis(base_duration.max(2000));
465
466    for attempt in 1..=max_attempts {
467        let scan_duration = base_duration * attempt;
468        let duration_secs = scan_duration.as_secs();
469
470        info!(
471            "Scan attempt {}/{} ({}s)...",
472            attempt, max_attempts, duration_secs
473        );
474
475        if let Some(ref cb) = progress {
476            cb(FindProgress::ScanAttempt {
477                attempt,
478                total: max_attempts,
479                duration_secs,
480            });
481        }
482
483        adapter.start_scan(ScanFilter::default()).await?;
484        sleep(scan_duration).await;
485        adapter.stop_scan().await?;
486
487        if let Some(peripheral) = find_peripheral_by_identifier(adapter, &identifier_lower).await? {
488            info!("Found device on attempt {}", attempt);
489            if let Some(ref cb) = progress {
490                cb(FindProgress::Found { attempt });
491            }
492            return Ok(peripheral);
493        }
494
495        if attempt < max_attempts {
496            warn!("Device not found, retrying...");
497            if let Some(ref cb) = progress {
498                cb(FindProgress::RetryNeeded { attempt });
499            }
500        }
501    }
502
503    warn!(
504        "Device not found after {} attempts: {}",
505        max_attempts, identifier
506    );
507    Err(Error::device_not_found(identifier))
508}
509
510/// Find a specific device with progress callback for UI feedback.
511///
512/// The progress callback is called with updates about the search progress,
513/// including cache hits, scan attempts, and retry information.
514pub async fn find_device_with_progress(
515    identifier: &str,
516    options: ScanOptions,
517    progress: Option<ProgressCallback>,
518) -> Result<(Adapter, Peripheral)> {
519    let adapter = get_adapter().await?;
520    let peripheral =
521        find_device_with_adapter_progress(&adapter, identifier, options, progress).await?;
522    Ok((adapter, peripheral))
523}
524
525/// Search through known peripherals to find one matching the identifier.
526async fn find_peripheral_by_identifier(
527    adapter: &Adapter,
528    identifier_lower: &str,
529) -> Result<Option<Peripheral>> {
530    let peripherals = adapter.peripherals().await?;
531
532    for peripheral in peripherals {
533        if let Ok(Some(props)) = peripheral.properties().await {
534            let address = props.address.to_string().to_lowercase();
535            let peripheral_id = format_peripheral_id(&peripheral.id()).to_lowercase();
536
537            // Check peripheral ID match (macOS uses UUIDs)
538            if peripheral_id.contains(identifier_lower) {
539                debug!("Matched by peripheral ID: {}", peripheral_id);
540                return Ok(Some(peripheral));
541            }
542
543            // Check address match (Linux/Windows use MAC addresses)
544            if address != "00:00:00:00:00:00"
545                && (address == identifier_lower
546                    || address.replace(':', "") == identifier_lower.replace(':', ""))
547            {
548                debug!("Matched by address: {}", address);
549                return Ok(Some(peripheral));
550            }
551
552            // Check name match (partial match supported)
553            if let Some(name) = &props.local_name
554                && name.to_lowercase().contains(identifier_lower)
555            {
556                debug!("Matched by name: {}", name);
557                return Ok(Some(peripheral));
558            }
559        }
560    }
561
562    Ok(None)
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    // ==================== ScanOptions Tests ====================
570
571    #[test]
572    fn test_scan_options_default() {
573        let options = ScanOptions::default();
574        assert_eq!(options.duration, Duration::from_secs(5));
575        assert!(options.filter_aranet_only);
576    }
577
578    #[test]
579    fn test_scan_options_new() {
580        let options = ScanOptions::new();
581        assert_eq!(options.duration, Duration::from_secs(5));
582        assert!(options.filter_aranet_only);
583    }
584
585    #[test]
586    fn test_scan_options_duration() {
587        let options = ScanOptions::new().duration(Duration::from_secs(10));
588        assert_eq!(options.duration, Duration::from_secs(10));
589    }
590
591    #[test]
592    fn test_scan_options_duration_secs() {
593        let options = ScanOptions::new().duration_secs(15);
594        assert_eq!(options.duration, Duration::from_secs(15));
595    }
596
597    #[test]
598    fn test_scan_options_filter_aranet_only() {
599        let options = ScanOptions::new().filter_aranet_only(false);
600        assert!(!options.filter_aranet_only);
601
602        let options = ScanOptions::new().filter_aranet_only(true);
603        assert!(options.filter_aranet_only);
604    }
605
606    #[test]
607    fn test_scan_options_all_devices() {
608        let options = ScanOptions::new().all_devices();
609        assert!(!options.filter_aranet_only);
610    }
611
612    #[test]
613    fn test_scan_options_chaining() {
614        let options = ScanOptions::new()
615            .duration_secs(20)
616            .filter_aranet_only(false);
617
618        assert_eq!(options.duration, Duration::from_secs(20));
619        assert!(!options.filter_aranet_only);
620    }
621
622    #[test]
623    fn test_scan_options_clone() {
624        let options1 = ScanOptions::new().duration_secs(8);
625        let options2 = options1.clone();
626
627        assert_eq!(options1.duration, options2.duration);
628        assert_eq!(options1.filter_aranet_only, options2.filter_aranet_only);
629    }
630
631    #[test]
632    fn test_scan_options_debug() {
633        let options = ScanOptions::new();
634        let debug = format!("{:?}", options);
635        assert!(debug.contains("ScanOptions"));
636        assert!(debug.contains("duration"));
637        assert!(debug.contains("filter_aranet_only"));
638    }
639
640    // ==================== FindProgress Tests ====================
641
642    #[test]
643    fn test_find_progress_cache_hit() {
644        let progress = FindProgress::CacheHit;
645        let debug = format!("{:?}", progress);
646        assert!(debug.contains("CacheHit"));
647    }
648
649    #[test]
650    fn test_find_progress_scan_attempt() {
651        let progress = FindProgress::ScanAttempt {
652            attempt: 2,
653            total: 3,
654            duration_secs: 5,
655        };
656
657        if let FindProgress::ScanAttempt {
658            attempt,
659            total,
660            duration_secs,
661        } = progress
662        {
663            assert_eq!(attempt, 2);
664            assert_eq!(total, 3);
665            assert_eq!(duration_secs, 5);
666        } else {
667            panic!("Expected ScanAttempt variant");
668        }
669    }
670
671    #[test]
672    fn test_find_progress_found() {
673        let progress = FindProgress::Found { attempt: 1 };
674        assert!(matches!(progress, FindProgress::Found { attempt: 1 }));
675    }
676
677    #[test]
678    fn test_find_progress_retry_needed() {
679        let progress = FindProgress::RetryNeeded { attempt: 2 };
680        assert!(matches!(progress, FindProgress::RetryNeeded { attempt: 2 }));
681    }
682
683    #[test]
684    fn test_find_progress_clone() {
685        let progress1 = FindProgress::ScanAttempt {
686            attempt: 1,
687            total: 3,
688            duration_secs: 4,
689        };
690        let progress2 = progress1.clone();
691
692        assert!(matches!(
693            (&progress1, &progress2),
694            (
695                FindProgress::ScanAttempt {
696                    attempt: 1,
697                    total: 3,
698                    duration_secs: 4,
699                },
700                FindProgress::ScanAttempt {
701                    attempt: 1,
702                    total: 3,
703                    duration_secs: 4,
704                },
705            )
706        ));
707    }
708
709    // ==================== DiscoveredDevice Tests ====================
710    // Note: DiscoveredDevice tests are removed because PeripheralId from btleplug
711    // has platform-specific implementations that cannot be easily mocked in tests.
712    // - macOS: PeripheralId wraps a UUID
713    // - Linux: PeripheralId wraps bluez_async::DeviceId (not directly accessible)
714    // - Windows: PeripheralId wraps a u64
715    //
716    // The DiscoveredDevice struct derives Clone and Debug, so these traits are
717    // guaranteed to work correctly by the compiler.
718}