use std::net::IpAddr;
use std::sync::Arc;
use sonos_api::SonosClient;
use sonos_discovery::Device;
use sonos_state::{Bass, Loudness, Mute, PlaybackState, SpeakerId, StateManager, Treble, Volume};
use crate::Group;
use sonos_api::operation::{ComposableOperation, UPnPOperation, ValidationError};
use sonos_api::services::{
av_transport::{
self, AddURIToQueueResponse, BecomeCoordinatorOfStandaloneGroupResponse,
CreateSavedQueueResponse, GetCrossfadeModeResponse, GetCurrentTransportActionsResponse,
GetDeviceCapabilitiesResponse, GetMediaInfoResponse,
GetRemainingSleepTimerDurationResponse, GetRunningAlarmPropertiesResponse,
GetTransportSettingsResponse, RemoveTrackRangeFromQueueResponse, SaveQueueResponse,
},
rendering_control::{self, SetRelativeVolumeResponse},
};
use crate::SdkError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SeekTarget {
Track(u32),
Time(String),
Delta(String),
}
impl SeekTarget {
fn unit(&self) -> &str {
match self {
SeekTarget::Track(_) => "TRACK_NR",
SeekTarget::Time(_) => "REL_TIME",
SeekTarget::Delta(_) => "TIME_DELTA",
}
}
fn target(&self) -> String {
match self {
SeekTarget::Track(n) => n.to_string(),
SeekTarget::Time(t) => t.clone(),
SeekTarget::Delta(d) => d.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlayMode {
Normal,
RepeatAll,
RepeatOne,
ShuffleNoRepeat,
Shuffle,
ShuffleRepeatOne,
}
impl std::fmt::Display for PlayMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlayMode::Normal => write!(f, "NORMAL"),
PlayMode::RepeatAll => write!(f, "REPEAT_ALL"),
PlayMode::RepeatOne => write!(f, "REPEAT_ONE"),
PlayMode::ShuffleNoRepeat => write!(f, "SHUFFLE_NOREPEAT"),
PlayMode::Shuffle => write!(f, "SHUFFLE"),
PlayMode::ShuffleRepeatOne => write!(f, "SHUFFLE_REPEAT_ONE"),
}
}
}
use crate::property::{
BassHandle, CurrentTrackHandle, GroupMembershipHandle, LoudnessHandle, MuteHandle,
PlaybackStateHandle, PositionHandle, PropertyHandle, SpeakerContext, TrebleHandle,
VolumeHandle,
};
#[derive(Clone)]
pub struct Speaker {
pub id: SpeakerId,
pub name: String,
pub ip: IpAddr,
pub model_name: String,
pub volume: VolumeHandle,
pub mute: MuteHandle,
pub bass: BassHandle,
pub treble: TrebleHandle,
pub loudness: LoudnessHandle,
pub playback_state: PlaybackStateHandle,
pub position: PositionHandle,
pub current_track: CurrentTrackHandle,
pub group_membership: GroupMembershipHandle,
context: Arc<SpeakerContext>,
}
impl Speaker {
pub fn from_device(
device: &Device,
state_manager: Arc<StateManager>,
api_client: SonosClient,
) -> Result<Self, SdkError> {
let ip: IpAddr = device
.ip_address
.parse()
.map_err(|_| SdkError::InvalidIpAddress)?;
let name = if device.room_name.is_empty() || device.room_name == "Unknown" {
device.name.clone()
} else {
device.room_name.clone()
};
Ok(Self::new(
SpeakerId::new(&device.id),
name,
ip,
device.model_name.clone(),
state_manager,
api_client,
))
}
pub fn new(
id: SpeakerId,
name: String,
ip: IpAddr,
model_name: String,
state_manager: Arc<StateManager>,
api_client: SonosClient,
) -> Self {
let context = SpeakerContext::new(id.clone(), ip, state_manager, api_client);
Self {
id,
name,
ip,
model_name,
volume: PropertyHandle::new(Arc::clone(&context)),
mute: PropertyHandle::new(Arc::clone(&context)),
bass: PropertyHandle::new(Arc::clone(&context)),
treble: PropertyHandle::new(Arc::clone(&context)),
loudness: PropertyHandle::new(Arc::clone(&context)),
playback_state: PropertyHandle::new(Arc::clone(&context)),
position: PropertyHandle::new(Arc::clone(&context)),
current_track: PropertyHandle::new(Arc::clone(&context)),
group_membership: PropertyHandle::new(Arc::clone(&context)),
context,
}
}
pub fn group(&self) -> Option<Group> {
let info = self
.context
.state_manager
.get_group_for_speaker(&self.context.speaker_id)?;
Group::from_info(
info,
Arc::clone(&self.context.state_manager),
self.context.api_client.clone(),
)
}
fn exec<Op: UPnPOperation>(
&self,
operation: Result<ComposableOperation<Op>, ValidationError>,
) -> Result<Op::Response, SdkError> {
let op = operation?;
self.context
.api_client
.execute_enhanced(&self.context.speaker_ip.to_string(), op)
.map_err(SdkError::ApiError)
}
pub fn play(&self) -> Result<(), SdkError> {
self.exec(av_transport::play("1".to_string()).build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, PlaybackState::Playing);
Ok(())
}
pub fn pause(&self) -> Result<(), SdkError> {
self.exec(av_transport::pause().build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, PlaybackState::Paused);
Ok(())
}
pub fn stop(&self) -> Result<(), SdkError> {
self.exec(av_transport::stop().build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, PlaybackState::Stopped);
Ok(())
}
pub fn next(&self) -> Result<(), SdkError> {
self.exec(av_transport::next().build())?;
Ok(())
}
pub fn previous(&self) -> Result<(), SdkError> {
self.exec(av_transport::previous().build())?;
Ok(())
}
pub fn seek(&self, target: SeekTarget) -> Result<(), SdkError> {
self.exec(av_transport::seek(target.unit().to_string(), target.target()).build())?;
Ok(())
}
pub fn set_av_transport_uri(&self, uri: &str, metadata: &str) -> Result<(), SdkError> {
self.exec(
av_transport::set_av_transport_uri(uri.to_string(), metadata.to_string()).build(),
)?;
Ok(())
}
pub fn set_next_av_transport_uri(&self, uri: &str, metadata: &str) -> Result<(), SdkError> {
self.exec(
av_transport::set_next_av_transport_uri(uri.to_string(), metadata.to_string()).build(),
)?;
Ok(())
}
pub fn get_media_info(&self) -> Result<GetMediaInfoResponse, SdkError> {
self.exec(av_transport::get_media_info().build())
}
pub fn get_transport_settings(&self) -> Result<GetTransportSettingsResponse, SdkError> {
self.exec(av_transport::get_transport_settings().build())
}
pub fn get_current_transport_actions(
&self,
) -> Result<GetCurrentTransportActionsResponse, SdkError> {
self.exec(av_transport::get_current_transport_actions().build())
}
pub fn set_play_mode(&self, mode: PlayMode) -> Result<(), SdkError> {
self.exec(av_transport::set_play_mode(mode.to_string()).build())?;
Ok(())
}
pub fn get_crossfade_mode(&self) -> Result<GetCrossfadeModeResponse, SdkError> {
self.exec(av_transport::get_crossfade_mode().build())
}
pub fn set_crossfade_mode(&self, enabled: bool) -> Result<(), SdkError> {
self.exec(av_transport::set_crossfade_mode(enabled).build())?;
Ok(())
}
pub fn configure_sleep_timer(&self, duration: &str) -> Result<(), SdkError> {
self.exec(av_transport::configure_sleep_timer(duration.to_string()).build())?;
Ok(())
}
pub fn cancel_sleep_timer(&self) -> Result<(), SdkError> {
self.configure_sleep_timer("")
}
pub fn get_remaining_sleep_timer(
&self,
) -> Result<GetRemainingSleepTimerDurationResponse, SdkError> {
self.exec(av_transport::get_remaining_sleep_timer_duration().build())
}
pub fn add_uri_to_queue(
&self,
uri: &str,
metadata: &str,
position: u32,
enqueue_as_next: bool,
) -> Result<AddURIToQueueResponse, SdkError> {
self.exec(
av_transport::add_uri_to_queue(
uri.to_string(),
metadata.to_string(),
position,
enqueue_as_next,
)
.build(),
)
}
pub fn remove_track_from_queue(&self, object_id: &str, update_id: u32) -> Result<(), SdkError> {
self.exec(av_transport::remove_track_from_queue(object_id.to_string(), update_id).build())?;
Ok(())
}
pub fn remove_all_tracks_from_queue(&self) -> Result<(), SdkError> {
self.exec(av_transport::remove_all_tracks_from_queue().build())?;
Ok(())
}
pub fn save_queue(&self, title: &str, object_id: &str) -> Result<SaveQueueResponse, SdkError> {
self.exec(av_transport::save_queue(title.to_string(), object_id.to_string()).build())
}
pub fn create_saved_queue(
&self,
title: &str,
uri: &str,
metadata: &str,
) -> Result<CreateSavedQueueResponse, SdkError> {
self.exec(
av_transport::create_saved_queue(
title.to_string(),
uri.to_string(),
metadata.to_string(),
)
.build(),
)
}
pub fn remove_track_range_from_queue(
&self,
update_id: u32,
starting_index: u32,
number_of_tracks: u32,
) -> Result<RemoveTrackRangeFromQueueResponse, SdkError> {
self.exec(
av_transport::remove_track_range_from_queue(
update_id,
starting_index,
number_of_tracks,
)
.build(),
)
}
pub fn backup_queue(&self) -> Result<(), SdkError> {
self.exec(av_transport::backup_queue().build())?;
Ok(())
}
pub fn get_device_capabilities(&self) -> Result<GetDeviceCapabilitiesResponse, SdkError> {
self.exec(av_transport::get_device_capabilities().build())
}
pub fn snooze_alarm(&self, duration: &str) -> Result<(), SdkError> {
self.exec(av_transport::snooze_alarm(duration.to_string()).build())?;
Ok(())
}
pub fn get_running_alarm_properties(
&self,
) -> Result<GetRunningAlarmPropertiesResponse, SdkError> {
self.exec(av_transport::get_running_alarm_properties().build())
}
pub fn become_standalone(
&self,
) -> Result<BecomeCoordinatorOfStandaloneGroupResponse, SdkError> {
self.exec(av_transport::become_coordinator_of_standalone_group().build())
}
pub fn delegate_coordination_to(
&self,
new_coordinator: &SpeakerId,
rejoin_group: bool,
) -> Result<(), SdkError> {
self.exec(
av_transport::delegate_group_coordination_to(
new_coordinator.as_str().to_string(),
rejoin_group,
)
.build(),
)?;
Ok(())
}
pub fn join_group(&self, group: &Group) -> Result<(), SdkError> {
group.add_speaker(self)
}
pub fn leave_group(&self) -> Result<BecomeCoordinatorOfStandaloneGroupResponse, SdkError> {
self.become_standalone()
}
pub fn set_volume(&self, volume: u8) -> Result<(), SdkError> {
self.exec(rendering_control::set_volume("Master".to_string(), volume).build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, Volume(volume));
Ok(())
}
pub fn set_relative_volume(
&self,
adjustment: i8,
) -> Result<SetRelativeVolumeResponse, SdkError> {
let response = self.exec(
rendering_control::set_relative_volume("Master".to_string(), adjustment).build(),
)?;
self.context
.state_manager
.set_property(&self.context.speaker_id, Volume(response.new_volume));
Ok(response)
}
pub fn set_mute(&self, muted: bool) -> Result<(), SdkError> {
self.exec(rendering_control::set_mute("Master".to_string(), muted).build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, Mute(muted));
Ok(())
}
pub fn set_bass(&self, level: i8) -> Result<(), SdkError> {
self.exec(rendering_control::set_bass(level).build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, Bass(level));
Ok(())
}
pub fn set_treble(&self, level: i8) -> Result<(), SdkError> {
self.exec(rendering_control::set_treble(level).build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, Treble(level));
Ok(())
}
pub fn set_loudness(&self, enabled: bool) -> Result<(), SdkError> {
self.exec(rendering_control::set_loudness("Master".to_string(), enabled).build())?;
self.context
.state_manager
.set_property(&self.context.speaker_id, Loudness(enabled));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use sonos_discovery::Device;
fn create_test_speaker() -> Speaker {
let manager = StateManager::new().unwrap();
let devices = vec![Device {
id: "RINCON_TEST123".to_string(),
name: "Test Speaker".to_string(),
room_name: "Test Room".to_string(),
ip_address: "192.168.1.100".to_string(),
port: 1400,
model_name: "Sonos One".to_string(),
}];
manager.add_devices(devices).unwrap();
let state_manager = Arc::new(manager);
let api_client = SonosClient::new();
Speaker::new(
SpeakerId::new("RINCON_TEST123"),
"Test Speaker".to_string(),
"192.168.1.100".parse().unwrap(),
"Sonos One".to_string(),
state_manager,
api_client,
)
}
#[test]
fn test_set_volume_rejects_invalid() {
let speaker = create_test_speaker();
let result = speaker.set_volume(150);
assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
}
#[test]
fn test_set_bass_rejects_invalid() {
let speaker = create_test_speaker();
let result = speaker.set_bass(15);
assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
}
#[test]
fn test_set_treble_rejects_invalid() {
let speaker = create_test_speaker();
let result = speaker.set_treble(-15);
assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
}
#[test]
fn test_speaker_action_methods_exist() {
fn assert_void(_r: Result<(), SdkError>) {}
fn assert_response<T>(_r: Result<T, SdkError>) {}
let speaker = create_test_speaker();
assert_void(speaker.play());
assert_void(speaker.pause());
assert_void(speaker.stop());
assert_void(speaker.next());
assert_void(speaker.previous());
assert_void(speaker.seek(SeekTarget::Time("0:00:00".into())));
assert_void(speaker.set_av_transport_uri("", ""));
assert_void(speaker.set_next_av_transport_uri("", ""));
assert_response::<GetMediaInfoResponse>(speaker.get_media_info());
assert_response::<GetTransportSettingsResponse>(speaker.get_transport_settings());
assert_response::<GetCurrentTransportActionsResponse>(
speaker.get_current_transport_actions(),
);
assert_void(speaker.set_play_mode(PlayMode::Normal));
assert_response::<GetCrossfadeModeResponse>(speaker.get_crossfade_mode());
assert_void(speaker.set_crossfade_mode(true));
assert_void(speaker.configure_sleep_timer(""));
assert_void(speaker.cancel_sleep_timer());
assert_response::<GetRemainingSleepTimerDurationResponse>(
speaker.get_remaining_sleep_timer(),
);
assert_response::<AddURIToQueueResponse>(speaker.add_uri_to_queue("", "", 0, false));
assert_void(speaker.remove_track_from_queue("", 0));
assert_void(speaker.remove_all_tracks_from_queue());
assert_response::<SaveQueueResponse>(speaker.save_queue("", ""));
assert_response::<CreateSavedQueueResponse>(speaker.create_saved_queue("", "", ""));
assert_response::<RemoveTrackRangeFromQueueResponse>(
speaker.remove_track_range_from_queue(0, 0, 1),
);
assert_void(speaker.backup_queue());
assert_response::<GetDeviceCapabilitiesResponse>(speaker.get_device_capabilities());
assert_void(speaker.snooze_alarm("00:10:00"));
assert_response::<GetRunningAlarmPropertiesResponse>(
speaker.get_running_alarm_properties(),
);
assert_response::<BecomeCoordinatorOfStandaloneGroupResponse>(speaker.become_standalone());
assert_void(speaker.delegate_coordination_to(&SpeakerId::new("RINCON_OTHER"), false));
assert_void(speaker.set_volume(50));
assert_response::<SetRelativeVolumeResponse>(speaker.set_relative_volume(5));
assert_void(speaker.set_mute(true));
assert_void(speaker.set_bass(0));
assert_void(speaker.set_treble(0));
assert_void(speaker.set_loudness(true));
let group = create_test_group_for_speaker(&speaker);
assert_void(speaker.join_group(&group));
assert_response::<BecomeCoordinatorOfStandaloneGroupResponse>(speaker.leave_group());
}
fn create_test_group_for_speaker(speaker: &Speaker) -> crate::Group {
use sonos_state::{GroupId, GroupInfo};
let state_manager = Arc::new(StateManager::new().unwrap());
let devices = vec![Device {
id: speaker.id.as_str().to_string(),
name: speaker.name.clone(),
room_name: speaker.name.clone(),
ip_address: speaker.ip.to_string(),
port: 1400,
model_name: speaker.model_name.clone(),
}];
state_manager.add_devices(devices).unwrap();
let group_info = GroupInfo::new(
GroupId::new(format!("{}:1", speaker.id.as_str())),
speaker.id.clone(),
vec![speaker.id.clone()],
);
crate::Group::from_info(group_info, state_manager, SonosClient::new()).unwrap()
}
}