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/// Information about a discovered Aranet device.
19#[derive(Debug, Clone)]
20pub struct DiscoveredDevice {
21    /// The device name (e.g., "Aranet4 12345").
22    pub name: Option<String>,
23    /// The peripheral ID for connecting.
24    pub id: PeripheralId,
25    /// The BLE address as a string (may be zeros on macOS, use `id` instead).
26    pub address: String,
27    /// A connection identifier (peripheral ID on macOS, address on other platforms).
28    pub identifier: String,
29    /// RSSI signal strength.
30    pub rssi: Option<i16>,
31    /// Device type if detected from advertisement.
32    pub device_type: Option<DeviceType>,
33    /// Whether the device is connectable.
34    pub is_aranet: bool,
35    /// Raw manufacturer data from advertisement (if available).
36    pub manufacturer_data: Option<Vec<u8>>,
37}
38
39/// Options for scanning.
40#[derive(Debug, Clone)]
41pub struct ScanOptions {
42    /// How long to scan for devices.
43    pub duration: Duration,
44    /// Only return devices that appear to be Aranet devices.
45    pub filter_aranet_only: bool,
46}
47
48impl Default for ScanOptions {
49    fn default() -> Self {
50        Self {
51            duration: Duration::from_secs(5),
52            filter_aranet_only: true,
53        }
54    }
55}
56
57impl ScanOptions {
58    /// Create new scan options with defaults.
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    /// Set the scan duration.
64    pub fn duration(mut self, duration: Duration) -> Self {
65        self.duration = duration;
66        self
67    }
68
69    /// Set scan duration in seconds.
70    pub fn duration_secs(mut self, secs: u64) -> Self {
71        self.duration = Duration::from_secs(secs);
72        self
73    }
74
75    /// Set whether to filter for Aranet devices only.
76    pub fn filter_aranet_only(mut self, filter: bool) -> Self {
77        self.filter_aranet_only = filter;
78        self
79    }
80
81    /// Scan for all BLE devices, not just Aranet.
82    pub fn all_devices(self) -> Self {
83        self.filter_aranet_only(false)
84    }
85}
86
87/// Get the first available Bluetooth adapter.
88pub async fn get_adapter() -> Result<Adapter> {
89    use crate::error::DeviceNotFoundReason;
90
91    let manager = Manager::new().await?;
92    let adapters = manager.adapters().await?;
93
94    adapters
95        .into_iter()
96        .next()
97        .ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
98}
99
100/// Scan for Aranet devices in range.
101///
102/// Returns a list of discovered devices, or an error if the scan failed.
103/// An empty list indicates no devices were found (not an error).
104///
105/// # Errors
106///
107/// Returns an error if:
108/// - No Bluetooth adapter is available
109/// - Bluetooth is not enabled
110/// - The scan could not be started or stopped
111pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
112    scan_with_options(ScanOptions::default()).await
113}
114
115/// Scan for devices with custom options.
116pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
117    let adapter = get_adapter().await?;
118    scan_with_adapter(&adapter, options).await
119}
120
121/// Scan for devices with retry logic for flaky Bluetooth environments.
122///
123/// This function will retry the scan up to `max_retries` times if:
124/// - The scan fails due to a Bluetooth error
125/// - No devices are found (when `retry_on_empty` is true)
126///
127/// A delay is applied between retries, starting at 500ms and doubling each attempt.
128///
129/// # Arguments
130///
131/// * `options` - Scan options
132/// * `max_retries` - Maximum number of retry attempts
133/// * `retry_on_empty` - Whether to retry if no devices are found
134///
135/// # Example
136///
137/// ```ignore
138/// use aranet_core::scan::{ScanOptions, scan_with_retry};
139///
140/// // Retry up to 3 times, including when no devices found
141/// let devices = scan_with_retry(ScanOptions::default(), 3, true).await?;
142/// ```
143pub async fn scan_with_retry(
144    options: ScanOptions,
145    max_retries: u32,
146    retry_on_empty: bool,
147) -> Result<Vec<DiscoveredDevice>> {
148    let mut attempt = 0;
149    let mut delay = Duration::from_millis(500);
150
151    loop {
152        match scan_with_options(options.clone()).await {
153            Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
154                attempt += 1;
155                warn!(
156                    "No devices found, retrying ({}/{})...",
157                    attempt, max_retries
158                );
159                sleep(delay).await;
160                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
161            }
162            Ok(devices) => return Ok(devices),
163            Err(e) if attempt < max_retries => {
164                attempt += 1;
165                warn!(
166                    "Scan failed ({}), retrying ({}/{})...",
167                    e, attempt, max_retries
168                );
169                sleep(delay).await;
170                delay = delay.saturating_mul(2).min(Duration::from_secs(5));
171            }
172            Err(e) => return Err(e),
173        }
174    }
175}
176
177/// Scan for devices using a specific adapter.
178pub async fn scan_with_adapter(
179    adapter: &Adapter,
180    options: ScanOptions,
181) -> Result<Vec<DiscoveredDevice>> {
182    info!(
183        "Starting BLE scan for {} seconds...",
184        options.duration.as_secs()
185    );
186
187    // Start scanning
188    adapter.start_scan(ScanFilter::default()).await?;
189
190    // Wait for the scan duration
191    sleep(options.duration).await;
192
193    // Stop scanning
194    adapter.stop_scan().await?;
195
196    // Get discovered peripherals
197    let peripherals = adapter.peripherals().await?;
198    let mut discovered = Vec::new();
199
200    for peripheral in peripherals {
201        match process_peripheral(&peripheral, options.filter_aranet_only).await {
202            Ok(Some(device)) => {
203                info!("Found Aranet device: {:?}", device.name);
204                discovered.push(device);
205            }
206            Ok(None) => {
207                // Not an Aranet device or filtered out
208            }
209            Err(e) => {
210                debug!("Error processing peripheral: {}", e);
211            }
212        }
213    }
214
215    info!("Scan complete. Found {} device(s)", discovered.len());
216    Ok(discovered)
217}
218
219/// Process a peripheral and determine if it's an Aranet device.
220async fn process_peripheral(
221    peripheral: &Peripheral,
222    filter_aranet_only: bool,
223) -> Result<Option<DiscoveredDevice>> {
224    let properties = peripheral.properties().await?;
225    let properties = match properties {
226        Some(p) => p,
227        None => return Ok(None),
228    };
229
230    let id = peripheral.id();
231    let address = properties.address.to_string();
232    let name = properties.local_name.clone();
233    let rssi = properties.rssi;
234
235    // Check if this is an Aranet device
236    let is_aranet = is_aranet_device(&properties);
237
238    if filter_aranet_only && !is_aranet {
239        return Ok(None);
240    }
241
242    // Try to determine device type from name
243    let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
244
245    // Get manufacturer data if available
246    let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
247
248    // Create identifier: use peripheral ID string on macOS (where address is 00:00:00:00:00:00)
249    // On other platforms, use the address
250    let identifier = create_identifier(&address, &id);
251
252    Ok(Some(DiscoveredDevice {
253        name,
254        id,
255        address,
256        identifier,
257        rssi,
258        device_type,
259        is_aranet,
260        manufacturer_data,
261    }))
262}
263
264/// Check if a peripheral is an Aranet device based on its properties.
265fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
266    // Check manufacturer data for Aranet manufacturer ID
267    if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
268        return true;
269    }
270
271    // Check service UUIDs for Aranet services
272    for service_uuid in properties.service_data.keys() {
273        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
274            return true;
275        }
276    }
277
278    // Check advertised services
279    for service_uuid in &properties.services {
280        if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
281            return true;
282        }
283    }
284
285    // Check device name for Aranet
286    if let Some(name) = &properties.local_name {
287        let name_lower = name.to_lowercase();
288        if name_lower.contains("aranet") {
289            return true;
290        }
291    }
292
293    false
294}
295
296/// Find a specific device by name or address.
297pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
298    find_device_with_options(identifier, ScanOptions::default()).await
299}
300
301/// Find a specific device by name or address with custom options.
302pub async fn find_device_with_options(
303    identifier: &str,
304    options: ScanOptions,
305) -> Result<(Adapter, Peripheral)> {
306    let adapter = get_adapter().await?;
307    let identifier_lower = identifier.to_lowercase();
308
309    info!("Scanning for device: {}", identifier);
310
311    // Start scanning
312    adapter.start_scan(ScanFilter::default()).await?;
313    sleep(options.duration).await;
314    adapter.stop_scan().await?;
315
316    // Search through peripherals
317    let peripherals = adapter.peripherals().await?;
318
319    for peripheral in peripherals {
320        if let Ok(Some(props)) = peripheral.properties().await {
321            let address = props.address.to_string().to_lowercase();
322            let peripheral_id = format_peripheral_id(&peripheral.id()).to_lowercase();
323
324            // Check peripheral ID match (macOS uses UUIDs)
325            if peripheral_id.contains(&identifier_lower) {
326                info!("Found device by peripheral ID");
327                return Ok((adapter, peripheral));
328            }
329
330            // Check address match (Linux/Windows use MAC addresses)
331            if address != "00:00:00:00:00:00"
332                && (address == identifier_lower
333                    || address.replace(':', "") == identifier_lower.replace(':', ""))
334            {
335                info!("Found device by address: {}", address);
336                return Ok((adapter, peripheral));
337            }
338
339            // Check name match (partial match supported)
340            if let Some(name) = &props.local_name
341                && name.to_lowercase().contains(&identifier_lower)
342            {
343                info!("Found device by name: {}", name);
344                return Ok((adapter, peripheral));
345            }
346        }
347    }
348
349    warn!("Device not found: {}", identifier);
350    Err(Error::device_not_found(identifier))
351}