rust/bt-bap-unicast: Implement discovery logic in client - Implement discover method in BapUnicastClient to read PACS and ASCS. - Use AudioStreamControlServiceClient::create for ASCS discovery. - Read PAC records from PACS service. - Update unit tests in manager.rs to set up fake PACS and ASCS services with characteristics. Test: unit tests passed Bug: 433288675 Change-Id: I6be5a966bbb34378499ee5e3165bf2fd6a6a6964 Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3063
diff --git a/rust/bt-bap-unicast/DESIGN.md b/rust/bt-bap-unicast/DESIGN.md index 0f7837a..cb01583 100644 --- a/rust/bt-bap-unicast/DESIGN.md +++ b/rust/bt-bap-unicast/DESIGN.md
@@ -109,8 +109,8 @@ InvalidState(String), /// The configuration requested is not valid according to the spec. - #[error("Invalid configuration: {0}")] - InvalidConfiguration(String), + #[error("Configuration issue: {0}")] + Configuration(String), /// The peer rejected the operation (e.g. QoS or Codec configuration rejected). #[error("Peer rejected operation: {0}")]
diff --git a/rust/bt-bap-unicast/src/client.rs b/rust/bt-bap-unicast/src/client.rs index a89532d..eb44a1d 100644 --- a/rust/bt-bap-unicast/src/client.rs +++ b/rust/bt-bap-unicast/src/client.rs
@@ -3,6 +3,10 @@ // found in the LICENSE file. use bt_common::PeerId; +use bt_gatt::client::FromCharacteristic; +use bt_gatt::client::PeerService; +use bt_gatt::client::PeerServiceHandle; +use bt_gatt::Client; use bt_gatt::GattTypes; use crate::types::{Error, SupportedCapabilities}; @@ -25,17 +29,135 @@ } /// Discovers PACS and ASCS and returns supported configurations. - pub async fn discover(&mut self) -> Result<SupportedCapabilities, Error> { - // TODO: Implement actual discovery. - // Using dummy data for now to trigger skeleton mapping logic. - let pac_records = vec![bt_pacs::PacRecord { - codec_id: bt_common::core::CodecId::Assigned(bt_common::core::CodingFormat::Lc3), - codec_specific_capabilities: vec![], - metadata: vec![], - }]; - let sink_ase_count = 1; - let source_ase_count = 1; + pub async fn discover(&mut self) -> Result<SupportedCapabilities, Error> + where + <T as GattTypes>::NotificationStream: Send, + { + // 1. Discover PACS and read capabilities. + let pacs_handles = self.gatt_client.find_service(bt_pacs::PACS_UUID).await?; + + if pacs_handles.len() != 1 { + return Err(Error::Configuration(format!( + "{} PACS service(s) - not unique", + pacs_handles.len() + ))); + } + + let handle = &pacs_handles[0]; + let mut pac_records = vec![]; + + let service = handle.connect().await?; + + let chrs = service.discover_characteristics(None).await?; + + for chr in chrs { + if chr.uuid == bt_pacs::SourcePac::UUID { + let source_pac = bt_pacs::SourcePac::try_read::<T>(chr, &service).await?; + pac_records.extend(source_pac.capabilities); + } else if chr.uuid == bt_pacs::SinkPac::UUID { + let sink_pac = bt_pacs::SinkPac::try_read::<T>(chr, &service).await?; + pac_records.extend(sink_pac.capabilities); + } + } + + // 2. Discover ASCS and count endpoints. + let ascs_handles = self.gatt_client.find_service(bt_ascs::server::ASCS_UUID).await?; + + if ascs_handles.len() != 1 { + return Err(Error::Configuration(format!( + "{} ASCS service(s) - not unique", + ascs_handles.len() + ))); + } + + let handle = &ascs_handles[0]; + + let mut sink_ase_count = 0; + let mut source_ase_count = 0; + + let service = handle.connect().await?; + + let ascs_client = + bt_ascs::client::AudioStreamControlServiceClient::<T>::create(service).await?; + + sink_ase_count += ascs_client.endpoints.sink_ases().len(); + source_ase_count += ascs_client.endpoints.source_ases().len(); Ok(crate::mapping::map_capabilities(&pac_records, sink_ase_count, source_ase_count)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{setup_ascs_service, setup_pacs_service}; + use crate::Configuration; + use bt_common::PeerId; + use bt_gatt::test_utils::{FakeClient, FakeTypes}; + + #[test] + fn test_discover_success() { + let mut client = FakeClient::new(); + let _pacs_service = setup_pacs_service(&mut client); + let _ascs_service = setup_ascs_service(&mut client, 1, 1); + + let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client); + let mapping = futures::executor::block_on(unicast_client.discover()) + .expect("discovery should succeed"); + assert!(mapping.contains_key(&Configuration::MonoAudioSink)); + assert!(mapping.contains_key(&Configuration::Conversational)); + } + + #[test] + fn test_discover_no_services() { + let client = FakeClient::new(); + + let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client); + let result = futures::executor::block_on(unicast_client.discover()); + assert!(matches!(result, Err(Error::Configuration(_)))); + } + + #[test] + fn test_discover_no_pacs_service() { + let mut client = FakeClient::new(); + let _ascs_service = setup_ascs_service(&mut client, 1, 1); + + let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client); + let result = futures::executor::block_on(unicast_client.discover()); + assert!(matches!(result, Err(Error::Configuration(_)))); + } + + #[test] + fn test_discover_multiple_pacs_services() { + let mut client = FakeClient::new(); + let _pacs_service1 = setup_pacs_service(&mut client); + let _pacs_service2 = setup_pacs_service(&mut client); + let _ascs_service = setup_ascs_service(&mut client, 1, 1); + + let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client); + let result = futures::executor::block_on(unicast_client.discover()); + assert!(matches!(result, Err(Error::Configuration(_)))); + } + + #[test] + fn test_discover_no_ascs_service() { + let mut client = FakeClient::new(); + let _pacs_service = setup_pacs_service(&mut client); + + let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client); + let result = futures::executor::block_on(unicast_client.discover()); + assert!(matches!(result, Err(Error::Configuration(_)))); + } + + #[test] + fn test_discover_multiple_ascs_services() { + let mut client = FakeClient::new(); + let _pacs_service = setup_pacs_service(&mut client); + let _ascs_service1 = setup_ascs_service(&mut client, 1, 1); + let _ascs_service2 = setup_ascs_service(&mut client, 1, 1); + + let mut unicast_client = BapUnicastClient::<FakeTypes>::new(PeerId(1), client); + let result = futures::executor::block_on(unicast_client.discover()); + assert!(matches!(result, Err(Error::Configuration(_)))); + } +}
diff --git a/rust/bt-bap-unicast/src/lib.rs b/rust/bt-bap-unicast/src/lib.rs index 12dc7b0..696aff4 100644 --- a/rust/bt-bap-unicast/src/lib.rs +++ b/rust/bt-bap-unicast/src/lib.rs
@@ -5,6 +5,8 @@ pub mod client; pub mod manager; pub mod mapping; +#[cfg(test)] +pub(crate) mod test_utils; pub mod types; pub use client::BapUnicastClient;
diff --git a/rust/bt-bap-unicast/src/manager.rs b/rust/bt-bap-unicast/src/manager.rs index 58e770b..5cadb9f 100644 --- a/rust/bt-bap-unicast/src/manager.rs +++ b/rust/bt-bap-unicast/src/manager.rs
@@ -26,7 +26,10 @@ &mut self, peer_id: PeerId, gatt_client: T::Client, - ) -> Result<SupportedCapabilities, Error> { + ) -> Result<SupportedCapabilities, Error> + where + <T as GattTypes>::NotificationStream: Send, + { if self.peers.contains_key(&peer_id) { return Err(Error::PeerAlreadyExists(peer_id)); } @@ -49,6 +52,7 @@ #[cfg(test)] mod tests { use super::*; + use crate::test_utils::{setup_ascs_service, setup_pacs_service}; use crate::Configuration; use bt_common::PeerId; use bt_gatt::test_utils::{FakeClient, FakeTypes}; @@ -57,7 +61,13 @@ fn test_add_remove_peer() { let mut manager = BapUnicastManager::<FakeTypes>::new(); let peer_id = PeerId(1); - let client = FakeClient::new(); + let mut client = FakeClient::new(); + + // 1. Setup PACS + let _pacs_service = setup_pacs_service(&mut client); + + // 2. Setup ASCS + let _ascs_service = setup_ascs_service(&mut client, 1, 1); let mapping = futures::executor::block_on(manager.add_peer(peer_id, client.clone())).unwrap(); @@ -69,4 +79,38 @@ assert!(manager.remove_peer(peer_id).is_ok()); assert!(manager.remove_peer(peer_id).is_err()); // Not found } + + #[test] + fn test_add_peer_fails_non_unique_services() { + let mut manager = BapUnicastManager::<FakeTypes>::new(); + let peer_id = PeerId(1); + + // 0 PACS services: add_peer fails and peer is not stored. + let mut client = FakeClient::new(); + let _ascs_service = setup_ascs_service(&mut client, 1, 1); + assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err()); + assert!(manager.remove_peer(peer_id).is_err()); + + // Multiple PACS services: add_peer fails and peer is not stored. + let mut client = FakeClient::new(); + let _pacs1 = setup_pacs_service(&mut client); + let _pacs2 = setup_pacs_service(&mut client); + let _ascs = setup_ascs_service(&mut client, 1, 1); + assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err()); + assert!(manager.remove_peer(peer_id).is_err()); + + // 0 ASCS services: add_peer fails and peer is not stored. + let mut client = FakeClient::new(); + let _pacs = setup_pacs_service(&mut client); + assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err()); + assert!(manager.remove_peer(peer_id).is_err()); + + // Multiple ASCS services: add_peer fails and peer is not stored. + let mut client = FakeClient::new(); + let _pacs = setup_pacs_service(&mut client); + let _ascs1 = setup_ascs_service(&mut client, 1, 1); + let _ascs2 = setup_ascs_service(&mut client, 1, 1); + assert!(futures::executor::block_on(manager.add_peer(peer_id, client.clone())).is_err()); + assert!(manager.remove_peer(peer_id).is_err()); + } }
diff --git a/rust/bt-bap-unicast/src/test_utils.rs b/rust/bt-bap-unicast/src/test_utils.rs new file mode 100644 index 0000000..1e779b1 --- /dev/null +++ b/rust/bt-bap-unicast/src/test_utils.rs
@@ -0,0 +1,142 @@ +// Copyright 2026 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#![allow(dead_code)] + +use bt_ascs::client::{ASE_CONTROL_POINT_UUID, SINK_ASE_UUID, SOURCE_ASE_UUID}; +use bt_ascs::server::ASCS_UUID; +use bt_ascs::types::{ + AseAdditionalParameters, AseId, AseState, AudioDirection, AudioStreamEndpoint, +}; +use bt_common::core::{CodecId, CodingFormat}; +use bt_common::packet_encoding::Encodable; +use bt_gatt::client::FromCharacteristic; +use bt_gatt::test_utils::{FakeClient, FakePeerService}; +use bt_gatt::types::{ + AttributePermissions, Characteristic, CharacteristicProperties, CharacteristicProperty, Handle, +}; +use bt_pacs::{PacRecord, SourcePac, PACS_UUID}; + +pub const SOURCE_PAC_HANDLE: Handle = Handle(1); +pub const SINK_ASE_HANDLE: Handle = Handle(2); +pub const CONTROL_POINT_HANDLE: Handle = Handle(3); +pub const SOURCE_ASE_HANDLE: Handle = Handle(4); + +pub const DEFAULT_SINK_ASE_ID: AseId = AseId(1); +pub const DEFAULT_SOURCE_ASE_ID: AseId = AseId(2); + +/// Creates a `FakePeerService` configured as a PACS service with a single +/// Source PAC record for LC3. +pub fn create_pacs_service() -> FakePeerService { + let mut pacs_service = FakePeerService::new(); + let pac_record = PacRecord { + codec_id: CodecId::Assigned(CodingFormat::Lc3), + codec_specific_capabilities: vec![], + metadata: vec![], + }; + + let mut pac_buf = vec![0; 1 + pac_record.encoded_len()]; + pac_buf[0] = 1; // 1 record + pac_record.encode(&mut pac_buf[1..]).unwrap(); + + let source_pac_chr = Characteristic { + handle: SOURCE_PAC_HANDLE, + uuid: SourcePac::UUID, + properties: CharacteristicProperty::Read.into(), + permissions: AttributePermissions::default(), + descriptors: vec![], + }; + pacs_service.add_characteristic(source_pac_chr, pac_buf); + pacs_service +} + +/// Sets up a PACS service in `client` with a single Source PAC record for LC3. +/// Adds the service to `client` and returns the `FakePeerService`. +pub fn setup_pacs_service(client: &mut FakeClient) -> FakePeerService { + let pacs_service = create_pacs_service(); + client.add_service(PACS_UUID, true, pacs_service.clone()); + pacs_service +} + +/// Creates a `FakePeerService` configured as an ASCS service with +/// `sink_ase_count` sink ASEs, `source_ase_count` source ASEs, and a Control +/// Point initialized with `control_point_val`. +pub fn create_ascs_service( + sink_ase_count: usize, + source_ase_count: usize, + control_point_val: Vec<u8>, +) -> FakePeerService { + let mut ascs_service = FakePeerService::new(); + + let control_point_chr = Characteristic { + handle: CONTROL_POINT_HANDLE, + uuid: ASE_CONTROL_POINT_UUID, + properties: CharacteristicProperties::WRITE_NOTIFY, + permissions: AttributePermissions::default(), + descriptors: vec![], + }; + ascs_service.add_characteristic(control_point_chr, control_point_val); + + // ASE ids and handles are allocated from iterators shared by every endpoint + // characteristic, so sinks and sources can never collide with each other. + // The handle reserved for the control point characteristic is skipped. + let mut ase_ids = (1..).map(AseId); + let mut handles = (SINK_ASE_HANDLE.0..).map(Handle).filter(|h| *h != CONTROL_POINT_HANDLE); + + let directions = std::iter::repeat_n(AudioDirection::Sink, sink_ase_count) + .chain(std::iter::repeat_n(AudioDirection::Source, source_ase_count)); + + for direction in directions { + let endpoint = AudioStreamEndpoint { + handle: Handle(0), + direction, + ase_id: ase_ids.next().expect("ase ids available"), + state: AseState::Idle, + additional: AseAdditionalParameters::None, + }; + let mut ase_buf = vec![0; endpoint.encoded_len()]; + endpoint.encode(&mut ase_buf).unwrap(); + + let uuid = match direction { + AudioDirection::Sink => SINK_ASE_UUID, + AudioDirection::Source => SOURCE_ASE_UUID, + }; + let ase_chr = Characteristic { + handle: handles.next().expect("handles available"), + uuid, + properties: CharacteristicProperties::READ_NOTIFY, + permissions: AttributePermissions::default(), + descriptors: vec![], + }; + ascs_service.add_characteristic(ase_chr, ase_buf); + } + + ascs_service +} + +/// Sets up an ASCS service in `client` with `sink_ase_count` sink ASEs, +/// `source_ase_count` source ASEs, and an empty Control Point. Adds the service +/// to `client` and returns the `FakePeerService`. +pub fn setup_ascs_service( + client: &mut FakeClient, + sink_ase_count: usize, + source_ase_count: usize, +) -> FakePeerService { + setup_ascs_service_with_cp_value(client, sink_ase_count, source_ase_count, vec![]) +} + +/// Sets up an ASCS service in `client` with `sink_ase_count` sink ASEs, +/// `source_ase_count` source ASEs, and a Control Point initialized with +/// `control_point_val`. Adds the service to `client` and returns the +/// `FakePeerService`. +pub fn setup_ascs_service_with_cp_value( + client: &mut FakeClient, + sink_ase_count: usize, + source_ase_count: usize, + control_point_val: Vec<u8>, +) -> FakePeerService { + let ascs_service = create_ascs_service(sink_ase_count, source_ase_count, control_point_val); + client.add_service(ASCS_UUID, true, ascs_service.clone()); + ascs_service +}
diff --git a/rust/bt-bap-unicast/src/types.rs b/rust/bt-bap-unicast/src/types.rs index eac9df9..22bb57a 100644 --- a/rust/bt-bap-unicast/src/types.rs +++ b/rust/bt-bap-unicast/src/types.rs
@@ -63,8 +63,8 @@ InvalidState(String), /// The configuration requested is not valid according to the spec. - #[error("Invalid configuration: {0}")] - InvalidConfiguration(String), + #[error("Configuration issue: {0}")] + Configuration(String), /// The peer rejected the operation (e.g. QoS or Codec configuration /// rejected).