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::ops::Deref;
12use std::sync::Arc;
13
14use sonos_api::operation::{ComposableOperation, UPnPOperation};
15use sonos_api::{ServiceScope, SonosClient};
16use sonos_event_manager::WatchGuard;
17use sonos_state::{property::SonosProperty, SpeakerId, StateManager};
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 snapshot of the current value
91/// along with a subscription guard. 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/// # Example
98///
99/// ```rust,ignore
100/// // Watch returns a handle — hold it to keep the subscription alive
101/// let volume = speaker.volume.watch()?;
102///
103/// // Deref to Option<P> for ergonomic access
104/// if let Some(v) = &*volume {
105///     println!("Volume: {}%", v.value());
106/// }
107///
108/// // Or use the value() convenience method
109/// if let Some(v) = volume.value() {
110///     println!("Volume: {}%", v.value());
111/// }
112///
113/// // Dropping the handle starts the 50ms grace period
114/// drop(volume);
115/// ```
116#[must_use = "dropping the handle starts the grace period — hold it to keep the subscription alive"]
117pub struct WatchHandle<P> {
118    value: Option<P>,
119    mode: WatchMode,
120    _cleanup: WatchCleanup,
121}
122
123impl<P> Deref for WatchHandle<P> {
124    type Target = Option<P>;
125    fn deref(&self) -> &Self::Target {
126        &self.value
127    }
128}
129
130impl<P> WatchHandle<P> {
131    /// Returns the watch mode (Events, Polling, or CacheOnly).
132    pub fn mode(&self) -> WatchMode {
133        self.mode
134    }
135
136    /// Convenience: returns a reference to the inner value, if available.
137    /// Equivalent to `(*handle).as_ref()` but more ergonomic.
138    pub fn value(&self) -> Option<&P> {
139        self.value.as_ref()
140    }
141
142    /// Returns true if a value has been received from the device.
143    pub fn has_value(&self) -> bool {
144        self.value.is_some()
145    }
146
147    /// Returns true if real-time UPnP events are active.
148    pub fn has_realtime_events(&self) -> bool {
149        self.mode == WatchMode::Events
150    }
151}
152
153impl<P: fmt::Debug> fmt::Debug for WatchHandle<P> {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.debug_struct("WatchHandle")
156            .field("value", &self.value)
157            .field("mode", &self.mode)
158            .finish()
159    }
160}
161
162/// Internal cleanup strategy for WatchHandle.
163///
164/// - `Guard`: Event manager is active — WatchGuard handles the subscription
165///   lifecycle (ref counting, grace period, unsubscribe).
166/// - `CacheOnly`: No event manager — just unregisters from the watched set.
167/// - `CoordinatorGuard`: PerCoordinator service routed to coordinator —
168///   WatchGuard manages the coordinator's subscription, CacheOnlyGuard cleans
169///   up the member's watched-set entry on drop.
170///
171/// Fields are never read — they exist solely for their Drop behavior.
172#[allow(dead_code)]
173enum WatchCleanup {
174    Guard(WatchGuard),
175    CacheOnly(CacheOnlyGuard),
176    CoordinatorGuard {
177        _guard: WatchGuard,
178        _member_cleanup: CacheOnlyGuard,
179    },
180}
181
182/// Cleanup guard for CacheOnly mode (no event manager).
183///
184/// Holds one reference on `(speaker_id, property_key)` in the state manager's
185/// watched set and releases it on drop. Because the set is reference-counted,
186/// dropping this guard only stops emission if no other watcher still holds the
187/// same pair — several `WatchHandle`s for one property can coexist, and one
188/// going away must not silence the others.
189struct CacheOnlyGuard {
190    state_manager: Arc<StateManager>,
191    speaker_id: SpeakerId,
192    property_key: &'static str,
193}
194
195impl Drop for CacheOnlyGuard {
196    fn drop(&mut self) {
197        // Releases exactly the one reference taken by the matching
198        // `register_watch()` above.
199        self.state_manager
200            .unregister_watch(&self.speaker_id, self.property_key);
201    }
202}
203
204/// Trait for properties that can be fetched from the device
205///
206/// This trait defines how to fetch a property value from a Sonos device.
207/// Each property type that supports fetching must implement this trait.
208///
209/// # Type Parameters
210///
211/// - `Op`: The UPnP operation type used to fetch this property
212///
213/// # Example
214///
215/// ```rust,ignore
216/// impl Fetchable for Volume {
217///     type Operation = GetVolumeOperation;
218///
219///     fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
220///         rendering_control::get_volume_operation("Master".to_string())
221///             .build()
222///             .map_err(|e| SdkError::FetchFailed(e.to_string()))
223///     }
224///
225///     fn from_response(response: GetVolumeResponse) -> Self {
226///         Volume::new(response.current_volume)
227///     }
228/// }
229/// ```
230pub trait Fetchable: SonosProperty {
231    /// The UPnP operation type used to fetch this property
232    type Operation: UPnPOperation;
233
234    /// Build the operation to fetch this property
235    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
236
237    /// Convert the operation response to the property value
238    fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
239}
240
241/// Trait for properties that require context (e.g., speaker_id) to interpret the response
242///
243/// Unlike `Fetchable`, the response contains data for multiple entities and
244/// the correct one must be extracted using context.
245pub trait FetchableWithContext: SonosProperty {
246    /// The UPnP operation type used to fetch this property
247    type Operation: UPnPOperation;
248
249    /// Build the operation to fetch this property
250    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
251
252    /// Convert the operation response to the property value using speaker context
253    fn from_response_with_context(
254        response: <Self::Operation as UPnPOperation>::Response,
255        speaker_id: &SpeakerId,
256    ) -> Option<Self>;
257}
258
259/// Generic property handle providing get/fetch/watch/unwatch pattern
260///
261/// This is the core abstraction for the DOM-like API. Each property on a Speaker
262/// is accessed through a PropertyHandle that provides consistent methods for
263/// reading cached values, fetching fresh values, and watching for changes.
264///
265/// # Type Parameter
266///
267/// - `P`: The property type, must implement `SonosProperty`
268///
269/// # Example
270///
271/// ```rust,ignore
272/// // Get cached value (instant, no network call)
273/// let volume = speaker.volume.get();
274///
275/// // Fetch fresh value from device (blocking API call)
276/// let fresh_volume = speaker.volume.fetch()?;
277///
278/// // Watch for changes — hold the handle to keep the subscription alive
279/// let handle = speaker.volume.watch()?;
280/// println!("Volume: {:?}", handle.value());
281/// // Dropping handle starts 50ms grace period
282/// ```
283#[derive(Clone)]
284pub struct PropertyHandle<P: SonosProperty> {
285    context: Arc<SpeakerContext>,
286    _phantom: PhantomData<P>,
287}
288
289impl<P: SonosProperty> PropertyHandle<P> {
290    /// Create a new PropertyHandle from a shared SpeakerContext
291    pub fn new(context: Arc<SpeakerContext>) -> Self {
292        Self {
293            context,
294            _phantom: PhantomData,
295        }
296    }
297
298    /// Get cached property value (sync, instant, no network call)
299    ///
300    /// Returns the currently cached value for this property, or `None` if
301    /// no value has been cached yet. This method never makes network calls.
302    ///
303    /// # Example
304    ///
305    /// ```rust,ignore
306    /// if let Some(volume) = speaker.volume.get() {
307    ///     println!("Current volume: {}%", volume.value());
308    /// }
309    /// ```
310    #[must_use = "returns the cached property value"]
311    pub fn get(&self) -> Option<P> {
312        self.context
313            .state_manager
314            .get_property::<P>(&self.context.speaker_id)
315    }
316
317    /// Start watching this property for changes (sync)
318    ///
319    /// Returns a [`WatchHandle`] that keeps the subscription alive. Hold
320    /// the handle for as long as you need updates — dropping it starts a
321    /// 50ms grace period before the UPnP subscription is torn down.
322    ///
323    /// # Example
324    ///
325    /// ```rust,ignore
326    /// // Watch returns a handle — hold it to keep the subscription alive
327    /// let volume = speaker.volume.watch()?;
328    ///
329    /// // Access the current value via Deref
330    /// if let Some(v) = &*volume {
331    ///     println!("Volume: {}%", v.value());
332    /// }
333    ///
334    /// // Changes will appear in system.iter() while the handle is alive
335    /// for event in system.iter() {
336    ///     // Re-watch each frame to refresh the snapshot
337    ///     let volume = speaker.volume.watch()?;
338    ///     println!("Volume: {:?}", volume.value());
339    /// }
340    /// ```
341    pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
342        tracing::trace!(
343            "watch() called for {:?} on {}",
344            P::SERVICE,
345            self.context.speaker_id.as_str()
346        );
347
348        // Trigger lazy event manager init if needed
349        if self.context.state_manager.event_manager().is_none() {
350            if let Some(init) = self.context.state_manager.event_init() {
351                tracing::debug!(
352                    "Event manager not initialized, triggering lazy init for {:?} on {}",
353                    P::SERVICE,
354                    self.context.speaker_id.as_str()
355                );
356                init().map_err(|e| SdkError::EventManager(e.to_string()))?;
357            } else {
358                tracing::debug!(
359                    "No event_init closure available (test mode?) for {}",
360                    self.context.speaker_id.as_str()
361                );
362            }
363        }
364
365        // Resolve subscription target: for PerCoordinator services, route to coordinator
366        let (sub_id, sub_ip) = self.context.state_manager.resolve_subscription_target(
367            &self.context.speaker_id,
368            self.context.speaker_ip,
369            P::SERVICE,
370        );
371        let routed_to_coordinator = sub_id != self.context.speaker_id;
372
373        let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
374            match em.acquire_watch(&sub_id, P::KEY, sub_ip, P::SERVICE) {
375                Ok(guard) => {
376                    if routed_to_coordinator {
377                        // Register the member's watch for notification forwarding
378                        self.context
379                            .state_manager
380                            .register_watch(&self.context.speaker_id, P::KEY);
381                        (
382                            WatchMode::Events,
383                            WatchCleanup::CoordinatorGuard {
384                                _guard: guard,
385                                _member_cleanup: CacheOnlyGuard {
386                                    state_manager: Arc::clone(&self.context.state_manager),
387                                    speaker_id: self.context.speaker_id.clone(),
388                                    property_key: P::KEY,
389                                },
390                            },
391                        )
392                    } else {
393                        (WatchMode::Events, WatchCleanup::Guard(guard))
394                    }
395                }
396                Err(e) => {
397                    tracing::warn!(
398                        "Failed to subscribe to {:?} for {}: {} - falling back to polling",
399                        P::SERVICE,
400                        self.context.speaker_id.as_str(),
401                        e
402                    );
403                    // Register directly for polling fallback
404                    self.context
405                        .state_manager
406                        .register_watch(&self.context.speaker_id, P::KEY);
407                    (
408                        WatchMode::Polling,
409                        WatchCleanup::CacheOnly(CacheOnlyGuard {
410                            state_manager: Arc::clone(&self.context.state_manager),
411                            speaker_id: self.context.speaker_id.clone(),
412                            property_key: P::KEY,
413                        }),
414                    )
415                }
416            }
417        } else {
418            // No event manager — cache-only mode
419            tracing::warn!(
420                "No event manager available for {} — falling back to cache-only mode",
421                self.context.speaker_id.as_str()
422            );
423            self.context
424                .state_manager
425                .register_watch(&self.context.speaker_id, P::KEY);
426            (
427                WatchMode::CacheOnly,
428                WatchCleanup::CacheOnly(CacheOnlyGuard {
429                    state_manager: Arc::clone(&self.context.state_manager),
430                    speaker_id: self.context.speaker_id.clone(),
431                    property_key: P::KEY,
432                }),
433            )
434        };
435
436        tracing::debug!(
437            "watch() resolved to {:?} for {} on {}",
438            mode,
439            P::KEY,
440            self.context.speaker_id.as_str()
441        );
442
443        Ok(WatchHandle {
444            value: self.get(),
445            mode,
446            _cleanup: cleanup,
447        })
448    }
449
450    /// Check if this property is currently being watched
451    ///
452    /// Returns `true` while *any* `WatchHandle` for this property is alive, or
453    /// during the grace period after the last handle was dropped. Watches are
454    /// reference-counted, so dropping one of several handles leaves this `true`.
455    ///
456    /// # Example
457    ///
458    /// ```rust,ignore
459    /// let handle = speaker.volume.watch()?;
460    /// assert!(speaker.volume.is_watched());
461    ///
462    /// drop(handle); // starts 50ms grace period
463    /// // is_watched() remains true during grace period
464    /// ```
465    #[must_use = "returns whether the property is being watched"]
466    pub fn is_watched(&self) -> bool {
467        self.context
468            .state_manager
469            .is_watched(&self.context.speaker_id, P::KEY)
470    }
471
472    /// Get the speaker ID this handle is associated with
473    pub fn speaker_id(&self) -> &SpeakerId {
474        &self.context.speaker_id
475    }
476
477    /// Get the speaker IP address
478    pub fn speaker_ip(&self) -> IpAddr {
479        self.context.speaker_ip
480    }
481}
482
483// ============================================================================
484// Fetch implementation for Fetchable properties
485// ============================================================================
486
487impl<P: Fetchable> PropertyHandle<P> {
488    /// Watch with lazy fetch: subscribes to events, and if the cache is empty,
489    /// performs a one-time fetch to seed the value.
490    ///
491    /// Use this instead of `watch()` when you need a value on the first frame
492    /// without waiting for a UPnP event to arrive.
493    pub fn watch_or_fetch(&self) -> Result<WatchHandle<P>, SdkError> {
494        let mut wh = self.watch()?;
495        if wh.value.is_none() {
496            match self.fetch() {
497                Ok(val) => wh.value = Some(val),
498                Err(e) => {
499                    tracing::warn!("watch_or_fetch: fetch failed for {}: {e}", P::KEY);
500                }
501            }
502        }
503        Ok(wh)
504    }
505
506    /// Fetch fresh value from device + update cache (sync)
507    ///
508    /// This makes a synchronous UPnP call to the device and updates
509    /// the local state cache with the result.
510    ///
511    /// # Example
512    ///
513    /// ```rust,ignore
514    /// // Fetch fresh volume from device
515    /// let volume = speaker.volume.fetch()?;
516    /// println!("Current volume: {}%", volume.value());
517    ///
518    /// // The cache is now updated, so get() returns the same value
519    /// assert_eq!(speaker.volume.get(), Some(volume));
520    /// ```
521    #[must_use = "returns the fetched value from the device"]
522    pub fn fetch(&self) -> Result<P, SdkError> {
523        let operation = P::build_operation()?;
524
525        // Resolve target: coordinator for PerCoordinator services, fresh IP for PerSpeaker
526        let (target_id, target_ip) = if P::SERVICE.scope() == ServiceScope::PerCoordinator {
527            self.context.state_manager.resolve_subscription_target(
528                &self.context.speaker_id,
529                self.context.speaker_ip,
530                P::SERVICE,
531            )
532        } else {
533            let current_ip = self
534                .context
535                .state_manager
536                .get_speaker_ip(&self.context.speaker_id)
537                .unwrap_or(self.context.speaker_ip);
538            (self.context.speaker_id.clone(), current_ip)
539        };
540
541        let response = self
542            .context
543            .api_client
544            .execute_enhanced(&target_ip.to_string(), operation)
545            .map_err(SdkError::ApiError)?;
546
547        let property_value = P::from_response(response);
548
549        // Store under target_id (coordinator for PerCoordinator, self for PerSpeaker)
550        self.context
551            .state_manager
552            .set_property(&target_id, property_value.clone());
553
554        Ok(property_value)
555    }
556}
557
558// ============================================================================
559// Concrete fetch for FetchableWithContext properties
560// ============================================================================
561//
562// Rust does not allow two generic impl blocks (Fetchable + FetchableWithContext)
563// defining the same `fetch()` method, so context-dependent properties get a
564// concrete impl instead.
565
566impl PropertyHandle<GroupMembership> {
567    /// Fetch fresh value from device using speaker context + update cache (sync)
568    ///
569    /// The response is interpreted using the speaker_id to extract the relevant
570    /// property value from the full topology response.
571    #[must_use = "returns the fetched value from the device"]
572    pub fn fetch(&self) -> Result<GroupMembership, SdkError> {
573        let operation = <GroupMembership as FetchableWithContext>::build_operation()?;
574
575        let response = self
576            .context
577            .api_client
578            .execute_enhanced(&self.context.speaker_ip.to_string(), operation)
579            .map_err(SdkError::ApiError)?;
580
581        let property_value =
582            GroupMembership::from_response_with_context(response, &self.context.speaker_id)
583                .ok_or_else(|| {
584                    SdkError::FetchFailed(format!(
585                        "Speaker {} not found in topology response",
586                        self.context.speaker_id.as_str()
587                    ))
588                })?;
589
590        self.context
591            .state_manager
592            .set_property(&self.context.speaker_id, property_value.clone());
593
594        Ok(property_value)
595    }
596}
597
598// ============================================================================
599// Type aliases for common property handles
600// ============================================================================
601
602use sonos_api::services::{
603    av_transport::{
604        self, GetPositionInfoOperation, GetPositionInfoResponse, GetTransportInfoOperation,
605        GetTransportInfoResponse,
606    },
607    group_rendering_control::{
608        self, GetGroupMuteOperation, GetGroupMuteResponse, GetGroupVolumeOperation,
609        GetGroupVolumeResponse,
610    },
611    rendering_control::{
612        self, GetBassOperation, GetBassResponse, GetLoudnessOperation, GetLoudnessResponse,
613        GetMuteOperation, GetMuteResponse, GetTrebleOperation, GetTrebleResponse,
614        GetVolumeOperation, GetVolumeResponse,
615    },
616    zone_group_topology::{self, GetZoneGroupStateOperation, GetZoneGroupStateResponse},
617};
618use sonos_state::{
619    Bass, CurrentTrack, GroupId, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
620    Loudness, Mute, PlaybackState, Position, Treble, Volume,
621};
622
623// ============================================================================
624// Helper functions
625// ============================================================================
626
627/// Helper to create consistent error messages for operation build failures
628fn build_error<E: std::fmt::Display>(operation_name: &str, e: E) -> SdkError {
629    SdkError::FetchFailed(format!("Failed to build {operation_name} operation: {e}"))
630}
631
632// ============================================================================
633// Fetchable implementations
634// ============================================================================
635
636impl Fetchable for Volume {
637    type Operation = GetVolumeOperation;
638
639    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
640        rendering_control::get_volume_operation("Master".to_string())
641            .build()
642            .map_err(|e| build_error("GetVolume", e))
643    }
644
645    fn from_response(response: GetVolumeResponse) -> Self {
646        Volume::new(response.current_volume)
647    }
648}
649
650impl Fetchable for PlaybackState {
651    type Operation = GetTransportInfoOperation;
652
653    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
654        av_transport::get_transport_info_operation()
655            .build()
656            .map_err(|e| build_error("GetTransportInfo", e))
657    }
658
659    fn from_response(response: GetTransportInfoResponse) -> Self {
660        match response.current_transport_state.as_str() {
661            "PLAYING" => PlaybackState::Playing,
662            "PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
663            "STOPPED" => PlaybackState::Stopped,
664            _ => PlaybackState::Transitioning,
665        }
666    }
667}
668
669impl Fetchable for Position {
670    type Operation = GetPositionInfoOperation;
671
672    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
673        av_transport::get_position_info_operation()
674            .build()
675            .map_err(|e| build_error("GetPositionInfo", e))
676    }
677
678    fn from_response(response: GetPositionInfoResponse) -> Self {
679        let position_ms = Position::parse_time_to_ms(&response.rel_time).unwrap_or(0);
680        let duration_ms = Position::parse_time_to_ms(&response.track_duration).unwrap_or(0);
681        Position::new(position_ms, duration_ms)
682    }
683}
684
685impl Fetchable for Mute {
686    type Operation = GetMuteOperation;
687
688    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
689        rendering_control::get_mute_operation("Master".to_string())
690            .build()
691            .map_err(|e| build_error("GetMute", e))
692    }
693
694    fn from_response(response: GetMuteResponse) -> Self {
695        Mute::new(response.current_mute)
696    }
697}
698
699impl Fetchable for Bass {
700    type Operation = GetBassOperation;
701
702    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
703        rendering_control::get_bass_operation()
704            .build()
705            .map_err(|e| build_error("GetBass", e))
706    }
707
708    fn from_response(response: GetBassResponse) -> Self {
709        Bass::new(response.current_bass)
710    }
711}
712
713impl Fetchable for Treble {
714    type Operation = GetTrebleOperation;
715
716    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
717        rendering_control::get_treble_operation()
718            .build()
719            .map_err(|e| build_error("GetTreble", e))
720    }
721
722    fn from_response(response: GetTrebleResponse) -> Self {
723        Treble::new(response.current_treble)
724    }
725}
726
727impl Fetchable for Loudness {
728    type Operation = GetLoudnessOperation;
729
730    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
731        rendering_control::get_loudness_operation("Master".to_string())
732            .build()
733            .map_err(|e| build_error("GetLoudness", e))
734    }
735
736    fn from_response(response: GetLoudnessResponse) -> Self {
737        Loudness::new(response.current_loudness)
738    }
739}
740
741impl Fetchable for CurrentTrack {
742    type Operation = GetPositionInfoOperation;
743
744    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
745        av_transport::get_position_info_operation()
746            .build()
747            .map_err(|e| build_error("GetPositionInfo", e))
748    }
749
750    fn from_response(response: GetPositionInfoResponse) -> Self {
751        let metadata = if response.track_meta_data.is_empty()
752            || response.track_meta_data == "NOT_IMPLEMENTED"
753        {
754            None
755        } else {
756            Some(response.track_meta_data.as_str())
757        };
758        let (title, artist, album, album_art_uri) = sonos_state::parse_track_metadata(metadata);
759        CurrentTrack {
760            title,
761            artist,
762            album,
763            album_art_uri,
764            uri: Some(response.track_uri).filter(|s| !s.is_empty()),
765        }
766    }
767}
768
769// ============================================================================
770// FetchableWithContext implementations
771// ============================================================================
772
773impl FetchableWithContext for GroupMembership {
774    type Operation = GetZoneGroupStateOperation;
775
776    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
777        zone_group_topology::get_zone_group_state_operation()
778            .build()
779            .map_err(|e| build_error("GetZoneGroupState", e))
780    }
781
782    fn from_response_with_context(
783        response: GetZoneGroupStateResponse,
784        speaker_id: &SpeakerId,
785    ) -> Option<Self> {
786        let zone_groups =
787            zone_group_topology::parse_zone_group_state_xml(&response.zone_group_state).ok()?;
788
789        for group in &zone_groups {
790            let is_member = group.members.iter().any(|m| m.uuid == speaker_id.as_str());
791            if is_member {
792                let is_coordinator = group.coordinator == speaker_id.as_str();
793                return Some(GroupMembership::new(
794                    GroupId::new(&group.id),
795                    is_coordinator,
796                ));
797            }
798        }
799
800        None
801    }
802}
803
804// ============================================================================
805// Event-only properties (no dedicated UPnP Get operation)
806// ============================================================================
807//
808// GroupVolumeChangeable is the only remaining event-only property — there is
809// no GetGroupVolumeChangeable operation in the Sonos UPnP API. Its value
810// is obtained exclusively from GroupRenderingControl events.
811//
812// All other properties now have fetch() via Fetchable, FetchableWithContext,
813// or GroupFetchable trait implementations.
814
815// ============================================================================
816// Type aliases
817// ============================================================================
818
819/// Handle for speaker volume (0-100)
820pub type VolumeHandle = PropertyHandle<Volume>;
821
822/// Handle for playback state (Playing/Paused/Stopped)
823pub type PlaybackStateHandle = PropertyHandle<PlaybackState>;
824
825/// Handle for mute state
826pub type MuteHandle = PropertyHandle<Mute>;
827
828/// Handle for bass EQ setting (-10 to +10)
829pub type BassHandle = PropertyHandle<Bass>;
830
831/// Handle for treble EQ setting (-10 to +10)
832pub type TrebleHandle = PropertyHandle<Treble>;
833
834/// Handle for loudness compensation setting
835pub type LoudnessHandle = PropertyHandle<Loudness>;
836
837/// Handle for current playback position
838pub type PositionHandle = PropertyHandle<Position>;
839
840/// Handle for current track information
841pub type CurrentTrackHandle = PropertyHandle<CurrentTrack>;
842
843/// Handle for group membership information
844pub type GroupMembershipHandle = PropertyHandle<GroupMembership>;
845
846// ============================================================================
847// Group Property Handles
848// ============================================================================
849
850/// Shared context for all property handles on a group
851///
852/// Analogous to `SpeakerContext` but scoped to a group. Operations are
853/// executed against the group's coordinator speaker.
854#[derive(Clone)]
855pub struct GroupContext {
856    pub(crate) group_id: GroupId,
857    pub(crate) coordinator_id: SpeakerId,
858    pub(crate) coordinator_ip: IpAddr,
859    pub(crate) state_manager: Arc<StateManager>,
860    pub(crate) api_client: SonosClient,
861}
862
863impl GroupContext {
864    /// Create a new GroupContext
865    pub fn new(
866        group_id: GroupId,
867        coordinator_id: SpeakerId,
868        coordinator_ip: IpAddr,
869        state_manager: Arc<StateManager>,
870        api_client: SonosClient,
871    ) -> Arc<Self> {
872        Arc::new(Self {
873            group_id,
874            coordinator_id,
875            coordinator_ip,
876            state_manager,
877            api_client,
878        })
879    }
880}
881
882/// Generic property handle for group-scoped properties
883///
884/// Provides the same get/fetch/watch/unwatch pattern as `PropertyHandle`,
885/// but reads from the group property store and executes API calls against
886/// the group's coordinator.
887#[derive(Clone)]
888pub struct GroupPropertyHandle<P: SonosProperty> {
889    context: Arc<GroupContext>,
890    _phantom: PhantomData<P>,
891}
892
893impl<P: SonosProperty> GroupPropertyHandle<P> {
894    /// Create a new GroupPropertyHandle from a shared GroupContext
895    pub fn new(context: Arc<GroupContext>) -> Self {
896        Self {
897            context,
898            _phantom: PhantomData,
899        }
900    }
901
902    /// Get cached group property value (sync, instant, no network call)
903    #[must_use = "returns the cached property value"]
904    pub fn get(&self) -> Option<P> {
905        self.context
906            .state_manager
907            .get_group_property::<P>(&self.context.group_id)
908    }
909
910    /// Start watching this group property for changes (sync)
911    ///
912    /// Returns a [`WatchHandle`] scoped to the group coordinator.
913    /// Hold the handle to keep the subscription alive.
914    pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
915        // Trigger lazy event manager init if needed
916        if self.context.state_manager.event_manager().is_none() {
917            if let Some(init) = self.context.state_manager.event_init() {
918                tracing::debug!(
919                    "Event manager not initialized, triggering lazy init for group {:?} on {}",
920                    P::SERVICE,
921                    self.context.group_id.as_str()
922                );
923                init().map_err(|e| SdkError::EventManager(e.to_string()))?;
924            } else {
925                tracing::debug!(
926                    "No event_init closure available (test mode?) for group {}",
927                    self.context.group_id.as_str()
928                );
929            }
930        }
931
932        let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
933            match em.acquire_watch(
934                &self.context.coordinator_id,
935                P::KEY,
936                self.context.coordinator_ip,
937                P::SERVICE,
938            ) {
939                Ok(guard) => (WatchMode::Events, WatchCleanup::Guard(guard)),
940                Err(e) => {
941                    tracing::warn!(
942                        "Failed to subscribe to {:?} for group {}: {} - falling back to polling",
943                        P::SERVICE,
944                        self.context.group_id.as_str(),
945                        e
946                    );
947                    self.context
948                        .state_manager
949                        .register_watch(&self.context.coordinator_id, P::KEY);
950                    (
951                        WatchMode::Polling,
952                        WatchCleanup::CacheOnly(CacheOnlyGuard {
953                            state_manager: Arc::clone(&self.context.state_manager),
954                            speaker_id: self.context.coordinator_id.clone(),
955                            property_key: P::KEY,
956                        }),
957                    )
958                }
959            }
960        } else {
961            self.context
962                .state_manager
963                .register_watch(&self.context.coordinator_id, P::KEY);
964            (
965                WatchMode::CacheOnly,
966                WatchCleanup::CacheOnly(CacheOnlyGuard {
967                    state_manager: Arc::clone(&self.context.state_manager),
968                    speaker_id: self.context.coordinator_id.clone(),
969                    property_key: P::KEY,
970                }),
971            )
972        };
973
974        Ok(WatchHandle {
975            value: self.get(),
976            mode,
977            _cleanup: cleanup,
978        })
979    }
980
981    /// Check if this group property is currently being watched
982    #[must_use = "returns whether the property is being watched"]
983    pub fn is_watched(&self) -> bool {
984        self.context
985            .state_manager
986            .is_watched(&self.context.coordinator_id, P::KEY)
987    }
988
989    /// Get the group ID this handle is associated with
990    pub fn group_id(&self) -> &GroupId {
991        &self.context.group_id
992    }
993}
994
995/// Trait for group properties that can be fetched from the coordinator
996pub trait GroupFetchable: SonosProperty {
997    /// The UPnP operation type used to fetch this property
998    type Operation: UPnPOperation;
999
1000    /// Build the operation to fetch this property
1001    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
1002
1003    /// Convert the operation response to the property value
1004    fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
1005}
1006
1007impl<P: GroupFetchable> GroupPropertyHandle<P> {
1008    /// Watch with lazy fetch: subscribes to events, and if the cache is empty,
1009    /// performs a one-time fetch from the coordinator to seed the value.
1010    pub fn watch_or_fetch(&self) -> Result<WatchHandle<P>, SdkError> {
1011        let mut wh = self.watch()?;
1012        if wh.value.is_none() {
1013            match self.fetch() {
1014                Ok(val) => wh.value = Some(val),
1015                Err(e) => {
1016                    tracing::warn!(
1017                        "watch_or_fetch: fetch failed for group {} {}: {e}",
1018                        self.context.group_id.as_str(),
1019                        P::KEY
1020                    );
1021                }
1022            }
1023        }
1024        Ok(wh)
1025    }
1026
1027    /// Fetch fresh value from coordinator + update group cache (sync)
1028    #[must_use = "returns the fetched value from the device"]
1029    pub fn fetch(&self) -> Result<P, SdkError> {
1030        let operation = P::build_operation()?;
1031
1032        let response = self
1033            .context
1034            .api_client
1035            .execute_enhanced(&self.context.coordinator_ip.to_string(), operation)
1036            .map_err(SdkError::ApiError)?;
1037
1038        let property_value = P::from_response(response);
1039
1040        self.context
1041            .state_manager
1042            .set_group_property(&self.context.group_id, property_value.clone());
1043
1044        Ok(property_value)
1045    }
1046}
1047
1048// ============================================================================
1049// GroupFetchable implementations
1050// ============================================================================
1051
1052impl GroupFetchable for GroupVolume {
1053    type Operation = GetGroupVolumeOperation;
1054
1055    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
1056        group_rendering_control::get_group_volume()
1057            .build()
1058            .map_err(|e| build_error("GetGroupVolume", e))
1059    }
1060
1061    fn from_response(response: GetGroupVolumeResponse) -> Self {
1062        GroupVolume::new(response.current_volume)
1063    }
1064}
1065
1066impl GroupFetchable for GroupMute {
1067    type Operation = GetGroupMuteOperation;
1068
1069    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
1070        group_rendering_control::get_group_mute()
1071            .build()
1072            .map_err(|e| build_error("GetGroupMute", e))
1073    }
1074
1075    fn from_response(response: GetGroupMuteResponse) -> Self {
1076        GroupMute::new(response.current_mute)
1077    }
1078}
1079
1080// ============================================================================
1081// Group type aliases
1082// ============================================================================
1083
1084/// Handle for group volume (0-100)
1085pub type GroupVolumeHandle = GroupPropertyHandle<GroupVolume>;
1086
1087/// Handle for group mute state
1088pub type GroupMuteHandle = GroupPropertyHandle<GroupMute>;
1089
1090/// Handle for group volume changeable flag (event-only, no fetch)
1091pub type GroupVolumeChangeableHandle = GroupPropertyHandle<GroupVolumeChangeable>;
1092
1093#[cfg(test)]
1094mod tests {
1095    use super::*;
1096    use sonos_discovery::Device;
1097    use sonos_state::Property;
1098
1099    fn create_test_state_manager() -> Arc<StateManager> {
1100        let manager = StateManager::new().unwrap();
1101        let devices = vec![Device {
1102            id: "RINCON_TEST123".to_string(),
1103            name: "Test Speaker".to_string(),
1104            room_name: "Test Room".to_string(),
1105            ip_address: "192.168.1.100".to_string(),
1106            port: 1400,
1107            model_name: "Sonos One".to_string(),
1108        }];
1109        manager.add_devices(devices).unwrap();
1110        Arc::new(manager)
1111    }
1112
1113    fn create_test_context(state_manager: Arc<StateManager>) -> Arc<SpeakerContext> {
1114        SpeakerContext::new(
1115            SpeakerId::new("RINCON_TEST123"),
1116            "192.168.1.100".parse().unwrap(),
1117            state_manager,
1118            SonosClient::new(),
1119        )
1120    }
1121
1122    #[test]
1123    fn test_property_handle_creation() {
1124        let state_manager = create_test_state_manager();
1125        let context = create_test_context(state_manager);
1126        let speaker_ip: IpAddr = "192.168.1.100".parse().unwrap();
1127
1128        let handle: VolumeHandle = PropertyHandle::new(context);
1129
1130        assert_eq!(handle.speaker_id().as_str(), "RINCON_TEST123");
1131        assert_eq!(handle.speaker_ip(), speaker_ip);
1132    }
1133
1134    #[test]
1135    fn test_get_returns_none_initially() {
1136        let state_manager = create_test_state_manager();
1137        let context = create_test_context(state_manager);
1138
1139        let handle: VolumeHandle = PropertyHandle::new(context);
1140
1141        assert!(handle.get().is_none());
1142    }
1143
1144    #[test]
1145    fn test_get_returns_cached_value() {
1146        let state_manager = create_test_state_manager();
1147        let speaker_id = SpeakerId::new("RINCON_TEST123");
1148
1149        state_manager.set_property(&speaker_id, Volume::new(75));
1150
1151        let context = create_test_context(Arc::clone(&state_manager));
1152        let handle: VolumeHandle = PropertyHandle::new(context);
1153
1154        assert_eq!(handle.get(), Some(Volume::new(75)));
1155    }
1156
1157    #[test]
1158    fn test_watch_registers_property() {
1159        let state_manager = create_test_state_manager();
1160        let context = create_test_context(Arc::clone(&state_manager));
1161
1162        let handle: VolumeHandle = PropertyHandle::new(context);
1163
1164        assert!(!handle.is_watched());
1165        let _wh = handle.watch().unwrap();
1166        assert!(handle.is_watched());
1167    }
1168
1169    #[test]
1170    fn test_drop_watch_handle_unregisters_property() {
1171        let state_manager = create_test_state_manager();
1172        let context = create_test_context(Arc::clone(&state_manager));
1173
1174        let handle: VolumeHandle = PropertyHandle::new(context);
1175
1176        let wh = handle.watch().unwrap();
1177        assert!(handle.is_watched());
1178
1179        drop(wh);
1180        assert!(!handle.is_watched());
1181    }
1182
1183    /// Dropping one `WatchHandle` must not silence a sibling property.
1184    ///
1185    /// Volume and Mute both belong to RenderingControl, so they shared a
1186    /// subscription and shared one `(ip, service)` ref count. Two overlapping
1187    /// handles are exactly what the re-watch-per-frame pattern in `watch()`'s
1188    /// own docs produces: frame N+1 acquires before frame N's handle drops. With
1189    /// a set-valued watched map the *first* drop removed the only entry, so the
1190    /// surviving handle went silent while the caller still held it — `is_watched()`
1191    /// said `false` and `system.iter()` stopped reporting the property.
1192    ///
1193    /// Asserts delivery, not just the flag: a watch that is "registered" but no
1194    /// longer emits is the failure users would actually see.
1195    #[test]
1196    fn test_dropping_one_of_two_handles_keeps_property_emitting() {
1197        let state_manager = create_test_state_manager();
1198        let speaker_id = SpeakerId::new("RINCON_TEST123");
1199        let context = create_test_context(Arc::clone(&state_manager));
1200
1201        let volume: VolumeHandle = PropertyHandle::new(Arc::clone(&context));
1202        let mute: MuteHandle = PropertyHandle::new(context);
1203
1204        let first = volume.watch().unwrap();
1205        let second = volume.watch().unwrap();
1206        // A sibling property of the same service, held once.
1207        let _mute_watch = mute.watch().unwrap();
1208        assert!(volume.is_watched());
1209        assert!(mute.is_watched());
1210
1211        drop(first);
1212
1213        assert!(
1214            volume.is_watched(),
1215            "one of two Volume handles dropped — the property must stay watched"
1216        );
1217        assert!(
1218            mute.is_watched(),
1219            "releasing a Volume handle must not disturb its RenderingControl sibling"
1220        );
1221
1222        // The surviving handle must still receive events, not merely be flagged.
1223        state_manager.set_property(&speaker_id, Volume::new(11));
1224        state_manager.set_property(&speaker_id, Mute::new(true));
1225
1226        let iter = state_manager.iter();
1227        let first_event = iter
1228            .recv_timeout(std::time::Duration::from_millis(100))
1229            .expect("Volume is still held by `second` and must still emit");
1230        assert_eq!(first_event.property_key, Volume::KEY);
1231        let second_event = iter
1232            .recv_timeout(std::time::Duration::from_millis(100))
1233            .expect("Mute is still held and must still emit");
1234        assert_eq!(second_event.property_key, Mute::KEY);
1235
1236        // Last holder goes away: now it really stops.
1237        drop(second);
1238        assert!(!volume.is_watched());
1239        state_manager.set_property(&speaker_id, Volume::new(22));
1240        assert!(
1241            iter.recv_timeout(std::time::Duration::from_millis(50))
1242                .is_none(),
1243            "with every Volume handle dropped the property must stop emitting"
1244        );
1245    }
1246
1247    #[test]
1248    fn test_watch_returns_current_value() {
1249        let state_manager = create_test_state_manager();
1250        let speaker_id = SpeakerId::new("RINCON_TEST123");
1251
1252        state_manager.set_property(&speaker_id, Volume::new(50));
1253
1254        let context = create_test_context(Arc::clone(&state_manager));
1255        let handle: VolumeHandle = PropertyHandle::new(context);
1256
1257        let wh = handle.watch().unwrap();
1258        assert_eq!(*wh, Some(Volume::new(50)));
1259        assert_eq!(wh.value(), Some(&Volume::new(50)));
1260        // No event manager configured, so should be CacheOnly mode
1261        assert_eq!(wh.mode(), WatchMode::CacheOnly);
1262    }
1263
1264    #[test]
1265    fn test_watch_handle_deref() {
1266        let state_manager = create_test_state_manager();
1267        let speaker_id = SpeakerId::new("RINCON_TEST123");
1268
1269        state_manager.set_property(&speaker_id, Volume::new(75));
1270
1271        let context = create_test_context(Arc::clone(&state_manager));
1272        let handle: VolumeHandle = PropertyHandle::new(context);
1273
1274        let wh = handle.watch().unwrap();
1275        // Deref<Target = Option<P>>
1276        assert!(wh.has_value());
1277        assert!(!wh.has_realtime_events());
1278        if let Some(v) = &*wh {
1279            assert_eq!(v.value(), 75);
1280        } else {
1281            panic!("Expected Some value");
1282        }
1283    }
1284
1285    #[test]
1286    fn test_property_handle_clone() {
1287        let state_manager = create_test_state_manager();
1288        let speaker_id = SpeakerId::new("RINCON_TEST123");
1289
1290        state_manager.set_property(&speaker_id, Volume::new(60));
1291
1292        let context = create_test_context(Arc::clone(&state_manager));
1293        let handle: VolumeHandle = PropertyHandle::new(context);
1294
1295        let cloned = handle.clone();
1296
1297        assert_eq!(handle.get(), cloned.get());
1298        assert_eq!(handle.get(), Some(Volume::new(60)));
1299    }
1300
1301    // ========================================================================
1302    // Group property handle tests
1303    // ========================================================================
1304
1305    fn create_test_group_context(state_manager: Arc<StateManager>) -> Arc<GroupContext> {
1306        GroupContext::new(
1307            GroupId::new("RINCON_TEST123:1"),
1308            SpeakerId::new("RINCON_TEST123"),
1309            "192.168.1.100".parse().unwrap(),
1310            state_manager,
1311            SonosClient::new(),
1312        )
1313    }
1314
1315    #[test]
1316    fn test_group_property_handle_get_returns_none_initially() {
1317        let state_manager = create_test_state_manager();
1318        let context = create_test_group_context(state_manager);
1319
1320        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1321
1322        assert!(handle.get().is_none());
1323    }
1324
1325    #[test]
1326    fn test_group_property_handle_get_returns_cached_value() {
1327        let state_manager = create_test_state_manager();
1328        let group_id = GroupId::new("RINCON_TEST123:1");
1329
1330        // Store a group property value
1331        state_manager.set_group_property(&group_id, GroupVolume::new(65));
1332
1333        let context = create_test_group_context(Arc::clone(&state_manager));
1334        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1335
1336        assert_eq!(handle.get(), Some(GroupVolume::new(65)));
1337    }
1338
1339    #[test]
1340    fn test_group_property_handle_watch_and_drop() {
1341        let state_manager = create_test_state_manager();
1342        let context = create_test_group_context(Arc::clone(&state_manager));
1343
1344        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1345
1346        assert!(!handle.is_watched());
1347        let wh = handle.watch().unwrap();
1348        assert!(handle.is_watched());
1349
1350        drop(wh);
1351        assert!(!handle.is_watched());
1352    }
1353
1354    #[test]
1355    fn test_group_property_handle_group_id() {
1356        let state_manager = create_test_state_manager();
1357        let context = create_test_group_context(state_manager);
1358
1359        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1360
1361        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1362    }
1363
1364    #[test]
1365    fn test_group_mute_handle_accessible() {
1366        let state_manager = create_test_state_manager();
1367        let context = create_test_group_context(state_manager);
1368
1369        let handle: GroupMuteHandle = GroupPropertyHandle::new(context);
1370
1371        assert!(handle.get().is_none());
1372        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1373    }
1374
1375    #[test]
1376    fn test_group_volume_changeable_handle_accessible() {
1377        let state_manager = create_test_state_manager();
1378        let context = create_test_group_context(state_manager);
1379
1380        let handle: GroupVolumeChangeableHandle = GroupPropertyHandle::new(context);
1381
1382        assert!(handle.get().is_none());
1383        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1384    }
1385
1386    // ========================================================================
1387    // Trait implementation assertions
1388    // ========================================================================
1389
1390    #[test]
1391    fn test_fetchable_impls_exist() {
1392        fn assert_fetchable<T: Fetchable>() {}
1393        assert_fetchable::<Volume>();
1394        assert_fetchable::<PlaybackState>();
1395        assert_fetchable::<Position>();
1396        assert_fetchable::<Mute>();
1397        assert_fetchable::<Bass>();
1398        assert_fetchable::<Treble>();
1399        assert_fetchable::<Loudness>();
1400        assert_fetchable::<CurrentTrack>();
1401    }
1402
1403    #[test]
1404    fn test_fetchable_with_context_impls_exist() {
1405        fn assert_fetchable_with_context<T: FetchableWithContext>() {}
1406        assert_fetchable_with_context::<GroupMembership>();
1407    }
1408
1409    #[test]
1410    fn test_group_fetchable_impls_exist() {
1411        fn assert_group_fetchable<T: GroupFetchable>() {}
1412        assert_group_fetchable::<GroupVolume>();
1413        assert_group_fetchable::<GroupMute>();
1414    }
1415}