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}
70
71impl Default for ScanOptions {
72    fn default() -> Self {
73        Self {
74            duration: Duration::from_secs(5),
75            filter_aranet_only: true,
76        }
77    }
78}
79
80impl ScanOptions {
81    /// Create new scan options with defaults.
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Set the scan duration.
87    pub fn duration(mut self, duration: Duration) -> Self {
88        self.duration = duration;
89        self
90    }
91
92    /// Set scan duration in seconds.
93    pub fn duration_secs(mut self, secs: u64) -> Self {
94        self.duration = Duration::from_secs(secs);
95        self
96    }
97
98    /// Set whether to filter for Aranet devices only.
99    pub fn filter_aranet_only(mut self, filter: bool) -> Self {
100        self.filter_aranet_only = filter;
101        self
102    }
103
104    /// Scan for all BLE devices, not just Aranet.
105    pub fn all_devices(self) -> Self {
106        self.filter_aranet_only(false)
107    }
108}
109
110/// Get the first available Bluetooth adapter.
111pub async fn get_adapter() -> Result<Adapter> {
112    use crate::error::DeviceNotFoundReason;
113
114    let manager = Manager::new().await?;
115    let adapters = manager.adapters().await?;
116
117    adapters
118        .into_iter()
119        .next()
120        .ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
121}
122
123/// Scan for Aranet devices in range.
124///
125/// Returns a list of discovered devices, or an error if the scan failed.
126/// An empty list indicates no devices were found (not an error).
127///
128/// # Errors
129///
130/// Returns an error if:
131/// - No Bluetooth adapter is available
132/// - Bluetooth is not enabled
133/// - The scan could not be started or stopped
134pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
135    scan_with_options(ScanOptions::default()).await
136}
137
138/// Scan for devices with custom options.
139pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
140    let adapter = get_adapter().await?;
141    scan_with_adapter(&adapter, options).await
142}
143
144/// Scan for devices with retry logic for flaky Bluetooth environments.
145///
146/// This function will retry the scan up to `max_retries` times if:
147/// - The scan fails due to a Bluetooth error
148/// - No devices are found (when `retry_on_empty` is true)
149///
150/// A delay is applied between retries, starting at 500ms and doubling each attempt.
151///
152/// # Arguments
153///
154/// * `options` - Scan options
155/// * `max_retries` - Maximum number of retry attempts
156/// * `retry_on_empty` - Whether to retry if no devices are found
157///
158/// # Example
159///
160/// ```ignore
161/// use aranet_core::scan::{ScanOptions, scan_with_retry};
162///
163/// // Retry up to 3 times, including when no devices found
164/// let devices = scan_with_retry(ScanOptions::default(), 3, true).await?;
165/// ```
166pub async fn scan_with_retry(
167    options: ScanOptions,
168    max_retries: u32,
169    retry_on_empty: bool,
170) -> Result<Vec<DiscoveredDevice>> {
171    let mut attempt = 0;
172    let mut delay = Duration::from_millis(500);
173
174    loop {
175        match scan_with_options(options.clone()).await {
176            Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
177                attempt += 1;
178                warn!(
179                    "No devices found, retrying ({}/{})...",
180                    attempt, max_retries
181                );
182                sleep(delay).await;
183                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
184            }
185            Ok(devices) => return Ok(devices),
186            Err(e) if attempt < max_retries => {
187                attempt += 1;
188                warn!(
189                    "Scan failed ({}), retrying ({}/{})...",
190                    e, attempt, max_retries
191                );
192                sleep(delay).await;
193                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
194            }
195            Err(e) => return Err(e),
196        }
197    }
198}
199
200/// Scan for devices using a specific adapter.
201pub async fn scan_with_adapter(
202    adapter: &Adapter,
203    options: ScanOptions,
204) -> Result<Vec<DiscoveredDevice>> {
205    info!(
206        "Starting BLE scan for {} seconds...",
207        options.duration.as_secs()
208    );
209
210    // Start scanning
211    adapter.start_scan(ScanFilter::default()).await?;
212
213    // Wait for the scan duration
214    sleep(options.duration).await;
215
216    // Stop scanning
217    adapter.stop_scan().await?;
218
219    // Get discovered peripherals
220    let peripherals = adapter.peripherals().await?;
221    let mut discovered = Vec::new();
222
223    for peripheral in peripherals {
224        match process_peripheral(&peripheral, options.filter_aranet_only).await {
225            Ok(Some(device)) => {
226                info!("Found Aranet device: {:?}", device.name);
227                discovered.push(device);
228            }
229            Ok(None) => {
230                // Not an Aranet device or filtered out
231            }
232            Err(e) => {
233                debug!("Error processing peripheral: {}", e);
234            }
235        }
236    }
237
238    info!("Scan complete. Found {} device(s)", discovered.len());
239    Ok(discovered)
240}
241
242/// Process a peripheral and determine if it's an Aranet device.
243async fn process_peripheral(
244    peripheral: &Peripheral,
245    filter_aranet_only: bool,
246) -> Result<Option<DiscoveredDevice>> {
247    let properties = peripheral.properties().await?;
248    let properties = match properties {
249        Some(p) => p,
250        None => return Ok(None),
251    };
252
253    let id = peripheral.id();
254    let address = properties.address.to_string();
255    let name = properties.local_name.clone();
256    let rssi = properties.rssi;
257
258    // Check if this is an Aranet device
259    let is_aranet = is_aranet_device(&properties);
260
261    if filter_aranet_only && !is_aranet {
262        return Ok(None);
263    }
264
265    // Try to determine device type from name
266    let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
267
268    // Get manufacturer data if available
269    let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
270
271    // Create identifier: use peripheral ID string on macOS (where address is 00:00:00:00:00:00)
272    // On other platforms, use the address
273    let identifier = create_identifier(&address, &id);
274
275    Ok(Some(DiscoveredDevice {
276        name,
277        id,
278        address,
279        identifier,
280        rssi,
281        device_type,
282        is_aranet,
283        manufacturer_data,
284    }))
285}
286
287/// Check if a peripheral is an Aranet device based on its properties.
288fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
289    // Check manufacturer data for Aranet manufacturer ID
290    if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
291        return true;
292    }
293
294    // Check service UUIDs for Aranet services
295    for service_uuid in properties.service_data.keys() {
296        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
297            return true;
298        }
299    }
300
301    // Check advertised services
302    for service_uuid in &properties.services {
303        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
304            return true;
305        }
306    }
307
308    // Check device name for Aranet
309    if let Some(name) = &properties.local_name {
310        let name_lower = name.to_lowercase();
311        if name_lower.contains("aranet") {
312            return true;
313        }
314    }
315
316    false
317}
318
319/// Find a specific device by name or address.
320pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
321    find_device_with_options(identifier, ScanOptions::default()).await
322}
323
324/// Find a specific device by name or address with custom options.
325///
326/// This function uses a retry strategy to improve reliability:
327/// 1. First checks if the device is already known (cached from previous scans)
328/// 2. Performs up to 3 scan attempts with increasing durations
329///
330/// This helps with BLE reliability issues where devices may not appear
331/// on every scan due to advertisement timing.
332pub async fn find_device_with_options(
333    identifier: &str,
334    options: ScanOptions,
335) -> Result<(Adapter, Peripheral)> {
336    find_device_with_progress(identifier, options, None).await
337}
338
339/// Find a specific device with progress callback for UI feedback.
340///
341/// The progress callback is called with updates about the search progress,
342/// including cache hits, scan attempts, and retry information.
343pub async fn find_device_with_progress(
344    identifier: &str,
345    options: ScanOptions,
346    progress: Option<ProgressCallback>,
347) -> Result<(Adapter, Peripheral)> {
348    let adapter = get_adapter().await?;
349    let identifier_lower = identifier.to_lowercase();
350
351    info!("Looking for device: {}", identifier);
352
353    // First, check if device is already known (cached from previous scans)
354    if let Some(peripheral) = find_peripheral_by_identifier(&adapter, &identifier_lower).await? {
355        info!("Found device in cache (no scan needed)");
356        if let Some(ref cb) = progress {
357            cb(FindProgress::CacheHit);
358        }
359        return Ok((adapter, peripheral));
360    }
361
362    // Retry with multiple scan attempts for better reliability
363    // BLE advertisements can be missed due to timing, so we try multiple times
364    let max_attempts: u32 = 3;
365    let base_duration = options.duration.as_millis() as u64 / 2;
366    let base_duration = Duration::from_millis(base_duration.max(2000)); // At least 2 seconds
367
368    for attempt in 1..=max_attempts {
369        let scan_duration = base_duration * attempt;
370        let duration_secs = scan_duration.as_secs();
371
372        info!(
373            "Scan attempt {}/{} ({}s)...",
374            attempt, max_attempts, duration_secs
375        );
376
377        if let Some(ref cb) = progress {
378            cb(FindProgress::ScanAttempt {
379                attempt,
380                total: max_attempts,
381                duration_secs,
382            });
383        }
384
385        // Start scanning
386        adapter.start_scan(ScanFilter::default()).await?;
387        sleep(scan_duration).await;
388        adapter.stop_scan().await?;
389
390        // Check if we found the device
391        if let Some(peripheral) = find_peripheral_by_identifier(&adapter, &identifier_lower).await?
392        {
393            info!("Found device on attempt {}", attempt);
394            if let Some(ref cb) = progress {
395                cb(FindProgress::Found { attempt });
396            }
397            return Ok((adapter, peripheral));
398        }
399
400        if attempt < max_attempts {
401            warn!("Device not found, retrying...");
402            if let Some(ref cb) = progress {
403                cb(FindProgress::RetryNeeded { attempt });
404            }
405        }
406    }
407
408    warn!(
409        "Device not found after {} attempts: {}",
410        max_attempts, identifier
411    );
412    Err(Error::device_not_found(identifier))
413}
414
415/// Search through known peripherals to find one matching the identifier.
416async fn find_peripheral_by_identifier(
417    adapter: &Adapter,
418    identifier_lower: &str,
419) -> Result<Option<Peripheral>> {
420    let peripherals = adapter.peripherals().await?;
421
422    for peripheral in peripherals {
423        if let Ok(Some(props)) = peripheral.properties().await {
424            let address = props.address.to_string().to_lowercase();
425            let peripheral_id = format_peripheral_id(&peripheral.id()).to_lowercase();
426
427            // Check peripheral ID match (macOS uses UUIDs)
428            if peripheral_id.contains(identifier_lower) {
429                debug!("Matched by peripheral ID: {}", peripheral_id);
430                return Ok(Some(peripheral));
431            }
432
433            // Check address match (Linux/Windows use MAC addresses)
434            if address != "00:00:00:00:00:00"
435                && (address == identifier_lower
436                    || address.replace(':', "") == identifier_lower.replace(':', ""))
437            {
438                debug!("Matched by address: {}", address);
439                return Ok(Some(peripheral));
440            }
441
442            // Check name match (partial match supported)
443            if let Some(name) = &props.local_name
444                && name.to_lowercase().contains(identifier_lower)
445            {
446                debug!("Matched by name: {}", name);
447                return Ok(Some(peripheral));
448            }
449        }
450    }
451
452    Ok(None)
453}