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::SonosClient;
16use sonos_event_manager::WatchGuard;
17use sonos_state::{property::SonosProperty, SpeakerId, StateManager};
18
19use crate::SdkError;
20
21/// Closure type for lazy event manager initialization.
22///
23/// Called by `PropertyHandle::watch()` to trigger event manager creation
24/// on first use. Captures shared `Arc`s to the system's `Mutex` and
25/// `StateManager`, avoiding a direct reference to `SonosSystem`.
26pub type EventInitFn = Arc<dyn Fn() -> Result<(), SdkError> + Send + Sync>;
27
28/// Shared context for all property handles on a speaker
29///
30/// This struct holds the common data needed by all PropertyHandles,
31/// allowing them to share a single Arc instead of duplicating data.
32#[derive(Clone)]
33pub struct SpeakerContext {
34    pub(crate) speaker_id: SpeakerId,
35    pub(crate) speaker_ip: IpAddr,
36    pub(crate) state_manager: Arc<StateManager>,
37    pub(crate) api_client: SonosClient,
38    /// Optional closure to trigger lazy event manager initialization.
39    /// `None` in test mode (no event infrastructure).
40    pub(crate) event_init: Option<EventInitFn>,
41}
42
43impl SpeakerContext {
44    /// Create a new SpeakerContext
45    pub fn new(
46        speaker_id: SpeakerId,
47        speaker_ip: IpAddr,
48        state_manager: Arc<StateManager>,
49        api_client: SonosClient,
50    ) -> Arc<Self> {
51        Arc::new(Self {
52            speaker_id,
53            speaker_ip,
54            state_manager,
55            api_client,
56            event_init: None,
57        })
58    }
59
60    /// Create a new SpeakerContext with an event init closure
61    pub fn with_event_init(
62        speaker_id: SpeakerId,
63        speaker_ip: IpAddr,
64        state_manager: Arc<StateManager>,
65        api_client: SonosClient,
66        event_init: EventInitFn,
67    ) -> Arc<Self> {
68        Arc::new(Self {
69            speaker_id,
70            speaker_ip,
71            state_manager,
72            api_client,
73            event_init: Some(event_init),
74        })
75    }
76}
77
78// ============================================================================
79// Watch status types
80// ============================================================================
81
82/// How property updates will be delivered after calling `watch()`
83///
84/// This enum indicates the mechanism that will be used to receive property
85/// updates. The SDK automatically selects the best available method.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum WatchMode {
88    /// UPnP event subscription is active - real-time updates will be received
89    ///
90    /// This is the preferred mode, providing immediate notifications when
91    /// properties change on the device.
92    Events,
93
94    /// UPnP subscription failed, updates may come via polling fallback
95    ///
96    /// The event manager was configured but subscription failed (possibly due
97    /// to firewall). The SDK's polling fallback may still provide updates,
98    /// but they won't be real-time.
99    Polling,
100
101    /// No event manager configured - cache-only mode
102    ///
103    /// Properties will only update when explicitly fetched via `fetch()`.
104    /// Call `system.configure_events()` to enable automatic updates.
105    CacheOnly,
106}
107
108impl fmt::Display for WatchMode {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        match self {
111            WatchMode::Events => write!(f, "Events (real-time)"),
112            WatchMode::Polling => write!(f, "Polling (fallback)"),
113            WatchMode::CacheOnly => write!(f, "CacheOnly (no events)"),
114        }
115    }
116}
117
118/// RAII handle returned by `watch()`. Holds a snapshot of the current value
119/// along with a subscription guard. Dropping the handle starts the grace
120/// period — the UPnP subscription persists for 50ms so it can be reacquired
121/// cheaply on the next frame.
122///
123/// Not `Clone` — each handle is one subscription hold.
124///
125/// # Example
126///
127/// ```rust,ignore
128/// // Watch returns a handle — hold it to keep the subscription alive
129/// let volume = speaker.volume.watch()?;
130///
131/// // Deref to Option<P> for ergonomic access
132/// if let Some(v) = &*volume {
133///     println!("Volume: {}%", v.value());
134/// }
135///
136/// // Or use the value() convenience method
137/// if let Some(v) = volume.value() {
138///     println!("Volume: {}%", v.value());
139/// }
140///
141/// // Dropping the handle starts the 50ms grace period
142/// drop(volume);
143/// ```
144#[must_use = "dropping the handle starts the grace period — hold it to keep the subscription alive"]
145pub struct WatchHandle<P> {
146    value: Option<P>,
147    mode: WatchMode,
148    _cleanup: WatchCleanup,
149}
150
151impl<P> Deref for WatchHandle<P> {
152    type Target = Option<P>;
153    fn deref(&self) -> &Self::Target {
154        &self.value
155    }
156}
157
158impl<P> WatchHandle<P> {
159    /// Returns the watch mode (Events, Polling, or CacheOnly).
160    pub fn mode(&self) -> WatchMode {
161        self.mode
162    }
163
164    /// Convenience: returns a reference to the inner value, if available.
165    /// Equivalent to `(*handle).as_ref()` but more ergonomic.
166    pub fn value(&self) -> Option<&P> {
167        self.value.as_ref()
168    }
169
170    /// Returns true if a value has been received from the device.
171    pub fn has_value(&self) -> bool {
172        self.value.is_some()
173    }
174
175    /// Returns true if real-time UPnP events are active.
176    pub fn has_realtime_events(&self) -> bool {
177        self.mode == WatchMode::Events
178    }
179}
180
181impl<P: fmt::Debug> fmt::Debug for WatchHandle<P> {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.debug_struct("WatchHandle")
184            .field("value", &self.value)
185            .field("mode", &self.mode)
186            .finish()
187    }
188}
189
190/// Internal cleanup strategy for WatchHandle.
191///
192/// - `Guard`: Event manager is active — WatchGuard handles the subscription
193///   lifecycle (ref counting, grace period, unsubscribe).
194/// - `CacheOnly`: No event manager — just unregisters from the watched set.
195///
196/// Fields are never read — they exist solely for their Drop behavior.
197#[allow(dead_code)]
198enum WatchCleanup {
199    Guard(WatchGuard),
200    CacheOnly(CacheOnlyGuard),
201}
202
203/// Cleanup guard for CacheOnly mode (no event manager).
204/// Unregisters the property from the watched set on drop.
205struct CacheOnlyGuard {
206    state_manager: Arc<StateManager>,
207    speaker_id: SpeakerId,
208    property_key: &'static str,
209}
210
211impl Drop for CacheOnlyGuard {
212    fn drop(&mut self) {
213        self.state_manager
214            .unregister_watch(&self.speaker_id, self.property_key);
215    }
216}
217
218/// Trait for properties that can be fetched from the device
219///
220/// This trait defines how to fetch a property value from a Sonos device.
221/// Each property type that supports fetching must implement this trait.
222///
223/// # Type Parameters
224///
225/// - `Op`: The UPnP operation type used to fetch this property
226///
227/// # Example
228///
229/// ```rust,ignore
230/// impl Fetchable for Volume {
231///     type Operation = GetVolumeOperation;
232///
233///     fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
234///         rendering_control::get_volume_operation("Master".to_string())
235///             .build()
236///             .map_err(|e| SdkError::FetchFailed(e.to_string()))
237///     }
238///
239///     fn from_response(response: GetVolumeResponse) -> Self {
240///         Volume::new(response.current_volume)
241///     }
242/// }
243/// ```
244pub trait Fetchable: SonosProperty {
245    /// The UPnP operation type used to fetch this property
246    type Operation: UPnPOperation;
247
248    /// Build the operation to fetch this property
249    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
250
251    /// Convert the operation response to the property value
252    fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
253}
254
255/// Trait for properties that require context (e.g., speaker_id) to interpret the response
256///
257/// Unlike `Fetchable`, the response contains data for multiple entities and
258/// the correct one must be extracted using context.
259pub trait FetchableWithContext: SonosProperty {
260    /// The UPnP operation type used to fetch this property
261    type Operation: UPnPOperation;
262
263    /// Build the operation to fetch this property
264    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
265
266    /// Convert the operation response to the property value using speaker context
267    fn from_response_with_context(
268        response: <Self::Operation as UPnPOperation>::Response,
269        speaker_id: &SpeakerId,
270    ) -> Option<Self>;
271}
272
273/// Generic property handle providing get/fetch/watch/unwatch pattern
274///
275/// This is the core abstraction for the DOM-like API. Each property on a Speaker
276/// is accessed through a PropertyHandle that provides consistent methods for
277/// reading cached values, fetching fresh values, and watching for changes.
278///
279/// # Type Parameter
280///
281/// - `P`: The property type, must implement `SonosProperty`
282///
283/// # Example
284///
285/// ```rust,ignore
286/// // Get cached value (instant, no network call)
287/// let volume = speaker.volume.get();
288///
289/// // Fetch fresh value from device (blocking API call)
290/// let fresh_volume = speaker.volume.fetch()?;
291///
292/// // Watch for changes — hold the handle to keep the subscription alive
293/// let handle = speaker.volume.watch()?;
294/// println!("Volume: {:?}", handle.value());
295/// // Dropping handle starts 50ms grace period
296/// ```
297#[derive(Clone)]
298pub struct PropertyHandle<P: SonosProperty> {
299    context: Arc<SpeakerContext>,
300    _phantom: PhantomData<P>,
301}
302
303impl<P: SonosProperty> PropertyHandle<P> {
304    /// Create a new PropertyHandle from a shared SpeakerContext
305    pub fn new(context: Arc<SpeakerContext>) -> Self {
306        Self {
307            context,
308            _phantom: PhantomData,
309        }
310    }
311
312    /// Get cached property value (sync, instant, no network call)
313    ///
314    /// Returns the currently cached value for this property, or `None` if
315    /// no value has been cached yet. This method never makes network calls.
316    ///
317    /// # Example
318    ///
319    /// ```rust,ignore
320    /// if let Some(volume) = speaker.volume.get() {
321    ///     println!("Current volume: {}%", volume.value());
322    /// }
323    /// ```
324    #[must_use = "returns the cached property value"]
325    pub fn get(&self) -> Option<P> {
326        self.context
327            .state_manager
328            .get_property::<P>(&self.context.speaker_id)
329    }
330
331    /// Start watching this property for changes (sync)
332    ///
333    /// Returns a [`WatchHandle`] that keeps the subscription alive. Hold
334    /// the handle for as long as you need updates — dropping it starts a
335    /// 50ms grace period before the UPnP subscription is torn down.
336    ///
337    /// # Example
338    ///
339    /// ```rust,ignore
340    /// // Watch returns a handle — hold it to keep the subscription alive
341    /// let volume = speaker.volume.watch()?;
342    ///
343    /// // Access the current value via Deref
344    /// if let Some(v) = &*volume {
345    ///     println!("Volume: {}%", v.value());
346    /// }
347    ///
348    /// // Changes will appear in system.iter() while the handle is alive
349    /// for event in system.iter() {
350    ///     // Re-watch each frame to refresh the snapshot
351    ///     let volume = speaker.volume.watch()?;
352    ///     println!("Volume: {:?}", volume.value());
353    /// }
354    /// ```
355    pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
356        // Trigger lazy event manager init if needed
357        if self.context.state_manager.event_manager().is_none() {
358            if let Some(ref init) = self.context.event_init {
359                init()?;
360            }
361        }
362
363        let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
364            match em.acquire_watch(
365                &self.context.speaker_id,
366                P::KEY,
367                self.context.speaker_ip,
368                P::SERVICE,
369            ) {
370                Ok(guard) => (WatchMode::Events, WatchCleanup::Guard(guard)),
371                Err(e) => {
372                    tracing::warn!(
373                        "Failed to subscribe to {:?} for {}: {} - falling back to polling",
374                        P::SERVICE,
375                        self.context.speaker_id.as_str(),
376                        e
377                    );
378                    // Register directly for polling fallback
379                    self.context
380                        .state_manager
381                        .register_watch(&self.context.speaker_id, P::KEY);
382                    (
383                        WatchMode::Polling,
384                        WatchCleanup::CacheOnly(CacheOnlyGuard {
385                            state_manager: Arc::clone(&self.context.state_manager),
386                            speaker_id: self.context.speaker_id.clone(),
387                            property_key: P::KEY,
388                        }),
389                    )
390                }
391            }
392        } else {
393            // No event manager — cache-only mode
394            self.context
395                .state_manager
396                .register_watch(&self.context.speaker_id, P::KEY);
397            (
398                WatchMode::CacheOnly,
399                WatchCleanup::CacheOnly(CacheOnlyGuard {
400                    state_manager: Arc::clone(&self.context.state_manager),
401                    speaker_id: self.context.speaker_id.clone(),
402                    property_key: P::KEY,
403                }),
404            )
405        };
406
407        Ok(WatchHandle {
408            value: self.get(),
409            mode,
410            _cleanup: cleanup,
411        })
412    }
413
414    /// Check if this property is currently being watched
415    ///
416    /// Returns `true` while a `WatchHandle` for this property is alive,
417    /// or during the grace period after the last handle was dropped.
418    ///
419    /// # Example
420    ///
421    /// ```rust,ignore
422    /// let handle = speaker.volume.watch()?;
423    /// assert!(speaker.volume.is_watched());
424    ///
425    /// drop(handle); // starts 50ms grace period
426    /// // is_watched() remains true during grace period
427    /// ```
428    #[must_use = "returns whether the property is being watched"]
429    pub fn is_watched(&self) -> bool {
430        self.context
431            .state_manager
432            .is_watched(&self.context.speaker_id, P::KEY)
433    }
434
435    /// Get the speaker ID this handle is associated with
436    pub fn speaker_id(&self) -> &SpeakerId {
437        &self.context.speaker_id
438    }
439
440    /// Get the speaker IP address
441    pub fn speaker_ip(&self) -> IpAddr {
442        self.context.speaker_ip
443    }
444}
445
446// ============================================================================
447// Fetch implementation for Fetchable properties
448// ============================================================================
449
450impl<P: Fetchable> PropertyHandle<P> {
451    /// Fetch fresh value from device + update cache (sync)
452    ///
453    /// This makes a synchronous UPnP call to the device and updates
454    /// the local state cache with the result.
455    ///
456    /// # Example
457    ///
458    /// ```rust,ignore
459    /// // Fetch fresh volume from device
460    /// let volume = speaker.volume.fetch()?;
461    /// println!("Current volume: {}%", volume.value());
462    ///
463    /// // The cache is now updated, so get() returns the same value
464    /// assert_eq!(speaker.volume.get(), Some(volume));
465    /// ```
466    #[must_use = "returns the fetched value from the device"]
467    pub fn fetch(&self) -> Result<P, SdkError> {
468        // 1. Build operation using the Fetchable trait
469        let operation = P::build_operation()?;
470
471        // 2. Execute operation using enhanced API (sync call)
472        let response = self
473            .context
474            .api_client
475            .execute_enhanced(&self.context.speaker_ip.to_string(), operation)
476            .map_err(SdkError::ApiError)?;
477
478        // 3. Convert response to property type
479        let property_value = P::from_response(response);
480
481        // 4. Update state store
482        self.context
483            .state_manager
484            .set_property(&self.context.speaker_id, property_value.clone());
485
486        Ok(property_value)
487    }
488}
489
490// ============================================================================
491// Concrete fetch for FetchableWithContext properties
492// ============================================================================
493//
494// Rust does not allow two generic impl blocks (Fetchable + FetchableWithContext)
495// defining the same `fetch()` method, so context-dependent properties get a
496// concrete impl instead.
497
498impl PropertyHandle<GroupMembership> {
499    /// Fetch fresh value from device using speaker context + update cache (sync)
500    ///
501    /// The response is interpreted using the speaker_id to extract the relevant
502    /// property value from the full topology response.
503    #[must_use = "returns the fetched value from the device"]
504    pub fn fetch(&self) -> Result<GroupMembership, SdkError> {
505        let operation = <GroupMembership as FetchableWithContext>::build_operation()?;
506
507        let response = self
508            .context
509            .api_client
510            .execute_enhanced(&self.context.speaker_ip.to_string(), operation)
511            .map_err(SdkError::ApiError)?;
512
513        let property_value =
514            GroupMembership::from_response_with_context(response, &self.context.speaker_id)
515                .ok_or_else(|| {
516                    SdkError::FetchFailed(format!(
517                        "Speaker {} not found in topology response",
518                        self.context.speaker_id.as_str()
519                    ))
520                })?;
521
522        self.context
523            .state_manager
524            .set_property(&self.context.speaker_id, property_value.clone());
525
526        Ok(property_value)
527    }
528}
529
530// ============================================================================
531// Type aliases for common property handles
532// ============================================================================
533
534use sonos_api::services::{
535    av_transport::{
536        self, GetPositionInfoOperation, GetPositionInfoResponse, GetTransportInfoOperation,
537        GetTransportInfoResponse,
538    },
539    group_rendering_control::{
540        self, GetGroupMuteOperation, GetGroupMuteResponse, GetGroupVolumeOperation,
541        GetGroupVolumeResponse,
542    },
543    rendering_control::{
544        self, GetBassOperation, GetBassResponse, GetLoudnessOperation, GetLoudnessResponse,
545        GetMuteOperation, GetMuteResponse, GetTrebleOperation, GetTrebleResponse,
546        GetVolumeOperation, GetVolumeResponse,
547    },
548    zone_group_topology::{self, GetZoneGroupStateOperation, GetZoneGroupStateResponse},
549};
550use sonos_state::{
551    Bass, CurrentTrack, GroupId, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
552    Loudness, Mute, PlaybackState, Position, Treble, Volume,
553};
554
555// ============================================================================
556// Helper functions
557// ============================================================================
558
559/// Helper to create consistent error messages for operation build failures
560fn build_error<E: std::fmt::Display>(operation_name: &str, e: E) -> SdkError {
561    SdkError::FetchFailed(format!("Failed to build {operation_name} operation: {e}"))
562}
563
564// ============================================================================
565// Fetchable implementations
566// ============================================================================
567
568impl Fetchable for Volume {
569    type Operation = GetVolumeOperation;
570
571    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
572        rendering_control::get_volume_operation("Master".to_string())
573            .build()
574            .map_err(|e| build_error("GetVolume", e))
575    }
576
577    fn from_response(response: GetVolumeResponse) -> Self {
578        Volume::new(response.current_volume)
579    }
580}
581
582impl Fetchable for PlaybackState {
583    type Operation = GetTransportInfoOperation;
584
585    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
586        av_transport::get_transport_info_operation()
587            .build()
588            .map_err(|e| build_error("GetTransportInfo", e))
589    }
590
591    fn from_response(response: GetTransportInfoResponse) -> Self {
592        match response.current_transport_state.as_str() {
593            "PLAYING" => PlaybackState::Playing,
594            "PAUSED" | "PAUSED_PLAYBACK" => PlaybackState::Paused,
595            "STOPPED" => PlaybackState::Stopped,
596            _ => PlaybackState::Transitioning,
597        }
598    }
599}
600
601impl Fetchable for Position {
602    type Operation = GetPositionInfoOperation;
603
604    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
605        av_transport::get_position_info_operation()
606            .build()
607            .map_err(|e| build_error("GetPositionInfo", e))
608    }
609
610    fn from_response(response: GetPositionInfoResponse) -> Self {
611        let position_ms = Position::parse_time_to_ms(&response.rel_time).unwrap_or(0);
612        let duration_ms = Position::parse_time_to_ms(&response.track_duration).unwrap_or(0);
613        Position::new(position_ms, duration_ms)
614    }
615}
616
617impl Fetchable for Mute {
618    type Operation = GetMuteOperation;
619
620    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
621        rendering_control::get_mute_operation("Master".to_string())
622            .build()
623            .map_err(|e| build_error("GetMute", e))
624    }
625
626    fn from_response(response: GetMuteResponse) -> Self {
627        Mute::new(response.current_mute)
628    }
629}
630
631impl Fetchable for Bass {
632    type Operation = GetBassOperation;
633
634    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
635        rendering_control::get_bass_operation()
636            .build()
637            .map_err(|e| build_error("GetBass", e))
638    }
639
640    fn from_response(response: GetBassResponse) -> Self {
641        Bass::new(response.current_bass)
642    }
643}
644
645impl Fetchable for Treble {
646    type Operation = GetTrebleOperation;
647
648    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
649        rendering_control::get_treble_operation()
650            .build()
651            .map_err(|e| build_error("GetTreble", e))
652    }
653
654    fn from_response(response: GetTrebleResponse) -> Self {
655        Treble::new(response.current_treble)
656    }
657}
658
659impl Fetchable for Loudness {
660    type Operation = GetLoudnessOperation;
661
662    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
663        rendering_control::get_loudness_operation("Master".to_string())
664            .build()
665            .map_err(|e| build_error("GetLoudness", e))
666    }
667
668    fn from_response(response: GetLoudnessResponse) -> Self {
669        Loudness::new(response.current_loudness)
670    }
671}
672
673impl Fetchable for CurrentTrack {
674    type Operation = GetPositionInfoOperation;
675
676    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
677        av_transport::get_position_info_operation()
678            .build()
679            .map_err(|e| build_error("GetPositionInfo", e))
680    }
681
682    fn from_response(response: GetPositionInfoResponse) -> Self {
683        let metadata = if response.track_meta_data.is_empty()
684            || response.track_meta_data == "NOT_IMPLEMENTED"
685        {
686            None
687        } else {
688            Some(response.track_meta_data.as_str())
689        };
690        let (title, artist, album, album_art_uri) = sonos_state::parse_track_metadata(metadata);
691        CurrentTrack {
692            title,
693            artist,
694            album,
695            album_art_uri,
696            uri: Some(response.track_uri).filter(|s| !s.is_empty()),
697        }
698    }
699}
700
701// ============================================================================
702// FetchableWithContext implementations
703// ============================================================================
704
705impl FetchableWithContext for GroupMembership {
706    type Operation = GetZoneGroupStateOperation;
707
708    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
709        zone_group_topology::get_zone_group_state_operation()
710            .build()
711            .map_err(|e| build_error("GetZoneGroupState", e))
712    }
713
714    fn from_response_with_context(
715        response: GetZoneGroupStateResponse,
716        speaker_id: &SpeakerId,
717    ) -> Option<Self> {
718        let zone_groups =
719            zone_group_topology::parse_zone_group_state_xml(&response.zone_group_state).ok()?;
720
721        for group in &zone_groups {
722            let is_member = group.members.iter().any(|m| m.uuid == speaker_id.as_str());
723            if is_member {
724                let is_coordinator = group.coordinator == speaker_id.as_str();
725                return Some(GroupMembership::new(
726                    GroupId::new(&group.id),
727                    is_coordinator,
728                ));
729            }
730        }
731
732        None
733    }
734}
735
736// ============================================================================
737// Event-only properties (no dedicated UPnP Get operation)
738// ============================================================================
739//
740// GroupVolumeChangeable is the only remaining event-only property — there is
741// no GetGroupVolumeChangeable operation in the Sonos UPnP API. Its value
742// is obtained exclusively from GroupRenderingControl events.
743//
744// All other properties now have fetch() via Fetchable, FetchableWithContext,
745// or GroupFetchable trait implementations.
746
747// ============================================================================
748// Type aliases
749// ============================================================================
750
751/// Handle for speaker volume (0-100)
752pub type VolumeHandle = PropertyHandle<Volume>;
753
754/// Handle for playback state (Playing/Paused/Stopped)
755pub type PlaybackStateHandle = PropertyHandle<PlaybackState>;
756
757/// Handle for mute state
758pub type MuteHandle = PropertyHandle<Mute>;
759
760/// Handle for bass EQ setting (-10 to +10)
761pub type BassHandle = PropertyHandle<Bass>;
762
763/// Handle for treble EQ setting (-10 to +10)
764pub type TrebleHandle = PropertyHandle<Treble>;
765
766/// Handle for loudness compensation setting
767pub type LoudnessHandle = PropertyHandle<Loudness>;
768
769/// Handle for current playback position
770pub type PositionHandle = PropertyHandle<Position>;
771
772/// Handle for current track information
773pub type CurrentTrackHandle = PropertyHandle<CurrentTrack>;
774
775/// Handle for group membership information
776pub type GroupMembershipHandle = PropertyHandle<GroupMembership>;
777
778// ============================================================================
779// Group Property Handles
780// ============================================================================
781
782/// Shared context for all property handles on a group
783///
784/// Analogous to `SpeakerContext` but scoped to a group. Operations are
785/// executed against the group's coordinator speaker.
786#[derive(Clone)]
787pub struct GroupContext {
788    pub(crate) group_id: GroupId,
789    pub(crate) coordinator_id: SpeakerId,
790    pub(crate) coordinator_ip: IpAddr,
791    pub(crate) state_manager: Arc<StateManager>,
792    pub(crate) api_client: SonosClient,
793}
794
795impl GroupContext {
796    /// Create a new GroupContext
797    pub fn new(
798        group_id: GroupId,
799        coordinator_id: SpeakerId,
800        coordinator_ip: IpAddr,
801        state_manager: Arc<StateManager>,
802        api_client: SonosClient,
803    ) -> Arc<Self> {
804        Arc::new(Self {
805            group_id,
806            coordinator_id,
807            coordinator_ip,
808            state_manager,
809            api_client,
810        })
811    }
812}
813
814/// Generic property handle for group-scoped properties
815///
816/// Provides the same get/fetch/watch/unwatch pattern as `PropertyHandle`,
817/// but reads from the group property store and executes API calls against
818/// the group's coordinator.
819#[derive(Clone)]
820pub struct GroupPropertyHandle<P: SonosProperty> {
821    context: Arc<GroupContext>,
822    _phantom: PhantomData<P>,
823}
824
825impl<P: SonosProperty> GroupPropertyHandle<P> {
826    /// Create a new GroupPropertyHandle from a shared GroupContext
827    pub fn new(context: Arc<GroupContext>) -> Self {
828        Self {
829            context,
830            _phantom: PhantomData,
831        }
832    }
833
834    /// Get cached group property value (sync, instant, no network call)
835    #[must_use = "returns the cached property value"]
836    pub fn get(&self) -> Option<P> {
837        self.context
838            .state_manager
839            .get_group_property::<P>(&self.context.group_id)
840    }
841
842    /// Start watching this group property for changes (sync)
843    ///
844    /// Returns a [`WatchHandle`] scoped to the group coordinator.
845    /// Hold the handle to keep the subscription alive.
846    pub fn watch(&self) -> Result<WatchHandle<P>, SdkError> {
847        let (mode, cleanup) = if let Some(em) = self.context.state_manager.event_manager() {
848            match em.acquire_watch(
849                &self.context.coordinator_id,
850                P::KEY,
851                self.context.coordinator_ip,
852                P::SERVICE,
853            ) {
854                Ok(guard) => (WatchMode::Events, WatchCleanup::Guard(guard)),
855                Err(e) => {
856                    tracing::warn!(
857                        "Failed to subscribe to {:?} for group {}: {} - falling back to polling",
858                        P::SERVICE,
859                        self.context.group_id.as_str(),
860                        e
861                    );
862                    self.context
863                        .state_manager
864                        .register_watch(&self.context.coordinator_id, P::KEY);
865                    (
866                        WatchMode::Polling,
867                        WatchCleanup::CacheOnly(CacheOnlyGuard {
868                            state_manager: Arc::clone(&self.context.state_manager),
869                            speaker_id: self.context.coordinator_id.clone(),
870                            property_key: P::KEY,
871                        }),
872                    )
873                }
874            }
875        } else {
876            self.context
877                .state_manager
878                .register_watch(&self.context.coordinator_id, P::KEY);
879            (
880                WatchMode::CacheOnly,
881                WatchCleanup::CacheOnly(CacheOnlyGuard {
882                    state_manager: Arc::clone(&self.context.state_manager),
883                    speaker_id: self.context.coordinator_id.clone(),
884                    property_key: P::KEY,
885                }),
886            )
887        };
888
889        Ok(WatchHandle {
890            value: self.get(),
891            mode,
892            _cleanup: cleanup,
893        })
894    }
895
896    /// Check if this group property is currently being watched
897    #[must_use = "returns whether the property is being watched"]
898    pub fn is_watched(&self) -> bool {
899        self.context
900            .state_manager
901            .is_watched(&self.context.coordinator_id, P::KEY)
902    }
903
904    /// Get the group ID this handle is associated with
905    pub fn group_id(&self) -> &GroupId {
906        &self.context.group_id
907    }
908}
909
910/// Trait for group properties that can be fetched from the coordinator
911pub trait GroupFetchable: SonosProperty {
912    /// The UPnP operation type used to fetch this property
913    type Operation: UPnPOperation;
914
915    /// Build the operation to fetch this property
916    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError>;
917
918    /// Convert the operation response to the property value
919    fn from_response(response: <Self::Operation as UPnPOperation>::Response) -> Self;
920}
921
922impl<P: GroupFetchable> GroupPropertyHandle<P> {
923    /// Fetch fresh value from coordinator + update group cache (sync)
924    #[must_use = "returns the fetched value from the device"]
925    pub fn fetch(&self) -> Result<P, SdkError> {
926        let operation = P::build_operation()?;
927
928        let response = self
929            .context
930            .api_client
931            .execute_enhanced(&self.context.coordinator_ip.to_string(), operation)
932            .map_err(SdkError::ApiError)?;
933
934        let property_value = P::from_response(response);
935
936        self.context
937            .state_manager
938            .set_group_property(&self.context.group_id, property_value.clone());
939
940        Ok(property_value)
941    }
942}
943
944// ============================================================================
945// GroupFetchable implementations
946// ============================================================================
947
948impl GroupFetchable for GroupVolume {
949    type Operation = GetGroupVolumeOperation;
950
951    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
952        group_rendering_control::get_group_volume()
953            .build()
954            .map_err(|e| build_error("GetGroupVolume", e))
955    }
956
957    fn from_response(response: GetGroupVolumeResponse) -> Self {
958        GroupVolume::new(response.current_volume)
959    }
960}
961
962impl GroupFetchable for GroupMute {
963    type Operation = GetGroupMuteOperation;
964
965    fn build_operation() -> Result<ComposableOperation<Self::Operation>, SdkError> {
966        group_rendering_control::get_group_mute()
967            .build()
968            .map_err(|e| build_error("GetGroupMute", e))
969    }
970
971    fn from_response(response: GetGroupMuteResponse) -> Self {
972        GroupMute::new(response.current_mute)
973    }
974}
975
976// ============================================================================
977// Group type aliases
978// ============================================================================
979
980/// Handle for group volume (0-100)
981pub type GroupVolumeHandle = GroupPropertyHandle<GroupVolume>;
982
983/// Handle for group mute state
984pub type GroupMuteHandle = GroupPropertyHandle<GroupMute>;
985
986/// Handle for group volume changeable flag (event-only, no fetch)
987pub type GroupVolumeChangeableHandle = GroupPropertyHandle<GroupVolumeChangeable>;
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992    use sonos_discovery::Device;
993
994    fn create_test_state_manager() -> Arc<StateManager> {
995        let manager = StateManager::new().unwrap();
996        let devices = vec![Device {
997            id: "RINCON_TEST123".to_string(),
998            name: "Test Speaker".to_string(),
999            room_name: "Test Room".to_string(),
1000            ip_address: "192.168.1.100".to_string(),
1001            port: 1400,
1002            model_name: "Sonos One".to_string(),
1003        }];
1004        manager.add_devices(devices).unwrap();
1005        Arc::new(manager)
1006    }
1007
1008    fn create_test_context(state_manager: Arc<StateManager>) -> Arc<SpeakerContext> {
1009        SpeakerContext::new(
1010            SpeakerId::new("RINCON_TEST123"),
1011            "192.168.1.100".parse().unwrap(),
1012            state_manager,
1013            SonosClient::new(),
1014        )
1015    }
1016
1017    #[test]
1018    fn test_property_handle_creation() {
1019        let state_manager = create_test_state_manager();
1020        let context = create_test_context(state_manager);
1021        let speaker_ip: IpAddr = "192.168.1.100".parse().unwrap();
1022
1023        let handle: VolumeHandle = PropertyHandle::new(context);
1024
1025        assert_eq!(handle.speaker_id().as_str(), "RINCON_TEST123");
1026        assert_eq!(handle.speaker_ip(), speaker_ip);
1027    }
1028
1029    #[test]
1030    fn test_get_returns_none_initially() {
1031        let state_manager = create_test_state_manager();
1032        let context = create_test_context(state_manager);
1033
1034        let handle: VolumeHandle = PropertyHandle::new(context);
1035
1036        assert!(handle.get().is_none());
1037    }
1038
1039    #[test]
1040    fn test_get_returns_cached_value() {
1041        let state_manager = create_test_state_manager();
1042        let speaker_id = SpeakerId::new("RINCON_TEST123");
1043
1044        state_manager.set_property(&speaker_id, Volume::new(75));
1045
1046        let context = create_test_context(Arc::clone(&state_manager));
1047        let handle: VolumeHandle = PropertyHandle::new(context);
1048
1049        assert_eq!(handle.get(), Some(Volume::new(75)));
1050    }
1051
1052    #[test]
1053    fn test_watch_registers_property() {
1054        let state_manager = create_test_state_manager();
1055        let context = create_test_context(Arc::clone(&state_manager));
1056
1057        let handle: VolumeHandle = PropertyHandle::new(context);
1058
1059        assert!(!handle.is_watched());
1060        let _wh = handle.watch().unwrap();
1061        assert!(handle.is_watched());
1062    }
1063
1064    #[test]
1065    fn test_drop_watch_handle_unregisters_property() {
1066        let state_manager = create_test_state_manager();
1067        let context = create_test_context(Arc::clone(&state_manager));
1068
1069        let handle: VolumeHandle = PropertyHandle::new(context);
1070
1071        let wh = handle.watch().unwrap();
1072        assert!(handle.is_watched());
1073
1074        drop(wh);
1075        assert!(!handle.is_watched());
1076    }
1077
1078    #[test]
1079    fn test_watch_returns_current_value() {
1080        let state_manager = create_test_state_manager();
1081        let speaker_id = SpeakerId::new("RINCON_TEST123");
1082
1083        state_manager.set_property(&speaker_id, Volume::new(50));
1084
1085        let context = create_test_context(Arc::clone(&state_manager));
1086        let handle: VolumeHandle = PropertyHandle::new(context);
1087
1088        let wh = handle.watch().unwrap();
1089        assert_eq!(*wh, Some(Volume::new(50)));
1090        assert_eq!(wh.value(), Some(&Volume::new(50)));
1091        // No event manager configured, so should be CacheOnly mode
1092        assert_eq!(wh.mode(), WatchMode::CacheOnly);
1093    }
1094
1095    #[test]
1096    fn test_watch_handle_deref() {
1097        let state_manager = create_test_state_manager();
1098        let speaker_id = SpeakerId::new("RINCON_TEST123");
1099
1100        state_manager.set_property(&speaker_id, Volume::new(75));
1101
1102        let context = create_test_context(Arc::clone(&state_manager));
1103        let handle: VolumeHandle = PropertyHandle::new(context);
1104
1105        let wh = handle.watch().unwrap();
1106        // Deref<Target = Option<P>>
1107        assert!(wh.has_value());
1108        assert!(!wh.has_realtime_events());
1109        if let Some(v) = &*wh {
1110            assert_eq!(v.value(), 75);
1111        } else {
1112            panic!("Expected Some value");
1113        }
1114    }
1115
1116    #[test]
1117    fn test_property_handle_clone() {
1118        let state_manager = create_test_state_manager();
1119        let speaker_id = SpeakerId::new("RINCON_TEST123");
1120
1121        state_manager.set_property(&speaker_id, Volume::new(60));
1122
1123        let context = create_test_context(Arc::clone(&state_manager));
1124        let handle: VolumeHandle = PropertyHandle::new(context);
1125
1126        let cloned = handle.clone();
1127
1128        assert_eq!(handle.get(), cloned.get());
1129        assert_eq!(handle.get(), Some(Volume::new(60)));
1130    }
1131
1132    // ========================================================================
1133    // Group property handle tests
1134    // ========================================================================
1135
1136    fn create_test_group_context(state_manager: Arc<StateManager>) -> Arc<GroupContext> {
1137        GroupContext::new(
1138            GroupId::new("RINCON_TEST123:1"),
1139            SpeakerId::new("RINCON_TEST123"),
1140            "192.168.1.100".parse().unwrap(),
1141            state_manager,
1142            SonosClient::new(),
1143        )
1144    }
1145
1146    #[test]
1147    fn test_group_property_handle_get_returns_none_initially() {
1148        let state_manager = create_test_state_manager();
1149        let context = create_test_group_context(state_manager);
1150
1151        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1152
1153        assert!(handle.get().is_none());
1154    }
1155
1156    #[test]
1157    fn test_group_property_handle_get_returns_cached_value() {
1158        let state_manager = create_test_state_manager();
1159        let group_id = GroupId::new("RINCON_TEST123:1");
1160
1161        // Store a group property value
1162        state_manager.set_group_property(&group_id, GroupVolume::new(65));
1163
1164        let context = create_test_group_context(Arc::clone(&state_manager));
1165        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1166
1167        assert_eq!(handle.get(), Some(GroupVolume::new(65)));
1168    }
1169
1170    #[test]
1171    fn test_group_property_handle_watch_and_drop() {
1172        let state_manager = create_test_state_manager();
1173        let context = create_test_group_context(Arc::clone(&state_manager));
1174
1175        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1176
1177        assert!(!handle.is_watched());
1178        let wh = handle.watch().unwrap();
1179        assert!(handle.is_watched());
1180
1181        drop(wh);
1182        assert!(!handle.is_watched());
1183    }
1184
1185    #[test]
1186    fn test_group_property_handle_group_id() {
1187        let state_manager = create_test_state_manager();
1188        let context = create_test_group_context(state_manager);
1189
1190        let handle: GroupVolumeHandle = GroupPropertyHandle::new(context);
1191
1192        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1193    }
1194
1195    #[test]
1196    fn test_group_mute_handle_accessible() {
1197        let state_manager = create_test_state_manager();
1198        let context = create_test_group_context(state_manager);
1199
1200        let handle: GroupMuteHandle = GroupPropertyHandle::new(context);
1201
1202        assert!(handle.get().is_none());
1203        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1204    }
1205
1206    #[test]
1207    fn test_group_volume_changeable_handle_accessible() {
1208        let state_manager = create_test_state_manager();
1209        let context = create_test_group_context(state_manager);
1210
1211        let handle: GroupVolumeChangeableHandle = GroupPropertyHandle::new(context);
1212
1213        assert!(handle.get().is_none());
1214        assert_eq!(handle.group_id().as_str(), "RINCON_TEST123:1");
1215    }
1216
1217    // ========================================================================
1218    // Trait implementation assertions
1219    // ========================================================================
1220
1221    #[test]
1222    fn test_fetchable_impls_exist() {
1223        fn assert_fetchable<T: Fetchable>() {}
1224        assert_fetchable::<Volume>();
1225        assert_fetchable::<PlaybackState>();
1226        assert_fetchable::<Position>();
1227        assert_fetchable::<Mute>();
1228        assert_fetchable::<Bass>();
1229        assert_fetchable::<Treble>();
1230        assert_fetchable::<Loudness>();
1231        assert_fetchable::<CurrentTrack>();
1232    }
1233
1234    #[test]
1235    fn test_fetchable_with_context_impls_exist() {
1236        fn assert_fetchable_with_context<T: FetchableWithContext>() {}
1237        assert_fetchable_with_context::<GroupMembership>();
1238    }
1239
1240    #[test]
1241    fn test_group_fetchable_impls_exist() {
1242        fn assert_group_fetchable<T: GroupFetchable>() {}
1243        assert_group_fetchable::<GroupVolume>();
1244        assert_group_fetchable::<GroupMute>();
1245    }
1246}