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