rust/bt-ascs: Add debug command-line tool for client Add a new debug tool to interactively test and debug the ASCS client implementation. This provides a convenient way to manually trigger all client-side operations and inspect the state of remote ASEs. - Implement a command-line interface in `src/debug.rs`. - Provide commands for all client operations (e.g., `config-codec`, `enable`, `release`). - Add unit tests for the new argument parsing logic. Bug: 431814103 Test: cargo test -p bt-ascs Change-Id: I3f0dd46b2d668545c4f216e0c156c4f4507d4db6 Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/2520
diff --git a/rust/bt-ascs/src/debug.rs b/rust/bt-ascs/src/debug.rs new file mode 100644 index 0000000..c8f176f --- /dev/null +++ b/rust/bt-ascs/src/debug.rs
@@ -0,0 +1,355 @@ +// Copyright 2024 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 bt_common::core::CodecId; +use bt_common::debug_command::{CommandRunner, CommandSet}; +use bt_common::gen_commandset; +use std::sync::Mutex; + +use bt_gatt::{client::PeerServiceHandle, Client}; + +use crate::client::{AudioStreamControlServiceClient, QosConfigurationRequest}; +use crate::server::ASCS_UUID; +use crate::types::*; + +gen_commandset! { + AscsCmd { + Print = ("print", [], [], "Print the current ASCS status and active configuration details"), + ConfigCodec = ("config-codec", [], ["configs"], "Configure codecs for one or more ASEs. Format for each ASE: <ase_id>,<latency>,<phy>,<codec>"), + ConfigQos = ("config-qos", [], ["configs"], "Configure QoS for one or more ASEs. Each config is a comma-separated string: <ase_id>,<cig_id>,<cis_id>,<sdu_interval>,<max_sdu>"), + Enable = ("enable", [], ["ase_ids"], "Enable ASEs (comma-separated)"), + ReceiverStartReady = ("receiver-start-ready", [], ["ase_ids"], "Signal receiver start ready for source ASEs (comma-separated)"), + Disable = ("disable", [], ["ase_ids"], "Disable ASEs (comma-separated)"), + ReceiverStopReady = ("receiver-stop-ready", [], ["ase_ids"], "Signal receiver stop ready for source ASEs (comma-separated)"), + UpdateMetadata = ("update-metadata", [], ["ase_ids"], "Update metadata for ASEs (comma-separated, empty metadata)"), + Release = ("release", [], ["ase_ids"], "Release ASEs (comma-separated)"), + } +} + +pub struct AscsDebug<T: bt_gatt::GattTypes> { + _peer_client: T::Client, + client: Mutex<Option<AudioStreamControlServiceClient<T>>>, +} + +impl<T: bt_gatt::GattTypes> AscsDebug<T> +where + <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, +{ + pub async fn new(client: T::Client) -> Result<Self, Error> { + let handles = client.find_service(ASCS_UUID).await?; + + let handle = handles.iter().find(|h| h.is_primary()).ok_or_else(|| { + Error::Gatt(bt_gatt::types::Error::ScanFailed( + "Primary ASCS service not found".to_string(), + )) + })?; + + let service = handle.connect().await?; + let ascs_client = AudioStreamControlServiceClient::create(service).await?; + + Ok(Self { _peer_client: client, client: Mutex::new(Some(ascs_client)) }) + } +} + +fn invalid_input_err(msg: impl Into<String>) -> Error { + Error::Other(Box::new(std::io::Error::new(std::io::ErrorKind::InvalidInput, msg.into()))) +} + +fn parse_ase_id(s: &str) -> Result<AseId, Error> { + let val = s.parse::<u8>().map_err(|_| invalid_input_err("Invalid ASE ID"))?; + Ok(AseId(val)) +} + +fn parse_codec_configuration(s: &str) -> Result<CodecConfiguration, Error> { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 4 { + return Err(invalid_input_err( + "Invalid config format. Expected: <ase_id>,<latency>,<phy>,<codec>", + )); + } + let ase_id = parse_ase_id(parts[0])?; + let target_latency = match parts[1].to_lowercase().as_str() { + "low" => TargetLatency::TargetLowLatency, + "balanced" => TargetLatency::TargetBalanced, + "high" => TargetLatency::TargetHighReliability, + _ => { + return Err(invalid_input_err( + "Invalid target latency. Expected 'low', 'balanced', or 'high'", + )); + } + }; + let target_phy = match parts[2].to_lowercase().as_str() { + "1m" => TargetPhy::Le1MPhy, + "2m" => TargetPhy::Le2MPhy, + "coded" => TargetPhy::LeCodedPhy, + _ => { + return Err(invalid_input_err("Invalid target PHY. Expected '1m', '2m', or 'coded'")); + } + }; + let codec_id = match parts[3].to_lowercase().as_str() { + "lc3" => CodecId::Assigned(bt_common::core::CodingFormat::Lc3), + "cvsd" => CodecId::Assigned(bt_common::core::CodingFormat::Cvsd), + _ => { + return Err(invalid_input_err("Invalid codec format. Expected 'lc3' or 'cvsd'")); + } + }; + Ok(CodecConfiguration { + ase_id, + target_latency, + target_phy, + codec_id, + codec_specific_configuration: vec![], + }) +} + +fn parse_qos_configuration(s: &str) -> Result<QosConfigurationRequest, Error> { + let parts: Vec<&str> = s.split(',').collect(); + if parts.len() != 5 { + return Err(invalid_input_err( + "Invalid config format. Expected: <ase_id>,<cig_id>,<cis_id>,<sdu_interval>,<max_sdu>", + )); + } + let ase_id = parse_ase_id(parts[0])?; + let cig_id = + CigId::try_from(parts[1].parse::<u8>().map_err(|_| invalid_input_err("Invalid CIG ID"))?) + .map_err(|_| invalid_input_err("CIG ID out of bounds"))?; + let cis_id = + CisId::try_from(parts[2].parse::<u8>().map_err(|_| invalid_input_err("Invalid CIS ID"))?) + .map_err(|_| invalid_input_err("CIS ID out of bounds"))?; + let micros = parts[3].parse::<u64>().map_err(|_| invalid_input_err("Invalid SDU interval"))?; + let sdu_interval = SduInterval::try_from(std::time::Duration::from_micros(micros)) + .map_err(|_| invalid_input_err("SDU interval out of bounds"))?; + let max_sdu = MaxSdu::try_from( + parts[4].parse::<u16>().map_err(|_| invalid_input_err("Invalid Max SDU"))?, + ) + .map_err(|_| invalid_input_err("Max SDU out of bounds"))?; + + Ok(QosConfigurationRequest::Preferred { ase_id, cig_id, cis_id, sdu_interval, max_sdu }) +} + +fn parse_ase_ids(s: &str) -> Result<Vec<AseId>, Error> { + if s.is_empty() { + return Err(invalid_input_err("Empty ASE ID list")); + } + s.split(',').map(parse_ase_id).collect() +} + +fn parse_single_arg_ase_ids(args: &[String]) -> Result<Vec<AseId>, Error> { + if args.len() != 1 { + return Err(invalid_input_err("Expected comma-separated ASE IDs")); + } + parse_ase_ids(&args[0]) +} + +impl<T: bt_gatt::GattTypes> CommandRunner for AscsDebug<T> +where + <T as bt_gatt::GattTypes>::NotificationStream: std::marker::Send, +{ + type Set = AscsCmd; + + fn run( + &self, + cmd: Self::Set, + args: Vec<String>, + ) -> impl futures::Future<Output = Result<(), impl std::error::Error>> { + async move { + let mut client = { + let mut lock = self.client.lock().unwrap(); + lock.take().ok_or_else(|| { + Error::Other(Box::new(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "Failed to connect to ASCS service", + ))) + })? + }; + + let result: Result<(), Error> = match cmd { + AscsCmd::Print => { + println!("ASCS Status:"); + println!("-- Sink Endpoints --"); + for endpoint in client.endpoints.sink_ases() { + if let Some(ep) = + client.endpoints.lookup_by_ase_id(endpoint).map(|e| &e.endpoint) + { + println!( + " ASE ID: {:?}, Handle: {:?}, Direction: Sink, State: {:?}\n Details: {:?}", + ep.ase_id, ep.handle, ep.state, ep.additional + ); + } + } + println!("-- Source Endpoints --"); + for endpoint in client.endpoints.source_ases() { + if let Some(ep) = + client.endpoints.lookup_by_ase_id(endpoint).map(|e| &e.endpoint) + { + println!( + " ASE ID: {:?}, Handle: {:?}, Direction: Source, State: {:?}\n Details: {:?}", + ep.ase_id, ep.handle, ep.state, ep.additional + ); + } + } + Ok(()) + } + AscsCmd::ConfigCodec => { + let mut configs = Vec::new(); + for arg in args { + configs.push(parse_codec_configuration(&arg)?); + } + let outcome = client.configure_codec(configs).await?; + println!("Codec Configured Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::ConfigQos => { + let mut requests = Vec::new(); + for arg in args { + requests.push(parse_qos_configuration(&arg)?); + } + let outcome = client.configure_qos(requests).await?; + println!("QoS Configured Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::Enable => { + let ase_ids = parse_single_arg_ase_ids(&args)?; + let requests = ase_ids + .into_iter() + .map(|ase_id| AseIdWithMetadata { ase_id, metadata: vec![] }) + .collect(); + let outcome = client.enable(requests).await?; + println!("Enable Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::ReceiverStartReady => { + let ase_ids = parse_single_arg_ase_ids(&args)?; + let outcome = client.receiver_start_ready(ase_ids).await?; + println!("Receiver Start Ready Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::Disable => { + let ase_ids = parse_single_arg_ase_ids(&args)?; + let outcome = client.disable(ase_ids).await?; + println!("Disable Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::ReceiverStopReady => { + let ase_ids = parse_single_arg_ase_ids(&args)?; + let outcome = client.receiver_stop_ready(ase_ids).await?; + println!("Receiver Stop Ready Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::UpdateMetadata => { + let ase_ids = parse_single_arg_ase_ids(&args)?; + let requests = ase_ids + .into_iter() + .map(|ase_id| AseIdWithMetadata { ase_id, metadata: vec![] }) + .collect(); + let outcome = client.update_metadata(requests).await?; + println!("Update Metadata Outcome: {:?}", outcome); + Ok(()) + } + AscsCmd::Release => { + let ase_ids = parse_single_arg_ase_ids(&args)?; + let outcome = client.release(ase_ids).await?; + println!("Release Outcome: {:?}", outcome); + Ok(()) + } + }; + + *self.client.lock().unwrap() = Some(client); + + result + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ase_id() { + assert_eq!(parse_ase_id("1").unwrap(), AseId(1)); + assert_eq!(parse_ase_id("255").unwrap(), AseId(255)); + assert!(parse_ase_id("").is_err()); + assert!(parse_ase_id("abc").is_err()); + assert!(parse_ase_id("256").is_err()); + } + + #[test] + fn test_parse_single_arg_ase_ids() { + assert_eq!( + parse_single_arg_ase_ids(&[String::from("1,2")]).unwrap(), + vec![AseId(1), AseId(2)] + ); + assert!(parse_single_arg_ase_ids(&[]).is_err()); + assert!(parse_single_arg_ase_ids(&[String::from("1"), String::from("2")]).is_err()); + } + + #[test] + fn test_parse_ase_ids() { + assert_eq!(parse_ase_ids("1,2,3").unwrap(), vec![AseId(1), AseId(2), AseId(3)]); + assert_eq!(parse_ase_ids("1").unwrap(), vec![AseId(1)]); + assert!(parse_ase_ids("").is_err()); + assert!(parse_ase_ids("1,abc").is_err()); + } + + #[test] + fn test_parse_codec_configuration() { + let valid = "2,low,2m,lc3"; + let config = parse_codec_configuration(valid).unwrap(); + assert_eq!(config.ase_id, AseId(2)); + assert_eq!(config.target_latency, TargetLatency::TargetLowLatency); + assert_eq!(config.target_phy, TargetPhy::Le2MPhy); + assert_eq!(config.codec_id, CodecId::Assigned(bt_common::core::CodingFormat::Lc3)); + + let valid_cvsd = "1,balanced,1m,cvsd"; + let config_cvsd = parse_codec_configuration(valid_cvsd).unwrap(); + assert_eq!(config_cvsd.ase_id, AseId(1)); + assert_eq!(config_cvsd.target_latency, TargetLatency::TargetBalanced); + assert_eq!(config_cvsd.target_phy, TargetPhy::Le1MPhy); + assert_eq!(config_cvsd.codec_id, CodecId::Assigned(bt_common::core::CodingFormat::Cvsd)); + + // missing codec + assert!(parse_codec_configuration("2,low,2m").is_err()); + // invalid target latency string + assert!(parse_codec_configuration("2,unknown,2m,lc3").is_err()); + // invalid target PHY string + assert!(parse_codec_configuration("2,low,9m,lc3").is_err()); + // invalid codec format string + assert!(parse_codec_configuration("2,low,2m,aac").is_err()); + } + + #[test] + fn test_parse_qos_configuration() { + let valid = "1,2,3,10000,120"; + let request = parse_qos_configuration(valid).unwrap(); + match request { + QosConfigurationRequest::Preferred { + ase_id, + cig_id, + cis_id, + sdu_interval, + max_sdu, + } => { + assert_eq!(ase_id, AseId(1)); + assert_eq!(cig_id, CigId::try_from(2).unwrap()); + assert_eq!(cis_id, CisId::try_from(3).unwrap()); + assert_eq!( + sdu_interval, + SduInterval::try_from(std::time::Duration::from_micros(10000)).unwrap() + ); + assert_eq!(max_sdu, MaxSdu::try_from(120).unwrap()); + } + _ => panic!("Expected Preferred request"), + } + + // missing max_sdu + assert!(parse_qos_configuration("1,2,3,10000").is_err()); + // CIG ID out of bounds + assert!(parse_qos_configuration("1,255,3,10000,120").is_err()); + // CIS ID out of bounds + assert!(parse_qos_configuration("1,2,255,10000,120").is_err()); + // SDU interval out of bounds + assert!(parse_qos_configuration("1,2,3,100000000,120").is_err()); + } +}
diff --git a/rust/bt-ascs/src/lib.rs b/rust/bt-ascs/src/lib.rs index 8a6a6b4..761bb0c 100644 --- a/rust/bt-ascs/src/lib.rs +++ b/rust/bt-ascs/src/lib.rs
@@ -3,6 +3,7 @@ // found in the LICENSE file. pub mod client; +pub mod debug; pub mod server; pub mod types; pub use types::Error;
diff --git a/rust/bt-ascs/src/types.rs b/rust/bt-ascs/src/types.rs index 27b81d3..a783bf3 100644 --- a/rust/bt-ascs/src/types.rs +++ b/rust/bt-ascs/src/types.rs
@@ -25,6 +25,8 @@ Gatt(#[from] BtGattError), #[error("Internal error occurred: {0}")] Internal(String), + #[error("Other error occurred: {0}")] + Other(Box<dyn std::error::Error + Send + Sync>), } #[non_exhaustive]