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