use std::time::Duration;
use btleplug::api::{Central, Manager as _, Peripheral as _, ScanFilter};
use btleplug::platform::{Adapter, Manager, Peripheral, PeripheralId};
use tokio::sync::RwLock;
use tokio::time::sleep;
use tracing::{debug, info, warn};
static MANAGER: RwLock<Option<Manager>> = RwLock::const_new(None);
async fn shared_manager() -> Result<Manager> {
{
let guard = MANAGER.read().await;
if let Some(m) = guard.as_ref() {
return Ok(m.clone());
}
}
let mut guard = MANAGER.write().await;
if let Some(m) = guard.as_ref() {
return Ok(m.clone());
}
let m = Manager::new().await?;
*guard = Some(m.clone());
Ok(m)
}
async fn reset_manager() {
let mut guard = MANAGER.write().await;
if guard.take().is_some() {
warn!("BLE manager reset — next operation will create a new D-Bus connection");
}
}
use crate::error::{Error, Result};
use crate::util::{create_identifier, format_peripheral_id};
use crate::uuid::{MANUFACTURER_ID, SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD};
use aranet_types::DeviceType;
#[derive(Debug, Clone)]
pub enum FindProgress {
CacheHit,
ScanAttempt {
attempt: u32,
total: u32,
duration_secs: u64,
},
Found { attempt: u32 },
RetryNeeded { attempt: u32 },
}
pub type ProgressCallback = Box<dyn Fn(FindProgress) + Send + Sync>;
#[derive(Debug, Clone)]
pub struct DiscoveredDevice {
pub name: Option<String>,
pub id: PeripheralId,
pub address: String,
pub identifier: String,
pub rssi: Option<i16>,
pub device_type: Option<DeviceType>,
pub is_aranet: bool,
pub manufacturer_data: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct ScanOptions {
pub duration: Duration,
pub filter_aranet_only: bool,
pub use_service_filter: bool,
}
impl Default for ScanOptions {
fn default() -> Self {
Self {
duration: Duration::from_secs(5),
filter_aranet_only: true,
use_service_filter: false,
}
}
}
impl ScanOptions {
pub fn new() -> Self {
Self::default()
}
pub fn duration(mut self, duration: Duration) -> Self {
self.duration = duration;
self
}
pub fn duration_secs(mut self, secs: u64) -> Self {
self.duration = Duration::from_secs(secs);
self
}
pub fn filter_aranet_only(mut self, filter: bool) -> Self {
self.filter_aranet_only = filter;
self
}
pub fn all_devices(self) -> Self {
self.filter_aranet_only(false)
}
pub fn use_service_filter(mut self, enable: bool) -> Self {
self.use_service_filter = enable;
self
}
pub fn optimized() -> Self {
Self {
duration: Duration::from_secs(3),
filter_aranet_only: true,
use_service_filter: true,
}
}
}
pub async fn get_adapter() -> Result<Adapter> {
use crate::error::DeviceNotFoundReason;
#[cfg(target_os = "linux")]
crate::bluez_agent::ensure_agent();
let manager = shared_manager().await?;
let adapters = match manager.adapters().await {
Ok(a) => a,
Err(e) => {
reset_manager().await;
return Err(e.into());
}
};
adapters
.into_iter()
.next()
.ok_or(Error::DeviceNotFound(DeviceNotFoundReason::NoAdapter))
}
pub async fn scan_for_devices() -> Result<Vec<DiscoveredDevice>> {
scan_with_options(ScanOptions::default()).await
}
pub async fn scan_with_options(options: ScanOptions) -> Result<Vec<DiscoveredDevice>> {
let adapter = get_adapter().await?;
scan_with_adapter(&adapter, options).await
}
pub async fn scan_with_retry(
options: ScanOptions,
max_retries: u32,
retry_on_empty: bool,
) -> Result<Vec<DiscoveredDevice>> {
let mut attempt = 0;
let mut delay = Duration::from_millis(500);
loop {
match scan_with_options(options.clone()).await {
Ok(devices) if devices.is_empty() && retry_on_empty && attempt < max_retries => {
attempt += 1;
warn!(
"No devices found, retrying ({}/{})...",
attempt, max_retries
);
sleep(delay).await;
delay = delay.saturating_mul(2).min(Duration::from_secs(5));
}
Ok(devices) => return Ok(devices),
Err(e) if attempt < max_retries => {
attempt += 1;
warn!(
"Scan failed ({}), retrying ({}/{})...",
e, attempt, max_retries
);
sleep(delay).await;
delay = delay.saturating_mul(2).min(Duration::from_secs(5));
}
Err(e) => return Err(e),
}
}
}
pub async fn scan_with_adapter(
adapter: &Adapter,
options: ScanOptions,
) -> Result<Vec<DiscoveredDevice>> {
info!(
"Starting BLE scan for {} seconds (service_filter={})...",
options.duration.as_secs(),
options.use_service_filter
);
let scan_filter = if options.use_service_filter {
ScanFilter {
services: vec![SAF_TEHNIKA_SERVICE_NEW, SAF_TEHNIKA_SERVICE_OLD],
}
} else {
ScanFilter::default()
};
adapter.start_scan(scan_filter).await?;
sleep(options.duration).await;
adapter.stop_scan().await?;
let peripherals = adapter.peripherals().await?;
let mut discovered = Vec::new();
for peripheral in peripherals {
match process_peripheral(&peripheral, options.filter_aranet_only).await {
Ok(Some(device)) => {
info!("Found Aranet device: {:?}", device.name);
discovered.push(device);
}
Ok(None) => {
}
Err(e) => {
debug!("Error processing peripheral: {}", e);
}
}
}
info!("Scan complete. Found {} device(s)", discovered.len());
Ok(discovered)
}
async fn process_peripheral(
peripheral: &Peripheral,
filter_aranet_only: bool,
) -> Result<Option<DiscoveredDevice>> {
let properties = peripheral.properties().await?;
let properties = match properties {
Some(p) => p,
None => return Ok(None),
};
let id = peripheral.id();
let address = properties.address.to_string();
let name = properties.local_name.clone();
let rssi = properties.rssi;
let is_aranet = is_aranet_device(&properties);
if filter_aranet_only && !is_aranet {
return Ok(None);
}
let device_type = name.as_ref().and_then(|n| DeviceType::from_name(n));
let manufacturer_data = properties.manufacturer_data.get(&MANUFACTURER_ID).cloned();
let identifier = create_identifier(&address, &id);
Ok(Some(DiscoveredDevice {
name,
id,
address,
identifier,
rssi,
device_type,
is_aranet,
manufacturer_data,
}))
}
fn is_aranet_device(properties: &btleplug::api::PeripheralProperties) -> bool {
if properties.manufacturer_data.contains_key(&MANUFACTURER_ID) {
return true;
}
for service_uuid in properties.service_data.keys() {
if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
return true;
}
}
for service_uuid in &properties.services {
if *service_uuid == SAF_TEHNIKA_SERVICE_NEW || *service_uuid == SAF_TEHNIKA_SERVICE_OLD {
return true;
}
}
if let Some(name) = &properties.local_name {
let name_lower = name.to_lowercase();
if name_lower.contains("aranet") {
return true;
}
}
false
}
pub async fn find_device(identifier: &str) -> Result<(Adapter, Peripheral)> {
find_device_with_options(identifier, ScanOptions::default()).await
}
pub async fn find_device_with_options(
identifier: &str,
options: ScanOptions,
) -> Result<(Adapter, Peripheral)> {
find_device_with_progress(identifier, options, None).await
}
pub async fn find_device_with_adapter(
adapter: &Adapter,
identifier: &str,
options: ScanOptions,
) -> Result<Peripheral> {
find_device_with_adapter_progress(adapter, identifier, options, None).await
}
pub async fn find_device_with_adapter_progress(
adapter: &Adapter,
identifier: &str,
options: ScanOptions,
progress: Option<ProgressCallback>,
) -> Result<Peripheral> {
let identifier_lower = identifier.to_lowercase();
info!("Looking for device: {}", identifier);
if let Some(peripheral) = find_peripheral_by_identifier(adapter, &identifier_lower).await? {
info!("Found device in cache (no scan needed)");
if let Some(ref cb) = progress {
cb(FindProgress::CacheHit);
}
return Ok(peripheral);
}
let max_attempts: u32 = 3;
let base_duration = options.duration.as_millis() as u64 / 2;
let base_duration = Duration::from_millis(base_duration.max(2000));
for attempt in 1..=max_attempts {
let scan_duration = base_duration * attempt;
let duration_secs = scan_duration.as_secs();
info!(
"Scan attempt {}/{} ({}s)...",
attempt, max_attempts, duration_secs
);
if let Some(ref cb) = progress {
cb(FindProgress::ScanAttempt {
attempt,
total: max_attempts,
duration_secs,
});
}
adapter.start_scan(ScanFilter::default()).await?;
sleep(scan_duration).await;
adapter.stop_scan().await?;
if let Some(peripheral) = find_peripheral_by_identifier(adapter, &identifier_lower).await? {
info!("Found device on attempt {}", attempt);
if let Some(ref cb) = progress {
cb(FindProgress::Found { attempt });
}
return Ok(peripheral);
}
if attempt < max_attempts {
warn!("Device not found, retrying...");
if let Some(ref cb) = progress {
cb(FindProgress::RetryNeeded { attempt });
}
}
}
warn!(
"Device not found after {} attempts: {}",
max_attempts, identifier
);
Err(Error::device_not_found(identifier))
}
pub async fn find_device_with_progress(
identifier: &str,
options: ScanOptions,
progress: Option<ProgressCallback>,
) -> Result<(Adapter, Peripheral)> {
let adapter = get_adapter().await?;
let peripheral =
find_device_with_adapter_progress(&adapter, identifier, options, progress).await?;
Ok((adapter, peripheral))
}
async fn find_peripheral_by_identifier(
adapter: &Adapter,
identifier_lower: &str,
) -> Result<Option<Peripheral>> {
let peripherals = adapter.peripherals().await?;
for peripheral in peripherals {
if let Ok(Some(props)) = peripheral.properties().await {
let address = props.address.to_string().to_lowercase();
let peripheral_id = format_peripheral_id(&peripheral.id()).to_lowercase();
if peripheral_id.contains(identifier_lower) {
debug!("Matched by peripheral ID: {}", peripheral_id);
return Ok(Some(peripheral));
}
if address != "00:00:00:00:00:00"
&& (address == identifier_lower
|| address.replace(':', "") == identifier_lower.replace(':', ""))
{
debug!("Matched by address: {}", address);
return Ok(Some(peripheral));
}
if let Some(name) = &props.local_name
&& name.to_lowercase().contains(identifier_lower)
{
debug!("Matched by name: {}", name);
return Ok(Some(peripheral));
}
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scan_options_default() {
let options = ScanOptions::default();
assert_eq!(options.duration, Duration::from_secs(5));
assert!(options.filter_aranet_only);
}
#[test]
fn test_scan_options_new() {
let options = ScanOptions::new();
assert_eq!(options.duration, Duration::from_secs(5));
assert!(options.filter_aranet_only);
}
#[test]
fn test_scan_options_duration() {
let options = ScanOptions::new().duration(Duration::from_secs(10));
assert_eq!(options.duration, Duration::from_secs(10));
}
#[test]
fn test_scan_options_duration_secs() {
let options = ScanOptions::new().duration_secs(15);
assert_eq!(options.duration, Duration::from_secs(15));
}
#[test]
fn test_scan_options_filter_aranet_only() {
let options = ScanOptions::new().filter_aranet_only(false);
assert!(!options.filter_aranet_only);
let options = ScanOptions::new().filter_aranet_only(true);
assert!(options.filter_aranet_only);
}
#[test]
fn test_scan_options_all_devices() {
let options = ScanOptions::new().all_devices();
assert!(!options.filter_aranet_only);
}
#[test]
fn test_scan_options_chaining() {
let options = ScanOptions::new()
.duration_secs(20)
.filter_aranet_only(false);
assert_eq!(options.duration, Duration::from_secs(20));
assert!(!options.filter_aranet_only);
}
#[test]
fn test_scan_options_clone() {
let options1 = ScanOptions::new().duration_secs(8);
let options2 = options1.clone();
assert_eq!(options1.duration, options2.duration);
assert_eq!(options1.filter_aranet_only, options2.filter_aranet_only);
}
#[test]
fn test_scan_options_debug() {
let options = ScanOptions::new();
let debug = format!("{:?}", options);
assert!(debug.contains("ScanOptions"));
assert!(debug.contains("duration"));
assert!(debug.contains("filter_aranet_only"));
}
#[test]
fn test_find_progress_cache_hit() {
let progress = FindProgress::CacheHit;
let debug = format!("{:?}", progress);
assert!(debug.contains("CacheHit"));
}
#[test]
fn test_find_progress_scan_attempt() {
let progress = FindProgress::ScanAttempt {
attempt: 2,
total: 3,
duration_secs: 5,
};
if let FindProgress::ScanAttempt {
attempt,
total,
duration_secs,
} = progress
{
assert_eq!(attempt, 2);
assert_eq!(total, 3);
assert_eq!(duration_secs, 5);
} else {
panic!("Expected ScanAttempt variant");
}
}
#[test]
fn test_find_progress_found() {
let progress = FindProgress::Found { attempt: 1 };
assert!(matches!(progress, FindProgress::Found { attempt: 1 }));
}
#[test]
fn test_find_progress_retry_needed() {
let progress = FindProgress::RetryNeeded { attempt: 2 };
assert!(matches!(progress, FindProgress::RetryNeeded { attempt: 2 }));
}
#[test]
fn test_find_progress_clone() {
let progress1 = FindProgress::ScanAttempt {
attempt: 1,
total: 3,
duration_secs: 4,
};
let progress2 = progress1.clone();
assert!(matches!(
(&progress1, &progress2),
(
FindProgress::ScanAttempt {
attempt: 1,
total: 3,
duration_secs: 4,
},
FindProgress::ScanAttempt {
attempt: 1,
total: 3,
duration_secs: 4,
},
)
));
}
}