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]