use std::fmt;
use std::marker::PhantomData;
use std::net::IpAddr;
use std::sync::Arc;
use std::time::Instant;
use sonos_api::operation::{ComposableOperation, UPnPOperation};
use sonos_api::{ServiceScope, SonosClient};
use sonos_event_manager::WatchGuard;
use sonos_state::{property::SonosProperty, ChangeSource, SpeakerId, StateManager, WriteStamp};
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)"),
}
}
}
#[must_use = "dropping the handle starts the grace period — hold it to keep the subscription alive"]
pub struct WatchHandle<P> {
read: Box<dyn Fn() -> Option<P> + Send + Sync>,
mode: WatchMode,
_cleanup: WatchCleanup,
}
impl<P> WatchHandle<P> {
pub fn mode(&self) -> WatchMode {
self.mode
}
pub fn value(&self) -> Option<P> {
(self.read)()
}
pub fn has_value(&self) -> bool {
self.value().is_some()
}
pub fn has_realtime_events(&self) -> bool {
self.mode == WatchMode::Events
}
}
impl<P: fmt::Debug> fmt::Debug for WatchHandle<P> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("WatchHandle")
.field("value", &self.value())
.field("mode", &self.mode)
.finish()
}
}
#[allow(dead_code)]
enum WatchCleanup {
Guard(WatchGuard),
CacheOnly(CacheOnlyGuard),
CoordinatorGuard {
_guard: WatchGuard,
_member_cleanup: CacheOnlyGuard,
},
}
struct CacheOnlyGuard {
state_manager: Arc<StateManager>,
speaker_id: SpeakerId,
property_key: &'static str,
}
impl Drop for CacheOnlyGuard {
fn drop(&mut self) {
self.state_manager
.unregister_watch(&self.speaker_id, self.property_key);
}
}
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)
}
pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
tracing::trace!(
"watch() called for {:?} on {}",
P::SERVICE,
self.context.speaker_id.as_str()
);
if self.context.state_manager.event_manager().is_none() {
if let Some(init) = self.context.state_manager.event_init() {
tracing::debug!(
"Event manager not initialized, triggering lazy init for {:?} on {}",
P::SERVICE,
self.context.speaker_id.as_str()
);
init().map_err(|e| SdkError::EventManager(e.to_string()))?;
} else {
tracing::debug!(
"No event_init closure available (test mode?) for {}",
self.context.speaker_id.as_str()
);
}
}
let (sub_id, sub_ip) = self.context.state_manager.resolve_subscription_target(
&self.context.speaker_id,
self.context.speaker_ip,
P::SERVICE,
);
let routed_to_coordinator = sub_id != self.context.speaker_id;
let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
match em.acquire_watch(&sub_id, P::KEY, sub_ip, P::SERVICE) {
Ok(guard) => {
if routed_to_coordinator {
self.context
.state_manager
.register_watch(&self.context.speaker_id, P::KEY);
(
WatchMode::Events,
WatchCleanup::CoordinatorGuard {
_guard: guard,
_member_cleanup: CacheOnlyGuard {
state_manager: Arc::clone(&self.context.state_manager),
speaker_id: self.context.speaker_id.clone(),
property_key: P::KEY,
},
},
)
} else {
(WatchMode::Events, WatchCleanup::Guard(guard))
}
}
Err(e) => {
tracing::warn!(
"Failed to subscribe to {:?} for {}: {} - falling back to polling",
P::SERVICE,
self.context.speaker_id.as_str(),
e
);
self.context
.state_manager
.register_watch(&self.context.speaker_id, P::KEY);
(
WatchMode::Polling,
WatchCleanup::CacheOnly(CacheOnlyGuard {
state_manager: Arc::clone(&self.context.state_manager),
speaker_id: self.context.speaker_id.clone(),
property_key: P::KEY,
}),
)
}
}
} else {
tracing::warn!(
"No event manager available for {} — falling back to cache-only mode",
self.context.speaker_id.as_str()
);
self.context
.state_manager
.register_watch(&self.context.speaker_id, P::KEY);
(
WatchMode::CacheOnly,
WatchCleanup::CacheOnly(CacheOnlyGuard {
state_manager: Arc::clone(&self.context.state_manager),
speaker_id: self.context.speaker_id.clone(),
property_key: P::KEY,
}),
)
};
tracing::debug!(
"watch() resolved to {:?} for {} on {}",
mode,
P::KEY,
self.context.speaker_id.as_str()
);
let context = Arc::clone(&self.context);
Ok(WatchHandle {
read: Box::new(move || context.state_manager.get_property::<P>(&context.speaker_id)),
mode,
_cleanup: cleanup,
})
}
#[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> {
pub fn watch_or_fetch(&self) -> Result<WatchHandle<P>, SdkError> {
let wh = self.watch()?;
if !wh.has_value() {
if let Err(e) = self.fetch() {
tracing::warn!("watch_or_fetch: fetch failed for {}: {e}", P::KEY);
}
}
Ok(wh)
}
#[must_use = "returns the fetched value from the device"]
pub fn fetch(&self) -> Result<P, SdkError> {
let operation = P::build_operation()?;
let (target_id, target_ip) = if P::SERVICE.scope() == ServiceScope::PerCoordinator {
self.context.state_manager.resolve_subscription_target(
&self.context.speaker_id,
self.context.speaker_ip,
P::SERVICE,
)
} else {
let current_ip = self
.context
.state_manager
.get_speaker_ip(&self.context.speaker_id)
.unwrap_or(self.context.speaker_ip);
(self.context.speaker_id.clone(), current_ip)
};
let observed_at = Instant::now();
let response = self
.context
.api_client
.execute_enhanced(&target_ip.to_string(), operation)
.map_err(SdkError::ApiError)?;
let property_value = P::from_response(response);
self.context.state_manager.set_property_stamped(
&target_id,
property_value.clone(),
WriteStamp::observed_at(ChangeSource::Fetch, observed_at),
);
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 observed_at = Instant::now();
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_stamped(
&self.context.speaker_id,
property_value.clone(),
WriteStamp::observed_at(ChangeSource::Fetch, observed_at),
);
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)
}
pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
if self.context.state_manager.event_manager().is_none() {
if let Some(init) = self.context.state_manager.event_init() {
tracing::debug!(
"Event manager not initialized, triggering lazy init for group {:?} on {}",
P::SERVICE,
self.context.group_id.as_str()
);
init().map_err(|e| SdkError::EventManager(e.to_string()))?;
} else {
tracing::debug!(
"No event_init closure available (test mode?) for group {}",
self.context.group_id.as_str()
);
}
}
let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
match em.acquire_watch(
&self.context.coordinator_id,
P::KEY,
self.context.coordinator_ip,
P::SERVICE,
) {
Ok(guard) => (WatchMode::Events, WatchCleanup::Guard(guard)),
Err(e) => {
tracing::warn!(
"Failed to subscribe to {:?} for group {}: {} - falling back to polling",
P::SERVICE,
self.context.group_id.as_str(),
e
);
self.context
.state_manager
.register_watch(&self.context.coordinator_id, P::KEY);
(
WatchMode::Polling,
WatchCleanup::CacheOnly(CacheOnlyGuard {
state_manager: Arc::clone(&self.context.state_manager),
speaker_id: self.context.coordinator_id.clone(),
property_key: P::KEY,
}),
)
}
}
} else {
self.context
.state_manager
.register_watch(&self.context.coordinator_id, P::KEY);
(
WatchMode::CacheOnly,
WatchCleanup::CacheOnly(CacheOnlyGuard {
state_manager: Arc::clone(&self.context.state_manager),
speaker_id: self.context.coordinator_id.clone(),
property_key: P::KEY,
}),
)
};
let context = Arc::clone(&self.context);
Ok(WatchHandle {
read: Box::new(move || {
context
.state_manager
.get_group_property::<P>(&context.group_id)
}),
mode,
_cleanup: cleanup,
})
}
#[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> {
pub fn watch_or_fetch(&self) -> Result<WatchHandle<P>, SdkError> {
let wh = self.watch()?;
if !wh.has_value() {
if let Err(e) = self.fetch() {
tracing::warn!(
"watch_or_fetch: fetch failed for group {} {}: {e}",
self.context.group_id.as_str(),
P::KEY
);
}
}
Ok(wh)
}
#[must_use = "returns the fetched value from the device"]
pub fn fetch(&self) -> Result<P, SdkError> {
let operation = P::build_operation()?;
let observed_at = Instant::now();
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_stamped(
&self.context.group_id,
property_value.clone(),
WriteStamp::observed_at(ChangeSource::Fetch, observed_at),
);
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;
use sonos_state::Property;
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());
let _wh = handle.watch().unwrap();
assert!(handle.is_watched());
}
#[test]
fn test_drop_watch_handle_unregisters_property() {
let state_manager = create_test_state_manager();
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
let wh = handle.watch().unwrap();
assert!(handle.is_watched());
drop(wh);
assert!(!handle.is_watched());
}
#[test]
fn test_dropping_one_of_two_handles_keeps_property_emitting() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
let context = create_test_context(Arc::clone(&state_manager));
let volume: VolumeHandle = PropertyHandle::new(Arc::clone(&context));
let mute: MuteHandle = PropertyHandle::new(context);
let first = volume.watch().unwrap();
let second = volume.watch().unwrap();
let _mute_watch = mute.watch().unwrap();
assert!(volume.is_watched());
assert!(mute.is_watched());
drop(first);
assert!(
volume.is_watched(),
"one of two Volume handles dropped — the property must stay watched"
);
assert!(
mute.is_watched(),
"releasing a Volume handle must not disturb its RenderingControl sibling"
);
let iter = state_manager.iter();
state_manager.set_property(&speaker_id, Volume::new(11));
state_manager.set_property(&speaker_id, Mute::new(true));
let first_event = iter
.recv_timeout(std::time::Duration::from_millis(100))
.expect("Volume is still held by `second` and must still emit");
assert_eq!(first_event.property_key(), Volume::KEY);
let second_event = iter
.recv_timeout(std::time::Duration::from_millis(100))
.expect("Mute is still held and must still emit");
assert_eq!(second_event.property_key(), Mute::KEY);
drop(second);
assert!(!volume.is_watched());
state_manager.set_property(&speaker_id, Volume::new(22));
assert!(
iter.recv_timeout(std::time::Duration::from_millis(50))
.is_none(),
"with every Volume handle dropped the property must stop emitting"
);
}
#[test]
fn test_sdk_change_event_carries_value_and_source() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
let _wh = handle.watch().unwrap();
let iter = state_manager.iter();
state_manager.set_property(&speaker_id, Volume::new(37));
let event = iter
.recv_timeout(std::time::Duration::from_millis(100))
.expect("a watched property write must emit");
assert!(
matches!(
event.change,
sonos_state::PropertyChange::Volume(Volume(37))
),
"the event must carry the written value, got {:?}",
event.change
);
assert_eq!(
event.source,
ChangeSource::LocalAction,
"`set_property` is a local write, not a device report"
);
}
#[test]
fn test_handle_held_across_change_reports_new_value() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
state_manager.set_property(&speaker_id, Volume::new(10));
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
let wh = handle.watch().unwrap();
assert_eq!(wh.value(), Some(Volume::new(10)));
state_manager.set_property(&speaker_id, Volume::new(42));
assert_eq!(
wh.value(),
Some(Volume::new(42)),
"the handle froze its value at creation — a held handle must read live"
);
state_manager.set_property(&speaker_id, Volume::new(43));
assert_eq!(
wh.value(),
Some(Volume::new(43)),
"the handle must keep tracking, not refresh once"
);
}
#[test]
fn test_handle_acquired_before_first_value_becomes_populated() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(context);
let wh = handle.watch().unwrap();
assert!(!wh.has_value(), "nothing has been observed yet");
assert_eq!(wh.value(), None);
state_manager.set_property(&speaker_id, Volume::new(7));
assert!(
wh.has_value(),
"has_value() froze at creation — it must reflect the store"
);
assert_eq!(wh.value(), Some(Volume::new(7)));
}
#[test]
fn test_all_handles_see_update_and_survive_a_sibling_drop() {
let state_manager = create_test_state_manager();
let speaker_id = SpeakerId::new("RINCON_TEST123");
let context = create_test_context(Arc::clone(&state_manager));
let handle: VolumeHandle = PropertyHandle::new(Arc::clone(&context));
let first = handle.watch().unwrap();
let second = handle.watch().unwrap();
let third = handle.watch().unwrap();
let iter = state_manager.iter();
state_manager.set_property(&speaker_id, Volume::new(31));
assert_eq!(first.value(), Some(Volume::new(31)));
assert_eq!(second.value(), Some(Volume::new(31)));
assert_eq!(third.value(), Some(Volume::new(31)));
assert!(iter
.recv_timeout(std::time::Duration::from_millis(100))
.is_some());
drop(first);
assert!(handle.is_watched(), "two handles still hold the property");
state_manager.set_property(&speaker_id, Volume::new(32));
assert_eq!(
second.value(),
Some(Volume::new(32)),
"a sibling handle dropping must not freeze the survivors"
);
assert_eq!(third.value(), Some(Volume::new(32)));
assert!(
iter.recv_timeout(std::time::Duration::from_millis(100))
.is_some(),
"the property is still held and must still emit"
);
drop(second);
state_manager.set_property(&speaker_id, Volume::new(33));
assert_eq!(
third.value(),
Some(Volume::new(33)),
"the last handle must still read live"
);
}
#[test]
fn test_handle_reads_none_when_value_becomes_unreachable() {
let manager = StateManager::new().unwrap();
manager
.add_devices(vec![
Device {
id: "RINCON_MEMBER".to_string(),
name: "Member".to_string(),
room_name: "Member".to_string(),
ip_address: "203.0.113.1".to_string(),
port: 1400,
model_name: "Sonos One".to_string(),
},
Device {
id: "RINCON_NEWCOORD".to_string(),
name: "New Coordinator".to_string(),
room_name: "New Coordinator".to_string(),
ip_address: "203.0.113.2".to_string(),
port: 1400,
model_name: "Sonos One".to_string(),
},
])
.unwrap();
let state_manager = Arc::new(manager);
let member = SpeakerId::new("RINCON_MEMBER");
let new_coord = SpeakerId::new("RINCON_NEWCOORD");
state_manager.set_property(&member, PlaybackState::Playing);
let context = SpeakerContext::new(
member.clone(),
"203.0.113.1".parse().unwrap(),
Arc::clone(&state_manager),
SonosClient::new(),
);
let handle: PlaybackStateHandle = PropertyHandle::new(context);
let wh = handle.watch().unwrap();
assert_eq!(wh.value(), Some(PlaybackState::Playing));
state_manager.initialize(sonos_state::Topology {
speakers: vec![],
groups: vec![sonos_state::GroupInfo::new(
GroupId::new("RINCON_NEWCOORD:1"),
new_coord.clone(),
vec![new_coord.clone(), member.clone()],
)],
});
assert_eq!(
wh.value(),
None,
"the coordinator holds no PlaybackState, so the answer is unknown — \
a handle must not report the value it captured at creation"
);
state_manager.set_property(&new_coord, PlaybackState::Paused);
assert_eq!(wh.value(), Some(PlaybackState::Paused));
}
#[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 wh = handle.watch().unwrap();
assert_eq!(wh.value(), Some(Volume::new(50)));
assert_eq!(wh.mode(), WatchMode::CacheOnly);
}
#[test]
fn test_watch_handle_accessors() {
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);
let wh = handle.watch().unwrap();
assert!(wh.has_value());
assert!(!wh.has_realtime_events());
assert_eq!(wh.value().map(|v| v.value()), Some(75));
}
#[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_and_drop() {
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());
let wh = handle.watch().unwrap();
assert!(handle.is_watched());
drop(wh);
assert!(!handle.is_watched());
}
#[test]
fn test_group_handle_held_across_change_reports_new_value() {
let state_manager = create_test_state_manager();
let group_id = GroupId::new("RINCON_TEST123:1");
let context = create_test_group_context(Arc::clone(&state_manager));
let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
let wh = handle.watch().unwrap();
assert!(!wh.has_value(), "the group store is empty at this point");
state_manager.set_group_property(&group_id, GroupVolume::new(20));
assert_eq!(
wh.value(),
Some(GroupVolume::new(20)),
"a group handle must read the group store live, not a snapshot"
);
state_manager.set_group_property(&group_id, GroupVolume::new(21));
assert_eq!(wh.value(), Some(GroupVolume::new(21)));
}
#[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>();
}
}