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::time::sleep;
11use tracing::{debug, info, warn};
12
13use crate::error::{Error, Result};
14use crate::util::{create_identifier, format_peripheral_id};
15use crate::uuid::{MANUFACTURER_ID, SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD};
16use aranet_types::DeviceType;
17
18/// Progress update for device finding operations.
19#[derive(Debug, Clone)]
20pub enum FindProgress {
21    /// Found device in cache, no scan needed.
22    CacheHit,
23    /// Starting scan attempt.
24    ScanAttempt {
25        /// Current attempt number (1-based).
26        attempt: u32,
27        /// Total number of attempts.
28        total: u32,
29        /// Duration of this scan attempt.
30        duration_secs: u64,
31    },
32    /// Device found on specific attempt.
33    Found { attempt: u32 },
34    /// Attempt failed, will retry.
35    RetryNeeded { attempt: u32 },
36}
37
38/// Callback type for progress updates during device finding.
39pub type ProgressCallback = Box<dyn Fn(FindProgress) + Send + Sync>;
40
41/// Information about a discovered Aranet device.
42#[derive(Debug, Clone)]
43pub struct DiscoveredDevice {
44    /// The device name (e.g., "Aranet4 12345").
45    pub name: Option<String>,
46    /// The peripheral ID for connecting.
47    pub id: PeripheralId,
48    /// The BLE address as a string (may be zeros on macOS, use `id` instead).
49    pub address: String,
50    /// A connection identifier (peripheral ID on macOS, address on other platforms).
51    pub identifier: String,
52    /// RSSI signal strength.
53    pub rssi: Option<i16>,
54    /// Device type if detected from advertisement.
55    pub device_type: Option<DeviceType>,
56    /// Whether the device is connectable.
57    pub is_aranet: bool,
58    /// Raw manufacturer data from advertisement (if available).
59    pub manufacturer_data: Option<Vec<u8>>,
60}
61
62/// Options for scanning.
63#[derive(Debug, Clone)]
64pub struct ScanOptions {
65    /// How long to scan for devices.
66    pub duration: Duration,
67    /// Only return devices that appear to be Aranet devices.
68    pub filter_aranet_only: bool,
69    /// Use targeted BLE scan filter for Aranet service UUIDs.
70    /// This reduces noise from non-Aranet devices but may not work on all platforms.
71    pub use_service_filter: bool,
72}
73
74impl Default for ScanOptions {
75    fn default() -> Self {
76        Self {
77            duration: Duration::from_secs(5),
78            filter_aranet_only: true,
79            // Default to false for maximum compatibility - service filtering
80            // may not work on all platforms/adapters
81            use_service_filter: false,
82        }
83    }
84}
85
86impl ScanOptions {
87    /// Create new scan options with defaults.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Set the scan duration.
93    pub fn duration(mut self, duration: Duration) -> Self {
94        self.duration = duration;
95        self
96    }
97
98    /// Set scan duration in seconds.
99    pub fn duration_secs(mut self, secs: u64) -> Self {
100        self.duration = Duration::from_secs(secs);
101        self
102    }
103
104    /// Set whether to filter for Aranet devices only.
105    pub fn filter_aranet_only(mut self, filter: bool) -> Self {
106        self.filter_aranet_only = filter;
107        self
108    }
109
110    /// Scan for all BLE devices, not just Aranet.
111    pub fn all_devices(self) -> Self {
112        self.filter_aranet_only(false)
113    }
114
115    /// Enable or disable BLE service UUID filtering.
116    ///
117    /// When enabled, the BLE scan will filter for Aranet service UUIDs at the
118    /// adapter level, reducing noise from non-Aranet devices. This may not
119    /// work on all platforms or with all BLE adapters.
120    ///
121    /// Default: `false` (for maximum compatibility)
122    pub fn use_service_filter(mut self, enable: bool) -> Self {
123        self.use_service_filter = enable;
124        self
125    }
126
127    /// Create optimized scan options for finding Aranet devices quickly.
128    ///
129    /// Uses service UUID filtering if available and a shorter scan duration.
130    pub fn optimized() -> Self {
131        Self {
132            duration: Duration::from_secs(3),
133            filter_aranet_only: true,
134            use_service_filter: true,
135        }
136    }
137}
138
139/// Get the first available Bluetooth adapter.
140pub async fn get_adapter() -> Result<Adapter> {
141    use crate::error::DeviceNotFoundReason;
142
143    // On Linux, register a BlueZ agent to handle authentication during service
144    // discovery. Without this, BlueZ hangs when it encounters characteristics
145    // that require authentication (e.g., Battery Level on Aranet devices).
146    #[cfg(target_os = "linux")]
147    crate::bluez_agent::ensure_agent();
148
149    let manager = Manager::new().await?;
150    let adapters = manager.adapters().await?;
151
152    adapters
153        .into_iter()
154        .next()
155        .ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
156}
157
158/// Scan for Aranet devices in range.
159///
160/// Returns a list of discovered devices, or an error if the scan failed.
161/// An empty list indicates no devices were found (not an error).
162///
163/// # Errors
164///
165/// Returns an error if:
166/// - No Bluetooth adapter is available
167/// - Bluetooth is not enabled
168/// - The scan could not be started or stopped
169pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
170    scan_with_options(ScanOptions::default()).await
171}
172
173/// Scan for devices with custom options.
174pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
175    let adapter = get_adapter().await?;
176    scan_with_adapter(&adapter, options).await
177}
178
179/// Scan for devices with retry logic for flaky Bluetooth environments.
180///
181/// This function will retry the scan up to `max_retries` times if:
182/// - The scan fails due to a Bluetooth error
183/// - No devices are found (when `retry_on_empty` is true)
184///
185/// A delay is applied between retries, starting at 500ms and doubling each attempt.
186///
187/// # Arguments
188///
189/// * `options` - Scan options
190/// * `max_retries` - Maximum number of retry attempts
191/// * `retry_on_empty` - Whether to retry if no devices are found
192///
193/// # Example
194///
195/// ```ignore
196/// use aranet_core::scan::{ScanOptions, scan_with_retry};
197///
198/// // Retry up to 3 times, including when no devices found
199/// let devices = scan_with_retry(ScanOptions::default(), 3, true).await?;
200/// ```
201pub async fn scan_with_retry(
202    options: ScanOptions,
203    max_retries: u32,
204    retry_on_empty: bool,
205) -> Result<Vec<DiscoveredDevice>> {
206    let mut attempt = 0;
207    let mut delay = Duration::from_millis(500);
208
209    loop {
210        match scan_with_options(options.clone()).await {
211            Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
212                attempt += 1;
213                warn!(
214                    "No devices found, retrying ({}/{})...",
215                    attempt, max_retries
216                );
217                sleep(delay).await;
218                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
219            }
220            Ok(devices) => return Ok(devices),
221            Err(e) if attempt < max_retries => {
222                attempt += 1;
223                warn!(
224                    "Scan failed ({}), retrying ({}/{})...",
225                    e, attempt, max_retries
226                );
227                sleep(delay).await;
228                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
229            }
230            Err(e) => return Err(e),
231        }
232    }
233}
234
235/// Scan for devices using a specific adapter.
236pub async fn scan_with_adapter(
237    adapter: &Adapter,
238    options: ScanOptions,
239) -> Result<Vec<DiscoveredDevice>> {
240    info!(
241        "Starting BLE scan for {} seconds (service_filter={})...",
242        options.duration.as_secs(),
243        options.use_service_filter
244    );
245
246    // Create scan filter - optionally filter for Aranet service UUIDs
247    let scan_filter = if options.use_service_filter {
248        ScanFilter {
249            services: vec![SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD],
250        }
251    } else {
252        ScanFilter::default()
253    };
254
255    // Start scanning
256    adapter.start_scan(scan_filter).await?;
257
258    // Wait for the scan duration
259    sleep(options.duration).await;
260
261    // Stop scanning
262    adapter.stop_scan().await?;
263
264    // Get discovered peripherals
265    let peripherals = adapter.peripherals().await?;
266    let mut discovered = Vec::new();
267
268    for peripheral in peripherals {
269        match process_peripheral(&peripheral, options.filter_aranet_only).await {
270            Ok(Some(device)) => {
271                info!("Found Aranet device: {:?}", device.name);
272                discovered.push(device);
273            }
274            Ok(None) => {
275                // Not an Aranet device or filtered out
276            }
277            Err(e) => {
278                debug!("Error processing peripheral: {}", e);
279            }
280        }
281    }
282
283    info!("Scan complete. Found {} device(s)", discovered.len());
284    Ok(discovered)
285}
286
287/// Process a peripheral and determine if it's an Aranet device.
288async fn process_peripheral(
289    peripheral: &Peripheral,
290    filter_aranet_only: bool,
291) -> Result<Option<DiscoveredDevice>> {
292    let properties = peripheral.properties().await?;
293    let properties = match properties {
294        Some(p) => p,
295        None => return Ok(None),
296    };
297
298    let id = peripheral.id();
299    let address = properties.address.to_string();
300    let name = properties.local_name.clone();
301    let rssi = properties.rssi;
302
303    // Check if this is an Aranet device
304    let is_aranet = is_aranet_device(&properties);
305
306    if filter_aranet_only && !is_aranet {
307        return Ok(None);
308    }
309
310    // Try to determine device type from name
311    let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
312
313    // Get manufacturer data if available
314    let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
315
316    // Create identifier: use peripheral ID string on macOS (where address is 00:00:00:00:00:00)
317    // On other platforms, use the address
318    let identifier = create_identifier(&address, &id);
319
320    Ok(Some(DiscoveredDevice {
321        name,
322        id,
323        address,
324        identifier,
325        rssi,
326        device_type,
327        is_aranet,
328        manufacturer_data,
329    }))
330}
331
332/// Check if a peripheral is an Aranet device based on its properties.
333fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
334    // Check manufacturer data for Aranet manufacturer ID
335    if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
336        return true;
337    }
338
339    // Check service UUIDs for Aranet services
340    for service_uuid in properties.service_data.keys() {
341        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
342            return true;
343        }
344    }
345
346    // Check advertised services
347    for service_uuid in &properties.services {
348        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
349            return true;
350        }
351    }
352
353    // Check device name for Aranet
354    if let Some(name) = &properties.local_name {
355        let name_lower = name.to_lowercase();
356        if name_lower.contains("aranet") {
357            return true;
358        }
359    }
360
361    false
362}
363
364/// Find a specific device by name or address.
365pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
366    find_device_with_options(identifier, ScanOptions::default()).await
367}
368
369/// Find a specific device by name or address with custom options.
370///
371/// This function uses a retry strategy to improve reliability:
372/// 1. First checks if the device is already known (cached from previous scans)
373/// 2. Performs up to 3 scan attempts with increasing durations
374///
375/// This helps with BLE reliability issues where devices may not appear
376/// on every scan due to advertisement timing.
377pub async fn find_device_with_options(
378    identifier: &str,
379    options: ScanOptions,
380) -> Result<(Adapter, Peripheral)> {
381    find_device_with_progress(identifier, options, None).await
382}
383
384/// Find a specific device with progress callback for UI feedback.
385///
386/// The progress callback is called with updates about the search progress,
387/// including cache hits, scan attempts, and retry information.
388pub async fn find_device_with_progress(
389    identifier: &str,
390    options: ScanOptions,
391    progress: Option<ProgressCallback>,
392) -> Result<(Adapter, Peripheral)> {
393    let adapter = get_adapter().await?;
394    let identifier_lower = identifier.to_lowercase();
395
396    info!("Looking for device: {}", identifier);
397
398    // First, check if device is already known (cached from previous scans)
399    if let Some(peripheral) = find_peripheral_by_identifier(&adapter, &identifier_lower).await? {
400        info!("Found device in cache (no scan needed)");
401        if let Some(ref cb) = progress {
402            cb(FindProgress::CacheHit);
403        }
404        return Ok((adapter, peripheral));
405    }
406
407    // Retry with multiple scan attempts for better reliability
408    // BLE advertisements can be missed due to timing, so we try multiple times
409    let max_attempts: u32 = 3;
410    let base_duration = options.duration.as_millis() as u64 / 2;
411    let base_duration = Duration::from_millis(base_duration.max(2000)); // At least 2 seconds
412
413    for attempt in 1..=max_attempts {
414        let scan_duration = base_duration * attempt;
415        let duration_secs = scan_duration.as_secs();
416
417        info!(
418            "Scan attempt {}/{} ({}s)...",
419            attempt, max_attempts, duration_secs
420        );
421
422        if let Some(ref cb) = progress {
423            cb(FindProgress::ScanAttempt {
424                attempt,
425                total: max_attempts,
426                duration_secs,
427            });
428        }
429
430        // Start scanning
431        adapter.start_scan(ScanFilter::default()).await?;
432        sleep(scan_duration).await;
433        adapter.stop_scan().await?;
434
435        // Check if we found the device
436        if let Some(peripheral) = find_peripheral_by_identifier(&adapter, &identifier_lower).await?
437        {
438            info!("Found device on attempt {}", attempt);
439            if let Some(ref cb) = progress {
440                cb(FindProgress::Found { attempt });
441            }
442            return Ok((adapter, peripheral));
443        }
444
445        if attempt < max_attempts {
446            warn!("Device not found, retrying...");
447            if let Some(ref cb) = progress {
448                cb(FindProgress::RetryNeeded { attempt });
449            }
450        }
451    }
452
453    warn!(
454        "Device not found after {} attempts: {}",
455        max_attempts, identifier
456    );
457    Err(Error::device_not_found(identifier))
458}
459
460/// Search through known peripherals to find one matching the identifier.
461async fn find_peripheral_by_identifier(
462    adapter: &Adapter,
463    identifier_lower: &str,
464) -> Result<Option<Peripheral>> {
465    let peripherals = adapter.peripherals().await?;
466
467    for peripheral in peripherals {
468        if let Ok(Some(props)) = peripheral.properties().await {
469            let address = props.address.to_string().to_lowercase();
470            let peripheral_id = format_peripheral_id(&peripheral.id()).to_lowercase();
471
472            // Check peripheral ID match (macOS uses UUIDs)
473            if peripheral_id.contains(identifier_lower) {
474                debug!("Matched by peripheral ID: {}", peripheral_id);
475                return Ok(Some(peripheral));
476            }
477
478            // Check address match (Linux/Windows use MAC addresses)
479            if address != "00:00:00:00:00:00"
480                && (address == identifier_lower
481                    || address.replace(':', "") == identifier_lower.replace(':', ""))
482            {
483                debug!("Matched by address: {}", address);
484                return Ok(Some(peripheral));
485            }
486
487            // Check name match (partial match supported)
488            if let Some(name) = &props.local_name
489                && name.to_lowercase().contains(identifier_lower)
490            {
491                debug!("Matched by name: {}", name);
492                return Ok(Some(peripheral));
493            }
494        }
495    }
496
497    Ok(None)
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    // ==================== ScanOptions Tests ====================
505
506    #[test]
507    fn test_scan_options_default() {
508        let options = ScanOptions::default();
509        assert_eq!(options.duration, Duration::from_secs(5));
510        assert!(options.filter_aranet_only);
511    }
512
513    #[test]
514    fn test_scan_options_new() {
515        let options = ScanOptions::new();
516        assert_eq!(options.duration, Duration::from_secs(5));
517        assert!(options.filter_aranet_only);
518    }
519
520    #[test]
521    fn test_scan_options_duration() {
522        let options = ScanOptions::new().duration(Duration::from_secs(10));
523        assert_eq!(options.duration, Duration::from_secs(10));
524    }
525
526    #[test]
527    fn test_scan_options_duration_secs() {
528        let options = ScanOptions::new().duration_secs(15);
529        assert_eq!(options.duration, Duration::from_secs(15));
530    }
531
532    #[test]
533    fn test_scan_options_filter_aranet_only() {
534        let options = ScanOptions::new().filter_aranet_only(false);
535        assert!(!options.filter_aranet_only);
536
537        let options = ScanOptions::new().filter_aranet_only(true);
538        assert!(options.filter_aranet_only);
539    }
540
541    #[test]
542    fn test_scan_options_all_devices() {
543        let options = ScanOptions::new().all_devices();
544        assert!(!options.filter_aranet_only);
545    }
546
547    #[test]
548    fn test_scan_options_chaining() {
549        let options = ScanOptions::new()
550            .duration_secs(20)
551            .filter_aranet_only(false);
552
553        assert_eq!(options.duration, Duration::from_secs(20));
554        assert!(!options.filter_aranet_only);
555    }
556
557    #[test]
558    fn test_scan_options_clone() {
559        let options1 = ScanOptions::new().duration_secs(8);
560        let options2 = options1.clone();
561
562        assert_eq!(options1.duration, options2.duration);
563        assert_eq!(options1.filter_aranet_only, options2.filter_aranet_only);
564    }
565
566    #[test]
567    fn test_scan_options_debug() {
568        let options = ScanOptions::new();
569        let debug = format!("{:?}", options);
570        assert!(debug.contains("ScanOptions"));
571        assert!(debug.contains("duration"));
572        assert!(debug.contains("filter_aranet_only"));
573    }
574
575    // ==================== FindProgress Tests ====================
576
577    #[test]
578    fn test_find_progress_cache_hit() {
579        let progress = FindProgress::CacheHit;
580        let debug = format!("{:?}", progress);
581        assert!(debug.contains("CacheHit"));
582    }
583
584    #[test]
585    fn test_find_progress_scan_attempt() {
586        let progress = FindProgress::ScanAttempt {
587            attempt: 2,
588            total: 3,
589            duration_secs: 5,
590        };
591
592        if let FindProgress::ScanAttempt {
593            attempt,
594            total,
595            duration_secs,
596        } = progress
597        {
598            assert_eq!(attempt, 2);
599            assert_eq!(total, 3);
600            assert_eq!(duration_secs, 5);
601        } else {
602            panic!("Expected ScanAttempt variant");
603        }
604    }
605
606    #[test]
607    fn test_find_progress_found() {
608        let progress = FindProgress::Found { attempt: 1 };
609        assert!(matches!(progress, FindProgress::Found { attempt: 1 }));
610    }
611
612    #[test]
613    fn test_find_progress_retry_needed() {
614        let progress = FindProgress::RetryNeeded { attempt: 2 };
615        assert!(matches!(progress, FindProgress::RetryNeeded { attempt: 2 }));
616    }
617
618    #[test]
619    fn test_find_progress_clone() {
620        let progress1 = FindProgress::ScanAttempt {
621            attempt: 1,
622            total: 3,
623            duration_secs: 4,
624        };
625        let progress2 = progress1.clone();
626
627        assert!(matches!(
628            (&progress1, &progress2),
629            (
630                FindProgress::ScanAttempt {
631                    attempt: 1,
632                    total: 3,
633                    duration_secs: 4,
634                },
635                FindProgress::ScanAttempt {
636                    attempt: 1,
637                    total: 3,
638                    duration_secs: 4,
639                },
640            )
641        ));
642    }
643
644    // ==================== DiscoveredDevice Tests ====================
645    // Note: DiscoveredDevice tests are removed because PeripheralId from btleplug
646    // has platform-specific implementations that cannot be easily mocked in tests.
647    // - macOS: PeripheralId wraps a UUID
648    // - Linux: PeripheralId wraps bluez_async::DeviceId (not directly accessible)
649    // - Windows: PeripheralId wraps a u64
650    //
651    // The DiscoveredDevice struct derives Clone and Debug, so these traits are
652    // guaranteed to work correctly by the compiler.
653}