Skip to main content

sonos_sdk/property/
handles.rs

1//! Generic PropertyHandle for DOM-like property access
2//!
3//! Provides a consistent pattern for accessing any property on a speaker:
4//! - `get()` - Get cached value (instant, no network)
5//! - `fetch()` - Fetch fresh value from device (blocking API call)
6//! - `watch()` - Returns a `WatchHandle` that keeps the subscription alive
7
8use std::fmt;
9use std::marker::PhantomData;
10use std::net::IpAddr;
11use std::sync::Arc;
12use std::time::Instant;
13
14use sonos_api::operation::{ComposableOperation, UPnPOperation};
15use sonos_api::{ServiceScope, SonosClient};
16use sonos_event_manager::WatchGuard;
17use sonos_state::{property::SonosProperty, ChangeSource, SpeakerId, StateManager, WriteStamp};
18
19use crate::SdkError;
20
21/// Shared context for all property handles on a speaker
22///
23/// This struct holds the common data needed by all PropertyHandles,
24/// allowing them to share a single Arc instead of duplicating data.
25#[derive(Clone)]
26pub struct SpeakerContext {
27    pub(crate) speaker_id: SpeakerId,
28    pub(crate) speaker_ip: IpAddr,
29    pub(crate) state_manager: Arc<StateManager>,
30    pub(crate) api_client: SonosClient,
31}
32
33impl SpeakerContext {
34    /// Create a new SpeakerContext
35    pub fn new(
36        speaker_id: SpeakerId,
37        speaker_ip: IpAddr,
38        state_manager: Arc<StateManager>,
39        api_client: SonosClient,
40    ) -> Arc<Self> {
41        Arc::new(Self {
42            speaker_id,
43            speaker_ip,
44            state_manager,
45            api_client,
46        })
47    }
48}
49
50// ============================================================================
51// Watch status types
52// ============================================================================
53
54/// How property updates will be delivered after calling `watch()`
55///
56/// This enum indicates the mechanism that will be used to receive property
57/// updates. The SDK automatically selects the best available method.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum WatchMode {
60    /// UPnP event subscription is active - real-time updates will be received
61    ///
62    /// This is the preferred mode, providing immediate notifications when
63    /// properties change on the device.
64    Events,
65
66    /// UPnP subscription failed, updates may come via polling fallback
67    ///
68    /// The event manager was configured but subscription failed (possibly due
69    /// to firewall). The SDK's polling fallback may still provide updates,
70    /// but they won't be real-time.
71    Polling,
72
73    /// No event manager configured - cache-only mode
74    ///
75    /// Properties will only update when explicitly fetched via `fetch()`.
76    /// Call `system.configure_events()` to enable automatic updates.
77    CacheOnly,
78}
79
80impl fmt::Display for WatchMode {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            WatchMode::Events => write!(f, "Events (real-time)"),
84            WatchMode::Polling => write!(f, "Polling (fallback)"),
85            WatchMode::CacheOnly => write!(f, "CacheOnly (no events)"),
86        }
87    }
88}
89
90/// RAII handle returned by `watch()`. Holds a subscription lease and reads the
91/// property live from the state store. Dropping the handle starts the grace
92/// period — the UPnP subscription persists for 50ms so it can be reacquired
93/// cheaply on the next frame.
94///
95/// Not `Clone` — each handle is one subscription hold.
96///
97/// # The value is live, not a snapshot
98///
99/// [`Self::value`] reads the store on every call, so a handle acquired once and
100/// held across many events keeps returning the *current* value. There is no need
101/// to re-`watch()` to refresh it; a handle is a lease on the subscription, and
102/// reading through it is the same read `get()` performs.
103///
104/// The value is therefore returned by clone rather than by reference: the store
105/// sits behind an `RwLock` shared with the event worker, and handing out a
106/// borrow into it would either hold that lock for the handle's whole lifetime or
107/// alias a value the worker is free to replace. `P` is a small `Clone` property
108/// (a `u8`, a `bool`, a few `String`s at worst), so the copy is cheaper than the
109/// lock it would otherwise pin.
110///
111/// # Example
112///
113/// ```rust,ignore
114/// // Watch returns a handle — hold it to keep the subscription alive
115/// let volume = speaker.volume.watch()?;
116///
117/// if let Some(v) = volume.value() {
118///     println!("Volume: {}%", v.value());
119/// }
120///
121/// // Hold the same handle across events — value() re-reads each time
122/// for _event in system.iter() {
123///     println!("Volume now: {:?}", volume.value());
124/// }
125///
126/// // Dropping the handle starts the 50ms grace period
127/// drop(volume);
128/// ```
129#[must_use = "dropping the handle starts the grace period — hold it to keep the subscription alive"]
130pub struct WatchHandle<P> {
131    /// Reads the property from the store on demand.
132    ///
133    /// A closure rather than a `SpeakerContext`/`GroupContext` pair because the
134    /// two `watch()` implementations read from different stores (`get_property`
135    /// vs `get_group_property`) and resolve different keys. Capturing the read
136    /// itself keeps `WatchHandle` unaware of which one it came from, so the two
137    /// construction sites cannot drift into two different notions of "current".
138    read: Box<dyn Fn() -> Option<P> + Send + Sync>,
139    mode: WatchMode,
140    _cleanup: WatchCleanup,
141}
142
143impl<P> WatchHandle<P> {
144    /// Returns the watch mode (Events, Polling, or CacheOnly).
145    pub fn mode(&self) -> WatchMode {
146        self.mode
147    }
148
149    /// Returns the property's current value, read live from the state store.
150    ///
151    /// Costs one read-lock acquisition plus a clone of `P` — not free, unlike
152    /// the frozen field this replaced, but it is the same cost as `get()` and it
153    /// is what makes the handle a live view instead of a stale snapshot.
154    ///
155    /// Returns `None` if no value has been observed yet, or if the value has
156    /// since become unreachable (a speaker that left the topology, or a
157    /// `PerCoordinator` property whose group was dissolved).
158    pub fn value(&self) -> Option<P> {
159        (self.read)()
160    }
161
162    /// Returns true if a value is currently available from the store.
163    ///
164    /// Also a live read: this can go from `false` to `true` as the first event
165    /// arrives, without the handle being re-acquired.
166    pub fn has_value(&self) -> bool {
167        self.value().is_some()
168    }
169
170    /// Returns true if real-time UPnP events are active.
171    pub fn has_realtime_events(&self) -> bool {
172        self.mode == WatchMode::Events
173    }
174}
175
176impl<P: fmt::Debug> fmt::Debug for WatchHandle<P> {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.debug_struct("WatchHandle")
179            .field("value", &self.value())
180            .field("mode", &self.mode)
181            .finish()
182    }
183}
184
185/// Internal cleanup strategy for WatchHandle.
186///
187/// - `Guard`: Event manager is active — WatchGuard handles the subscription
188///   lifecycle (ref counting, grace period, unsubscribe).
189/// - `CacheOnly`: No event manager — just unregisters from the watched set.
190/// - `CoordinatorGuard`: PerCoordinator service routed to coordinator —
191///   WatchGuard manages the coordinator's subscription, CacheOnlyGuard cleans
192///   up the member's watched-set entry on drop.
193///
194/// Fields are never read — they exist solely for their Drop behavior.
195#[allow(dead_code)]
196enum WatchCleanup {
197    Guard(WatchGuard),
198    CacheOnly(CacheOnlyGuard),
199    CoordinatorGuard {
200        _guard: WatchGuard,
201        _member_cleanup: CacheOnlyGuard,
202    },
203}
204
205/// Cleanup guard for CacheOnly mode (no event manager).
206///
207/// Holds one reference on `(speaker_id, property_key)` in the state manager's
208/// watched set and releases it on drop. Because the set is reference-counted,
209/// dropping this guard only stops emission if no other watcher still holds the
210/// same pair — several `WatchHandle`s for one property can coexist, and one
211/// going away must not silence the others.
212struct CacheOnlyGuard {
213    state_manager: Arc<StateManager>,
214    speaker_id: SpeakerId,
215    property_key: &'static str,
216}
217
218impl Drop for CacheOnlyGuard {
219    fn drop(&mut self) {
220        // Releases exactly the one reference taken by the matching
221        // `register_watch()` above.
222        self.state_manager
223            .unregister_watch(&self.speaker_id, self.property_key);
224    }
225}
226
227/// Trait for properties that can be fetched from the device
228///
229/// This trait defines how to fetch a property value from a Sonos device.
230/// Each property type that supports fetching must implement this trait.
231///
232/// # Type Parameters
233///
234/// - `Op`: The UPnP operation type used to fetch this property
235///
236/// # Example
237///
238/// ```rust,ignore
239/// impl Fetchable for Volume {
240///     type Operation = GetVolumeOperation;
241///
242///     fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
243///         rendering_control::get_volume_operation("Master".to_string())
244///             .build()
245///             .map_err(|e| SdkError::FetchFailed(e.to_string()))
246///     }
247///
248///     fn from_response(response: GetVolumeResponse) -> Self {
249///         Volume::new(response.current_volume)
250///     }
251/// }
252/// ```
253pub trait Fetchable: SonosProperty {
254    /// The UPnP operation type used to fetch this property
255    type Operation: UPnPOperation;
256
257    /// Build the operation to fetch this property
258    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
259
260    /// Convert the operation response to the property value
261    fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
262}
263
264/// Trait for properties that require context (e.g., speaker_id) to interpret the response
265///
266/// Unlike `Fetchable`, the response contains data for multiple entities and
267/// the correct one must be extracted using context.
268pub trait FetchableWithContext: SonosProperty {
269    /// The UPnP operation type used to fetch this property
270    type Operation: UPnPOperation;
271
272    /// Build the operation to fetch this property
273    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
274
275    /// Convert the operation response to the property value using speaker context
276    fn from_response_with_context(
277        response: <Self::Operation as UPnPOperation>::Response,
278        speaker_id: &SpeakerId,
279    ) -> Option<Self>;
280}
281
282/// Generic property handle providing get/fetch/watch/unwatch pattern
283///
284/// This is the core abstraction for the DOM-like API. Each property on a Speaker
285/// is accessed through a PropertyHandle that provides consistent methods for
286/// reading cached values, fetching fresh values, and watching for changes.
287///
288/// # Type Parameter
289///
290/// - `P`: The property type, must implement `SonosProperty`
291///
292/// # Example
293///
294/// ```rust,ignore
295/// // Get cached value (instant, no network call)
296/// let volume = speaker.volume.get();
297///
298/// // Fetch fresh value from device (blocking API call)
299/// let fresh_volume = speaker.volume.fetch()?;
300///
301/// // Watch for changes — hold the handle to keep the subscription alive
302/// let handle = speaker.volume.watch()?;
303/// println!("Volume: {:?}", handle.value());
304/// // Dropping handle starts 50ms grace period
305/// ```
306#[derive(Clone)]
307pub struct PropertyHandle<P: SonosProperty> {
308    context: Arc<SpeakerContext>,
309    _phantom: PhantomData<P>,
310}
311
312impl<P: SonosProperty> PropertyHandle<P> {
313    /// Create a new PropertyHandle from a shared SpeakerContext
314    pub fn new(context: Arc<SpeakerContext>) -> Self {
315        Self {
316            context,
317            _phantom: PhantomData,
318        }
319    }
320
321    /// Get cached property value (sync, instant, no network call)
322    ///
323    /// Returns the currently cached value for this property, or `None` if
324    /// no value has been cached yet. This method never makes network calls.
325    ///
326    /// # Example
327    ///
328    /// ```rust,ignore
329    /// if let Some(volume) = speaker.volume.get() {
330    ///     println!("Current volume: {}%", volume.value());
331    /// }
332    /// ```
333    #[must_use = "returns the cached property value"]
334    pub fn get(&self) -> Option<P> {
335        self.context
336            .state_manager
337            .get_property::<P>(&self.context.speaker_id)
338    }
339
340    /// Start watching this property for changes (sync)
341    ///
342    /// Returns a [`WatchHandle`] that keeps the subscription alive. Hold
343    /// the handle for as long as you need updates — dropping it starts a
344    /// 50ms grace period before the UPnP subscription is torn down.
345    ///
346    /// Acquire the handle **once** and keep it: [`WatchHandle::value`] reads the
347    /// store on every call, so one handle held across a whole render loop reports
348    /// every change. Re-watching per frame is not needed to refresh the value.
349    ///
350    /// # Example
351    ///
352    /// ```rust,ignore
353    /// // Acquire once, outside the loop — the handle is a live view
354    /// let volume = speaker.volume.watch()?;
355    ///
356    /// if let Some(v) = volume.value() {
357    ///     println!("Volume: {}%", v.value());
358    /// }
359    ///
360    /// // Changes appear in system.iter() while the handle is alive, and the
361    /// // same handle reports the new value.
362    /// for _event in system.iter() {
363    ///     println!("Volume: {:?}", volume.value());
364    /// }
365    /// ```
366    pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
367        tracing::trace!(
368            "watch() called for {:?} on {}",
369            P::SERVICE,
370            self.context.speaker_id.as_str()
371        );
372
373        // Trigger lazy event manager init if needed
374        if self.context.state_manager.event_manager().is_none() {
375            if let Some(init) = self.context.state_manager.event_init() {
376                tracing::debug!(
377                    "Event manager not initialized, triggering lazy init for {:?} on {}",
378                    P::SERVICE,
379                    self.context.speaker_id.as_str()
380                );
381                init().map_err(|e| SdkError::EventManager(e.to_string()))?;
382            } else {
383                tracing::debug!(
384                    "No event_init closure available (test mode?) for {}",
385                    self.context.speaker_id.as_str()
386                );
387            }
388        }
389
390        // Resolve subscription target: for PerCoordinator services, route to coordinator
391        let (sub_id, sub_ip) = self.context.state_manager.resolve_subscription_target(
392            &self.context.speaker_id,
393            self.context.speaker_ip,
394            P::SERVICE,
395        );
396        let routed_to_coordinator = sub_id != self.context.speaker_id;
397
398        let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
399            match em.acquire_watch(&sub_id, P::KEY, sub_ip, P::SERVICE) {
400                Ok(guard) => {
401                    if routed_to_coordinator {
402                        // Register the member's watch for notification forwarding
403                        self.context
404                            .state_manager
405                            .register_watch(&self.context.speaker_id, P::KEY);
406                        (
407                            WatchMode::Events,
408                            WatchCleanup::CoordinatorGuard {
409                                _guard: guard,
410                                _member_cleanup: CacheOnlyGuard {
411                                    state_manager: Arc::clone(&self.context.state_manager),
412                                    speaker_id: self.context.speaker_id.clone(),
413                                    property_key: P::KEY,
414                                },
415                            },
416                        )
417                    } else {
418                        (WatchMode::Events, WatchCleanup::Guard(guard))
419                    }
420                }
421                Err(e) => {
422                    tracing::warn!(
423                        "Failed to subscribe to {:?} for {}: {} - falling back to polling",
424                        P::SERVICE,
425                        self.context.speaker_id.as_str(),
426                        e
427                    );
428                    // Register directly for polling fallback
429                    self.context
430                        .state_manager
431                        .register_watch(&self.context.speaker_id, P::KEY);
432                    (
433                        WatchMode::Polling,
434                        WatchCleanup::CacheOnly(CacheOnlyGuard {
435                            state_manager: Arc::clone(&self.context.state_manager),
436                            speaker_id: self.context.speaker_id.clone(),
437                            property_key: P::KEY,
438                        }),
439                    )
440                }
441            }
442        } else {
443            // No event manager — cache-only mode
444            tracing::warn!(
445                "No event manager available for {} — falling back to cache-only mode",
446                self.context.speaker_id.as_str()
447            );
448            self.context
449                .state_manager
450                .register_watch(&self.context.speaker_id, P::KEY);
451            (
452                WatchMode::CacheOnly,
453                WatchCleanup::CacheOnly(CacheOnlyGuard {
454                    state_manager: Arc::clone(&self.context.state_manager),
455                    speaker_id: self.context.speaker_id.clone(),
456                    property_key: P::KEY,
457                }),
458            )
459        };
460
461        tracing::debug!(
462            "watch() resolved to {:?} for {} on {}",
463            mode,
464            P::KEY,
465            self.context.speaker_id.as_str()
466        );
467
468        // The handle reads through a clone of the context rather than capturing a
469        // value, so `value()` returns what the store holds *now*. It reads by the
470        // same `get_property` path `get()` uses, which means it inherits
471        // coordinator resolution and the write-ordering guard (§4.1a of the
472        // sonos-state spec) for free: the store only ever holds the
473        // newest-observed value, so a live read cannot resurrect a stale one.
474        let context = Arc::clone(&self.context);
475        Ok(WatchHandle {
476            read: Box::new(move || context.state_manager.get_property::<P>(&context.speaker_id)),
477            mode,
478            _cleanup: cleanup,
479        })
480    }
481
482    /// Check if this property is currently being watched
483    ///
484    /// Returns `true` while *any* `WatchHandle` for this property is alive, or
485    /// during the grace period after the last handle was dropped. Watches are
486    /// reference-counted, so dropping one of several handles leaves this `true`.
487    ///
488    /// # Example
489    ///
490    /// ```rust,ignore
491    /// let handle = speaker.volume.watch()?;
492    /// assert!(speaker.volume.is_watched());
493    ///
494    /// drop(handle); // starts 50ms grace period
495    /// // is_watched() remains true during grace period
496    /// ```
497    #[must_use = "returns whether the property is being watched"]
498    pub fn is_watched(&self) -> bool {
499        self.context
500            .state_manager
501            .is_watched(&self.context.speaker_id, P::KEY)
502    }
503
504    /// Get the speaker ID this handle is associated with
505    pub fn speaker_id(&self) -> &SpeakerId {
506        &self.context.speaker_id
507    }
508
509    /// Get the speaker IP address
510    pub fn speaker_ip(&self) -> IpAddr {
511        self.context.speaker_ip
512    }
513}
514
515// ============================================================================
516// Fetch implementation for Fetchable properties
517// ============================================================================
518
519impl<P: Fetchable> PropertyHandle<P> {
520    /// Watch with lazy fetch: subscribes to events, and if the cache is empty,
521    /// performs a one-time fetch to seed the value.
522    ///
523    /// Use this instead of `watch()` when you need a value on the first frame
524    /// without waiting for a UPnP event to arrive.
525    pub fn watch_or_fetch(&self) -> Result<WatchHandle<P>, SdkError> {
526        let wh = self.watch()?;
527        if !wh.has_value() {
528            // `fetch()` writes the value into the store, and the handle reads the
529            // store live, so there is nothing to patch onto the handle — unlike
530            // when the handle carried a frozen snapshot. A stale-rejected write
531            // is fine here too: it means an event already delivered something
532            // newer, which is exactly what `value()` will then return.
533            if let Err(e) = self.fetch() {
534                tracing::warn!("watch_or_fetch: fetch failed for {}: {e}", P::KEY);
535            }
536        }
537        Ok(wh)
538    }
539
540    /// Fetch fresh value from device + update cache (sync)
541    ///
542    /// This makes a synchronous UPnP call to the device and updates
543    /// the local state cache with the result.
544    ///
545    /// # Example
546    ///
547    /// ```rust,ignore
548    /// // Fetch fresh volume from device
549    /// let volume = speaker.volume.fetch()?;
550    /// println!("Current volume: {}%", volume.value());
551    ///
552    /// // The cache is now updated, so get() returns the same value
553    /// assert_eq!(speaker.volume.get(), Some(volume));
554    /// ```
555    #[must_use = "returns the fetched value from the device"]
556    pub fn fetch(&self) -> Result<P, SdkError> {
557        let operation = P::build_operation()?;
558
559        // Resolve target: coordinator for PerCoordinator services, fresh IP for PerSpeaker
560        let (target_id, target_ip) = if P::SERVICE.scope() == ServiceScope::PerCoordinator {
561            self.context.state_manager.resolve_subscription_target(
562                &self.context.speaker_id,
563                self.context.speaker_ip,
564                P::SERVICE,
565            )
566        } else {
567            let current_ip = self
568                .context
569                .state_manager
570                .get_speaker_ip(&self.context.speaker_id)
571                .unwrap_or(self.context.speaker_ip);
572            (self.context.speaker_id.clone(), current_ip)
573        };
574
575        // Stamped *before* the request, not after. The device's answer describes
576        // it as of this instant; by the time the response lands, an event may
577        // have already delivered a newer value, and the store must keep that one.
578        // Stamping after the round trip would make this stale read look like the
579        // freshest write and clobber it.
580        let observed_at = Instant::now();
581
582        let response = self
583            .context
584            .api_client
585            .execute_enhanced(&target_ip.to_string(), operation)
586            .map_err(SdkError::ApiError)?;
587
588        let property_value = P::from_response(response);
589
590        // Store under target_id (coordinator for PerCoordinator, self for PerSpeaker).
591        // May be rejected as stale, which is correct — the caller still gets the
592        // value it fetched, it just does not overwrite a newer one in the cache.
593        self.context.state_manager.set_property_stamped(
594            &target_id,
595            property_value.clone(),
596            WriteStamp::observed_at(ChangeSource::Fetch, observed_at),
597        );
598
599        Ok(property_value)
600    }
601}
602
603// ============================================================================
604// Concrete fetch for FetchableWithContext properties
605// ============================================================================
606//
607// Rust does not allow two generic impl blocks (Fetchable + FetchableWithContext)
608// defining the same `fetch()` method, so context-dependent properties get a
609// concrete impl instead.
610
611impl PropertyHandle<GroupMembership> {
612    /// Fetch fresh value from device using speaker context + update cache (sync)
613    ///
614    /// The response is interpreted using the speaker_id to extract the relevant
615    /// property value from the full topology response.
616    #[must_use = "returns the fetched value from the device"]
617    pub fn fetch(&self) -> Result<GroupMembership, SdkError> {
618        let operation = <GroupMembership as FetchableWithContext>::build_operation()?;
619
620        // Stamped before the request — see `Fetchable::fetch`.
621        let observed_at = Instant::now();
622
623        let response = self
624            .context
625            .api_client
626            .execute_enhanced(&self.context.speaker_ip.to_string(), operation)
627            .map_err(SdkError::ApiError)?;
628
629        let property_value =
630            GroupMembership::from_response_with_context(response, &self.context.speaker_id)
631                .ok_or_else(|| {
632                    SdkError::FetchFailed(format!(
633                        "Speaker {} not found in topology response",
634                        self.context.speaker_id.as_str()
635                    ))
636                })?;
637
638        self.context.state_manager.set_property_stamped(
639            &self.context.speaker_id,
640            property_value.clone(),
641            WriteStamp::observed_at(ChangeSource::Fetch, observed_at),
642        );
643
644        Ok(property_value)
645    }
646}
647
648// ============================================================================
649// Type aliases for common property handles
650// ============================================================================
651
652use sonos_api::services::{
653    av_transport::{
654        self, GetPositionInfoOperation, GetPositionInfoResponse, GetTransportInfoOperation,
655        GetTransportInfoResponse,
656    },
657    group_rendering_control::{
658        self, GetGroupMuteOperation, GetGroupMuteResponse, GetGroupVolumeOperation,
659        GetGroupVolumeResponse,
660    },
661    rendering_control::{
662        self, GetBassOperation, GetBassResponse, GetLoudnessOperation, GetLoudnessResponse,
663        GetMuteOperation, GetMuteResponse, GetTrebleOperation, GetTrebleResponse,
664        GetVolumeOperation, GetVolumeResponse,
665    },
666    zone_group_topology::{self, GetZoneGroupStateOperation, GetZoneGroupStateResponse},
667};
668use sonos_state::{
669    Bass, CurrentTrack, GroupId, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
670    Loudness, Mute, PlaybackState, Position, Treble, Volume,
671};
672
673// ============================================================================
674// Helper functions
675// ============================================================================
676
677/// Helper to create consistent error messages for operation build failures
678fn build_error<E: std::fmt::Display>(operation_name: &str, e: E) -> SdkError {
679    SdkError::FetchFailed(format!("Failed to build {operation_name} operation: {e}"))
680}
681
682// ============================================================================
683// Fetchable implementations
684// ============================================================================
685
686impl Fetchable for Volume {
687    type Operation = GetVolumeOperation;
688
689    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
690        rendering_control::get_volume_operation("Master".to_string())
691            .build()
692            .map_err(|e| build_error("GetVolume", e))
693    }
694
695    fn from_response(response: GetVolumeResponse) -> Self {
696        Volume::new(response.current_volume)
697    }
698}
699
700impl Fetchable for PlaybackState {
701    type Operation = GetTransportInfoOperation;
702
703    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
704        av_transport::get_transport_info_operation()
705            .build()
706            .map_err(|e| build_error("GetTransportInfo", e))
707    }
708
709    fn from_response(response: GetTransportInfoResponse) -> Self {
710        match response.current_transport_state.as_str() {
711            "PLAYING" => PlaybackState::Playing,
712            "PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
713            "STOPPED" => PlaybackState::Stopped,
714            _ => PlaybackState::Transitioning,
715        }
716    }
717}
718
719impl Fetchable for Position {
720    type Operation = GetPositionInfoOperation;
721
722    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
723        av_transport::get_position_info_operation()
724            .build()
725            .map_err(|e| build_error("GetPositionInfo", e))
726    }
727
728    fn from_response(response: GetPositionInfoResponse) -> Self {
729        let position_ms = Position::parse_time_to_ms(&response.rel_time).unwrap_or(0);
730        let duration_ms = Position::parse_time_to_ms(&response.track_duration).unwrap_or(0);
731        Position::new(position_ms, duration_ms)
732    }
733}
734
735impl Fetchable for Mute {
736    type Operation = GetMuteOperation;
737
738    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
739        rendering_control::get_mute_operation("Master".to_string())
740            .build()
741            .map_err(|e| build_error("GetMute", e))
742    }
743
744    fn from_response(response: GetMuteResponse) -> Self {
745        Mute::new(response.current_mute)
746    }
747}
748
749impl Fetchable for Bass {
750    type Operation = GetBassOperation;
751
752    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
753        rendering_control::get_bass_operation()
754            .build()
755            .map_err(|e| build_error("GetBass", e))
756    }
757
758    fn from_response(response: GetBassResponse) -> Self {
759        Bass::new(response.current_bass)
760    }
761}
762
763impl Fetchable for Treble {
764    type Operation = GetTrebleOperation;
765
766    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
767        rendering_control::get_treble_operation()
768            .build()
769            .map_err(|e| build_error("GetTreble", e))
770    }
771
772    fn from_response(response: GetTrebleResponse) -> Self {
773        Treble::new(response.current_treble)
774    }
775}
776
777impl Fetchable for Loudness {
778    type Operation = GetLoudnessOperation;
779
780    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
781        rendering_control::get_loudness_operation("Master".to_string())
782            .build()
783            .map_err(|e| build_error("GetLoudness", e))
784    }
785
786    fn from_response(response: GetLoudnessResponse) -> Self {
787        Loudness::new(response.current_loudness)
788    }
789}
790
791impl Fetchable for CurrentTrack {
792    type Operation = GetPositionInfoOperation;
793
794    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
795        av_transport::get_position_info_operation()
796            .build()
797            .map_err(|e| build_error("GetPositionInfo", e))
798    }
799
800    fn from_response(response: GetPositionInfoResponse) -> Self {
801        let metadata = if response.track_meta_data.is_empty()
802            || response.track_meta_data == "NOT_IMPLEMENTED"
803        {
804            None
805        } else {
806            Some(response.track_meta_data.as_str())
807        };
808        let (title, artist, album, album_art_uri) = sonos_state::parse_track_metadata(metadata);
809        CurrentTrack {
810            title,
811            artist,
812            album,
813            album_art_uri,
814            uri: Some(response.track_uri).filter(|s| !s.is_empty()),
815        }
816    }
817}
818
819// ============================================================================
820// FetchableWithContext implementations
821// ============================================================================
822
823impl FetchableWithContext for GroupMembership {
824    type Operation = GetZoneGroupStateOperation;
825
826    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
827        zone_group_topology::get_zone_group_state_operation()
828            .build()
829            .map_err(|e| build_error("GetZoneGroupState", e))
830    }
831
832    fn from_response_with_context(
833        response: GetZoneGroupStateResponse,
834        speaker_id: &SpeakerId,
835    ) -> Option<Self> {
836        let zone_groups =
837            zone_group_topology::parse_zone_group_state_xml(&response.zone_group_state).ok()?;
838
839        for group in &zone_groups {
840            let is_member = group.members.iter().any(|m| m.uuid == speaker_id.as_str());
841            if is_member {
842                let is_coordinator = group.coordinator == speaker_id.as_str();
843                return Some(GroupMembership::new(
844                    GroupId::new(&group.id),
845                    is_coordinator,
846                ));
847            }
848        }
849
850        None
851    }
852}
853
854// ============================================================================
855// Event-only properties (no dedicated UPnP Get operation)
856// ============================================================================
857//
858// GroupVolumeChangeable is the only remaining event-only property — there is
859// no GetGroupVolumeChangeable operation in the Sonos UPnP API. Its value
860// is obtained exclusively from GroupRenderingControl events.
861//
862// All other properties now have fetch() via Fetchable, FetchableWithContext,
863// or GroupFetchable trait implementations.
864
865// ============================================================================
866// Type aliases
867// ============================================================================
868
869/// Handle for speaker volume (0-100)
870pub type VolumeHandle = PropertyHandle<Volume>;
871
872/// Handle for playback state (Playing/Paused/Stopped)
873pub type PlaybackStateHandle = PropertyHandle<PlaybackState>;
874
875/// Handle for mute state
876pub type MuteHandle = PropertyHandle<Mute>;
877
878/// Handle for bass EQ setting (-10 to +10)
879pub type BassHandle = PropertyHandle<Bass>;
880
881/// Handle for treble EQ setting (-10 to +10)
882pub type TrebleHandle = PropertyHandle<Treble>;
883
884/// Handle for loudness compensation setting
885pub type LoudnessHandle = PropertyHandle<Loudness>;
886
887/// Handle for current playback position
888pub type PositionHandle = PropertyHandle<Position>;
889
890/// Handle for current track information
891pub type CurrentTrackHandle = PropertyHandle<CurrentTrack>;
892
893/// Handle for group membership information
894pub type GroupMembershipHandle = PropertyHandle<GroupMembership>;
895
896// ============================================================================
897// Group Property Handles
898// ============================================================================
899
900/// Shared context for all property handles on a group
901///
902/// Analogous to `SpeakerContext` but scoped to a group. Operations are
903/// executed against the group's coordinator speaker.
904#[derive(Clone)]
905pub struct GroupContext {
906    pub(crate) group_id: GroupId,
907    pub(crate) coordinator_id: SpeakerId,
908    pub(crate) coordinator_ip: IpAddr,
909    pub(crate) state_manager: Arc<StateManager>,
910    pub(crate) api_client: SonosClient,
911}
912
913impl GroupContext {
914    /// Create a new GroupContext
915    pub fn new(
916        group_id: GroupId,
917        coordinator_id: SpeakerId,
918        coordinator_ip: IpAddr,
919        state_manager: Arc<StateManager>,
920        api_client: SonosClient,
921    ) -> Arc<Self> {
922        Arc::new(Self {
923            group_id,
924            coordinator_id,
925            coordinator_ip,
926            state_manager,
927            api_client,
928        })
929    }
930}
931
932/// Generic property handle for group-scoped properties
933///
934/// Provides the same get/fetch/watch/unwatch pattern as `PropertyHandle`,
935/// but reads from the group property store and executes API calls against
936/// the group's coordinator.
937#[derive(Clone)]
938pub struct GroupPropertyHandle<P: SonosProperty> {
939    context: Arc<GroupContext>,
940    _phantom: PhantomData<P>,
941}
942
943impl<P: SonosProperty> GroupPropertyHandle<P> {
944    /// Create a new GroupPropertyHandle from a shared GroupContext
945    pub fn new(context: Arc<GroupContext>) -> Self {
946        Self {
947            context,
948            _phantom: PhantomData,
949        }
950    }
951
952    /// Get cached group property value (sync, instant, no network call)
953    #[must_use = "returns the cached property value"]
954    pub fn get(&self) -> Option<P> {
955        self.context
956            .state_manager
957            .get_group_property::<P>(&self.context.group_id)
958    }
959
960    /// Start watching this group property for changes (sync)
961    ///
962    /// Returns a [`WatchHandle`] scoped to the group coordinator.
963    /// Hold the handle to keep the subscription alive.
964    pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
965        // Trigger lazy event manager init if needed
966        if self.context.state_manager.event_manager().is_none() {
967            if let Some(init) = self.context.state_manager.event_init() {
968                tracing::debug!(
969                    "Event manager not initialized, triggering lazy init for group {:?} on {}",
970                    P::SERVICE,
971                    self.context.group_id.as_str()
972                );
973                init().map_err(|e| SdkError::EventManager(e.to_string()))?;
974            } else {
975                tracing::debug!(
976                    "No event_init closure available (test mode?) for group {}",
977                    self.context.group_id.as_str()
978                );
979            }
980        }
981
982        let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
983            match em.acquire_watch(
984                &self.context.coordinator_id,
985                P::KEY,
986                self.context.coordinator_ip,
987                P::SERVICE,
988            ) {
989                Ok(guard) => (WatchMode::Events, WatchCleanup::Guard(guard)),
990                Err(e) => {
991                    tracing::warn!(
992                        "Failed to subscribe to {:?} for group {}: {} - falling back to polling",
993                        P::SERVICE,
994                        self.context.group_id.as_str(),
995                        e
996                    );
997                    self.context
998                        .state_manager
999                        .register_watch(&self.context.coordinator_id, P::KEY);
1000                    (
1001                        WatchMode::Polling,
1002                        WatchCleanup::CacheOnly(CacheOnlyGuard {
1003                            state_manager: Arc::clone(&self.context.state_manager),
1004                            speaker_id: self.context.coordinator_id.clone(),
1005                            property_key: P::KEY,
1006                        }),
1007                    )
1008                }
1009            }
1010        } else {
1011            self.context
1012                .state_manager
1013                .register_watch(&self.context.coordinator_id, P::KEY);
1014            (
1015                WatchMode::CacheOnly,
1016                WatchCleanup::CacheOnly(CacheOnlyGuard {
1017                    state_manager: Arc::clone(&self.context.state_manager),
1018                    speaker_id: self.context.coordinator_id.clone(),
1019                    property_key: P::KEY,
1020                }),
1021            )
1022        };
1023
1024        // Live read against the group store — see the speaker `watch()` above.
1025        let context = Arc::clone(&self.context);
1026        Ok(WatchHandle {
1027            read: Box::new(move || {
1028                context
1029                    .state_manager
1030                    .get_group_property::<P>(&context.group_id)
1031            }),
1032            mode,
1033            _cleanup: cleanup,
1034        })
1035    }
1036
1037    /// Check if this group property is currently being watched
1038    #[must_use = "returns whether the property is being watched"]
1039    pub fn is_watched(&self) -> bool {
1040        self.context
1041            .state_manager
1042            .is_watched(&self.context.coordinator_id, P::KEY)
1043    }
1044
1045    /// Get the group ID this handle is associated with
1046    pub fn group_id(&self) -> &GroupId {
1047        &self.context.group_id
1048    }
1049}
1050
1051/// Trait for group properties that can be fetched from the coordinator
1052pub trait GroupFetchable: SonosProperty {
1053    /// The UPnP operation type used to fetch this property
1054    type Operation: UPnPOperation;
1055
1056    /// Build the operation to fetch this property
1057    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
1058
1059    /// Convert the operation response to the property value
1060    fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
1061}
1062
1063impl<P: GroupFetchable> GroupPropertyHandle<P> {
1064    /// Watch with lazy fetch: subscribes to events, and if the cache is empty,
1065    /// performs a one-time fetch from the coordinator to seed the value.
1066    pub fn watch_or_fetch(&self) -> Result<WatchHandle<P>, SdkError> {
1067        let wh = self.watch()?;
1068        if !wh.has_value() {
1069            // See `PropertyHandle::watch_or_fetch` — the fetch lands in the store
1070            // the handle reads from, so no patching is needed.
1071            if let Err(e) = self.fetch() {
1072                tracing::warn!(
1073                    "watch_or_fetch: fetch failed for group {} {}: {e}",
1074                    self.context.group_id.as_str(),
1075                    P::KEY
1076                );
1077            }
1078        }
1079        Ok(wh)
1080    }
1081
1082    /// Fetch fresh value from coordinator + update group cache (sync)
1083    #[must_use = "returns the fetched value from the device"]
1084    pub fn fetch(&self) -> Result<P, SdkError> {
1085        let operation = P::build_operation()?;
1086
1087        // Stamped before the request — see `Fetchable::fetch`.
1088        let observed_at = Instant::now();
1089
1090        let response = self
1091            .context
1092            .api_client
1093            .execute_enhanced(&self.context.coordinator_ip.to_string(), operation)
1094            .map_err(SdkError::ApiError)?;
1095
1096        let property_value = P::from_response(response);
1097
1098        self.context.state_manager.set_group_property_stamped(
1099            &self.context.group_id,
1100            property_value.clone(),
1101            WriteStamp::observed_at(ChangeSource::Fetch, observed_at),
1102        );
1103
1104        Ok(property_value)
1105    }
1106}
1107
1108// ============================================================================
1109// GroupFetchable implementations
1110// ============================================================================
1111
1112impl GroupFetchable for GroupVolume {
1113    type Operation = GetGroupVolumeOperation;
1114
1115    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
1116        group_rendering_control::get_group_volume()
1117            .build()
1118            .map_err(|e| build_error("GetGroupVolume", e))
1119    }
1120
1121    fn from_response(response: GetGroupVolumeResponse) -> Self {
1122        GroupVolume::new(response.current_volume)
1123    }
1124}
1125
1126impl GroupFetchable for GroupMute {
1127    type Operation = GetGroupMuteOperation;
1128
1129    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
1130        group_rendering_control::get_group_mute()
1131            .build()
1132            .map_err(|e| build_error("GetGroupMute", e))
1133    }
1134
1135    fn from_response(response: GetGroupMuteResponse) -> Self {
1136        GroupMute::new(response.current_mute)
1137    }
1138}
1139
1140// ============================================================================
1141// Group type aliases
1142// ============================================================================
1143
1144/// Handle for group volume (0-100)
1145pub type GroupVolumeHandle = GroupPropertyHandle<GroupVolume>;
1146
1147/// Handle for group mute state
1148pub type GroupMuteHandle = GroupPropertyHandle<GroupMute>;
1149
1150/// Handle for group volume changeable flag (event-only, no fetch)
1151pub type GroupVolumeChangeableHandle = GroupPropertyHandle<GroupVolumeChangeable>;
1152
1153#[cfg(test)]
1154mod tests {
1155    use super::*;
1156    use sonos_discovery::Device;
1157    use sonos_state::Property;
1158
1159    fn create_test_state_manager() -> Arc<StateManager> {
1160        let manager = StateManager::new().unwrap();
1161        let devices = vec![Device {
1162            id: "RINCON_TEST123".to_string(),
1163            name: "Test Speaker".to_string(),
1164            room_name: "Test Room".to_string(),
1165            ip_address: "192.168.1.100".to_string(),
1166            port: 1400,
1167            model_name: "Sonos One".to_string(),
1168        }];
1169        manager.add_devices(devices).unwrap();
1170        Arc::new(manager)
1171    }
1172
1173    fn create_test_context(state_manager: Arc<StateManager>) -> Arc<SpeakerContext> {
1174        SpeakerContext::new(
1175            SpeakerId::new("RINCON_TEST123"),
1176            "192.168.1.100".parse().unwrap(),
1177            state_manager,
1178            SonosClient::new(),
1179        )
1180    }
1181
1182    #[test]
1183    fn test_property_handle_creation() {
1184        let state_manager = create_test_state_manager();
1185        let context = create_test_context(state_manager);
1186        let speaker_ip: IpAddr = "192.168.1.100".parse().unwrap();
1187
1188        let handle: VolumeHandle = PropertyHandle::new(context);
1189
1190        assert_eq!(handle.speaker_id().as_str(), "RINCON_TEST123");
1191        assert_eq!(handle.speaker_ip(), speaker_ip);
1192    }
1193
1194    #[test]
1195    fn test_get_returns_none_initially() {
1196        let state_manager = create_test_state_manager();
1197        let context = create_test_context(state_manager);
1198
1199        let handle: VolumeHandle = PropertyHandle::new(context);
1200
1201        assert!(handle.get().is_none());
1202    }
1203
1204    #[test]
1205    fn test_get_returns_cached_value() {
1206        let state_manager = create_test_state_manager();
1207        let speaker_id = SpeakerId::new("RINCON_TEST123");
1208
1209        state_manager.set_property(&speaker_id, Volume::new(75));
1210
1211        let context = create_test_context(Arc::clone(&state_manager));
1212        let handle: VolumeHandle = PropertyHandle::new(context);
1213
1214        assert_eq!(handle.get(), Some(Volume::new(75)));
1215    }
1216
1217    #[test]
1218    fn test_watch_registers_property() {
1219        let state_manager = create_test_state_manager();
1220        let context = create_test_context(Arc::clone(&state_manager));
1221
1222        let handle: VolumeHandle = PropertyHandle::new(context);
1223
1224        assert!(!handle.is_watched());
1225        let _wh = handle.watch().unwrap();
1226        assert!(handle.is_watched());
1227    }
1228
1229    #[test]
1230    fn test_drop_watch_handle_unregisters_property() {
1231        let state_manager = create_test_state_manager();
1232        let context = create_test_context(Arc::clone(&state_manager));
1233
1234        let handle: VolumeHandle = PropertyHandle::new(context);
1235
1236        let wh = handle.watch().unwrap();
1237        assert!(handle.is_watched());
1238
1239        drop(wh);
1240        assert!(!handle.is_watched());
1241    }
1242
1243    /// Dropping one `WatchHandle` must not silence a sibling property.
1244    ///
1245    /// Volume and Mute both belong to RenderingControl, so they shared a
1246    /// subscription and shared one `(ip, service)` ref count. With a set-valued
1247    /// watched map the *first* drop removed the only entry, so the surviving
1248    /// handle went silent while the caller still held it — `is_watched()` said
1249    /// `false` and `system.iter()` stopped reporting the property.
1250    ///
1251    /// Overlapping holds no longer come from re-watching per frame (handles read
1252    /// live now, so nothing needs to), but they still arise wherever two
1253    /// independent watchers want the same property — which is the case this
1254    /// guards.
1255    ///
1256    /// Asserts delivery, not just the flag: a watch that is "registered" but no
1257    /// longer emits is the failure users would actually see.
1258    #[test]
1259    fn test_dropping_one_of_two_handles_keeps_property_emitting() {
1260        let state_manager = create_test_state_manager();
1261        let speaker_id = SpeakerId::new("RINCON_TEST123");
1262        let context = create_test_context(Arc::clone(&state_manager));
1263
1264        let volume: VolumeHandle = PropertyHandle::new(Arc::clone(&context));
1265        let mute: MuteHandle = PropertyHandle::new(context);
1266
1267        let first = volume.watch().unwrap();
1268        let second = volume.watch().unwrap();
1269        // A sibling property of the same service, held once.
1270        let _mute_watch = mute.watch().unwrap();
1271        assert!(volume.is_watched());
1272        assert!(mute.is_watched());
1273
1274        drop(first);
1275
1276        assert!(
1277            volume.is_watched(),
1278            "one of two Volume handles dropped — the property must stay watched"
1279        );
1280        assert!(
1281            mute.is_watched(),
1282            "releasing a Volume handle must not disturb its RenderingControl sibling"
1283        );
1284
1285        // The surviving handle must still receive events, not merely be flagged.
1286        // Subscribe first: an iterator receives events emitted after it exists,
1287        // not a replay of everything since the manager was built.
1288        let iter = state_manager.iter();
1289        state_manager.set_property(&speaker_id, Volume::new(11));
1290        state_manager.set_property(&speaker_id, Mute::new(true));
1291
1292        let first_event = iter
1293            .recv_timeout(std::time::Duration::from_millis(100))
1294            .expect("Volume is still held by `second` and must still emit");
1295        assert_eq!(first_event.property_key(), Volume::KEY);
1296        let second_event = iter
1297            .recv_timeout(std::time::Duration::from_millis(100))
1298            .expect("Mute is still held and must still emit");
1299        assert_eq!(second_event.property_key(), Mute::KEY);
1300
1301        // Last holder goes away: now it really stops.
1302        drop(second);
1303        assert!(!volume.is_watched());
1304        state_manager.set_property(&speaker_id, Volume::new(22));
1305        assert!(
1306            iter.recv_timeout(std::time::Duration::from_millis(50))
1307                .is_none(),
1308            "with every Volume handle dropped the property must stop emitting"
1309        );
1310    }
1311
1312    /// A watcher reading through the SDK sees the value on the event, and sees
1313    /// it attributed to the write that caused it.
1314    ///
1315    /// Covers the SDK re-export boundary: `ChangeEvent.change` / `.source` must
1316    /// be reachable and correct from `sonos_sdk`, not just inside `sonos-state`.
1317    #[test]
1318    fn test_sdk_change_event_carries_value_and_source() {
1319        let state_manager = create_test_state_manager();
1320        let speaker_id = SpeakerId::new("RINCON_TEST123");
1321
1322        let context = create_test_context(Arc::clone(&state_manager));
1323        let handle: VolumeHandle = PropertyHandle::new(context);
1324        let _wh = handle.watch().unwrap();
1325
1326        let iter = state_manager.iter();
1327        state_manager.set_property(&speaker_id, Volume::new(37));
1328
1329        let event = iter
1330            .recv_timeout(std::time::Duration::from_millis(100))
1331            .expect("a watched property write must emit");
1332
1333        assert!(
1334            matches!(
1335                event.change,
1336                sonos_state::PropertyChange::Volume(Volume(37))
1337            ),
1338            "the event must carry the written value, got {:?}",
1339            event.change
1340        );
1341        assert_eq!(
1342            event.source,
1343            ChangeSource::LocalAction,
1344            "`set_property` is a local write, not a device report"
1345        );
1346    }
1347
1348    /// A handle acquired *before* a change reports the new value afterwards,
1349    /// with no re-`watch()`.
1350    ///
1351    /// This is the defect PR-11b exists to fix: `WatchHandle` used to capture
1352    /// `self.get()` into a field at construction, so a handle held across a whole
1353    /// render loop kept reporting whatever the store happened to hold at the
1354    /// instant `watch()` ran. The only workaround was to re-acquire a handle
1355    /// every frame — which the docs recommended, and which is what made the
1356    /// refcount bug in PR #93 reachable in the first place.
1357    ///
1358    /// Asserts the *sequence*, not just the endpoint: two successive writes must
1359    /// both be visible through the one handle, so a fix that merely refreshed
1360    /// once cannot pass.
1361    #[test]
1362    fn test_handle_held_across_change_reports_new_value() {
1363        let state_manager = create_test_state_manager();
1364        let speaker_id = SpeakerId::new("RINCON_TEST123");
1365
1366        state_manager.set_property(&speaker_id, Volume::new(10));
1367
1368        let context = create_test_context(Arc::clone(&state_manager));
1369        let handle: VolumeHandle = PropertyHandle::new(context);
1370
1371        // Acquired once, before either change, and never re-acquired.
1372        let wh = handle.watch().unwrap();
1373        assert_eq!(wh.value(), Some(Volume::new(10)));
1374
1375        state_manager.set_property(&speaker_id, Volume::new(42));
1376        assert_eq!(
1377            wh.value(),
1378            Some(Volume::new(42)),
1379            "the handle froze its value at creation — a held handle must read live"
1380        );
1381
1382        state_manager.set_property(&speaker_id, Volume::new(43));
1383        assert_eq!(
1384            wh.value(),
1385            Some(Volume::new(43)),
1386            "the handle must keep tracking, not refresh once"
1387        );
1388    }
1389
1390    /// `has_value()` is a live read too: a handle acquired on an empty store must
1391    /// start reporting a value once one arrives, without being re-acquired.
1392    ///
1393    /// Separate from the test above because the `None → Some` transition is the
1394    /// case a snapshot gets *most* wrong — a handle acquired before the first
1395    /// event stayed permanently empty, so a dashboard that watched at startup
1396    /// rendered "—" forever.
1397    #[test]
1398    fn test_handle_acquired_before_first_value_becomes_populated() {
1399        let state_manager = create_test_state_manager();
1400        let speaker_id = SpeakerId::new("RINCON_TEST123");
1401
1402        let context = create_test_context(Arc::clone(&state_manager));
1403        let handle: VolumeHandle = PropertyHandle::new(context);
1404
1405        let wh = handle.watch().unwrap();
1406        assert!(!wh.has_value(), "nothing has been observed yet");
1407        assert_eq!(wh.value(), None);
1408
1409        state_manager.set_property(&speaker_id, Volume::new(7));
1410
1411        assert!(
1412            wh.has_value(),
1413            "has_value() froze at creation — it must reflect the store"
1414        );
1415        assert_eq!(wh.value(), Some(Volume::new(7)));
1416    }
1417
1418    /// Every handle on the same property sees the update, and dropping one
1419    /// leaves the survivors both *live* and *receiving*.
1420    ///
1421    /// Combines the multi-handle and drop-a-sibling cases deliberately: the
1422    /// interesting failure is the interaction — a live read that works only while
1423    /// no handle has been dropped would pass them separately. Guards PR #93's
1424    /// refcount fix (delivery through `iter()`) alongside the new live read.
1425    #[test]
1426    fn test_all_handles_see_update_and_survive_a_sibling_drop() {
1427        let state_manager = create_test_state_manager();
1428        let speaker_id = SpeakerId::new("RINCON_TEST123");
1429        let context = create_test_context(Arc::clone(&state_manager));
1430
1431        let handle: VolumeHandle = PropertyHandle::new(Arc::clone(&context));
1432
1433        let first = handle.watch().unwrap();
1434        let second = handle.watch().unwrap();
1435        let third = handle.watch().unwrap();
1436
1437        let iter = state_manager.iter();
1438        state_manager.set_property(&speaker_id, Volume::new(31));
1439
1440        // All three read the same new value.
1441        assert_eq!(first.value(), Some(Volume::new(31)));
1442        assert_eq!(second.value(), Some(Volume::new(31)));
1443        assert_eq!(third.value(), Some(Volume::new(31)));
1444        assert!(iter
1445            .recv_timeout(std::time::Duration::from_millis(100))
1446            .is_some());
1447
1448        drop(first);
1449        assert!(handle.is_watched(), "two handles still hold the property");
1450
1451        // Survivors keep reading live *and* keep receiving.
1452        state_manager.set_property(&speaker_id, Volume::new(32));
1453        assert_eq!(
1454            second.value(),
1455            Some(Volume::new(32)),
1456            "a sibling handle dropping must not freeze the survivors"
1457        );
1458        assert_eq!(third.value(), Some(Volume::new(32)));
1459        assert!(
1460            iter.recv_timeout(std::time::Duration::from_millis(100))
1461                .is_some(),
1462            "the property is still held and must still emit"
1463        );
1464
1465        drop(second);
1466        state_manager.set_property(&speaker_id, Volume::new(33));
1467        assert_eq!(
1468            third.value(),
1469            Some(Volume::new(33)),
1470            "the last handle must still read live"
1471        );
1472    }
1473
1474    /// Reading through a handle whose value has become unreachable yields `None`,
1475    /// not a stale value resurrected from the handle's own memory.
1476    ///
1477    /// The edge case the live-read design introduces. `PlaybackState` comes from
1478    /// `AVTransport`, a `PerCoordinator` service, so `get_property` resolves it
1479    /// through the speaker's *coordinator*. Regroup the speaker under a
1480    /// coordinator that holds no `PlaybackState` and the correct answer becomes
1481    /// "unknown" — a snapshot handle would confidently report the old
1482    /// coordinator's value instead.
1483    ///
1484    /// The two speaker IDs are written explicitly rather than taken from
1485    /// `with_speakers`' fixed `RINCON_{i:03}` pattern, so the assertion depends on
1486    /// the regrouping rather than on either ID's spelling.
1487    #[test]
1488    fn test_handle_reads_none_when_value_becomes_unreachable() {
1489        let manager = StateManager::new().unwrap();
1490        manager
1491            .add_devices(vec![
1492                Device {
1493                    id: "RINCON_MEMBER".to_string(),
1494                    name: "Member".to_string(),
1495                    room_name: "Member".to_string(),
1496                    ip_address: "203.0.113.1".to_string(),
1497                    port: 1400,
1498                    model_name: "Sonos One".to_string(),
1499                },
1500                Device {
1501                    id: "RINCON_NEWCOORD".to_string(),
1502                    name: "New Coordinator".to_string(),
1503                    room_name: "New Coordinator".to_string(),
1504                    ip_address: "203.0.113.2".to_string(),
1505                    port: 1400,
1506                    model_name: "Sonos One".to_string(),
1507                },
1508            ])
1509            .unwrap();
1510        let state_manager = Arc::new(manager);
1511
1512        let member = SpeakerId::new("RINCON_MEMBER");
1513        let new_coord = SpeakerId::new("RINCON_NEWCOORD");
1514
1515        // The member is its own coordinator and knows it is playing.
1516        state_manager.set_property(&member, PlaybackState::Playing);
1517
1518        let context = SpeakerContext::new(
1519            member.clone(),
1520            "203.0.113.1".parse().unwrap(),
1521            Arc::clone(&state_manager),
1522            SonosClient::new(),
1523        );
1524        let handle: PlaybackStateHandle = PropertyHandle::new(context);
1525        let wh = handle.watch().unwrap();
1526        assert_eq!(wh.value(), Some(PlaybackState::Playing));
1527
1528        // Regroup: the member now follows a coordinator with no PlaybackState.
1529        state_manager.initialize(sonos_state::Topology {
1530            speakers: vec![],
1531            groups: vec![sonos_state::GroupInfo::new(
1532                GroupId::new("RINCON_NEWCOORD:1"),
1533                new_coord.clone(),
1534                vec![new_coord.clone(), member.clone()],
1535            )],
1536        });
1537
1538        assert_eq!(
1539            wh.value(),
1540            None,
1541            "the coordinator holds no PlaybackState, so the answer is unknown — \
1542             a handle must not report the value it captured at creation"
1543        );
1544
1545        // And it picks the new coordinator's value up when there is one.
1546        state_manager.set_property(&new_coord, PlaybackState::Paused);
1547        assert_eq!(wh.value(), Some(PlaybackState::Paused));
1548    }
1549
1550    #[test]
1551    fn test_watch_returns_current_value() {
1552        let state_manager = create_test_state_manager();
1553        let speaker_id = SpeakerId::new("RINCON_TEST123");
1554
1555        state_manager.set_property(&speaker_id, Volume::new(50));
1556
1557        let context = create_test_context(Arc::clone(&state_manager));
1558        let handle: VolumeHandle = PropertyHandle::new(context);
1559
1560        let wh = handle.watch().unwrap();
1561        assert_eq!(wh.value(), Some(Volume::new(50)));
1562        // No event manager configured, so should be CacheOnly mode
1563        assert_eq!(wh.mode(), WatchMode::CacheOnly);
1564    }
1565
1566    #[test]
1567    fn test_watch_handle_accessors() {
1568        let state_manager = create_test_state_manager();
1569        let speaker_id = SpeakerId::new("RINCON_TEST123");
1570
1571        state_manager.set_property(&speaker_id, Volume::new(75));
1572
1573        let context = create_test_context(Arc::clone(&state_manager));
1574        let handle: VolumeHandle = PropertyHandle::new(context);
1575
1576        let wh = handle.watch().unwrap();
1577        assert!(wh.has_value());
1578        assert!(!wh.has_realtime_events());
1579        assert_eq!(wh.value().map(|v| v.value()), Some(75));
1580    }
1581
1582    #[test]
1583    fn test_property_handle_clone() {
1584        let state_manager = create_test_state_manager();
1585        let speaker_id = SpeakerId::new("RINCON_TEST123");
1586
1587        state_manager.set_property(&speaker_id, Volume::new(60));
1588
1589        let context = create_test_context(Arc::clone(&state_manager));
1590        let handle: VolumeHandle = PropertyHandle::new(context);
1591
1592        let cloned = handle.clone();
1593
1594        assert_eq!(handle.get(), cloned.get());
1595        assert_eq!(handle.get(), Some(Volume::new(60)));
1596    }
1597
1598    // ========================================================================
1599    // Group property handle tests
1600    // ========================================================================
1601
1602    fn create_test_group_context(state_manager: Arc<StateManager>) -> Arc<GroupContext> {
1603        GroupContext::new(
1604            GroupId::new("RINCON_TEST123:1"),
1605            SpeakerId::new("RINCON_TEST123"),
1606            "192.168.1.100".parse().unwrap(),
1607            state_manager,
1608            SonosClient::new(),
1609        )
1610    }
1611
1612    #[test]
1613    fn test_group_property_handle_get_returns_none_initially() {
1614        let state_manager = create_test_state_manager();
1615        let context = create_test_group_context(state_manager);
1616
1617        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1618
1619        assert!(handle.get().is_none());
1620    }
1621
1622    #[test]
1623    fn test_group_property_handle_get_returns_cached_value() {
1624        let state_manager = create_test_state_manager();
1625        let group_id = GroupId::new("RINCON_TEST123:1");
1626
1627        // Store a group property value
1628        state_manager.set_group_property(&group_id, GroupVolume::new(65));
1629
1630        let context = create_test_group_context(Arc::clone(&state_manager));
1631        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1632
1633        assert_eq!(handle.get(), Some(GroupVolume::new(65)));
1634    }
1635
1636    #[test]
1637    fn test_group_property_handle_watch_and_drop() {
1638        let state_manager = create_test_state_manager();
1639        let context = create_test_group_context(Arc::clone(&state_manager));
1640
1641        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1642
1643        assert!(!handle.is_watched());
1644        let wh = handle.watch().unwrap();
1645        assert!(handle.is_watched());
1646
1647        drop(wh);
1648        assert!(!handle.is_watched());
1649    }
1650
1651    /// The group `watch()` is a second, independent construction site — it reads
1652    /// the *group* store rather than the speaker store, so the speaker tests above
1653    /// say nothing about it. Without this, reverting only the group site left the
1654    /// whole suite green.
1655    #[test]
1656    fn test_group_handle_held_across_change_reports_new_value() {
1657        let state_manager = create_test_state_manager();
1658        let group_id = GroupId::new("RINCON_TEST123:1");
1659        let context = create_test_group_context(Arc::clone(&state_manager));
1660
1661        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1662
1663        let wh = handle.watch().unwrap();
1664        assert!(!wh.has_value(), "the group store is empty at this point");
1665
1666        state_manager.set_group_property(&group_id, GroupVolume::new(20));
1667        assert_eq!(
1668            wh.value(),
1669            Some(GroupVolume::new(20)),
1670            "a group handle must read the group store live, not a snapshot"
1671        );
1672
1673        state_manager.set_group_property(&group_id, GroupVolume::new(21));
1674        assert_eq!(wh.value(), Some(GroupVolume::new(21)));
1675    }
1676
1677    #[test]
1678    fn test_group_property_handle_group_id() {
1679        let state_manager = create_test_state_manager();
1680        let context = create_test_group_context(state_manager);
1681
1682        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1683
1684        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1685    }
1686
1687    #[test]
1688    fn test_group_mute_handle_accessible() {
1689        let state_manager = create_test_state_manager();
1690        let context = create_test_group_context(state_manager);
1691
1692        let handle: GroupMuteHandle = GroupPropertyHandle::new(context);
1693
1694        assert!(handle.get().is_none());
1695        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1696    }
1697
1698    #[test]
1699    fn test_group_volume_changeable_handle_accessible() {
1700        let state_manager = create_test_state_manager();
1701        let context = create_test_group_context(state_manager);
1702
1703        let handle: GroupVolumeChangeableHandle = GroupPropertyHandle::new(context);
1704
1705        assert!(handle.get().is_none());
1706        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1707    }
1708
1709    // ========================================================================
1710    // Trait implementation assertions
1711    // ========================================================================
1712
1713    #[test]
1714    fn test_fetchable_impls_exist() {
1715        fn assert_fetchable<T: Fetchable>() {}
1716        assert_fetchable::<Volume>();
1717        assert_fetchable::<PlaybackState>();
1718        assert_fetchable::<Position>();
1719        assert_fetchable::<Mute>();
1720        assert_fetchable::<Bass>();
1721        assert_fetchable::<Treble>();
1722        assert_fetchable::<Loudness>();
1723        assert_fetchable::<CurrentTrack>();
1724    }
1725
1726    #[test]
1727    fn test_fetchable_with_context_impls_exist() {
1728        fn assert_fetchable_with_context<T: FetchableWithContext>() {}
1729        assert_fetchable_with_context::<GroupMembership>();
1730    }
1731
1732    #[test]
1733    fn test_group_fetchable_impls_exist() {
1734        fn assert_group_fetchable<T: GroupFetchable>() {}
1735        assert_group_fetchable::<GroupVolume>();
1736        assert_group_fetchable::<GroupMute>();
1737    }
1738}