use std::collections::HashMap;
use std::io::{self, Write};
use std::time::Duration;
use sonos_api::operation::{OperationBuilder, ValidationError};
use sonos_api::services::av_transport::{
GetTransportInfoOperation, GetTransportInfoOperationRequest, PauseOperation,
PauseOperationRequest, PlayOperation, PlayOperationRequest, StopOperation,
StopOperationRequest,
};
use sonos_api::services::rendering_control::{
GetBassOperation, GetBassOperationRequest, GetLoudnessOperation, GetLoudnessOperationRequest,
GetMuteOperation, GetMuteOperationRequest, GetTrebleOperation, GetTrebleOperationRequest,
GetVolumeOperation, GetVolumeOperationRequest, SetBassOperation, SetBassOperationRequest,
SetLoudnessOperation, SetLoudnessOperationRequest, SetMuteOperation, SetMuteOperationRequest,
SetRelativeVolumeOperation, SetRelativeVolumeOperationRequest, SetTrebleOperation,
SetTrebleOperationRequest, SetVolumeOperation, SetVolumeOperationRequest,
};
use sonos_api::{ApiError, SonosClient};
use sonos_discovery::{get_with_timeout, Device, DiscoveryError};
#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error("Device discovery error: {0}")]
Discovery(#[from] DiscoveryError),
#[error("API operation error: {0}")]
Api(#[from] ApiError),
#[error("Validation error: {0}")]
Validation(#[from] ValidationError),
#[error("Input/output error: {0}")]
Io(#[from] io::Error),
#[error("Input validation error: {0}")]
Input(String),
#[error("No devices found on the network")]
NoDevicesFound,
#[error("Operation not supported: {0}")]
UnsupportedOperation(String),
#[error("Missing required parameter: {0}")]
MissingParameter(String),
#[error("Invalid parameter value: {0}")]
InvalidParameter(String),
}
pub type Result<T> = std::result::Result<T, CliError>;
#[derive(Debug, Clone)]
pub struct OperationInfo {
pub name: String,
pub service: String,
pub description: String,
pub parameters: Vec<ParameterInfo>,
}
#[derive(Debug, Clone)]
pub struct ParameterInfo {
pub name: String,
pub param_type: String,
pub required: bool,
pub default_value: Option<String>,
}
impl OperationInfo {
pub fn new(name: &str, service: &str, description: &str) -> Self {
Self {
name: name.to_string(),
service: service.to_string(),
description: description.to_string(),
parameters: Vec::new(),
}
}
pub fn with_required_param(mut self, name: &str, param_type: &str) -> Self {
self.parameters.push(ParameterInfo {
name: name.to_string(),
param_type: param_type.to_string(),
required: true,
default_value: None,
});
self
}
pub fn with_optional_param(mut self, name: &str, param_type: &str, default: &str) -> Self {
self.parameters.push(ParameterInfo {
name: name.to_string(),
param_type: param_type.to_string(),
required: false,
default_value: Some(default.to_string()),
});
self
}
}
pub struct OperationRegistry {
operations: Vec<OperationInfo>,
}
impl Default for OperationRegistry {
fn default() -> Self {
Self::new()
}
}
impl OperationRegistry {
pub fn new() -> Self {
Self {
operations: vec![
OperationInfo::new("Play", "AVTransport", "Start playback")
.with_optional_param("speed", "String", "1"),
OperationInfo::new("Pause", "AVTransport", "Pause playback"),
OperationInfo::new("Stop", "AVTransport", "Stop playback"),
OperationInfo::new(
"GetTransportInfo",
"AVTransport",
"Get current playback state",
),
OperationInfo::new("GetVolume", "RenderingControl", "Get current volume")
.with_optional_param("channel", "String", "Master"),
OperationInfo::new("SetVolume", "RenderingControl", "Set volume level")
.with_required_param("volume", "u8")
.with_optional_param("channel", "String", "Master"),
OperationInfo::new(
"SetRelativeVolume",
"RenderingControl",
"Adjust volume relatively",
)
.with_required_param("adjustment", "i8")
.with_optional_param("channel", "String", "Master"),
OperationInfo::new("GetMute", "RenderingControl", "Get mute state")
.with_optional_param("channel", "String", "Master"),
OperationInfo::new("SetMute", "RenderingControl", "Set mute state")
.with_required_param("mute", "bool")
.with_optional_param("channel", "String", "Master"),
OperationInfo::new("GetBass", "RenderingControl", "Get bass level (-10 to +10)"),
OperationInfo::new("SetBass", "RenderingControl", "Set bass level (-10 to +10)")
.with_required_param("bass", "i8"),
OperationInfo::new(
"GetTreble",
"RenderingControl",
"Get treble level (-10 to +10)",
),
OperationInfo::new(
"SetTreble",
"RenderingControl",
"Set treble level (-10 to +10)",
)
.with_required_param("treble", "i8"),
OperationInfo::new(
"GetLoudness",
"RenderingControl",
"Get loudness compensation state",
)
.with_optional_param("channel", "String", "Master"),
OperationInfo::new(
"SetLoudness",
"RenderingControl",
"Set loudness compensation",
)
.with_required_param("loudness", "bool")
.with_optional_param("channel", "String", "Master"),
],
}
}
pub fn get_operations(&self) -> &[OperationInfo] {
&self.operations
}
pub fn get_by_service(&self) -> HashMap<String, Vec<&OperationInfo>> {
let mut grouped = HashMap::new();
for op in &self.operations {
grouped
.entry(op.service.clone())
.or_insert_with(Vec::new)
.push(op);
}
grouped
}
}
pub async fn discover_devices() -> Result<Vec<Device>> {
discover_devices_with_timeout(Duration::from_secs(5)).await
}
pub async fn discover_devices_with_timeout(timeout: Duration) -> Result<Vec<Device>> {
println!("Discovering Sonos devices on the network...");
println!("This may take up to {} seconds...", timeout.as_secs());
let devices = tokio::task::spawn_blocking(move || get_with_timeout(timeout))
.await
.map_err(|e| {
CliError::Discovery(DiscoveryError::NetworkError(format!(
"Task join error: {e}"
)))
})?;
if devices.is_empty() {
println!("No Sonos devices found on the network.");
println!("Please ensure:");
println!(" - Your Sonos speakers are powered on");
println!(" - You're connected to the same network as your speakers");
println!(" - Your firewall allows network discovery");
return Err(CliError::NoDevicesFound);
}
println!("✓ Found {} Sonos device(s)", devices.len());
Ok(devices)
}
pub fn display_devices(devices: &[Device]) {
println!("\nDiscovered Sonos Devices:");
println!("{}", "=".repeat(25));
for (i, device) in devices.iter().enumerate() {
println!("{}. {} ({})", i + 1, device.name, device.room_name);
println!(
" IP: {} | Model: {}",
device.ip_address, device.model_name
);
if i < devices.len() - 1 {
println!();
}
}
println!();
}
pub fn display_menu<T>(title: &str, items: &[T], formatter: impl Fn(&T) -> String) {
println!("\n{title}");
println!("{}", "=".repeat(title.len()));
for (i, item) in items.iter().enumerate() {
println!("{}. {}", i + 1, formatter(item));
}
println!("0. Exit");
println!();
}
pub fn get_user_selection(max_value: usize) -> Result<Option<usize>> {
loop {
print!("Enter your choice (0-{max_value}): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
println!("Please enter a number between 0 and {max_value}");
continue;
}
let choice: usize = match trimmed.parse() {
Ok(num) => num,
Err(_) => {
println!("Invalid input: '{trimmed}' is not a valid number");
println!("Please enter a number between 0 and {max_value}");
continue;
}
};
if choice == 0 {
return Ok(None);
}
if choice > max_value {
println!("Invalid selection: {choice} is out of range");
println!("Please enter a number between 0 and {max_value}");
continue;
}
return Ok(Some(choice - 1));
}
}
pub fn get_user_selection_with_prompt(prompt: &str, max_value: usize) -> Result<Option<usize>> {
print!("{prompt} (0-{max_value}): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(CliError::Input("Please enter a valid number".to_string()));
}
let choice: usize = trimmed
.parse()
.map_err(|_| CliError::Input(format!("'{trimmed}' is not a valid number")))?;
if choice == 0 {
return Ok(None);
}
if choice > max_value {
return Err(CliError::Input(format!(
"Selection {choice} is out of range (1-{max_value})"
)));
}
Ok(Some(choice - 1))
}
pub fn select_device(devices: &[Device]) -> Result<Option<&Device>> {
if devices.is_empty() {
return Err(CliError::NoDevicesFound);
}
display_menu("Select a Sonos Device", devices, |device| {
format!(
"{} ({}) - {}",
device.name, device.room_name, device.ip_address
)
});
match get_user_selection(devices.len())? {
Some(index) => Ok(Some(&devices[index])),
None => Ok(None),
}
}
pub fn collect_parameters(operation: &OperationInfo) -> Result<HashMap<String, String>> {
let mut params = HashMap::new();
if operation.parameters.is_empty() {
println!("This operation requires no parameters.");
return Ok(params);
}
println!("\nCollecting parameters for operation: {}", operation.name);
println!("{}", "=".repeat(40));
for param in &operation.parameters {
if param.required {
let value = prompt_for_parameter(param)?;
params.insert(param.name.clone(), value);
} else {
if should_prompt_optional_parameter(param)? {
let value = prompt_for_parameter(param)?;
params.insert(param.name.clone(), value);
} else if let Some(default) = ¶m.default_value {
params.insert(param.name.clone(), default.clone());
println!(
"Using default value '{}' for parameter '{}'",
default, param.name
);
}
}
}
println!();
Ok(params)
}
pub fn prompt_for_parameter(param: &ParameterInfo) -> Result<String> {
loop {
print!("Enter {} ({})", param.name, param.param_type);
if !param.required {
if let Some(default) = ¶m.default_value {
print!(" [default: {default}]");
} else {
print!(" [optional]");
}
}
print!(": ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let value = input.trim().to_string();
if value.is_empty() && !param.required {
if let Some(default) = ¶m.default_value {
return Ok(default.clone());
} else {
return Ok(String::new());
}
}
if value.is_empty() && param.required {
println!("Error: {} is required and cannot be empty", param.name);
continue;
}
match validate_parameter_value(&value, ¶m.param_type) {
Ok(()) => return Ok(value),
Err(e) => {
println!("Error: {e}");
println!("Please try again.");
continue;
}
}
}
}
fn should_prompt_optional_parameter(param: &ParameterInfo) -> Result<bool> {
if param.required {
return Ok(true);
}
let default_text = if let Some(default) = ¶m.default_value {
format!(" (default: {default})")
} else {
" (no default)".to_string()
};
loop {
print!(
"Provide custom value for optional parameter '{}'{}? (y/n): ",
param.name, default_text
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let response = input.trim().to_lowercase();
match response.as_str() {
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
"" => return Ok(false), _ => {
println!("Please enter 'y' for yes or 'n' for no");
continue;
}
}
}
}
pub fn validate_parameter_value(value: &str, param_type: &str) -> Result<()> {
match param_type {
"String" => {
Ok(())
}
"u8" => {
match value.parse::<u8>() {
Ok(_) => Ok(()),
Err(_) => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid u8 value (must be 0-255)"
))),
}
}
"i8" => {
match value.parse::<i8>() {
Ok(_) => Ok(()),
Err(_) => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid i8 value (must be -128 to 127)"
))),
}
}
"u16" => {
match value.parse::<u16>() {
Ok(_) => Ok(()),
Err(_) => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid u16 value (must be 0-65535)"
))),
}
}
"i16" => {
match value.parse::<i16>() {
Ok(_) => Ok(()),
Err(_) => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid i16 value (must be -32768 to 32767)"
))),
}
}
"u32" => {
match value.parse::<u32>() {
Ok(_) => Ok(()),
Err(_) => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid u32 value"
))),
}
}
"i32" => {
match value.parse::<i32>() {
Ok(_) => Ok(()),
Err(_) => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid i32 value"
))),
}
}
"bool" => {
match value.to_lowercase().as_str() {
"true" | "false" | "1" | "0" | "yes" | "no" => Ok(()),
_ => Err(CliError::InvalidParameter(format!(
"'{value}' is not a valid boolean value (use true/false, 1/0, or yes/no)"
))),
}
}
_ => {
println!("Warning: Unknown parameter type '{param_type}', treating as string");
Ok(())
}
}
}
pub fn execute_operation(
client: &SonosClient,
device: &Device,
operation: &OperationInfo,
params: HashMap<String, String>,
) -> Result<String> {
let device_ip = &device.ip_address;
println!(
"Executing {} operation on {} ({})...",
operation.name, device.name, device.room_name
);
match (operation.service.as_str(), operation.name.as_str()) {
("AVTransport", "Play") => {
let speed = params.get("speed").unwrap_or(&"1".to_string()).clone();
let request = PlayOperationRequest {
instance_id: 0,
speed,
};
let operation = OperationBuilder::<PlayOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!("✓ Playback started on {}", device.name))
}
("AVTransport", "Pause") => {
let request = PauseOperationRequest { instance_id: 0 };
let operation = OperationBuilder::<PauseOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!("✓ Playback paused on {}", device.name))
}
("AVTransport", "Stop") => {
let request = StopOperationRequest { instance_id: 0 };
let operation = OperationBuilder::<StopOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!("✓ Playback stopped on {}", device.name))
}
("AVTransport", "GetTransportInfo") => {
let request = GetTransportInfoOperationRequest { instance_id: 0 };
let operation = OperationBuilder::<GetTransportInfoOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Transport Info for {}:\n State: {}\n Status: {}\n Speed: {}",
device.name,
response.current_transport_state,
response.current_transport_status,
response.current_speed
))
}
("RenderingControl", "GetVolume") => {
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = GetVolumeOperationRequest {
instance_id: 0,
channel: channel.clone(),
};
let operation = OperationBuilder::<GetVolumeOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Current volume on {} ({}): {}",
device.name, channel, response.current_volume
))
}
("RenderingControl", "SetVolume") => {
let volume_str = params
.get("volume")
.ok_or_else(|| CliError::MissingParameter("volume".to_string()))?;
let volume: u8 = volume_str.parse().map_err(|_| {
CliError::InvalidParameter(format!(
"Volume must be a number between 0-100, got '{volume_str}'"
))
})?;
if volume > 100 {
return Err(CliError::InvalidParameter(format!(
"Volume must be between 0-100, got {volume}"
)));
}
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = SetVolumeOperationRequest {
instance_id: 0,
channel: channel.clone(),
desired_volume: volume,
};
let operation = OperationBuilder::<SetVolumeOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Volume set to {} on {} ({})",
volume, device.name, channel
))
}
("RenderingControl", "SetRelativeVolume") => {
let adjustment_str = params
.get("adjustment")
.ok_or_else(|| CliError::MissingParameter("adjustment".to_string()))?;
let adjustment: i8 = adjustment_str.parse().map_err(|_| {
CliError::InvalidParameter(format!(
"Adjustment must be a number between -128 to 127, got '{adjustment_str}'"
))
})?;
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = SetRelativeVolumeOperationRequest {
instance_id: 0,
channel: channel.clone(),
adjustment,
};
let operation = OperationBuilder::<SetRelativeVolumeOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
let direction = if adjustment > 0 {
"increased"
} else if adjustment < 0 {
"decreased"
} else {
"unchanged"
};
Ok(format!(
"✓ Volume {} by {} on {} ({})\n New volume: {}",
direction,
adjustment.abs(),
device.name,
channel,
response.new_volume
))
}
("RenderingControl", "GetMute") => {
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = GetMuteOperationRequest {
instance_id: 0,
channel: channel.clone(),
};
let operation = OperationBuilder::<GetMuteOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Mute state on {} ({}): {}",
device.name,
channel,
if response.current_mute {
"MUTED"
} else {
"unmuted"
}
))
}
("RenderingControl", "SetMute") => {
let mute_str = params
.get("mute")
.ok_or_else(|| CliError::MissingParameter("mute".to_string()))?;
let mute: bool = match mute_str.to_lowercase().as_str() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
_ => {
return Err(CliError::InvalidParameter(format!(
"Mute must be true/false, got '{mute_str}'"
)))
}
};
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = SetMuteOperationRequest {
instance_id: 0,
channel: channel.clone(),
desired_mute: mute,
};
let operation = OperationBuilder::<SetMuteOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ {} on {} ({})",
if mute { "Muted" } else { "Unmuted" },
device.name,
channel
))
}
("RenderingControl", "GetBass") => {
let request = GetBassOperationRequest { instance_id: 0 };
let operation = OperationBuilder::<GetBassOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Bass on {}: {} (range: -10 to +10)",
device.name, response.current_bass
))
}
("RenderingControl", "SetBass") => {
let bass_str = params
.get("bass")
.ok_or_else(|| CliError::MissingParameter("bass".to_string()))?;
let bass: i8 = bass_str.parse().map_err(|_| {
CliError::InvalidParameter(format!(
"Bass must be a number between -10 and 10, got '{bass_str}'"
))
})?;
let request = SetBassOperationRequest {
instance_id: 0,
desired_bass: bass,
};
let operation = OperationBuilder::<SetBassOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!("✓ Bass set to {} on {}", bass, device.name))
}
("RenderingControl", "GetTreble") => {
let request = GetTrebleOperationRequest { instance_id: 0 };
let operation = OperationBuilder::<GetTrebleOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Treble on {}: {} (range: -10 to +10)",
device.name, response.current_treble
))
}
("RenderingControl", "SetTreble") => {
let treble_str = params
.get("treble")
.ok_or_else(|| CliError::MissingParameter("treble".to_string()))?;
let treble: i8 = treble_str.parse().map_err(|_| {
CliError::InvalidParameter(format!(
"Treble must be a number between -10 and 10, got '{treble_str}'"
))
})?;
let request = SetTrebleOperationRequest {
instance_id: 0,
desired_treble: treble,
};
let operation = OperationBuilder::<SetTrebleOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!("✓ Treble set to {} on {}", treble, device.name))
}
("RenderingControl", "GetLoudness") => {
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = GetLoudnessOperationRequest {
instance_id: 0,
channel: channel.clone(),
};
let operation = OperationBuilder::<GetLoudnessOperation>::new(request).build()?;
let response = client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Loudness on {} ({}): {}",
device.name,
channel,
if response.current_loudness {
"ON"
} else {
"off"
}
))
}
("RenderingControl", "SetLoudness") => {
let loudness_str = params
.get("loudness")
.ok_or_else(|| CliError::MissingParameter("loudness".to_string()))?;
let loudness: bool = match loudness_str.to_lowercase().as_str() {
"true" | "1" | "yes" => true,
"false" | "0" | "no" => false,
_ => {
return Err(CliError::InvalidParameter(format!(
"Loudness must be true/false, got '{loudness_str}'"
)))
}
};
let channel = params
.get("channel")
.unwrap_or(&"Master".to_string())
.clone();
let request = SetLoudnessOperationRequest {
instance_id: 0,
channel: channel.clone(),
desired_loudness: loudness,
};
let operation = OperationBuilder::<SetLoudnessOperation>::new(request).build()?;
client.execute_enhanced(device_ip, operation)?;
Ok(format!(
"✓ Loudness {} on {} ({})",
if loudness { "enabled" } else { "disabled" },
device.name,
channel
))
}
_ => Err(CliError::UnsupportedOperation(format!(
"Operation {}.{} is not supported",
operation.service, operation.name
))),
}
}
pub fn run_operation_menu(
client: &SonosClient,
device: &Device,
registry: &OperationRegistry,
) -> Result<bool> {
let _operations = registry.get_operations();
let grouped_operations = registry.get_by_service();
println!(
"\nAvailable Operations for {} ({}):",
device.name, device.room_name
);
println!("{}", "=".repeat(50));
let mut operation_list = Vec::new();
for (service, ops) in &grouped_operations {
println!("\n{service}:");
for op in ops {
operation_list.push(*op);
println!(
" {}. {} - {}",
operation_list.len(),
op.name,
op.description
);
}
}
println!("\n0. Return to device selection");
println!();
match get_user_selection(operation_list.len())? {
Some(index) => {
let selected_operation = &operation_list[index];
println!(
"\nSelected operation: {} - {}",
selected_operation.name, selected_operation.description
);
let params = collect_parameters(selected_operation)?;
match execute_operation(client, device, selected_operation, params) {
Ok(result) => {
println!("\n{result}");
println!("\nPress Enter to continue...");
let mut input = String::new();
io::stdin().read_line(&mut input)?;
}
Err(e) => {
eprintln!("\nOperation failed: {e}");
println!("Press Enter to continue...");
let mut input = String::new();
io::stdin().read_line(&mut input)?;
}
}
Ok(true) }
None => Ok(false), }
}
#[tokio::main]
async fn main() -> Result<()> {
display_welcome_message();
let registry = OperationRegistry::new();
let client = SonosClient::new();
println!("✓ Sonos API client initialized (using shared HTTP connection pool)");
println!(
"✓ Operation registry loaded with {} operations",
registry.get_operations().len()
);
setup_signal_handling();
println!();
let devices = match discover_devices_with_enhanced_error_handling().await {
Ok(devices) => devices,
Err(e) => {
display_discovery_error(&e);
return Err(e);
}
};
display_devices(&devices);
println!("🎵 Ready to control your Sonos speakers!");
println!(" Use Ctrl+C at any time to exit gracefully");
println!();
loop {
match select_device_with_enhanced_prompts(&devices)? {
Some(device) => {
println!(
"\n✓ Selected device: {} ({})",
device.name, device.room_name
);
println!(" IP Address: {}", device.ip_address);
println!(" Model: {}", device.model_name);
loop {
match run_operation_menu_with_enhanced_error_handling(
&client, device, ®istry,
) {
Ok(should_continue) => {
if !should_continue {
println!("\n← Returning to device selection...");
break; }
}
Err(e) => {
display_operation_error(&e);
if !should_retry_after_error(&e)? {
break; }
}
}
}
}
None => {
display_goodbye_message();
break; }
}
}
Ok(())
}
fn display_welcome_message() {
println!("🎵 Sonos API CLI Example");
println!("========================");
println!();
println!("This interactive CLI demonstrates the sonos-api crate functionality.");
println!("You can discover Sonos devices and execute various control operations.");
println!();
println!("📋 What you can do:");
println!(" • Discover Sonos speakers on your network");
println!(" • Control playback (play, pause, stop)");
println!(" • Adjust volume settings");
println!(" • Get device status information");
println!();
println!("🔧 Requirements:");
println!(" • Sonos speakers must be powered on");
println!(" • Connected to the same network as this computer");
println!(" • Network discovery allowed (check firewall)");
println!();
}
fn setup_signal_handling() {
println!("✓ Signal handling configured (Ctrl+C to exit)");
}
async fn discover_devices_with_enhanced_error_handling() -> Result<Vec<Device>> {
const MAX_RETRIES: u32 = 3;
let mut attempt = 1;
loop {
println!("🔍 Discovering Sonos devices... (attempt {attempt}/{MAX_RETRIES})");
match discover_devices().await {
Ok(devices) => return Ok(devices),
Err(CliError::NoDevicesFound) if attempt < MAX_RETRIES => {
println!(" No devices found on attempt {attempt}");
if should_retry_discovery()? {
attempt += 1;
continue;
} else {
return Err(CliError::NoDevicesFound);
}
}
Err(e) if attempt < MAX_RETRIES => {
println!(" Discovery failed: {e}");
if should_retry_discovery()? {
attempt += 1;
continue;
} else {
return Err(e);
}
}
Err(e) => return Err(e),
}
}
}
fn should_retry_discovery() -> Result<bool> {
println!();
println!("Would you like to try discovering devices again?");
println!("This might help if devices are still starting up or network is slow.");
loop {
print!("Retry discovery? (y/n): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let response = input.trim().to_lowercase();
match response.as_str() {
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
"" => return Ok(false), _ => {
println!("Please enter 'y' for yes or 'n' for no");
continue;
}
}
}
}
fn display_discovery_error(error: &CliError) {
println!();
println!("❌ Device Discovery Failed");
println!("{}", "=".repeat(25));
println!();
match error {
CliError::NoDevicesFound => {
println!("No Sonos devices were found on your network.");
println!();
println!("💡 Troubleshooting tips:");
println!(" 1. Ensure your Sonos speakers are powered on");
println!(" 2. Check that you're on the same WiFi network as your speakers");
println!(" 3. Verify your firewall allows network discovery");
println!(" 4. Try opening the Sonos app to ensure speakers are responsive");
println!(" 5. Wait a moment and try running the example again");
}
CliError::Discovery(discovery_error) => {
println!("Network discovery error: {discovery_error}");
println!();
println!("💡 This might be a temporary network issue.");
println!(" Try running the example again in a few moments.");
}
_ => {
println!("Unexpected error during discovery: {error}");
}
}
println!();
println!("This CLI example requires Sonos devices to demonstrate the");
println!("full operation execution functionality of the sonos-api crate.");
}
fn select_device_with_enhanced_prompts(devices: &[Device]) -> Result<Option<&Device>> {
if devices.is_empty() {
return Err(CliError::NoDevicesFound);
}
println!("📱 Select a Sonos Device to Control");
println!("{}", "=".repeat(35));
for (i, device) in devices.iter().enumerate() {
println!("{}. {} ({})", i + 1, device.name, device.room_name);
println!(" 📍 {} | 🔧 {}", device.ip_address, device.model_name);
if i < devices.len() - 1 {
println!();
}
}
println!();
println!("0. Exit application");
println!();
println!("💡 Tip: Choose the device you want to control");
match get_user_selection(devices.len())? {
Some(index) => Ok(Some(&devices[index])),
None => Ok(None),
}
}
fn run_operation_menu_with_enhanced_error_handling(
client: &SonosClient,
device: &Device,
registry: &OperationRegistry,
) -> Result<bool> {
let _operations = registry.get_operations();
let grouped_operations = registry.get_by_service();
println!(
"\n🎛️ Available Operations for {} ({})",
device.name, device.room_name
);
println!("{}", "=".repeat(60));
let mut operation_list = Vec::new();
for (service, ops) in &grouped_operations {
println!("\n📂 {service}:");
for op in ops {
operation_list.push(*op);
println!(
" {}. {} - {}",
operation_list.len(),
op.name,
op.description
);
}
}
println!();
println!("0. ← Return to device selection");
println!();
println!("💡 Tip: Select an operation to execute on {}", device.name);
match get_user_selection(operation_list.len())? {
Some(index) => {
let selected_operation = &operation_list[index];
println!(
"\n🚀 Executing: {} - {}",
selected_operation.name, selected_operation.description
);
println!(" Target device: {} ({})", device.name, device.room_name);
let params = collect_parameters_with_enhanced_prompts(selected_operation)?;
match execute_operation_with_enhanced_feedback(
client,
device,
selected_operation,
params,
) {
Ok(result) => {
display_operation_success(&result);
}
Err(e) => {
display_operation_error(&e);
}
}
Ok(true)
}
None => Ok(false), }
}
fn collect_parameters_with_enhanced_prompts(
operation: &OperationInfo,
) -> Result<HashMap<String, String>> {
let mut params = HashMap::new();
if operation.parameters.is_empty() {
println!("✓ This operation requires no parameters - ready to execute!");
return Ok(params);
}
println!("\n📝 Parameter Collection for: {}", operation.name);
println!("{}", "=".repeat(50));
println!("Please provide the following parameters:");
println!();
for (i, param) in operation.parameters.iter().enumerate() {
println!("Parameter {} of {}:", i + 1, operation.parameters.len());
if param.required {
let value = prompt_for_parameter_with_enhanced_help(param)?;
params.insert(param.name.clone(), value);
} else {
if should_prompt_optional_parameter_with_enhanced_help(param)? {
let value = prompt_for_parameter_with_enhanced_help(param)?;
params.insert(param.name.clone(), value);
} else if let Some(default) = ¶m.default_value {
params.insert(param.name.clone(), default.clone());
println!(
"✓ Using default value '{}' for parameter '{}'",
default, param.name
);
}
}
if i < operation.parameters.len() - 1 {
println!();
}
}
println!("\n✓ All parameters collected successfully!");
Ok(params)
}
fn prompt_for_parameter_with_enhanced_help(param: &ParameterInfo) -> Result<String> {
println!(" 📋 Parameter: {}", param.name);
println!(" Type: {}", param.param_type);
match param.param_type.as_str() {
"u8" => println!(" Range: 0-255 (e.g., volume: 0-100)"),
"i8" => println!(" Range: -128 to 127 (e.g., volume adjustment: -10 to +10)"),
"String" => println!(" Text value (e.g., 'Master' for channel)"),
_ => {}
}
if param.required {
println!(" ⚠️ Required parameter");
} else if let Some(default) = ¶m.default_value {
println!(" 💡 Default: {default}");
}
loop {
print!(" Enter {} ({})", param.name, param.param_type);
if !param.required {
if let Some(default) = ¶m.default_value {
print!(" [default: {default}]");
} else {
print!(" [optional]");
}
}
print!(": ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let value = input.trim().to_string();
if value.is_empty() && !param.required {
if let Some(default) = ¶m.default_value {
return Ok(default.clone());
} else {
return Ok(String::new());
}
}
if value.is_empty() && param.required {
println!(
" ❌ Error: {} is required and cannot be empty",
param.name
);
continue;
}
match validate_parameter_value(&value, ¶m.param_type) {
Ok(()) => {
println!(" ✓ Valid {} value: {}", param.param_type, value);
return Ok(value);
}
Err(e) => {
println!(" ❌ Error: {e}");
println!(
" Please try again with a valid {} value.",
param.param_type
);
continue;
}
}
}
}
fn should_prompt_optional_parameter_with_enhanced_help(param: &ParameterInfo) -> Result<bool> {
if param.required {
return Ok(true);
}
println!(" 🔧 Optional Parameter: {}", param.name);
let default_text = if let Some(default) = ¶m.default_value {
format!(" (default: {default})")
} else {
" (no default)".to_string()
};
println!(" Type: {}{}", param.param_type, default_text);
loop {
print!(" Provide custom value? (y/n): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let response = input.trim().to_lowercase();
match response.as_str() {
"y" | "yes" => {
println!(" → Will prompt for custom value");
return Ok(true);
}
"n" | "no" | "" => {
if let Some(default) = ¶m.default_value {
println!(" → Will use default value: {default}");
} else {
println!(" → Will skip this parameter");
}
return Ok(false);
}
_ => {
println!(" Please enter 'y' for yes or 'n' for no");
continue;
}
}
}
}
fn execute_operation_with_enhanced_feedback(
client: &SonosClient,
device: &Device,
operation: &OperationInfo,
params: HashMap<String, String>,
) -> Result<String> {
println!("\n⚡ Executing operation...");
println!(" Operation: {}", operation.name);
println!(" Service: {}", operation.service);
println!(" Target: {} ({})", device.name, device.ip_address);
if !params.is_empty() {
println!(" Parameters:");
for (key, value) in ¶ms {
println!(" {key}: {value}");
}
}
println!();
execute_operation(client, device, operation, params)
}
fn display_operation_success(result: &str) {
println!("✅ Operation Completed Successfully!");
println!("{}", "=".repeat(35));
println!();
println!("{result}");
println!();
println!("Press Enter to continue...");
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
}
fn display_operation_error(error: &CliError) {
println!();
println!("❌ Operation Failed");
println!("{}", "=".repeat(18));
println!();
match error {
CliError::Api(api_error) => {
println!("SOAP API Error: {api_error}");
println!();
println!("💡 This might be because:");
println!(" • The device is busy with another operation");
println!(" • The requested operation is not supported in current state");
println!(" • Network connectivity issues");
println!(" • The device needs to be restarted");
}
CliError::InvalidParameter(msg) => {
println!("Parameter Error: {msg}");
println!();
println!("💡 Please check your parameter values and try again.");
}
CliError::MissingParameter(param) => {
println!("Missing Parameter: {param}");
println!();
println!("💡 This operation requires the '{param}' parameter.");
}
CliError::UnsupportedOperation(op) => {
println!("Unsupported Operation: {op}");
println!();
println!("💡 This operation is not yet implemented in the CLI example.");
}
_ => {
println!("Error: {error}");
println!();
println!("💡 This might be a temporary issue - you can try again.");
}
}
println!();
println!("Press Enter to continue...");
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
}
fn should_retry_after_error(error: &CliError) -> Result<bool> {
match error {
CliError::Api(_) | CliError::InvalidParameter(_) | CliError::MissingParameter(_) => {
Ok(true)
}
_ => {
println!("Would you like to try another operation on this device?");
loop {
print!("Continue with this device? (y/n): ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let response = input.trim().to_lowercase();
match response.as_str() {
"y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
"" => return Ok(false), _ => {
println!("Please enter 'y' for yes or 'n' for no");
continue;
}
}
}
}
}
}
fn display_goodbye_message() {
println!();
println!("👋 Thank you for using the Sonos API CLI Example!");
println!("{}", "=".repeat(45));
println!();
println!("🎵 What you experienced:");
println!(" • Device discovery using the sonos-discovery crate");
println!(" • Interactive operation selection and execution");
println!(" • Type-safe SOAP operations via the sonos-api crate");
println!(" • Comprehensive error handling and recovery");
println!();
println!("📚 To learn more:");
println!(" • Check the sonos-api crate documentation");
println!(" • Explore the source code in sonos-api/examples/cli_example.rs");
println!(" • Try integrating these operations into your own applications");
println!();
println!("Happy coding! 🚀");
}