use std::fmt;
use std::marker::PhantomData;
use std::net::IpAddr;
use std::sync::Arc;
use sonos_api::operation::{ComposableOperation, UPnPOperation};
use sonos_api::SonosClient;
use sonos_state::{property::SonosProperty, SpeakerId, StateManager};
use crate::SdkError;
#[derive(Clone)]
pub struct SpeakerContext {
pub(crate) speaker_id: SpeakerId,
pub(crate) speaker_ip: IpAddr,
pub(crate) state_manager: Arc<StateManager>,
pub(crate) api_client: SonosClient,
}
impl SpeakerContext {
pub fn new(
speaker_id: SpeakerId,
speaker_ip: IpAddr,
state_manager: Arc<StateManager>,
api_client: SonosClient,
) -> Arc<Self> {
Arc::new(Self {
speaker_id,
speaker_ip,
state_manager,
api_client,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WatchMode {
Events,
Polling,
CacheOnly,
}
impl fmt::Display for WatchMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WatchMode::Events => write!(f, "Events (real-time)"),
WatchMode::Polling => write!(f, "Polling (fallback)"),
WatchMode::CacheOnly => write!(f, "CacheOnly (no events)"),
}
}
}
#[derive(Debug, Clone)]
pub struct WatchStatus<P> {
pub current: Option<P>,
pub mode: WatchMode,
}
impl<P> WatchStatus<P> {
pub fn new(current: Option<P>, mode: WatchMode) -> Self {
Self { current, mode }
}
#[must_use]
pub fn has_realtime_events(&self) -> bool {
self.mode == WatchMode::Events
}
}
pub trait Fetchable: SonosProperty {
type Operation: UPnPOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
}
pub trait FetchableWithContext: SonosProperty {
type Operation: UPnPOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
fn from_response_with_context(
response: <Self::Operation as UPnPOperation>::Response,
speaker_id: &SpeakerId,
) -> Option<Self>;
}
#[derive(Clone)]
pub struct PropertyHandle<P: SonosProperty> {
context: Arc<SpeakerContext>,
_phantom: PhantomData<P>,
}
impl<P: SonosProperty> PropertyHandle<P> {
pub fn new(context: Arc<SpeakerContext>) -> Self {
Self {
context,
_phantom: PhantomData,
}
}
#[must_use = "returns the cached property value"]
pub fn get(&self) -> Option<P> {
self.context
.state_manager
.get_property::<P>(&self.context.speaker_id)
}
#[must_use = "returns watch status including the delivery mode"]
pub fn watch(&self) -> Result<WatchStatus<P>, SdkError> {
self.context
.state_manager
.register_watch(&self.context.speaker_id, P::KEY);
let mode = if let Some(em) = self.context.state_manager.event_manager() {
match em.ensure_service_subscribed(self.context.speaker_ip, P::SERVICE) {
Ok(()) => WatchMode::Events,
Err(e) => {
tracing::warn!(
"Failed to subscribe to {:?} for {}: {} - falling back to polling",
P::SERVICE,
self.context.speaker_id.as_str(),
e
);
WatchMode::Polling
}
}
} else {
WatchMode::CacheOnly
};
Ok(WatchStatus::new(self.get(), mode))
}
pub fn unwatch(&self) {
self.context
.state_manager
.unregister_watch(&self.context.speaker_id, P::KEY);
if let Some(em) = self.context.state_manager.event_manager() {
if let Err(e) = em.release_service_subscription(self.context.speaker_ip, P::SERVICE) {
tracing::warn!(
"Failed to release subscription for {:?} on {}: {}",
P::SERVICE,
self.context.speaker_id.as_str(),
e
);
}
}
}
#[must_use = "returns whether the property is being watched"]
pub fn is_watched(&self) -> bool {
self.context
.state_manager
.is_watched(&self.context.speaker_id, P::KEY)
}
pub fn speaker_id(&self) -> &SpeakerId {
&self.context.speaker_id
}
pub fn speaker_ip(&self) -> IpAddr {
self.context.speaker_ip
}
}
impl<P: Fetchable> PropertyHandle<P> {
#[must_use = "returns the fetched value from the device"]
pub fn fetch(&self) -> Result<P, SdkError> {
let operation = P::build_operation()?;
let response = self
.context
.api_client
.execute_enhanced(&self.context.speaker_ip.to_string(), operation)
.map_err(SdkError::ApiError)?;
let property_value = P::from_response(response);
self.context
.state_manager
.set_property(&self.context.speaker_id, property_value.clone());
Ok(property_value)
}
}
impl PropertyHandle<GroupMembership> {
#[must_use = "returns the fetched value from the device"]
pub fn fetch(&self) -> Result<GroupMembership, SdkError> {
let operation = <GroupMembership as FetchableWithContext>::build_operation()?;
let response = self
.context
.api_client
.execute_enhanced(&self.context.speaker_ip.to_string(), operation)
.map_err(SdkError::ApiError)?;
let property_value =
GroupMembership::from_response_with_context(response, &self.context.speaker_id)
.ok_or_else(|| {
SdkError::FetchFailed(format!(
"Speaker {} not found in topology response",
self.context.speaker_id.as_str()
))
})?;
self.context
.state_manager
.set_property(&self.context.speaker_id, property_value.clone());
Ok(property_value)
}
}
use sonos_api::services::{
av_transport::{
self, GetPositionInfoOperation, GetPositionInfoResponse, GetTransportInfoOperation,
GetTransportInfoResponse,
},
group_rendering_control::{
self, GetGroupMuteOperation, GetGroupMuteResponse, GetGroupVolumeOperation,
GetGroupVolumeResponse,
},
rendering_control::{
self, GetBassOperation, GetBassResponse, GetLoudnessOperation, GetLoudnessResponse,
GetMuteOperation, GetMuteResponse, GetTrebleOperation, GetTrebleResponse,
GetVolumeOperation, GetVolumeResponse,
},
zone_group_topology::{self, GetZoneGroupStateOperation, GetZoneGroupStateResponse},
};
use sonos_state::{
Bass, CurrentTrack, GroupId, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
Loudness, Mute, PlaybackState, Position, Treble, Volume,
};
fn build_error<E: std::fmt::Display>(operation_name: &str, e: E) -> SdkError {
SdkError::FetchFailed(format!("Failed to build {operation_name} operation: {e}"))
}
impl Fetchable for Volume {
type Operation = GetVolumeOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
rendering_control::get_volume_operation("Master".to_string())
.build()
.map_err(|e| build_error("GetVolume", e))
}
fn from_response(response: GetVolumeResponse) -> Self {
Volume::new(response.current_volume)
}
}
impl Fetchable for PlaybackState {
type Operation = GetTransportInfoOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
av_transport::get_transport_info_operation()
.build()
.map_err(|e| build_error("GetTransportInfo", e))
}
fn from_response(response: GetTransportInfoResponse) -> Self {
match response.current_transport_state.as_str() {
"PLAYING" => PlaybackState::Playing,
"PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
"STOPPED" => PlaybackState::Stopped,
_ => PlaybackState::Transitioning,
}
}
}
impl Fetchable for Position {
type Operation = GetPositionInfoOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
av_transport::get_position_info_operation()
.build()
.map_err(|e| build_error("GetPositionInfo", e))
}
fn from_response(response: GetPositionInfoResponse) -> Self {
let position_ms = Position::parse_time_to_ms(&response.rel_time).unwrap_or(0);
let duration_ms = Position::parse_time_to_ms(&response.track_duration).unwrap_or(0);
Position::new(position_ms, duration_ms)
}
}
impl Fetchable for Mute {
type Operation = GetMuteOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
rendering_control::get_mute_operation("Master".to_string())
.build()
.map_err(|e| build_error("GetMute", e))
}
fn from_response(response: GetMuteResponse) -> Self {
Mute::new(response.current_mute)
}
}
impl Fetchable for Bass {
type Operation = GetBassOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
rendering_control::get_bass_operation()
.build()
.map_err(|e| build_error("GetBass", e))
}
fn from_response(response: GetBassResponse) -> Self {
Bass::new(response.current_bass)
}
}
impl Fetchable for Treble {
type Operation = GetTrebleOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
rendering_control::get_treble_operation()
.build()
.map_err(|e| build_error("GetTreble", e))
}
fn from_response(response: GetTrebleResponse) -> Self {
Treble::new(response.current_treble)
}
}
impl Fetchable for Loudness {
type Operation = GetLoudnessOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
rendering_control::get_loudness_operation("Master".to_string())
.build()
.map_err(|e| build_error("GetLoudness", e))
}
fn from_response(response: GetLoudnessResponse) -> Self {
Loudness::new(response.current_loudness)
}
}
impl Fetchable for CurrentTrack {
type Operation = GetPositionInfoOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
av_transport::get_position_info_operation()
.build()
.map_err(|e| build_error("GetPositionInfo", e))
}
fn from_response(response: GetPositionInfoResponse) -> Self {
let metadata = if response.track_meta_data.is_empty()
|| response.track_meta_data == "NOT_IMPLEMENTED"
{
None
} else {
Some(response.track_meta_data.as_str())
};
let (title, artist, album, album_art_uri) = sonos_state::parse_track_metadata(metadata);
CurrentTrack {
title,
artist,
album,
album_art_uri,
uri: Some(response.track_uri).filter(|s| !s.is_empty()),
}
}
}
impl FetchableWithContext for GroupMembership {
type Operation = GetZoneGroupStateOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
zone_group_topology::get_zone_group_state_operation()
.build()
.map_err(|e| build_error("GetZoneGroupState", e))
}
fn from_response_with_context(
response: GetZoneGroupStateResponse,
speaker_id: &SpeakerId,
) -> Option<Self> {
let zone_groups =
zone_group_topology::parse_zone_group_state_xml(&response.zone_group_state).ok()?;
for group in &zone_groups {
let is_member = group.members.iter().any(|m| m.uuid == speaker_id.as_str());
if is_member {
let is_coordinator = group.coordinator == speaker_id.as_str();
return Some(GroupMembership::new(
GroupId::new(&group.id),
is_coordinator,
));
}
}
None
}
}
pub type VolumeHandle = PropertyHandle<Volume>;
pub type PlaybackStateHandle = PropertyHandle<PlaybackState>;
pub type MuteHandle = PropertyHandle<Mute>;
pub type BassHandle = PropertyHandle<Bass>;
pub type TrebleHandle = PropertyHandle<Treble>;
pub type LoudnessHandle = PropertyHandle<Loudness>;
pub type PositionHandle = PropertyHandle<Position>;
pub type CurrentTrackHandle = PropertyHandle<CurrentTrack>;
pub type GroupMembershipHandle = PropertyHandle<GroupMembership>;
#[derive(Clone)]
pub struct GroupContext {
pub(crate) group_id: GroupId,
pub(crate) coordinator_id: SpeakerId,
pub(crate) coordinator_ip: IpAddr,
pub(crate) state_manager: Arc<StateManager>,
pub(crate) api_client: SonosClient,
}
impl GroupContext {
pub fn new(
group_id: GroupId,
coordinator_id: SpeakerId,
coordinator_ip: IpAddr,
state_manager: Arc<StateManager>,
api_client: SonosClient,
) -> Arc<Self> {
Arc::new(Self {
group_id,
coordinator_id,
coordinator_ip,
state_manager,
api_client,
})
}
}
#[derive(Clone)]
pub struct GroupPropertyHandle<P: SonosProperty> {
context: Arc<GroupContext>,
_phantom: PhantomData<P>,
}
impl<P: SonosProperty> GroupPropertyHandle<P> {
pub fn new(context: Arc<GroupContext>) -> Self {
Self {
context,
_phantom: PhantomData,
}
}
#[must_use = "returns the cached property value"]
pub fn get(&self) -> Option<P> {
self.context
.state_manager
.get_group_property::<P>(&self.context.group_id)
}
#[must_use = "returns watch status including the delivery mode"]
pub fn watch(&self) -> Result<WatchStatus<P>, SdkError> {
self.context
.state_manager
.register_watch(&self.context.coordinator_id, P::KEY);
let mode = if let Some(em) = self.context.state_manager.event_manager() {
match em.ensure_service_subscribed(self.context.coordinator_ip, P::SERVICE) {
Ok(()) => WatchMode::Events,
Err(e) => {
tracing::warn!(
"Failed to subscribe to {:?} for group {}: {} - falling back to polling",
P::SERVICE,
self.context.group_id.as_str(),
e
);
WatchMode::Polling
}
}
} else {
WatchMode::CacheOnly
};
Ok(WatchStatus::new(self.get(), mode))
}
pub fn unwatch(&self) {
self.context
.state_manager
.unregister_watch(&self.context.coordinator_id, P::KEY);
if let Some(em) = self.context.state_manager.event_manager() {
if let Err(e) = em.release_service_subscription(self.context.coordinator_ip, P::SERVICE)
{
tracing::warn!(
"Failed to release subscription for {:?} on group {}: {}",
P::SERVICE,
self.context.group_id.as_str(),
e
);
}
}
}
#[must_use = "returns whether the property is being watched"]
pub fn is_watched(&self) -> bool {
self.context
.state_manager
.is_watched(&self.context.coordinator_id, P::KEY)
}
pub fn group_id(&self) -> &GroupId {
&self.context.group_id
}
}
pub trait GroupFetchable: SonosProperty {
type Operation: UPnPOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
}
impl<P: GroupFetchable> GroupPropertyHandle<P> {
#[must_use = "returns the fetched value from the device"]
pub fn fetch(&self) -> Result<P, SdkError> {
let operation = P::build_operation()?;
let response = self
.context
.api_client
.execute_enhanced(&self.context.coordinator_ip.to_string(), operation)
.map_err(SdkError::ApiError)?;
let property_value = P::from_response(response);
self.context
.state_manager
.set_group_property(&self.context.group_id, property_value.clone());
Ok(property_value)
}
}
impl GroupFetchable for GroupVolume {
type Operation = GetGroupVolumeOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
group_rendering_control::get_group_volume()
.build()
.map_err(|e| build_error("GetGroupVolume", e))
}
fn from_response(response: GetGroupVolumeResponse) -> Self {
GroupVolume::new(response.current_volume)
}
}
impl GroupFetchable for GroupMute {
type Operation = GetGroupMuteOperation;
fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
group_rendering_control::get_group_mute()
.build()
.map_err(|e| build_error("GetGroupMute", e))
}
fn from_response(response: GetGroupMuteResponse) -> Self {
GroupMute::new(response.current_mute)
}
}
pub type GroupVolumeHandle = GroupPropertyHandle<GroupVolume>;
pub type GroupMuteHandle = GroupPropertyHandle<GroupMute>;
pub type GroupVolumeChangeableHandle = GroupPropertyHandle<GroupVolumeChangeable>;
#[cfg(test)]
mod tests {
use super::*;
use sonos_discovery::Device;
fn create_test_state_manager() -> Arc<StateManager> {
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();
Arc::new(manager)
}
fn create_test_context(state_manager: Arc<StateManager>) -> Arc<SpeakerContext> {
SpeakerContext::new(
SpeakerId::new("RINCON_TEST123"),
"192.168.1.100".parse().unwrap(),
state_manager,
SonosClient::new(),
)
}
#[test]
fn test_property_handle_creation() {
let state_manager = create_test_state_manager();
let context = create_test_context(state_manager);
let speaker_ip: IpAddr = "192.168.1.100".parse().unwrap();
let handle: VolumeHandle = PropertyHandle::new(context);
assert_eq!(handle.speaker_id().as_str(), "RINCON_TEST123");
assert_eq!(handle.speaker_ip(), speaker_ip);
}
#[test]
fn test_get_returns_none_initially() {
let state_manager = create_test_state_manager();
let context = create_test_context(state_manager);
let handle: VolumeHandle = PropertyHandle::new(context);
assert!(handle.get().is_none());
}
#[test]
fn test_get_returns_cached_value() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
state_manager.set_property(&speaker_id, Volume::new(75));
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
assert_eq!(handle.get(), Some(Volume::new(75)));
}
#[test]
fn test_watch_registers_property() {
let state_manager = create_test_state_manager();
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
assert!(!handle.is_watched());
handle.watch().unwrap();
assert!(handle.is_watched());
}
#[test]
fn test_unwatch_unregisters_property() {
let state_manager = create_test_state_manager();
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
handle.watch().unwrap();
assert!(handle.is_watched());
handle.unwatch();
assert!(!handle.is_watched());
}
#[test]
fn test_watch_returns_current_value() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
state_manager.set_property(&speaker_id, Volume::new(50));
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
let status = handle.watch().unwrap();
assert_eq!(status.current, Some(Volume::new(50)));
assert_eq!(status.mode, WatchMode::CacheOnly);
}
#[test]
fn test_property_handle_clone() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
state_manager.set_property(&speaker_id, Volume::new(60));
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
let cloned = handle.clone();
assert_eq!(handle.get(), cloned.get());
assert_eq!(handle.get(), Some(Volume::new(60)));
}
fn create_test_group_context(state_manager: Arc<StateManager>) -> Arc<GroupContext> {
GroupContext::new(
GroupId::new("RINCON_TEST123:1"),
SpeakerId::new("RINCON_TEST123"),
"192.168.1.100".parse().unwrap(),
state_manager,
SonosClient::new(),
)
}
#[test]
fn test_group_property_handle_get_returns_none_initially() {
let state_manager = create_test_state_manager();
let context = create_test_group_context(state_manager);
let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
assert!(handle.get().is_none());
}
#[test]
fn test_group_property_handle_get_returns_cached_value() {
let state_manager = create_test_state_manager();
let group_id = GroupId::new("RINCON_TEST123:1");
state_manager.set_group_property(&group_id, GroupVolume::new(65));
let context = create_test_group_context(Arc::clone(&state_manager));
let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
assert_eq!(handle.get(), Some(GroupVolume::new(65)));
}
#[test]
fn test_group_property_handle_watch_unwatch() {
let state_manager = create_test_state_manager();
let context = create_test_group_context(Arc::clone(&state_manager));
let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
assert!(!handle.is_watched());
handle.watch().unwrap();
assert!(handle.is_watched());
handle.unwatch();
assert!(!handle.is_watched());
}
#[test]
fn test_group_property_handle_group_id() {
let state_manager = create_test_state_manager();
let context = create_test_group_context(state_manager);
let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
}
#[test]
fn test_group_mute_handle_accessible() {
let state_manager = create_test_state_manager();
let context = create_test_group_context(state_manager);
let handle: GroupMuteHandle = GroupPropertyHandle::new(context);
assert!(handle.get().is_none());
assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
}
#[test]
fn test_group_volume_changeable_handle_accessible() {
let state_manager = create_test_state_manager();
let context = create_test_group_context(state_manager);
let handle: GroupVolumeChangeableHandle = GroupPropertyHandle::new(context);
assert!(handle.get().is_none());
assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
}
#[test]
fn test_fetchable_impls_exist() {
fn assert_fetchable<T: Fetchable>() {}
assert_fetchable::<Volume>();
assert_fetchable::<PlaybackState>();
assert_fetchable::<Position>();
assert_fetchable::<Mute>();
assert_fetchable::<Bass>();
assert_fetchable::<Treble>();
assert_fetchable::<Loudness>();
assert_fetchable::<CurrentTrack>();
}
#[test]
fn test_fetchable_with_context_impls_exist() {
fn assert_fetchable_with_context<T: FetchableWithContext>() {}
assert_fetchable_with_context::<GroupMembership>();
}
#[test]
fn test_group_fetchable_impls_exist() {
fn assert_group_fetchable<T: GroupFetchable>() {}
assert_group_fetchable::<GroupVolume>();
assert_group_fetchable::<GroupMute>();
}
}