use std::net::IpAddr;
use std::sync::Arc;
use sonos_api::operation::{ComposableOperation, UPnPOperation, ValidationError};
use sonos_api::services::av_transport;
use sonos_api::services::group_rendering_control::{self, SetRelativeGroupVolumeResponse};
use sonos_api::SonosClient;
use sonos_state::{GroupId, GroupInfo, GroupMute, GroupVolume, SpeakerId, StateManager};
use crate::property::{
GroupContext, GroupMuteHandle, GroupPropertyHandle, GroupVolumeChangeableHandle,
GroupVolumeHandle,
};
use crate::SdkError;
use crate::Speaker;
#[derive(Debug)]
pub struct GroupChangeResult {
pub succeeded: Vec<SpeakerId>,
pub failed: Vec<(SpeakerId, SdkError)>,
}
impl GroupChangeResult {
pub fn is_success(&self) -> bool {
self.failed.is_empty()
}
pub fn is_partial(&self) -> bool {
!self.succeeded.is_empty() && !self.failed.is_empty()
}
}
#[derive(Clone)]
pub struct Group {
pub id: GroupId,
pub coordinator_id: SpeakerId,
pub member_ids: Vec<SpeakerId>,
pub volume: GroupVolumeHandle,
pub mute: GroupMuteHandle,
pub volume_changeable: GroupVolumeChangeableHandle,
coordinator_ip: IpAddr,
state_manager: Arc<StateManager>,
api_client: SonosClient,
}
impl Group {
pub(crate) fn from_info(
info: GroupInfo,
state_manager: Arc<StateManager>,
api_client: SonosClient,
) -> Option<Self> {
let coordinator_ip = state_manager.get_speaker_ip(&info.coordinator_id)?;
let group_context = GroupContext::new(
info.id.clone(),
info.coordinator_id.clone(),
coordinator_ip,
Arc::clone(&state_manager),
api_client.clone(),
);
Some(Self {
id: info.id,
coordinator_id: info.coordinator_id,
member_ids: info.member_ids,
volume: GroupPropertyHandle::new(Arc::clone(&group_context)),
mute: GroupPropertyHandle::new(Arc::clone(&group_context)),
volume_changeable: GroupPropertyHandle::new(group_context),
coordinator_ip,
state_manager,
api_client,
})
}
pub fn coordinator(&self) -> Option<Speaker> {
let info = self.state_manager.speaker_info(&self.coordinator_id)?;
Some(Speaker::new(
self.coordinator_id.clone(),
info.name,
info.ip_address,
info.model_name,
Arc::clone(&self.state_manager),
self.api_client.clone(),
))
}
pub fn members(&self) -> Vec<Speaker> {
self.member_ids
.iter()
.filter_map(|id| {
let info = self.state_manager.speaker_info(id)?;
Some(Speaker::new(
id.clone(),
info.name,
info.ip_address,
info.model_name,
Arc::clone(&self.state_manager),
self.api_client.clone(),
))
})
.collect()
}
pub fn speaker(&self, name: &str) -> Option<Speaker> {
self.members()
.into_iter()
.find(|s| s.name.eq_ignore_ascii_case(name))
}
pub fn is_coordinator(&self, speaker_id: &SpeakerId) -> bool {
self.coordinator_id == *speaker_id
}
pub fn member_count(&self) -> usize {
self.member_ids.len()
}
pub fn is_standalone(&self) -> bool {
self.member_ids.len() == 1
}
fn exec<Op: UPnPOperation>(
&self,
operation: Result<ComposableOperation<Op>, ValidationError>,
) -> Result<Op::Response, SdkError> {
let op = operation?;
self.api_client
.execute_enhanced(&self.coordinator_ip.to_string(), op)
.map_err(SdkError::ApiError)
}
pub fn add_speaker(&self, speaker: &Speaker) -> Result<(), SdkError> {
if speaker.id == self.coordinator_id {
return Err(SdkError::InvalidOperation(
"Cannot add coordinator to its own group".to_string(),
));
}
let rincon_uri = format!("x-rincon:{}", self.coordinator_id.as_str());
let op = av_transport::set_av_transport_uri(rincon_uri, String::new()).build()?;
self.api_client
.execute_enhanced::<av_transport::SetAVTransportURIOperation>(
&speaker.ip.to_string(),
op,
)
.map_err(SdkError::ApiError)?;
Ok(())
}
pub fn remove_speaker(&self, speaker: &Speaker) -> Result<(), SdkError> {
if speaker.id == self.coordinator_id {
return Err(SdkError::InvalidOperation(
"Cannot remove coordinator from its own group; use delegate_coordination_to() first".to_string(),
));
}
let op = av_transport::become_coordinator_of_standalone_group().build()?;
self.api_client
.execute_enhanced::<av_transport::BecomeCoordinatorOfStandaloneGroupOperation>(
&speaker.ip.to_string(),
op,
)
.map_err(SdkError::ApiError)?;
Ok(())
}
pub fn dissolve(&self) -> GroupChangeResult {
let mut succeeded = Vec::new();
let mut failed = Vec::new();
for member in self.members() {
if !self.is_coordinator(&member.id) {
match self.remove_speaker(&member) {
Ok(()) => succeeded.push(member.id.clone()),
Err(e) => failed.push((member.id.clone(), e)),
}
}
}
GroupChangeResult { succeeded, failed }
}
pub fn set_volume(&self, volume: u16) -> Result<(), SdkError> {
self.exec(group_rendering_control::set_group_volume(volume).build())?;
self.state_manager
.set_group_property(&self.id, GroupVolume(volume));
Ok(())
}
pub fn set_relative_volume(
&self,
adjustment: i16,
) -> Result<SetRelativeGroupVolumeResponse, SdkError> {
let response =
self.exec(group_rendering_control::set_relative_group_volume(adjustment).build())?;
self.state_manager
.set_group_property(&self.id, GroupVolume(response.new_volume));
Ok(response)
}
pub fn set_mute(&self, muted: bool) -> Result<(), SdkError> {
self.exec(group_rendering_control::set_group_mute(muted).build())?;
self.state_manager
.set_group_property(&self.id, GroupMute(muted));
Ok(())
}
pub fn snapshot_volume(&self) -> Result<(), SdkError> {
self.exec(group_rendering_control::snapshot_group_volume().build())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use sonos_discovery::Device;
fn create_test_state_manager_with_speakers(
speakers: Vec<(&str, &str, &str)>,
) -> Arc<StateManager> {
let manager = StateManager::new().unwrap();
let devices: Vec<Device> = speakers
.into_iter()
.map(|(id, name, ip)| Device {
id: id.to_string(),
name: name.to_string(),
room_name: name.to_string(),
ip_address: ip.to_string(),
port: 1400,
model_name: "Sonos One".to_string(),
})
.collect();
manager.add_devices(devices).unwrap();
Arc::new(manager)
}
#[test]
fn test_group_from_info() {
let state_manager = create_test_state_manager_with_speakers(vec![(
"RINCON_111",
"Living Room",
"192.168.1.100",
)]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111")],
);
let group = Group::from_info(group_info, state_manager, api_client).unwrap();
assert_eq!(group.id.as_str(), "RINCON_111:1");
assert_eq!(group.coordinator_id.as_str(), "RINCON_111");
assert_eq!(group.member_ids.len(), 1);
}
#[test]
fn test_group_from_info_returns_none_for_unknown_coordinator() {
let state_manager = create_test_state_manager_with_speakers(vec![(
"RINCON_111",
"Living Room",
"192.168.1.100",
)]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_UNKNOWN:1"),
SpeakerId::new("RINCON_UNKNOWN"),
vec![SpeakerId::new("RINCON_UNKNOWN")],
);
let group = Group::from_info(group_info, state_manager, api_client);
assert!(group.is_none());
}
#[test]
fn test_coordinator_returns_correct_speaker() {
let state_manager = create_test_state_manager_with_speakers(vec![
("RINCON_111", "Living Room", "192.168.1.100"),
("RINCON_222", "Kitchen", "192.168.1.101"),
]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
);
let group = Group::from_info(group_info, state_manager, api_client).unwrap();
let coordinator = group.coordinator();
assert!(coordinator.is_some());
let coordinator = coordinator.unwrap();
assert_eq!(coordinator.id.as_str(), "RINCON_111");
assert_eq!(coordinator.name, "Living Room");
}
#[test]
fn test_members_returns_all_members() {
let state_manager = create_test_state_manager_with_speakers(vec![
("RINCON_111", "Living Room", "192.168.1.100"),
("RINCON_222", "Kitchen", "192.168.1.101"),
]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
);
let group = Group::from_info(group_info, state_manager, api_client).unwrap();
let members = group.members();
assert_eq!(members.len(), 2);
let member_ids: Vec<_> = members.iter().map(|m| m.id.as_str()).collect();
assert!(member_ids.contains(&"RINCON_111"));
assert!(member_ids.contains(&"RINCON_222"));
}
#[test]
fn test_is_coordinator_returns_correct_values() {
let state_manager = create_test_state_manager_with_speakers(vec![
("RINCON_111", "Living Room", "192.168.1.100"),
("RINCON_222", "Kitchen", "192.168.1.101"),
]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
);
let group = Group::from_info(group_info, state_manager, api_client).unwrap();
assert!(group.is_coordinator(&SpeakerId::new("RINCON_111")));
assert!(!group.is_coordinator(&SpeakerId::new("RINCON_222")));
}
#[test]
fn test_member_count() {
let state_manager = create_test_state_manager_with_speakers(vec![
("RINCON_111", "Living Room", "192.168.1.100"),
("RINCON_222", "Kitchen", "192.168.1.101"),
]);
let api_client = SonosClient::new();
let single_group = Group::from_info(
GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111")],
),
Arc::clone(&state_manager),
api_client.clone(),
)
.unwrap();
assert_eq!(single_group.member_count(), 1);
let multi_group = Group::from_info(
GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
),
state_manager,
api_client,
)
.unwrap();
assert_eq!(multi_group.member_count(), 2);
}
#[test]
fn test_is_standalone() {
let state_manager = create_test_state_manager_with_speakers(vec![
("RINCON_111", "Living Room", "192.168.1.100"),
("RINCON_222", "Kitchen", "192.168.1.101"),
]);
let api_client = SonosClient::new();
let standalone = Group::from_info(
GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111")],
),
Arc::clone(&state_manager),
api_client.clone(),
)
.unwrap();
assert!(standalone.is_standalone());
let grouped = Group::from_info(
GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
),
state_manager,
api_client,
)
.unwrap();
assert!(!grouped.is_standalone());
}
#[test]
fn test_group_volume_handle_accessible() {
let state_manager = create_test_state_manager_with_speakers(vec![(
"RINCON_111",
"Living Room",
"192.168.1.100",
)]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111")],
);
let group = Group::from_info(group_info, state_manager, api_client).unwrap();
assert!(group.volume.get().is_none());
assert_eq!(group.volume.group_id().as_str(), "RINCON_111:1");
}
fn create_test_group() -> Group {
let state_manager = create_test_state_manager_with_speakers(vec![(
"RINCON_111",
"Living Room",
"192.168.1.100",
)]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111")],
);
Group::from_info(group_info, state_manager, api_client).unwrap()
}
#[test]
fn test_group_set_volume_rejects_over_100() {
let group = create_test_group();
let result = group.set_volume(150);
assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
}
#[test]
fn test_group_action_methods_exist() {
fn assert_void(_r: Result<(), SdkError>) {}
fn assert_response<T>(_r: Result<T, SdkError>) {}
let group = create_test_group();
assert_void(group.set_volume(50));
assert_response::<SetRelativeGroupVolumeResponse>(group.set_relative_volume(5));
assert_void(group.set_mute(true));
assert_void(group.snapshot_volume());
}
fn create_test_group_with_member() -> (Group, Speaker) {
let state_manager = create_test_state_manager_with_speakers(vec![
("RINCON_111", "Living Room", "192.168.1.100"),
("RINCON_222", "Kitchen", "192.168.1.101"),
]);
let api_client = SonosClient::new();
let group_info = GroupInfo::new(
GroupId::new("RINCON_111:1"),
SpeakerId::new("RINCON_111"),
vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
);
let group =
Group::from_info(group_info, Arc::clone(&state_manager), api_client.clone()).unwrap();
let member = Speaker::new(
SpeakerId::new("RINCON_222"),
"Kitchen".to_string(),
"192.168.1.101".parse().unwrap(),
"Sonos One".to_string(),
state_manager,
api_client,
);
(group, member)
}
#[test]
fn test_add_speaker_rejects_coordinator_self_add() {
let (group, _) = create_test_group_with_member();
let coordinator = group.coordinator().unwrap();
let result = group.add_speaker(&coordinator);
assert!(matches!(result, Err(SdkError::InvalidOperation(_))));
}
#[test]
fn test_remove_speaker_rejects_coordinator_removal() {
let (group, _) = create_test_group_with_member();
let coordinator = group.coordinator().unwrap();
let result = group.remove_speaker(&coordinator);
assert!(matches!(result, Err(SdkError::InvalidOperation(_))));
}
#[test]
fn test_group_lifecycle_methods_exist() {
fn assert_void(_r: Result<(), SdkError>) {}
fn assert_change_result(_r: GroupChangeResult) {}
let (group, member) = create_test_group_with_member();
assert_void(group.add_speaker(&member));
assert_void(group.remove_speaker(&member));
assert_change_result(group.dissolve());
}
#[test]
fn test_dissolve_standalone_returns_empty_result() {
let group = create_test_group();
let result = group.dissolve();
assert!(result.is_success());
assert!(result.succeeded.is_empty());
assert!(result.failed.is_empty());
}
}