| // 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. |
| |
| use futures::stream::{BoxStream, FuturesUnordered, StreamExt}; |
| use std::collections::{HashMap, HashSet}; |
| |
| use bt_common::packet_encoding::Decodable; |
| use bt_common::Uuid; |
| use bt_gatt::client::{CharacteristicNotification, PeerService, ServiceCharacteristic}; |
| use bt_gatt::types::Handle; |
| |
| use crate::types::*; |
| |
| /// 16-bit UUID value for the characteristics offered by the Audio |
| /// Stream Control Service. |
| pub const ASE_CONTROL_POINT_UUID: Uuid = Uuid::from_u16(0x2BC6); |
| pub const SINK_ASE_UUID: Uuid = Uuid::from_u16(0x2BC4); |
| pub const SOURCE_ASE_UUID: Uuid = Uuid::from_u16(0x2BC5); |
| |
| #[derive(Debug, thiserror::Error, PartialEq, Clone)] |
| pub enum ClientError { |
| #[error("Remote server is missing control point characteristic")] |
| MissingControlPointCharacteristic, |
| #[error("Remote server should only have 1 control point characteristic")] |
| ExtraControlPointCharacteristic, |
| #[error("Remote server doesn't have any audio stream endpoints")] |
| MissingAudioStreamEndpoints, |
| #[error("Notification stream closed before receiving all responses")] |
| NotificationStreamClosed, |
| #[error( |
| "ASE {ase_id:?} transitioned to unexpected state: expected {expected:?}, got {actual:?}" |
| )] |
| UnexpectedStateTransition { ase_id: AseId, expected: AseState, actual: AseState }, |
| #[error("Unknown ASE ID: {0:?}")] |
| UnknownAseId(AseId), |
| #[error("ASE {ase_id:?} is in invalid starting state {actual:?} for opcode {opcode:?}")] |
| InvalidStartState { ase_id: AseId, opcode: AseControlPointOpcode, actual: AseState }, |
| #[error("Remote server rejected operation: {0:?}")] |
| RejectedOperation(Vec<ResponseCode>), |
| } |
| |
| /// Represents a single source/sink ASE state characteristic. |
| pub struct ClientEndpoint { |
| pub endpoint: AudioStreamEndpoint, |
| pub notification_stream: |
| BoxStream<'static, Result<CharacteristicNotification, bt_gatt::types::Error>>, |
| } |
| |
| impl ClientEndpoint { |
| pub async fn next_update(&mut self) -> Result<(AseId, AudioStreamEndpoint), Error> { |
| let notification = self |
| .notification_stream |
| .next() |
| .await |
| .ok_or(Error::Client(ClientError::NotificationStreamClosed))??; |
| let new_endpoint = AudioStreamEndpoint::from_char_value( |
| self.endpoint.handle, |
| self.endpoint.direction, |
| ¬ification.value[..], |
| ) |
| .map_err(|e| { |
| bt_gatt::types::Error::Conversion(format!( |
| "Failed to decode AudioStreamEndpoint: {:?}", |
| e |
| )) |
| })?; |
| Ok((self.endpoint.ase_id, new_endpoint)) |
| } |
| } |
| |
| /// Represents a client request to configure QoS parameters on an ASE. |
| #[derive(Debug, Clone, PartialEq)] |
| pub enum QosConfigurationRequest { |
| /// Completely custom QoS configuration parameters. |
| Custom(QosConfiguration), |
| |
| /// Automatically derive constraint parameters (framing, PHYs, |
| /// retransmissions, latency, presentation delay) from the server's |
| /// published preferred parameters in the local cache, only requiring |
| /// the host-allocated dynamic properties. |
| Preferred { |
| ase_id: AseId, |
| cig_id: CigId, |
| cis_id: CisId, |
| sdu_interval: SduInterval, |
| max_sdu: MaxSdu, |
| }, |
| } |
| |
| impl QosConfigurationRequest { |
| pub fn try_into_config<T: bt_gatt::GattTypes>( |
| self, |
| client: &AudioStreamControlServiceClient<T>, |
| ) -> Result<QosConfiguration, Error> { |
| match self { |
| Self::Custom(c) => Ok(c), |
| Self::Preferred { ase_id, cig_id, cis_id, sdu_interval, max_sdu } => { |
| let endpoint = &client |
| .endpoints |
| .lookup_by_ase_id(ase_id) |
| .ok_or(Error::Client(ClientError::UnknownAseId(ase_id)))? |
| .endpoint; |
| |
| let params = endpoint.qos_parameters().ok_or(Error::Client( |
| ClientError::InvalidStartState { |
| ase_id, |
| opcode: AseControlPointOpcode::ConfigQos, |
| actual: endpoint.state, |
| }, |
| ))?; |
| |
| let presentation_delay = params.presentation_delay_range.min().clone(); |
| |
| Ok(QosConfiguration { |
| ase_id, |
| cig_id, |
| cis_id, |
| sdu_interval, |
| framing: params.framing, |
| phy: params.preferred_phys.clone(), |
| max_sdu, |
| retransmission_number: params.preferred_retransmission_number, |
| max_transport_latency: params.max_transport_latency, |
| presentation_delay, |
| }) |
| } |
| } |
| } |
| } |
| |
| // See ASCS v1.0.1 4.2 for details. |
| pub struct AseControlPoint { |
| pub handle: Handle, |
| pub notification_stream: |
| BoxStream<'static, Result<CharacteristicNotification, bt_gatt::types::Error>>, |
| } |
| |
| /// Creates Audio Stream Control Service (ASCS) client instance |
| pub struct AudioStreamControlServiceClient<T: bt_gatt::GattTypes> { |
| pub gatt_client: T::PeerService, |
| pub control_point: AseControlPoint, |
| pub endpoints: DiscoveredEndpoints, |
| } |
| |
| pub struct DiscoveredEndpoints { |
| pub sink: HashMap<Handle, ClientEndpoint>, |
| pub source: HashMap<Handle, ClientEndpoint>, |
| } |
| |
| impl DiscoveredEndpoints { |
| /// Returns the ASE IDs of all discovered sink Audio Stream Endpoints. |
| pub fn sink_ases(&self) -> Vec<AseId> { |
| self.sink.values().map(|e| e.endpoint.ase_id).collect() |
| } |
| |
| /// Returns the ASE IDs of all discovered source Audio Stream Endpoints. |
| pub fn source_ases(&self) -> Vec<AseId> { |
| self.source.values().map(|e| e.endpoint.ase_id).collect() |
| } |
| |
| pub(crate) fn lookup_by_ase_id(&self, ase_id: AseId) -> Option<&ClientEndpoint> { |
| if let Some(e) = self.sink.values().find(|e| e.endpoint.ase_id == ase_id) { |
| return Some(e); |
| } |
| if let Some(e) = self.source.values().find(|e| e.endpoint.ase_id == ase_id) { |
| return Some(e); |
| } |
| None |
| } |
| |
| pub(crate) fn lookup_by_ase_id_mut(&mut self, ase_id: AseId) -> Option<&mut ClientEndpoint> { |
| if let Some(e) = self.sink.values_mut().find(|e| e.endpoint.ase_id == ase_id) { |
| return Some(e); |
| } |
| if let Some(e) = self.source.values_mut().find(|e| e.endpoint.ase_id == ase_id) { |
| return Some(e); |
| } |
| None |
| } |
| } |
| |
| /// The outcome of an ASE control point operation, partitioning successfully |
| /// transitioned endpoints and failed response codes. |
| #[derive(Debug, Clone, PartialEq)] |
| pub struct AseControlOperationOutcome { |
| accepted: HashMap<AseId, AudioStreamEndpoint>, |
| rejected: HashMap<AseId, ResponseCode>, |
| } |
| |
| impl AseControlOperationOutcome { |
| pub(crate) fn new( |
| accepted: HashMap<AseId, AudioStreamEndpoint>, |
| rejected: HashMap<AseId, ResponseCode>, |
| ) -> Self { |
| Self { accepted, rejected } |
| } |
| |
| /// Returns the map of successfully transitioned [`AudioStreamEndpoint`]s, |
| /// keyed by their [`AseId`]. |
| pub fn accepted(&self) -> &HashMap<AseId, AudioStreamEndpoint> { |
| &self.accepted |
| } |
| |
| /// Returns the map of rejected [`ResponseCode`]s, keyed by their [`AseId`]. |
| pub fn rejected(&self) -> &HashMap<AseId, ResponseCode> { |
| &self.rejected |
| } |
| } |
| |
| impl<T: bt_gatt::GattTypes> AudioStreamControlServiceClient<T> { |
| pub async fn create(gatt_client: T::PeerService) -> Result<Self, Error> |
| where |
| <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, |
| { |
| let control_point = Self::discover_control_point(&gatt_client).await?; |
| let endpoints = Self::discover_all_endpoints(&gatt_client).await?; |
| |
| Ok(Self { gatt_client, control_point, endpoints }) |
| } |
| |
| async fn discover_control_point(gatt_client: &T::PeerService) -> Result<AseControlPoint, Error> |
| where |
| <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, |
| { |
| let cp_chars = ServiceCharacteristic::<T>::find(gatt_client, ASE_CONTROL_POINT_UUID) |
| .await |
| .map_err(Error::Gatt)?; |
| if cp_chars.is_empty() { |
| return Err(ClientError::MissingControlPointCharacteristic.into()); |
| } |
| if cp_chars.len() > 1 { |
| return Err(ClientError::ExtraControlPointCharacteristic.into()); |
| } |
| let cp_handle = *cp_chars[0].handle(); |
| let cp_stream = gatt_client.subscribe(&cp_handle); |
| Ok(AseControlPoint { handle: cp_handle, notification_stream: cp_stream.boxed() }) |
| } |
| |
| async fn discover_all_endpoints( |
| gatt_client: &T::PeerService, |
| ) -> Result<DiscoveredEndpoints, Error> |
| where |
| <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, |
| { |
| let sink_chars = ServiceCharacteristic::<T>::find(gatt_client, SINK_ASE_UUID) |
| .await |
| .map_err(Error::Gatt)?; |
| |
| let source_chars = ServiceCharacteristic::<T>::find(gatt_client, SOURCE_ASE_UUID) |
| .await |
| .map_err(Error::Gatt)?; |
| |
| if sink_chars.is_empty() && source_chars.is_empty() { |
| return Err(ClientError::MissingAudioStreamEndpoints.into()); |
| } |
| |
| let mut sink_endpoints = HashMap::new(); |
| for c in sink_chars { |
| let handle = *c.handle(); |
| let endpoint = |
| Self::read_and_create_endpoint(gatt_client, handle, AudioDirection::Sink).await?; |
| let notification_stream = gatt_client.subscribe(&handle).boxed(); |
| sink_endpoints.insert(handle, ClientEndpoint { endpoint, notification_stream }); |
| } |
| |
| let mut source_endpoints = HashMap::new(); |
| for c in source_chars { |
| let handle = *c.handle(); |
| let endpoint = |
| Self::read_and_create_endpoint(gatt_client, handle, AudioDirection::Source).await?; |
| let notification_stream = gatt_client.subscribe(&handle).boxed(); |
| source_endpoints.insert(handle, ClientEndpoint { endpoint, notification_stream }); |
| } |
| |
| Ok(DiscoveredEndpoints { sink: sink_endpoints, source: source_endpoints }) |
| } |
| |
| async fn read_and_create_endpoint( |
| gatt_client: &T::PeerService, |
| handle: Handle, |
| direction: AudioDirection, |
| ) -> Result<AudioStreamEndpoint, Error> { |
| let mut buf = vec![0; 255]; |
| let (read_bytes, _truncated) = |
| gatt_client.read_characteristic(&handle, 0, &mut buf[..]).await.map_err(Error::Gatt)?; |
| let endpoint = AudioStreamEndpoint::from_char_value(handle, direction, &buf[0..read_bytes]) |
| .map_err(|e| { |
| bt_gatt::types::Error::Conversion(format!( |
| "Failed to decode AudioStreamEndpoint: {:?}", |
| e |
| )) |
| })?; |
| Ok(endpoint) |
| } |
| |
| fn verify_ase_ids<I>(&self, ase_ids: I) -> Result<(), Error> |
| where |
| I: IntoIterator<Item = AseId>, |
| { |
| for ase_id in ase_ids { |
| if self.endpoints.lookup_by_ase_id(ase_id).is_none() { |
| return Err(Error::Client(ClientError::UnknownAseId(ase_id))); |
| } |
| } |
| Ok(()) |
| } |
| |
| fn validate_arguments( |
| &mut self, |
| opcode: AseControlPointOpcode, |
| pending_ases: &HashSet<AseId>, |
| ) -> Result<(), Error> { |
| self.verify_ase_ids(pending_ases.iter().cloned())?; |
| |
| for ase_id in pending_ases { |
| let endpoint = &self |
| .endpoints |
| .lookup_by_ase_id(*ase_id) |
| .ok_or(Error::Client(ClientError::UnknownAseId(*ase_id)))? |
| .endpoint; |
| |
| if !opcode.allowed_in_state(&endpoint.state) { |
| return Err(Error::Client(ClientError::InvalidStartState { |
| ase_id: *ase_id, |
| opcode, |
| actual: endpoint.state, |
| })); |
| } |
| } |
| Ok(()) |
| } |
| |
| /// Reads the current value of an ASE characteristic from the remote server, |
| /// decodes it as an [`AudioStreamEndpoint`], updates the local cache of |
| /// sink or source endpoints, and returns the updated endpoint. |
| /// |
| /// # Arguments |
| /// * `handle` - The GATT characteristic handle of the ASE to read. |
| /// * `direction` - The direction (Sink or Source) of the ASE. |
| /// |
| /// # Returns |
| /// * `Ok(AudioStreamEndpoint)` - The newly read and updated endpoint |
| /// struct. |
| /// * `Err(Error)` - If reading from the GATT client fails, or if the read |
| /// value cannot be decoded. |
| async fn write_operation(&mut self, operation: &AseControlOperation) -> Result<(), Error> { |
| use bt_common::packet_encoding::Encodable; |
| |
| let mut buf = vec![0; operation.encoded_len()]; |
| operation |
| .encode(&mut buf[..]) |
| .map_err(|e| Error::Internal(format!("Failed to encode operation: {:?}", e)))?; |
| |
| self.gatt_client |
| .write_characteristic( |
| &self.control_point.handle, |
| bt_gatt::types::WriteMode::WithoutResponse, |
| 0, |
| &buf[..], |
| ) |
| .await |
| .map_err(Error::Gatt) |
| } |
| |
| async fn collect_operation_responses( |
| &mut self, |
| opcode: u8, |
| mut remaining_ases: HashSet<AseId>, |
| ) -> Result<HashMap<AseId, ResponseCode>, Error> { |
| let mut responses = HashMap::new(); |
| |
| while !remaining_ases.is_empty() { |
| let notification = self |
| .control_point |
| .notification_stream |
| .next() |
| .await |
| .ok_or(Error::Client(ClientError::NotificationStreamClosed))?; |
| let notification = notification.map_err(Error::from)?; |
| |
| let (cp_notification, _) = ControlPointNotification::decode(¬ification.value[..]); |
| let cp_notification = cp_notification.map_err(bt_gatt::types::Error::Encoding)?; |
| if cp_notification.opcode != opcode { |
| continue; |
| } |
| |
| if cp_notification.number_of_ases == 0xFF { |
| return Err(Error::Client(ClientError::RejectedOperation( |
| cp_notification.responses, |
| ))); |
| } |
| |
| for r in cp_notification.responses { |
| if remaining_ases.remove(&r.ase_id()) { |
| responses.insert(r.ase_id(), r); |
| } |
| } |
| } |
| |
| Ok(responses) |
| } |
| |
| async fn perform_and_verify_operation( |
| &mut self, |
| operation: AseControlOperation, |
| pending_ases: HashSet<AseId>, |
| ) -> Result<AseControlOperationOutcome, Error> { |
| self.write_operation(&operation).await?; |
| |
| let opcode_u8 = u8::try_from(&operation)?; |
| let responses = self.collect_operation_responses(opcode_u8, pending_ases).await?; |
| |
| let mut successful_endpoints = HashMap::new(); |
| let opcode = AseControlPointOpcode::try_from(&operation).map_err(|e| { |
| Error::Internal(format!("Failed to resolve opcode from operation: {:?}", e)) |
| })?; |
| |
| let (successful, failed_responses): ( |
| HashMap<AseId, ResponseCode>, |
| HashMap<AseId, ResponseCode>, |
| ) = responses.into_iter().partition(|(_, r)| r.is_success()); |
| |
| let successful_ases: HashSet<AseId> = successful.keys().cloned().collect(); |
| |
| if successful_ases.is_empty() { |
| return Ok(AseControlOperationOutcome::new(HashMap::new(), failed_responses)); |
| } |
| |
| let updated_endpoints = { |
| let mut notification_futures = FuturesUnordered::new(); |
| for endpoint in |
| self.endpoints.sink.values_mut().chain(self.endpoints.source.values_mut()) |
| { |
| if !successful_ases.contains(&endpoint.endpoint.ase_id) { |
| continue; |
| } |
| notification_futures.push(endpoint.next_update()); |
| } |
| |
| let mut updated_endpoints = Vec::with_capacity(successful_ases.len()); |
| while let Some(res) = notification_futures.next().await { |
| updated_endpoints.push(res?); |
| } |
| updated_endpoints |
| }; |
| |
| for (ase_id, endpoint) in &updated_endpoints { |
| if let Some(client_endpoint) = self.endpoints.lookup_by_ase_id_mut(*ase_id) { |
| client_endpoint.endpoint = endpoint.clone(); |
| } |
| } |
| |
| for (ase_id, endpoint) in updated_endpoints { |
| if !opcode.allows_transition_to(endpoint.direction, &endpoint.state) { |
| let expected = opcode.operation_target_states(endpoint.direction)[0]; |
| return Err(Error::Client(ClientError::UnexpectedStateTransition { |
| ase_id: endpoint.ase_id, |
| expected, |
| actual: endpoint.state, |
| })); |
| } |
| successful_endpoints.insert(ase_id, endpoint); |
| } |
| |
| Ok(AseControlOperationOutcome::new(successful_endpoints, failed_responses)) |
| } |
| |
| /// Performs the Configure Codec operation on one or more ASEs. |
| /// |
| /// This method sends the Configure Codec request to the remote server, |
| /// waits for control point notifications for all involved ASEs, and |
| /// verifies that the successfully configured ASEs have transitioned to |
| /// the `CodecConfigured` state. |
| /// |
| /// # Arguments |
| /// * `codec_configurations` - A vector of `CodecConfiguration` structs |
| /// containing the parameters for each ASE to be configured. |
| /// |
| /// # Returns |
| /// On success, returns a tuple containing: |
| /// * A `HashMap` mapping each successfully configured `AseId` to its |
| /// supported `QosParameters` published by the server. |
| /// * A `Vec` containing the `ResponseCode` for any ASEs where the |
| /// configuration failed. |
| pub async fn configure_codec( |
| &mut self, |
| codec_configurations: Vec<CodecConfiguration>, |
| ) -> Result<AseControlOperationOutcome, Error> { |
| let pending_ases: HashSet<AseId> = codec_configurations.iter().map(|c| c.ase_id).collect(); |
| self.validate_arguments(AseControlPointOpcode::ConfigCodec, &pending_ases)?; |
| |
| let op = AseControlOperation::ConfigCodec { codec_configurations, responses: vec![] }; |
| |
| self.perform_and_verify_operation(op, pending_ases).await |
| } |
| |
| /// Performs the Configure QoS operation on one or more ASEs. |
| /// |
| /// This method sends the Configure QoS request to the remote server, |
| /// waits for control point notifications for all involved ASEs, and |
| /// verifies that the successfully configured ASEs have transitioned to |
| /// the `QosConfigured` state. |
| /// |
| /// # Arguments |
| /// * `qos_configurations` - A vector of `QosConfiguration` structs |
| /// containing the QoS parameters for each ASE. |
| /// |
| /// # Returns |
| /// On success, returns a `Vec<ResponseCode>` containing the response |
| /// codes for any ASEs where the configuration failed. |
| /// Performs the Configure QoS operation on one or more ASEs. |
| /// |
| /// This method sends the Configure QoS request to the remote server, |
| /// waits for control point notifications for all involved ASEs, and |
| /// verifies that the successfully configured ASEs have transitioned to |
| /// the `QosConfigured` state. |
| /// |
| /// # Arguments |
| /// * `requests` - A vector of `QosConfigurationRequest` enums specifying |
| /// either custom or server-preferred configurations for each ASE. |
| /// |
| /// # Returns |
| /// On success, returns a `Vec<ResponseCode>` containing the response |
| /// codes for any ASEs where the configuration failed. |
| pub async fn configure_qos( |
| &mut self, |
| requests: Vec<QosConfigurationRequest>, |
| ) -> Result<AseControlOperationOutcome, Error> { |
| let qos_configurations: Vec<QosConfiguration> = requests |
| .into_iter() |
| .map(|req| req.try_into_config(self)) |
| .collect::<Result<Vec<_>, Error>>()?; |
| |
| let pending_ases: HashSet<AseId> = qos_configurations.iter().map(|q| q.ase_id).collect(); |
| self.validate_arguments(AseControlPointOpcode::ConfigQos, &pending_ases)?; |
| |
| // TODO(b/431814103): Consider ATT MTU limits when configuring multiple ASEs. |
| // If the encoded request exceeds the current MTU, we may need to break it into |
| // multiple Control Point writes. |
| let op = AseControlOperation::ConfigQos { qos_configurations, responses: vec![] }; |
| |
| self.perform_and_verify_operation(op, pending_ases).await |
| } |
| |
| /// Performs the Enable operation on one or more ASEs. |
| /// |
| /// This method sends the Enable request to the remote server, |
| /// waits for control point notifications for all involved ASEs, and |
| /// verifies that the successfully enabled ASEs have transitioned to |
| /// the `Enabling` state. |
| /// |
| /// # Arguments |
| /// * `ases_with_metadata` - A vector of `AseIdWithMetadata` structs |
| /// specifying the target ASE IDs and their codec metadata. |
| /// |
| /// # Returns |
| /// On success, returns a `Vec<ResponseCode>` containing the response |
| /// codes for any ASEs where the enable operation failed. |
| pub async fn enable( |
| &mut self, |
| ases_with_metadata: Vec<AseIdWithMetadata>, |
| ) -> Result<AseControlOperationOutcome, Error> { |
| let pending_ases: HashSet<AseId> = ases_with_metadata.iter().map(|a| a.ase_id).collect(); |
| self.validate_arguments(AseControlPointOpcode::Enable, &pending_ases)?; |
| |
| let op = AseControlOperation::Enable { ases_with_metadata, responses: vec![] }; |
| |
| self.perform_and_verify_operation(op, pending_ases).await |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| use bt_common::core::CodecId; |
| use bt_gatt::test_utils::{FakePeerService, FakeTypes}; |
| use bt_gatt::types::{AttributePermissions, CharacteristicProperty}; |
| use bt_gatt::Characteristic; |
| |
| const CONTROL_POINT_HANDLE: Handle = Handle(1); |
| const SINK_ASE_HANDLE: Handle = Handle(2); |
| const SOURCE_ASE_HANDLE: Handle = Handle(3); |
| |
| fn setup_fake_service() -> FakePeerService { |
| let mut service = FakePeerService::new(); |
| // Add Control Point |
| service.add_characteristic( |
| Characteristic { |
| handle: CONTROL_POINT_HANDLE, |
| uuid: ASE_CONTROL_POINT_UUID, |
| properties: CharacteristicProperty::Write |
| | CharacteristicProperty::WriteWithoutResponse |
| | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![], |
| ); |
| // Add Sink ASE |
| service.add_characteristic( |
| Characteristic { |
| handle: SINK_ASE_HANDLE, |
| uuid: SINK_ASE_UUID, |
| properties: CharacteristicProperty::Read | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![0x01, 0x00], // ASE ID 1, State Idle |
| ); |
| // Add Source ASE |
| service.add_characteristic( |
| Characteristic { |
| handle: SOURCE_ASE_HANDLE, |
| uuid: SOURCE_ASE_UUID, |
| properties: CharacteristicProperty::Read | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![0x02, 0x00], // ASE ID 2, State Idle |
| ); |
| service |
| } |
| |
| fn run_to_completion<F: std::future::Future>(fut: F) -> F::Output { |
| let mut fut = std::pin::pin!(fut); |
| let mut cx = futures::task::Context::from_waker(futures::task::noop_waker_ref()); |
| match fut.as_mut().poll(&mut cx) { |
| std::task::Poll::Ready(res) => res, |
| std::task::Poll::Pending => panic!("Future did not complete synchronously"), |
| } |
| } |
| |
| #[test] |
| fn client_creation_success() { |
| let service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| assert_eq!(client.control_point.handle, CONTROL_POINT_HANDLE); |
| assert_eq!(client.endpoints.sink.len(), 1); |
| assert_eq!(client.endpoints.source.len(), 1); |
| |
| let sink = &client.endpoints.sink[&SINK_ASE_HANDLE].endpoint; |
| assert_eq!(sink.ase_id, AseId(1)); |
| assert_eq!(sink.state, AseState::Idle); |
| assert_eq!(sink.direction, AudioDirection::Sink); |
| |
| let source = &client.endpoints.source[&SOURCE_ASE_HANDLE].endpoint; |
| assert_eq!(source.ase_id, AseId(2)); |
| assert_eq!(source.state, AseState::Idle); |
| assert_eq!(source.direction, AudioDirection::Source); |
| |
| assert_eq!(client.endpoints.sink_ases(), vec![AseId(1)]); |
| assert_eq!(client.endpoints.source_ases(), vec![AseId(2)]); |
| } |
| |
| #[test] |
| fn client_creation_missing_control_point() { |
| let mut service = FakePeerService::new(); |
| // Only add Sink (no Control Point) |
| service.add_characteristic( |
| Characteristic { |
| handle: SINK_ASE_HANDLE, |
| uuid: SINK_ASE_UUID, |
| properties: CharacteristicProperty::Read | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![0x01, 0x00], |
| ); |
| |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service); |
| let err = match run_to_completion(client_fut) { |
| Err(e) => e, |
| Ok(_) => panic!("expected error, got Ok"), |
| }; |
| assert!(matches!(err, Error::Client(ClientError::MissingControlPointCharacteristic))); |
| } |
| |
| #[test] |
| fn client_creation_missing_endpoints() { |
| let mut service = FakePeerService::new(); |
| // Only add Control Point (no endpoints) |
| service.add_characteristic( |
| Characteristic { |
| handle: CONTROL_POINT_HANDLE, |
| uuid: ASE_CONTROL_POINT_UUID, |
| properties: CharacteristicProperty::Write | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![], |
| ); |
| |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service); |
| let err = match run_to_completion(client_fut) { |
| Err(e) => e, |
| Ok(_) => panic!("expected error, got Ok"), |
| }; |
| assert!(matches!(err, Error::Client(ClientError::MissingAudioStreamEndpoints))); |
| } |
| |
| #[test] |
| fn client_creation_extra_control_point() { |
| let mut service = FakePeerService::new(); |
| // Add first Control Point |
| service.add_characteristic( |
| Characteristic { |
| handle: CONTROL_POINT_HANDLE, |
| uuid: ASE_CONTROL_POINT_UUID, |
| properties: CharacteristicProperty::Write | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![], |
| ); |
| // Add second Control Point |
| service.add_characteristic( |
| Characteristic { |
| handle: Handle(4), |
| uuid: ASE_CONTROL_POINT_UUID, |
| properties: CharacteristicProperty::Write | CharacteristicProperty::Notify, |
| permissions: AttributePermissions::default(), |
| descriptors: vec![], |
| }, |
| vec![], |
| ); |
| |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service); |
| let err = match run_to_completion(client_fut) { |
| Err(e) => e, |
| Ok(_) => panic!("expected error, got Ok"), |
| }; |
| assert!(matches!(err, Error::Client(ClientError::ExtraControlPointCharacteristic))); |
| } |
| |
| #[test] |
| fn configure_codec() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| let config1 = CodecConfiguration { |
| ase_id: AseId(1), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| let config2 = CodecConfiguration { |
| ase_id: AseId(2), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x01, // Opcode: Config Codec |
| 0x02, // Num ASEs |
| 0x01, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 1 Config |
| 0x02, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 2 Config |
| ], |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![ |
| 0x01, // Opcode |
| 0x02, // Num ASEs |
| 0x01, 0x00, 0x00, // ASE 1 Response: Success |
| 0x02, 0x03, 0x02, // ASE 2 Response: Failed (Invalid ASE ID) |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &SINK_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SINK_ASE_HANDLE, |
| value: vec![ |
| 0x01, // ASE ID: 1 |
| 0x01, // ASE State: Codec Configured |
| 0x00, 0x01, 0x02, 0x0A, 0x00, 0x10, 0x27, 0x00, 0x40, 0x9C, 0x00, |
| 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let configure_fut = client.configure_codec(vec![config1, config2]); |
| let outcome = run_to_completion(configure_fut).expect("configure codec should succeed"); |
| |
| assert_eq!( |
| client.endpoints.sink[&SINK_ASE_HANDLE].endpoint.state, |
| AseState::CodecConfigured |
| ); |
| assert_eq!(outcome.accepted().len(), 1); |
| let endpoint = &outcome.accepted()[&AseId(1)]; |
| let qos = endpoint.qos_parameters().unwrap(); |
| assert_eq!(qos.framing, Framing::Unframed); |
| assert_eq!(qos.preferred_phys, vec![Phy::Le1MPhy]); |
| assert_eq!(qos.preferred_retransmission_number, 2); |
| |
| assert_eq!(outcome.rejected().len(), 1); |
| assert_eq!(outcome.rejected()[&AseId(2)], ResponseCode::InvalidAseId { value: 2 }); |
| } |
| |
| #[test] |
| fn configure_codec_fail_unknown_ase_id() { |
| let service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| let config = CodecConfiguration { |
| ase_id: AseId(99), // Non-existent ASE ID |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| |
| let configure_fut = client.configure_codec(vec![config]); |
| let err = run_to_completion(configure_fut).expect_err("should fail due to unknown ASE ID"); |
| assert!(matches!(err, Error::Client(ClientError::UnknownAseId(AseId(99))))); |
| } |
| |
| #[test] |
| fn configure_codec_global_error() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| let config = CodecConfiguration { |
| ase_id: AseId(1), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x01, // Opcode: Config Codec |
| 0x01, // Num ASEs |
| 0x01, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 1 Config |
| ], |
| ); |
| |
| // Let's mimic server side throwing a global invalid length error. |
| // (in reality this wouldn't happen because our configure codec operation was |
| // encoded correctly). |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![ |
| 0x01, // Opcode |
| 0xFF, // Num ASEs: Global Error |
| 0x00, 0x02, 0x00, // ASE 0, Invalid Length, None |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let configure_fut = client.configure_codec(vec![config]); |
| let err = run_to_completion(configure_fut).expect_err("should fail with global error"); |
| |
| assert!(matches!( |
| err, |
| Error::Client(ClientError::RejectedOperation(ref responses)) if matches!( |
| responses.as_slice(), |
| [ResponseCode::InvalidLength { .. }] |
| ) |
| )); |
| } |
| |
| #[test] |
| fn configure_qos_success() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| // Pre-condition both Sink and Source endpoints to CodecConfigured with valid |
| // QoS constraints |
| let sink_value = vec![ |
| 0x01, // ASE ID: 1 |
| 0x01, // ASE State: Codec Configured |
| 0x00, 0x01, 0x02, 0x0A, 0x00, 0x10, 0x27, 0x00, 0x40, 0x9C, 0x00, 0x00, 0x00, 0x00, |
| 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, |
| ]; |
| client.endpoints.sink.get_mut(&SINK_ASE_HANDLE).unwrap().endpoint = |
| AudioStreamEndpoint::from_char_value( |
| SINK_ASE_HANDLE, |
| AudioDirection::Sink, |
| &sink_value, |
| ) |
| .unwrap(); |
| |
| let source_value = vec![ |
| 0x02, // ASE ID: 2 |
| 0x01, // ASE State: Codec Configured |
| 0x00, 0x01, 0x02, 0x0A, 0x00, 0x10, 0x27, 0x00, 0x40, 0x9C, 0x00, 0x00, 0x00, 0x00, |
| 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, |
| ]; |
| client.endpoints.source.get_mut(&SOURCE_ASE_HANDLE).unwrap().endpoint = |
| AudioStreamEndpoint::from_char_value( |
| SOURCE_ASE_HANDLE, |
| AudioDirection::Source, |
| &source_value, |
| ) |
| .unwrap(); |
| |
| // Request 1 (Preferred): Resolves constraints for AseId(1) from the cache |
| let req1 = QosConfigurationRequest::Preferred { |
| ase_id: AseId(1), |
| cig_id: CigId::try_from(1).unwrap(), |
| cis_id: CisId::try_from(1).unwrap(), |
| sdu_interval: SduInterval::try_from(std::time::Duration::from_micros(10000)).unwrap(), |
| max_sdu: MaxSdu::try_from(100).unwrap(), |
| }; |
| |
| // Request 2 (Custom): Custom configuration for AseId(2) |
| let req2 = QosConfigurationRequest::Custom(QosConfiguration { |
| ase_id: AseId(2), |
| cig_id: CigId::try_from(1).unwrap(), |
| cis_id: CisId::try_from(2).unwrap(), |
| sdu_interval: SduInterval::try_from(std::time::Duration::from_micros(10000)).unwrap(), |
| framing: Framing::Unframed, |
| phy: vec![Phy::Le1MPhy], |
| max_sdu: MaxSdu::try_from(100).unwrap(), |
| retransmission_number: 2, |
| max_transport_latency: MaxTransportLatency::try_from(std::time::Duration::from_millis( |
| 10, |
| )) |
| .unwrap(), |
| presentation_delay: PresentationDelay { microseconds: 40000 }, |
| }); |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x02, // Opcode: Config QoS |
| 0x02, // Num ASEs: 2 |
| 0x01, // ASE ID: 1 |
| 0x01, 0x01, 0x10, 0x27, 0x00, 0x00, 0x01, 0x64, 0x00, 0x02, 0x0A, 0x00, 0x10, 0x27, 0x00, // ASE 1 Preferred QoS |
| 0x02, // ASE ID: 2 |
| 0x01, 0x02, 0x10, 0x27, 0x00, 0x00, 0x01, 0x64, 0x00, 0x02, 0x0A, 0x00, 0x40, 0x9C, 0x00, // ASE 2 Custom QoS |
| ], |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![ |
| 0x02, // Opcode: Config QoS |
| 0x02, // Num ASEs: 2 |
| 0x01, 0x00, 0x00, // ASE ID: 1, Success |
| 0x02, 0x00, 0x00, // ASE ID: 2, Success |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &SINK_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SINK_ASE_HANDLE, |
| value: vec![ |
| 0x01, // ASE ID: 1 |
| 0x02, // ASE State: QoS Configured |
| 0x01, 0x01, 0x10, 0x27, 0x00, 0x00, 0x01, 0x64, 0x00, 0x02, 0x0A, 0x00, 0x40, 0x9C, 0x00, |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &SOURCE_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SOURCE_ASE_HANDLE, |
| value: vec![ |
| 0x02, // ASE ID: 2 |
| 0x02, // ASE State: QoS Configured |
| 0x01, 0x02, 0x10, 0x27, 0x00, 0x00, 0x01, 0x64, 0x00, 0x02, 0x0A, 0x00, 0x40, 0x9C, 0x00, |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let configure_qos_fut = client.configure_qos(vec![req1, req2]); |
| let outcome = run_to_completion(configure_qos_fut).expect("configure QoS should succeed"); |
| |
| assert_eq!(outcome.rejected().len(), 0); |
| assert_eq!(client.endpoints.sink[&SINK_ASE_HANDLE].endpoint.state, AseState::QosConfigured); |
| assert_eq!( |
| client.endpoints.source[&SOURCE_ASE_HANDLE].endpoint.state, |
| AseState::QosConfigured |
| ); |
| } |
| |
| #[test] |
| fn configure_qos_fail_invalid_start_state() { |
| let service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| // Transition the sink ASE to Streaming state to test client-side validation. |
| let streaming_value = vec![ |
| 0x01, // ASE ID: 1 |
| 0x04, // ASE State: Streaming |
| 0x01, 0x01, 0x00, |
| ]; |
| client.endpoints.sink.get_mut(&SINK_ASE_HANDLE).unwrap().endpoint = |
| AudioStreamEndpoint::from_char_value( |
| SINK_ASE_HANDLE, |
| AudioDirection::Sink, |
| &streaming_value, |
| ) |
| .unwrap(); |
| |
| let qos_config = QosConfiguration { |
| ase_id: AseId(1), |
| cig_id: CigId::try_from(1).unwrap(), |
| cis_id: CisId::try_from(1).unwrap(), |
| sdu_interval: SduInterval::try_from(std::time::Duration::from_micros(10000)).unwrap(), |
| framing: Framing::Unframed, |
| phy: vec![Phy::Le1MPhy], |
| max_sdu: MaxSdu::try_from(100).unwrap(), |
| retransmission_number: 2, |
| max_transport_latency: MaxTransportLatency::try_from(std::time::Duration::from_millis( |
| 10, |
| )) |
| .unwrap(), |
| presentation_delay: PresentationDelay { microseconds: 40000 }, |
| }; |
| |
| let configure_qos_fut = |
| client.configure_qos(vec![QosConfigurationRequest::Custom(qos_config)]); |
| let err = |
| run_to_completion(configure_qos_fut).expect_err("should fail client-side validation"); |
| |
| assert!(matches!( |
| err, |
| Error::Client(ClientError::InvalidStartState { |
| ase_id: AseId(1), |
| opcode: AseControlPointOpcode::ConfigQos, |
| actual: AseState::Streaming, |
| }) |
| )); |
| } |
| |
| #[test] |
| fn configure_codec_fail_malformed_control_point_notification() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| let config = CodecConfiguration { |
| ase_id: AseId(1), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x01, // Opcode: Config Codec |
| 0x01, // Num ASEs |
| 0x01, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 1 Config |
| ], |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![0x01], // Only 1 byte, malformed (< 2 bytes) |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let configure_fut = client.configure_codec(vec![config]); |
| let err = run_to_completion(configure_fut).expect_err("should fail with decoding error"); |
| |
| assert!(matches!( |
| err, |
| Error::Gatt(bt_gatt::types::Error::Encoding( |
| bt_common::packet_encoding::Error::UnexpectedDataLength |
| )) |
| )); |
| } |
| |
| #[test] |
| fn configure_codec_fail_malformed_ase_notification() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| let config = CodecConfiguration { |
| ase_id: AseId(1), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x01, // Opcode: Config Codec |
| 0x01, // Num ASEs |
| 0x01, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 1 Config |
| ], |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![ |
| 0x01, // Opcode: Config Codec |
| 0x01, // Num ASEs: 1 |
| 0x01, 0x00, 0x00, // ASE 1 Response: Success |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &SINK_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SINK_ASE_HANDLE, |
| value: vec![0x01], // Only 1 byte, malformed (< 2 bytes) |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let configure_fut = client.configure_codec(vec![config]); |
| let err = run_to_completion(configure_fut).expect_err("should fail with conversion error"); |
| |
| assert!(matches!( |
| err, |
| Error::Gatt(bt_gatt::types::Error::Conversion(ref s)) if s.contains("Failed to decode AudioStreamEndpoint") |
| )); |
| } |
| |
| #[test] |
| fn configure_codec_fail_unexpected_state_updates_local_cache() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| let config1 = CodecConfiguration { |
| ase_id: AseId(1), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| let config2 = CodecConfiguration { |
| ase_id: AseId(2), |
| target_latency: TargetLatency::TargetLowLatency, |
| target_phy: TargetPhy::Le1MPhy, |
| codec_id: CodecId::Assigned(bt_common::core::CodingFormat::Lc3), |
| codec_specific_configuration: vec![], |
| }; |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x01, // Opcode: Config Codec |
| 0x02, // Num ASEs |
| 0x01, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 1 Config |
| 0x02, 0x01, 0x01, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, // ASE 2 Config |
| ], |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![ |
| 0x01, // Opcode: Config Codec |
| 0x02, // Num ASEs: 2 |
| 0x01, 0x00, 0x00, // ASE 1 Response: Success |
| 0x02, 0x00, 0x00, // ASE 2 Response: Success |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| // Notify ASE 1 with Codec Configured (valid!) |
| #[rustfmt::skip] |
| service.notify( |
| &SINK_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SINK_ASE_HANDLE, |
| value: vec![ |
| 0x01, // ASE ID: 1 |
| 0x01, // ASE State: Codec Configured |
| 0x00, 0x01, 0x02, 0x0A, 0x00, 0x10, 0x27, 0x00, 0x40, 0x9C, 0x00, |
| 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| // Notify ASE 2 with Streaming (invalid for Config Codec!) |
| #[rustfmt::skip] |
| service.notify( |
| &SOURCE_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SOURCE_ASE_HANDLE, |
| value: vec![ |
| 0x02, // ASE ID: 2 |
| 0x04, // ASE State: Streaming (unexpected!) |
| 0x01, // CIG ID: 1 |
| 0x02, // CIS ID: 2 |
| 0x00, // Metadata Length: 0 |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let configure_fut = client.configure_codec(vec![config1, config2]); |
| let err = run_to_completion(configure_fut) |
| .expect_err("should fail with unexpected state transition"); |
| |
| assert!(matches!( |
| err, |
| Error::Client(ClientError::UnexpectedStateTransition { |
| ase_id: AseId(2), |
| expected: AseState::CodecConfigured, |
| actual: AseState::Streaming, |
| }) |
| )); |
| |
| // Both endpoints in our local cache MUST be updated with the latest notified |
| // states! |
| assert_eq!( |
| client.endpoints.sink[&SINK_ASE_HANDLE].endpoint.state, |
| AseState::CodecConfigured |
| ); |
| assert_eq!(client.endpoints.source[&SOURCE_ASE_HANDLE].endpoint.state, AseState::Streaming); |
| } |
| |
| #[test] |
| fn enable_success() { |
| let mut service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| // Pre-condition SINK_ASE_HANDLE to QosConfigured state |
| let sink_value = vec![ |
| 0x01, // ASE ID: 1 |
| 0x02, // ASE State: QoS Configured |
| 0x01, 0x01, 0x10, 0x27, 0x00, 0x00, 0x01, 0x64, 0x00, 0x02, 0x0A, 0x00, 0x40, 0x9C, |
| 0x00, |
| ]; |
| client.endpoints.sink.get_mut(&SINK_ASE_HANDLE).unwrap().endpoint = |
| AudioStreamEndpoint::from_char_value( |
| SINK_ASE_HANDLE, |
| AudioDirection::Sink, |
| &sink_value, |
| ) |
| .unwrap(); |
| |
| let enable_req = AseIdWithMetadata { ase_id: AseId(1), metadata: vec![] }; |
| |
| #[rustfmt::skip] |
| service.expect_characteristic_value( |
| &CONTROL_POINT_HANDLE, |
| vec![ |
| 0x03, // Opcode: Enable |
| 0x01, // Num ASEs |
| 0x01, // ASE ID: 1 |
| 0x00, // Metadata Length: 0 |
| ], |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &CONTROL_POINT_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: CONTROL_POINT_HANDLE, |
| value: vec![ |
| 0x03, // Opcode: Enable |
| 0x01, // Num ASEs |
| 0x01, 0x00, 0x00, // ASE ID: 1, Success |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| #[rustfmt::skip] |
| service.notify( |
| &SINK_ASE_HANDLE, |
| Ok(CharacteristicNotification { |
| handle: SINK_ASE_HANDLE, |
| value: vec![ |
| 0x01, // ASE ID: 1 |
| 0x03, // ASE State: Enabling |
| 0x01, // CIG ID: 1 |
| 0x01, // CIS ID: 1 |
| 0x00, // Metadata Length: 0 |
| ], |
| maybe_truncated: false, |
| }), |
| ); |
| |
| let enable_fut = client.enable(vec![enable_req]); |
| let outcome = run_to_completion(enable_fut).expect("enable should succeed"); |
| |
| assert_eq!(outcome.rejected().len(), 0); |
| assert_eq!(client.endpoints.sink[&SINK_ASE_HANDLE].endpoint.state, AseState::Enabling); |
| } |
| |
| #[test] |
| fn enable_fail_invalid_start_state() { |
| let service = setup_fake_service(); |
| let client_fut = AudioStreamControlServiceClient::<FakeTypes>::create(service.clone()); |
| let mut client = run_to_completion(client_fut).expect("client creation should succeed"); |
| |
| // Transition the sink ASE to Streaming state to test client-side validation. |
| let streaming_value = vec![ |
| 0x01, // ASE ID: 1 |
| 0x04, // ASE State: Streaming |
| 0x01, // CIG ID: 1 |
| 0x01, // CIS ID: 1 |
| 0x00, // Metadata Length: 0 |
| ]; |
| client.endpoints.sink.get_mut(&SINK_ASE_HANDLE).unwrap().endpoint = |
| AudioStreamEndpoint::from_char_value( |
| SINK_ASE_HANDLE, |
| AudioDirection::Sink, |
| &streaming_value, |
| ) |
| .unwrap(); |
| |
| let enable_req = AseIdWithMetadata { ase_id: AseId(1), metadata: vec![] }; |
| |
| let enable_fut = client.enable(vec![enable_req]); |
| let err = run_to_completion(enable_fut).expect_err("should fail client-side validation"); |
| |
| assert!(matches!( |
| err, |
| Error::Client(ClientError::InvalidStartState { |
| ase_id: AseId(1), |
| opcode: AseControlPointOpcode::Enable, |
| actual: AseState::Streaming, |
| }) |
| )); |
| } |
| } |