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