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,
+            &notification.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(&notification.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.