From 331a213df30f05471d547e6c92767fb21ad2e3a2 Mon Sep 17 00:00:00 2001 From: Vladimir Bondarevskiy Date: Sun, 27 Sep 2026 03:32:36 +0300 Subject: [PATCH] Fix Matter BLE discovery from service data advertisements (#1) --- .github/workflows/rust.yml | 14 + src/ble.rs | 152 +++++++--- src/ble/tests.rs | 599 +++++++++++++++++++++++++++++++++++++ src/ble/tests/backend.rs | 249 +++++++++++++++ src/ble/tests/fixtures.rs | 97 ++++++ 5 files changed, 1062 insertions(+), 49 deletions(-) create mode 100644 src/ble/tests.rs create mode 100644 src/ble/tests/backend.rs create mode 100644 src/ble/tests/fixtures.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 9fd45e0..23c7dd3 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -20,3 +20,17 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + + ble: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Install BLE build dependencies + run: | + sudo apt-get update + sudo apt-get install --yes libdbus-1-dev pkg-config + - name: Build BLE library and all targets + run: cargo build --locked --all-targets --features ble --verbose + - name: Run BLE-enabled tests without hardware + run: cargo test --locked --features ble --verbose diff --git a/src/ble.rs b/src/ble.rs index aab98a1..25d06b2 100644 --- a/src/ble.rs +++ b/src/ble.rs @@ -8,12 +8,14 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; +use btleplug::api::{ScanFilter, WriteType}; +#[cfg(not(test))] use btleplug::{ - api::{ - Central, CentralEvent, Manager as _, Peripheral as _, ScanFilter, WriteType, - }, + api::{Central, CentralEvent, Manager as _, Peripheral as _}, platform::{Manager, Peripheral}, }; +#[cfg(test)] +use self::tests::backend::{CentralEvent, Manager, Peripheral}; use futures::StreamExt; use tokio::sync::mpsc; use uuid::Uuid; @@ -51,56 +53,95 @@ pub async fn find_by_discriminator(discriminator: u16, short_match: bool, scan_t let adapters = manager.adapters().await.context("listing BLE adapters")?; let central = adapters.into_iter().next().context("no BLE adapter found")?; - let filter = ScanFilter { - services: vec![MATTER_SERVICE_UUID], - }; - central.start_scan(filter).await.context("start BLE scan")?; - let mut events = central.events().await.context("BLE event stream")?; + // Matter UUID can be present only in service data, which BlueZ's UUID filter can omit. + central.start_scan(ScanFilter::default()).await.context("start BLE scan")?; let deadline = tokio::time::Instant::now() + scan_timeout; - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - bail!("BLE scan timeout: no device with discriminator {} found", discriminator); - } - let event = tokio::time::timeout(remaining, events.next()) - .await - .context("BLE scan timeout")? - .context("BLE event stream ended")?; + let found: Result = async { + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + bail!("BLE scan timeout: no device with discriminator {} found", discriminator); + } + let event = tokio::time::timeout(remaining, events.next()) + .await + .context("BLE scan timeout")? + .context("BLE event stream ended")?; - match event { - CentralEvent::DeviceDiscovered(id) | CentralEvent::DeviceUpdated(id) => { - let peripheral = central.peripheral(&id).await?; - let props = match peripheral.properties().await? { - Some(p) => p, - None => continue, - }; - // Look for service data for our UUID - let svc_data = props - .service_data - .get(&MATTER_SERVICE_UUID) - .cloned() - .unwrap_or_default(); - if svc_data.len() < 8 { - continue; + let (id, peripheral, svc_data) = match event { + CentralEvent::DeviceDiscovered(id) | CentralEvent::DeviceUpdated(id) => { + let peripheral = match central.peripheral(&id).await { + Ok(peripheral) => peripheral, + Err(error) => { + log::debug!( + "Skipping BLE candidate {}: peripheral lookup failed: {:?}", + id, error, + ); + continue; + } + }; + let props = match peripheral.properties().await { + Ok(Some(props)) => props, + Ok(None) => continue, + Err(error) => { + log::debug!( + "Skipping BLE candidate {}: properties unavailable: {:?}", + id, error, + ); + continue; + } + }; + ( + id, + Some(peripheral), + props.service_data.get(&MATTER_SERVICE_UUID).cloned(), + ) } - let (disc, vid, pid, cm_flag) = parse_service_data(&svc_data); - log::debug!("BLE found device: disc={} vid={} pid={} cm={}", disc, vid, pid, cm_flag); - let matches = if short_match { - disc >> 8 == discriminator >> 8 - } else { - disc == discriminator - }; - if matches { - central.stop_scan().await.ok(); - log::debug!("BLE device with matching discriminator found, connecting..."); - return connect_peripheral(peripheral).await; + CentralEvent::ServiceDataAdvertisement { id, service_data } => { + (id, None, service_data.get(&MATTER_SERVICE_UUID).cloned()) + } + _ => continue, + }; + let Some(svc_data) = svc_data else { + continue; + }; + if matches_discriminator(&svc_data, discriminator, short_match) { + if let Some(peripheral) = peripheral { + return Ok(peripheral); + } + match central.peripheral(&id).await { + Ok(peripheral) => return Ok(peripheral), + Err(error) => { + log::debug!( + "Skipping BLE candidate {}: peripheral lookup failed: {:?}", + id, error, + ); + } } } - _ => {} } } + .await; + if let Err(error) = central.stop_scan().await { + log::warn!("BLE stop_scan failed: {:?}", error); + } + let peripheral = found?; + log::debug!("BLE device with matching discriminator found, connecting..."); + connect_peripheral(peripheral).await +} + +fn matches_discriminator(svc_data: &[u8], discriminator: u16, short_match: bool) -> bool { + if svc_data.len() < 8 { + return false; + } + let (disc, vid, pid, cm_flag) = parse_service_data(svc_data); + log::debug!("BLE found device: disc={} vid={} pid={} cm={}", disc, vid, pid, cm_flag); + if short_match { + disc >> 8 == discriminator >> 8 + } else { + disc == discriminator + } } pub async fn scan_commissionable(scan_timeout: Duration) -> Result> { @@ -109,18 +150,28 @@ pub async fn scan_commissionable(scan_timeout: Duration) -> Result p, - None => continue, + let props = match peripheral.properties().await { + Ok(Some(props)) => props, + Ok(None) => continue, + Err(error) => { + log::debug!( + "Skipping BLE candidate {}: properties unavailable: {:?}", + peripheral.id(), error, + ); + continue; + } }; if let Some(svc_data) = props.service_data.get(&MATTER_SERVICE_UUID) { if svc_data.len() >= 8 { @@ -257,3 +308,6 @@ fn hex_dump(data: &[u8]) -> String { } s.trim_end().to_string() } + +#[cfg(test)] +mod tests; diff --git a/src/ble/tests.rs b/src/ble/tests.rs new file mode 100644 index 0000000..638c8b6 --- /dev/null +++ b/src/ble/tests.rs @@ -0,0 +1,599 @@ +use std::{collections::HashMap, time::Duration}; + +use btleplug::api::{PeripheralProperties, ScanFilter}; + +use super::{find_by_discriminator, scan_commissionable, MATTER_SERVICE_UUID}; +use backend::{Call, CentralEvent, Device, State}; +use fixtures::{raw_advertisement, raw_device, TestDeviceDefinition}; + +pub(super) mod backend; +mod fixtures; + +// Independent vectors retained from the original PR, not verified device captures. +// OpCode, discriminator + advertisement version (LE), VID (LE), PID (LE), flags. +const MATTER_SERVICE_DATA_ABC: [u8; 8] = [0x00, 0xbc, 0x0a, 0x2f, 0x13, 0x0d, 0x02, 0x00]; +const MATTER_SERVICE_DATA_560: [u8; 8] = [0x00, 0x60, 0x05, 0x2f, 0x13, 0x0d, 0x02, 0x00]; +const TIMEOUT: Duration = Duration::from_secs(30); + +fn finish(result: anyhow::Result) { + // No BTP handshake is run; stop the notification task created by connect_peripheral. + let peripheral = result.unwrap(); + peripheral.c2_abort.abort(); + (peripheral.disconnect)(); +} + +#[tokio::test(start_paused = true)] +async fn find_skips_disappeared_peripheral_and_unreadable_properties() { + let unreadable = TestDeviceDefinition { + id: 2, + discriminator: 0x0123, + ..Default::default() + }; + let target = TestDeviceDefinition { + id: 3, + discriminator: 0x0abc, + ..Default::default() + }; + let mut state = State { + events: vec![ + CentralEvent::DeviceDiscovered(1), + CentralEvent::DeviceUpdated(unreadable.id), + CentralEvent::DeviceDiscovered(target.id), + ], + ..Default::default() + }; + state.devices.insert( + unreadable.id, + Device { + properties_failures: 1, + ..unreadable.device() + }, + ); + state.devices.insert(target.id, target.device()); + + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + finish(result); + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Events, + Call::Start(ScanFilter::default()), + Call::Lookup(1), + Call::Lookup(2), + Call::Properties(2), + Call::Lookup(3), + Call::Properties(3), + Call::Stop, + Call::Connect(3), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn find_retries_later_advertisement_after_failed_lookup() { + let target = TestDeviceDefinition { + id: 1, + discriminator: 0x0abc, + ..Default::default() + }; + let mut state = State { + events: vec![target.advertisement(), target.advertisement()], + ..Default::default() + }; + state.devices.insert( + target.id, + Device { + lookup_failures: 1, + ..Default::default() + }, + ); + + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + finish(result); + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Events, + Call::Start(ScanFilter::default()), + Call::Lookup(1), + Call::Lookup(1), + Call::Stop, + Call::Connect(1), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn find_filters_fixed_service_data_vectors_before_looking_up_peripheral() { + for (data, discriminator, short_match) in [ + (MATTER_SERVICE_DATA_ABC, 0x0abc, false), + (MATTER_SERVICE_DATA_ABC, 0x0a00, true), + (MATTER_SERVICE_DATA_560, 0x0500, true), + ] { + let mut wrong = data; + wrong[1] ^= 1; + if short_match { + wrong[2] = 0x0b; + } + let mut state = State { + events: vec![ + CentralEvent::Other, + CentralEvent::ServiceDataAdvertisement { + id: 1, + service_data: HashMap::new(), + }, + raw_advertisement(1, &data[..7]), + raw_advertisement(1, &wrong), + raw_advertisement(2, &data), + ], + ..Default::default() + }; + // Service-data-only device: there are no cached properties or discovery events. + state.devices.insert(2, Device::default()); + let (result, state) = backend::run( + state, + find_by_discriminator(discriminator, short_match, TIMEOUT), + ) + .await; + finish(result); + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Events, + Call::Start(ScanFilter::default()), + Call::Lookup(2), + Call::Stop, + Call::Connect(2), + ] + ); + } +} + +#[tokio::test(start_paused = true)] +async fn find_stops_after_timeout_even_when_candidates_fail() { + let state = State { + events: vec![CentralEvent::DeviceDiscovered(1)], + ..Default::default() + }; + let before = tokio::time::Instant::now(); + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + assert!(result + .err() + .unwrap() + .to_string() + .contains("BLE scan timeout")); + assert_eq!(before.elapsed(), TIMEOUT); + assert_eq!(state.lock().unwrap().calls.last(), Some(&Call::Stop)); +} + +#[tokio::test(start_paused = true)] +async fn find_preserves_scan_and_stream_errors() { + for (state, message, stops) in [ + ( + State { + events_error: true, + ..Default::default() + }, + "BLE event stream", + false, + ), + ( + State { + start_error: true, + ..Default::default() + }, + "start BLE scan", + false, + ), + ( + State { + end_events: true, + ..Default::default() + }, + "BLE event stream ended", + true, + ), + ] { + let (result, state) = + backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + assert_eq!(result.err().unwrap().to_string(), message); + assert_eq!(state.lock().unwrap().calls.contains(&Call::Stop), stops); + } +} + +#[tokio::test(start_paused = true)] +async fn find_preserves_connection_errors() { + let target = TestDeviceDefinition { + id: 1, + discriminator: 0x0abc, + ..Default::default() + }; + let mut state = State { + events: vec![target.advertisement()], + ..Default::default() + }; + state.devices.insert( + target.id, + Device { + connect_error: true, + ..Default::default() + }, + ); + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + assert_eq!(result.err().unwrap().to_string(), "BLE connect"); + assert!(state + .lock() + .unwrap() + .calls + .ends_with(&[Call::Stop, Call::Connect(1)])); +} + +#[tokio::test(start_paused = true)] +async fn scan_includes_service_data_only_devices_and_skips_bad_candidates() { + let mut state = State::default(); + state.devices.insert( + 1, + Device { + properties_failures: 1, + ..Default::default() + }, + ); + let target = TestDeviceDefinition { + id: 2, + discriminator: 0x0abc, + vendor_id: 0x1234, + product_id: 0x0042, + name: Some("Matter test device".to_owned()), + rssi: Some(-60), + tx_power: Some(-4), + ..Default::default() + }; + assert!(target.advertised_services.is_empty()); + state.devices.insert(target.id, target.device()); + state.devices.insert( + 3, + Device { + properties: Some(PeripheralProperties::default()), + ..Default::default() + }, + ); + state + .devices + .insert(4, raw_device(&MATTER_SERVICE_DATA_ABC[..7])); + state.devices.insert(5, Device::default()); + + let before = tokio::time::Instant::now(); + let (result, state) = backend::run(state, scan_commissionable(TIMEOUT)).await; + let found = result.unwrap(); + assert_eq!(before.elapsed(), TIMEOUT); + assert_eq!(found.len(), 1); + let found = &found[0]; + assert_eq!(found.discriminator, 0x0abc); + assert_eq!(found.vendor_id, 0x1234); + assert_eq!(found.product_id, 0x0042); + assert_eq!(found.name.as_deref(), Some("Matter test device")); + assert_eq!(found.rssi, Some(-60)); + assert_eq!(found.tx_power, Some(-4)); + assert_eq!(found.address, "2"); + assert_eq!(found.peripheral.id(), 2); + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Start(ScanFilter::default()), + Call::Stop, + Call::List, + Call::Properties(1), + Call::Properties(2), + Call::Properties(3), + Call::Properties(4), + Call::Properties(5), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn scan_with_no_devices_returns_an_empty_list() { + let (result, state) = backend::run(State::default(), scan_commissionable(TIMEOUT)).await; + assert!(result.unwrap().is_empty()); + assert_eq!( + state.lock().unwrap().calls, + vec![Call::Start(ScanFilter::default()), Call::Stop, Call::List,] + ); +} + +#[tokio::test(start_paused = true)] +async fn scan_preserves_start_and_enumeration_errors() { + let (result, state) = backend::run( + State { + start_error: true, + ..Default::default() + }, + scan_commissionable(TIMEOUT), + ) + .await; + assert_eq!(result.err().unwrap().to_string(), "start BLE scan"); + assert_eq!( + state.lock().unwrap().calls, + vec![Call::Start(ScanFilter::default())] + ); + + let (result, state) = backend::run( + State { + list_error: true, + ..Default::default() + }, + scan_commissionable(TIMEOUT), + ) + .await; + assert_eq!(result.err().unwrap().to_string(), "Permission denied"); + assert_eq!( + state.lock().unwrap().calls, + vec![Call::Start(ScanFilter::default()), Call::Stop, Call::List,] + ); +} + +#[tokio::test(start_paused = true)] +async fn find_warns_when_stop_fails_without_blocking_connection() { + let target = TestDeviceDefinition { + id: 1, + discriminator: 0x0abc, + ..Default::default() + }; + let mut state = State { + events: vec![target.advertisement()], + stop_error: true, + ..Default::default() + }; + state.devices.insert(target.id, Device::default()); + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + finish(result); + let state = state.lock().unwrap(); + assert!(state.calls.ends_with(&[Call::Stop, Call::Connect(1)])); + assert_eq!( + state.warnings, + vec!["BLE stop_scan failed: PermissionDenied"] + ); +} + +#[tokio::test(start_paused = true)] +async fn find_warns_when_stop_fails_without_masking_search_or_connection_errors() { + for end_events in [false, true] { + let state = State { + stop_error: true, + end_events, + ..Default::default() + }; + let (result, state) = + backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + let expected = if end_events { + "BLE event stream ended" + } else { + "BLE scan timeout" + }; + assert_eq!(result.err().unwrap().to_string(), expected); + assert_eq!( + state.lock().unwrap().warnings, + vec!["BLE stop_scan failed: PermissionDenied"] + ); + } + + let target = TestDeviceDefinition { + id: 1, + discriminator: 0x0abc, + ..Default::default() + }; + let mut state = State { + events: vec![target.advertisement()], + stop_error: true, + ..Default::default() + }; + state.devices.insert( + target.id, + Device { + connect_error: true, + ..Default::default() + }, + ); + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + assert_eq!(result.err().unwrap().to_string(), "BLE connect"); + assert_eq!( + state.lock().unwrap().warnings, + vec!["BLE stop_scan failed: PermissionDenied"] + ); +} + +#[tokio::test(start_paused = true)] +async fn scan_warns_when_stop_fails_without_masking_results_or_enumeration_errors() { + let target = TestDeviceDefinition { + id: 1, + discriminator: 0x0abc, + ..Default::default() + }; + for list_error in [false, true] { + let mut state = State { + stop_error: true, + list_error, + ..Default::default() + }; + state.devices.insert(target.id, target.device()); + let (result, state) = backend::run(state, scan_commissionable(TIMEOUT)).await; + if list_error { + assert_eq!(result.err().unwrap().to_string(), "Permission denied"); + } else { + let found = result.unwrap(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].discriminator, 0x0abc); + } + assert_eq!( + state.lock().unwrap().warnings, + vec!["BLE stop_scan failed: PermissionDenied"] + ); + } +} + +#[tokio::test(start_paused = true)] +async fn find_selects_the_matching_definition_among_different_devices() { + let neighbor = TestDeviceDefinition { + id: 1, + discriminator: 0x0247, + vendor_id: 0x1234, + product_id: 0x0001, + advertised_services: vec![MATTER_SERVICE_UUID], + ..Default::default() + }; + let target = TestDeviceDefinition { + id: 2, + discriminator: 0x0246, + vendor_id: 0x4321, + product_id: 0x0002, + ..Default::default() + }; + let other = TestDeviceDefinition { + id: 3, + discriminator: 0x024a, + ..target.clone() + }; + let mut state = State { + events: vec![ + CentralEvent::DeviceDiscovered(neighbor.id), + other.advertisement(), + CentralEvent::DeviceUpdated(target.id), + ], + ..Default::default() + }; + for definition in [&neighbor, &target, &other] { + state.devices.insert(definition.id, definition.device()); + } + let (result, state) = backend::run(state, find_by_discriminator(0x0246, false, TIMEOUT)).await; + finish(result); + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Events, + Call::Start(ScanFilter::default()), + Call::Lookup(1), + Call::Properties(1), + Call::Lookup(2), + Call::Properties(2), + Call::Stop, + Call::Connect(2), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn find_uses_advertisement_data_independently_of_cached_properties() { + let cached = TestDeviceDefinition { + id: 7, + discriminator: 0x0111, + ..Default::default() + }; + let advertised = TestDeviceDefinition { + discriminator: 0x0abc, + ..cached.clone() + }; + let mut state = State { + events: vec![advertised.advertisement()], + ..Default::default() + }; + state.devices.insert(cached.id, cached.device()); + + let (result, state) = backend::run(state, find_by_discriminator(0x0abc, false, TIMEOUT)).await; + finish(result); + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Events, + Call::Start(ScanFilter::default()), + Call::Lookup(7), + Call::Stop, + Call::Connect(7), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn scan_returns_metadata_for_multiple_device_definitions() { + let definitions = [ + TestDeviceDefinition { + id: 9, + discriminator: 0x0abc, + advertisement_version: 2, + vendor_id: 0x1234, + product_id: 0x5678, + additional_flags: 0x03, + name: Some("Service-data-only fixture".to_owned()), + rssi: Some(-60), + tx_power: Some(-4), + ..Default::default() + }, + TestDeviceDefinition { + id: 4, + discriminator: 0x0560, + vendor_id: 0x4321, + product_id: 0x00ff, + advertised_services: vec![MATTER_SERVICE_UUID], + ..Default::default() + }, + ]; + // Anchor fixture encoding independently: bit 12..15 is the advertisement version, + // not a commissioning flag. Flags occupy the final byte. + assert_eq!( + definitions[0].service_data(), + [0x00, 0xbc, 0x2a, 0x34, 0x12, 0x78, 0x56, 0x03] + ); + assert_eq!( + definitions[1].service_data(), + [0x00, 0x60, 0x05, 0x21, 0x43, 0xff, 0x00, 0x00] + ); + let mut state = State::default(); + for definition in &definitions { + state.devices.insert(definition.id, definition.device()); + } + let (result, state) = backend::run(state, scan_commissionable(TIMEOUT)).await; + let found = result.unwrap(); + assert_eq!(found.len(), definitions.len()); + for definition in &definitions { + let actual = found + .iter() + .find(|device| device.peripheral.id() == definition.id) + .unwrap(); + assert_eq!(actual.discriminator, definition.discriminator); + assert_eq!(actual.vendor_id, definition.vendor_id); + assert_eq!(actual.product_id, definition.product_id); + assert_eq!(actual.name, definition.name); + assert_eq!(actual.rssi, definition.rssi); + assert_eq!(actual.tx_power, definition.tx_power); + assert_eq!(actual.address, definition.id.to_string()); + } + assert_eq!( + state.lock().unwrap().calls, + vec![ + Call::Start(ScanFilter::default()), + Call::Stop, + Call::List, + Call::Properties(4), + Call::Properties(9), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn scan_reads_metadata_from_fixed_wire_vectors() { + let cases = [ + (MATTER_SERVICE_DATA_ABC, 0x0abc), + (MATTER_SERVICE_DATA_560, 0x0560), + ]; + let mut state = State::default(); + for (id, (data, _)) in cases.iter().enumerate() { + state.devices.insert(id as u64, raw_device(data)); + } + let (result, _) = backend::run(state, scan_commissionable(TIMEOUT)).await; + let found = result.unwrap(); + assert_eq!(found.len(), cases.len()); + for (actual, (_, discriminator)) in found.iter().zip(cases) { + assert_eq!(actual.discriminator, discriminator); + assert_eq!(actual.vendor_id, 0x132f); + assert_eq!(actual.product_id, 0x020d); + } +} diff --git a/src/ble/tests/backend.rs b/src/ble/tests/backend.rs new file mode 100644 index 0000000..3161da7 --- /dev/null +++ b/src/ble/tests/backend.rs @@ -0,0 +1,249 @@ +//! Test-only platform facade for exercising the public BLE functions without hardware. +//! Advertising properties, scan filters and GATT descriptors use the real btleplug types. + +use std::{ + collections::{BTreeMap, BTreeSet, HashMap}, + future::Future, + pin::Pin, + sync::{Arc, Mutex, Once}, +}; + +use btleplug::{ + api::{ + CharPropFlags, Characteristic, PeripheralProperties, ScanFilter, ValueNotification, + WriteType, + }, + Error, Result, +}; +use futures::{stream, Stream, StreamExt}; +use uuid::Uuid; + +use super::super::{C1_UUID, C2_UUID, MATTER_SERVICE_UUID}; + +#[derive(Debug)] +pub enum CentralEvent { + DeviceDiscovered(u64), + DeviceUpdated(u64), + ServiceDataAdvertisement { + id: u64, + service_data: HashMap>, + }, + Other, +} + +#[derive(Debug, PartialEq)] +pub enum Call { + Events, + Start(ScanFilter), + Stop, + List, + Lookup(u64), + Properties(u64), + Connect(u64), +} + +#[derive(Debug, Default)] +pub struct Device { + pub properties: Option, + pub lookup_failures: usize, + pub properties_failures: usize, + pub connect_error: bool, +} + +#[derive(Debug, Default)] +pub struct State { + pub events: Vec, + pub devices: BTreeMap, + pub calls: Vec, + pub end_events: bool, + pub start_error: bool, + pub events_error: bool, + pub list_error: bool, + pub stop_error: bool, + pub warnings: Vec, +} + +tokio::task_local! { + static CURRENT: Arc>; +} + +struct TestLogger; + +impl log::Log for TestLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Warn + } + + fn log(&self, record: &log::Record<'_>) { + if self.enabled(record.metadata()) { + // Each test captures only logs from its own task-local scenario. + let _ = CURRENT.try_with(|state| { + state + .lock() + .unwrap() + .warnings + .push(record.args().to_string()); + }); + } + } + + fn flush(&self) {} +} + +pub async fn run(state: State, future: F) -> (F::Output, Arc>) { + static INIT_LOGGER: Once = Once::new(); + INIT_LOGGER.call_once(|| { + log::set_logger(&TestLogger).unwrap(); + log::set_max_level(log::LevelFilter::Warn); + }); + let state = Arc::new(Mutex::new(state)); + let output = CURRENT.scope(state.clone(), future).await; + (output, state) +} + +pub struct Manager(Arc>); + +impl Manager { + pub async fn new() -> Result { + Ok(Self(CURRENT.with(Arc::clone))) + } + + pub async fn adapters(&self) -> Result> { + Ok(vec![Adapter(self.0.clone())]) + } +} + +pub struct Adapter(Arc>); + +impl Adapter { + pub async fn events(&self) -> Result + Send>>> { + let mut state = self.0.lock().unwrap(); + state.calls.push(Call::Events); + if state.events_error { + return Err(Error::PermissionDenied); + } + let events = stream::iter(std::mem::take(&mut state.events)); + if state.end_events { + Ok(Box::pin(events)) + } else { + Ok(Box::pin(events.chain(stream::pending()))) + } + } + + pub async fn start_scan(&self, filter: ScanFilter) -> Result<()> { + let mut state = self.0.lock().unwrap(); + state.calls.push(Call::Start(filter)); + if state.start_error { + return Err(Error::PermissionDenied); + } + Ok(()) + } + + pub async fn stop_scan(&self) -> Result<()> { + let mut state = self.0.lock().unwrap(); + state.calls.push(Call::Stop); + if state.stop_error { + return Err(Error::PermissionDenied); + } + Ok(()) + } + + pub async fn peripheral(&self, id: &u64) -> Result { + let mut state = self.0.lock().unwrap(); + state.calls.push(Call::Lookup(*id)); + let device = state.devices.get_mut(id).ok_or(Error::DeviceNotFound)?; + if device.lookup_failures > 0 { + device.lookup_failures -= 1; + return Err(Error::DeviceNotFound); + } + Ok(Peripheral { + id: *id, + state: self.0.clone(), + }) + } + + pub async fn peripherals(&self) -> Result> { + let mut state = self.0.lock().unwrap(); + state.calls.push(Call::List); + if state.list_error { + return Err(Error::PermissionDenied); + } + Ok(state + .devices + .keys() + .map(|id| Peripheral { + id: *id, + state: self.0.clone(), + }) + .collect()) + } +} + +#[derive(Clone, Debug)] +pub struct Peripheral { + id: u64, + state: Arc>, +} + +impl Peripheral { + pub fn id(&self) -> u64 { + self.id + } + + pub async fn properties(&self) -> Result> { + let mut state = self.state.lock().unwrap(); + state.calls.push(Call::Properties(self.id)); + let device = state + .devices + .get_mut(&self.id) + .ok_or(Error::DeviceNotFound)?; + if device.properties_failures > 0 { + device.properties_failures -= 1; + return Err(Error::DeviceNotFound); + } + Ok(device.properties.clone()) + } + + pub async fn connect(&self) -> Result<()> { + let mut state = self.state.lock().unwrap(); + state.calls.push(Call::Connect(self.id)); + if state.devices[&self.id].connect_error { + return Err(Error::NotConnected); + } + Ok(()) + } + + pub async fn discover_services(&self) -> Result<()> { + Ok(()) + } + + pub fn characteristics(&self) -> BTreeSet { + [C1_UUID, C2_UUID] + .into_iter() + .map(|uuid| Characteristic { + uuid, + service_uuid: MATTER_SERVICE_UUID, + properties: CharPropFlags::WRITE | CharPropFlags::INDICATE, + descriptors: BTreeSet::new(), + }) + .collect() + } + + pub async fn notifications( + &self, + ) -> Result + Send>>> { + Ok(Box::pin(stream::pending())) + } + + pub async fn write(&self, _: &Characteristic, _: &[u8], _: WriteType) -> Result<()> { + panic!("discovery must not send GATT data") + } + + pub async fn subscribe(&self, _: &Characteristic) -> Result<()> { + panic!("discovery must not subscribe before the BTP handshake") + } + + pub async fn disconnect(&self) -> Result<()> { + Ok(()) + } +} diff --git a/src/ble/tests/fixtures.rs b/src/ble/tests/fixtures.rs new file mode 100644 index 0000000..9c9ed26 --- /dev/null +++ b/src/ble/tests/fixtures.rs @@ -0,0 +1,97 @@ +//! Named test-device data, independent of backend failures and event ordering. + +use std::collections::HashMap; + +use btleplug::api::PeripheralProperties; +use uuid::Uuid; + +use super::{ + backend::{CentralEvent, Device}, + MATTER_SERVICE_UUID, +}; + +#[derive(Clone, Debug, Default)] +pub struct TestDeviceDefinition { + pub id: u64, + pub discriminator: u16, + pub advertisement_version: u8, + pub vendor_id: u16, + pub product_id: u16, + pub additional_flags: u8, + pub name: Option, + pub rssi: Option, + pub tx_power: Option, + pub advertised_services: Vec, +} + +impl TestDeviceDefinition { + /// Encode the Matter service-data payload, excluding the 0xFFF6 service UUID. + /// Layout: + pub fn service_data(&self) -> [u8; 8] { + assert!( + self.discriminator <= 0x0fff, + "discriminator must fit in 12 bits" + ); + assert!( + self.advertisement_version <= 0x0f, + "advertisement version must fit in 4 bits" + ); + let discriminator_and_version = + self.discriminator | (u16::from(self.advertisement_version) << 12); + let [disc_low, disc_high] = discriminator_and_version.to_le_bytes(); + let [vendor_low, vendor_high] = self.vendor_id.to_le_bytes(); + let [product_low, product_high] = self.product_id.to_le_bytes(); + [ + 0x00, + disc_low, + disc_high, + vendor_low, + vendor_high, + product_low, + product_high, + self.additional_flags, + ] + } + + pub fn properties(&self) -> PeripheralProperties { + PeripheralProperties { + local_name: self.name.clone(), + rssi: self.rssi, + tx_power_level: self.tx_power, + services: self.advertised_services.clone(), + service_data: HashMap::from([(MATTER_SERVICE_UUID, self.service_data().to_vec())]), + ..Default::default() + } + } + + /// Build cached properties explicitly; creating an event does not populate this cache. + pub fn device(&self) -> Device { + Device { + properties: Some(self.properties()), + ..Default::default() + } + } + + /// Build an event independently of the peripheral's cached properties. + pub fn advertisement(&self) -> CentralEvent { + raw_advertisement(self.id, &self.service_data()) + } +} + +/// Bypass fixture encoding for independent wire vectors and malformed payloads. +pub fn raw_advertisement(id: u64, data: &[u8]) -> CentralEvent { + CentralEvent::ServiceDataAdvertisement { + id, + service_data: HashMap::from([(MATTER_SERVICE_UUID, data.to_vec())]), + } +} + +pub fn raw_device(data: &[u8]) -> Device { + Device { + properties: Some(PeripheralProperties { + service_data: HashMap::from([(MATTER_SERVICE_UUID, data.to_vec())]), + ..Default::default() + }), + ..Default::default() + } +}