rust/bt-ascs: Implement Config QoS operation This change implements the "Config QoS" operation for the ASCS client. The implementation includes: - Concurrent handling of state transitions for improved performance when configuring multiple ASEs. - Comprehensive unit tests for success and failure scenarios. - A new unit test for the decoding logic. Test: cargo test -p bt-ascs Bug: b/431814103 Change-Id: Ia636539c56861cb6b4095f238011ef1af3fa37de Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/2400
diff --git a/rust/bt-ascs/src/client.rs b/rust/bt-ascs/src/client.rs index 691f15a..90a64c4 100644 --- a/rust/bt-ascs/src/client.rs +++ b/rust/bt-ascs/src/client.rs
@@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -use futures::stream::{BoxStream, StreamExt}; +use futures::stream::{BoxStream, FuturesUnordered, StreamExt}; use std::collections::{HashMap, HashSet}; use bt_common::packet_encoding::Decodable; @@ -36,6 +36,8 @@ 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. @@ -45,6 +47,88 @@ 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, @@ -84,6 +168,16 @@ } 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 @@ -192,9 +286,9 @@ 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::other(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Failed to decode AudioStreamEndpoint: {:?}", e), + bt_gatt::types::Error::Conversion(format!( + "Failed to decode AudioStreamEndpoint: {:?}", + e )) })?; Ok(endpoint) @@ -212,37 +306,44 @@ Ok(()) } - async fn read_and_update_endpoint( + fn validate_arguments( &mut self, - handle: &Handle, - direction: AudioDirection, - ) -> Result<AudioStreamEndpoint, Error> { - let mut buf = vec![0; 255]; - let (read_bytes, _truncated) = self - .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::other(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Failed to decode AudioStreamEndpoint: {:?}", e), - )) - }, - )?; + opcode: AseControlPointOpcode, + pending_ases: &HashSet<AseId>, + ) -> Result<(), Error> { + self.verify_ase_ids(pending_ases.iter().cloned())?; - let endpoints = match direction { - AudioDirection::Sink => &mut self.endpoints.sink, - AudioDirection::Source => &mut self.endpoints.source, - }; - if let Some(endpoint_handle) = endpoints.get_mut(handle) { - endpoint_handle.endpoint = endpoint.clone(); + 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(endpoint) + 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; @@ -279,20 +380,14 @@ let notification = notification.map_err(Error::from)?; let (cp_notification, _) = ControlPointNotification::decode(¬ification.value[..]); - let cp_notification = cp_notification.map_err(|e| { - bt_gatt::types::Error::other(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("Failed to decode ControlPointNotification: {:?}", e), - )) - })?; + 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::Internal(format!( - "Server rejected operation: {:?}", - cp_notification.responses + return Err(Error::Client(ClientError::RejectedOperation( + cp_notification.responses, ))); } @@ -317,25 +412,46 @@ let responses = self.collect_operation_responses(opcode_u8, pending_ases).await?; let mut successful_endpoints = HashMap::new(); - let mut failed_responses = HashMap::new(); let opcode = AseControlPointOpcode::try_from(&operation).map_err(|e| { Error::Internal(format!("Failed to resolve opcode from operation: {:?}", e)) })?; - for r in responses.into_values() { - if !r.is_success() { - failed_responses.insert(r.ase_id(), r); - continue; + 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 client_endpoint = self - .endpoints - .lookup_by_ase_id(r.ase_id()) - .ok_or(Error::Client(ClientError::UnknownAseId(r.ase_id())))?; - let handle = client_endpoint.endpoint.handle; - let direction = client_endpoint.endpoint.direction; - let endpoint = self.read_and_update_endpoint(&handle, direction).await?; + 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 { @@ -344,7 +460,7 @@ actual: endpoint.state, })); } - successful_endpoints.insert(endpoint.ase_id, endpoint); + successful_endpoints.insert(ase_id, endpoint); } Ok(AseControlOperationOutcome::new(successful_endpoints, failed_responses)) @@ -372,12 +488,60 @@ codec_configurations: Vec<CodecConfiguration>, ) -> Result<AseControlOperationOutcome, Error> { let pending_ases: HashSet<AseId> = codec_configurations.iter().map(|c| c.ase_id).collect(); - self.verify_ase_ids(pending_ases.iter().cloned())?; + 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 + } } #[cfg(test)] @@ -593,20 +757,18 @@ ); #[rustfmt::skip] - service.add_characteristic( - Characteristic { + service.notify( + &SINK_ASE_HANDLE, + Ok(CharacteristicNotification { handle: SINK_ASE_HANDLE, - uuid: SINK_ASE_UUID, - properties: CharacteristicProperty::Read | CharacteristicProperty::Notify, - permissions: AttributePermissions::default(), - descriptors: vec![], - }, - 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, - ], + 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]); @@ -692,7 +854,394 @@ assert!(matches!( err, - Error::Internal(ref s) if s.contains("Server rejected operation") && s.contains("InvalidLength") + 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); + } }
diff --git a/rust/bt-ascs/src/types.rs b/rust/bt-ascs/src/types.rs index 6175baf..fc330d2 100644 --- a/rust/bt-ascs/src/types.rs +++ b/rust/bt-ascs/src/types.rs
@@ -658,7 +658,7 @@ } impl AseControlPointOpcode { - pub(crate) fn allowed_in_state(&self, state: &AseState) -> bool { + pub fn allowed_in_state(&self, state: &AseState) -> bool { let allowed_states: &[AseState] = match self { Self::ConfigCodec { .. } => { &[AseState::Idle, AseState::CodecConfigured, AseState::QosConfigured] @@ -1335,6 +1335,29 @@ const BYTE_SIZE: usize = 3; } +impl TryFrom<std::time::Duration> for SduInterval { + type Error = bt_common::packet_encoding::Error; + fn try_from(value: std::time::Duration) -> Result<Self, Self::Error> { + let Ok(microseconds) = u32::try_from(value.as_micros()) else { + return Err(Self::Error::OutOfRange); + }; + if microseconds < 0xFF || microseconds > 0x0FFFFF { + return Err(Self::Error::OutOfRange); + } + Ok(Self(microseconds)) + } +} + +impl TryFrom<u32> for SduInterval { + type Error = bt_common::packet_encoding::Error; + fn try_from(value: u32) -> Result<Self, Self::Error> { + if value < 0xFF || value > 0x0FFFFF { + return Err(Self::Error::OutOfRange); + } + Ok(Self(value)) + } +} + impl Decodable for SduInterval { type Error = bt_common::packet_encoding::Error; @@ -1425,6 +1448,16 @@ const BYTE_SIZE: usize = 2; } +impl TryFrom<u16> for MaxSdu { + type Error = bt_common::packet_encoding::Error; + fn try_from(value: u16) -> Result<Self, Self::Error> { + if value > 0xFFF { + return Err(Self::Error::OutOfRange); + } + Ok(Self(value)) + } +} + /// Max Transport Latency /// Valid range is [0x0005, 0x0FA0]. /// Transmitted in little-endian, Stored in native-endian.