Skip to main content

sonos_state/
property.rs

1//! Property trait and built-in properties for Sonos state management
2//!
3//! Properties are the fundamental unit of state in sonos-state. Each property:
4//! - Has a unique key for identification (from state-store::Property)
5//! - Belongs to a scope (Speaker, Group, or System)
6//! - Is associated with a UPnP service (for subscription hints)
7//! - Can be watched for changes
8
9use serde::{Deserialize, Serialize};
10use sonos_api::Service;
11
12use crate::model::{GroupId, SpeakerInfo};
13
14// Re-export the base Property trait from state-store
15pub use state_store::Property;
16
17// ============================================================================
18// Sonos-specific Extensions
19// ============================================================================
20
21/// Scope of a property - determines where it's stored and how it's queried
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Scope {
24    /// Property belongs to individual speakers (e.g., volume, mute)
25    Speaker,
26    /// Property belongs to groups/zones (e.g., group playback state)
27    Group,
28    /// Property is system-wide (e.g., topology, alarms)
29    System,
30}
31
32/// Extension trait for Sonos-specific property metadata
33///
34/// Extends the base `state_store::Property` trait with Sonos-specific
35/// information about scope and UPnP service.
36///
37/// # Example
38///
39/// ```rust,ignore
40/// #[derive(Clone, PartialEq, Debug)]
41/// pub struct Volume(pub u8);
42///
43/// impl Property for Volume {
44///     const KEY: &'static str = "volume";
45/// }
46///
47/// impl SonosProperty for Volume {
48///     const SCOPE: Scope = Scope::Speaker;
49///     const SERVICE: Service = Service::RenderingControl;
50/// }
51/// ```
52pub trait SonosProperty: Property {
53    /// Scope of this property
54    const SCOPE: Scope;
55
56    /// UPnP service this property comes from
57    ///
58    /// Used for subscription hints - to know which services need subscriptions
59    /// when this property is being watched.
60    const SERVICE: Service;
61
62    /// Wrap this value in its [`PropertyChange`] variant, for the change event
63    /// payload.
64    ///
65    /// Returns `None` for properties that have no variant — currently only
66    /// [`Topology`], which is written wholesale through `initialize()` rather
67    /// than per-property and is not watchable through the SDK. Such a property
68    /// still updates the store; it just cannot be carried in a `ChangeEvent`,
69    /// so no event is emitted for it.
70    ///
71    /// Defaulted to `None` so adding a property does not silently gain a wrong
72    /// payload — a new watchable property must opt in explicitly, and the
73    /// missing-variant path logs.
74    ///
75    /// [`PropertyChange`]: crate::decoder::PropertyChange
76    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
77        None
78    }
79}
80
81// ============================================================================
82// Speaker-scoped Properties (from RenderingControl)
83// ============================================================================
84
85/// Master volume level (0-100)
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct Volume(pub u8);
88
89impl Property for Volume {
90    const KEY: &'static str = "volume";
91}
92
93impl SonosProperty for Volume {
94    const SCOPE: Scope = Scope::Speaker;
95    const SERVICE: Service = Service::RenderingControl;
96
97    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
98        Some(crate::decoder::PropertyChange::Volume(self.clone()))
99    }
100}
101
102impl Volume {
103    pub fn new(value: u8) -> Self {
104        Self(value.min(100))
105    }
106
107    pub fn value(&self) -> u8 {
108        self.0
109    }
110}
111
112/// Master mute state
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct Mute(pub bool);
115
116impl Property for Mute {
117    const KEY: &'static str = "mute";
118}
119
120impl SonosProperty for Mute {
121    const SCOPE: Scope = Scope::Speaker;
122    const SERVICE: Service = Service::RenderingControl;
123
124    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
125        Some(crate::decoder::PropertyChange::Mute(self.clone()))
126    }
127}
128
129impl Mute {
130    pub fn new(muted: bool) -> Self {
131        Self(muted)
132    }
133
134    pub fn is_muted(&self) -> bool {
135        self.0
136    }
137}
138
139/// Bass EQ setting (-10 to +10)
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub struct Bass(pub i8);
142
143impl Property for Bass {
144    const KEY: &'static str = "bass";
145}
146
147impl SonosProperty for Bass {
148    const SCOPE: Scope = Scope::Speaker;
149    const SERVICE: Service = Service::RenderingControl;
150
151    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
152        Some(crate::decoder::PropertyChange::Bass(self.clone()))
153    }
154}
155
156impl Bass {
157    pub fn new(value: i8) -> Self {
158        Self(value.clamp(-10, 10))
159    }
160
161    pub fn value(&self) -> i8 {
162        self.0
163    }
164}
165
166/// Treble EQ setting (-10 to +10)
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub struct Treble(pub i8);
169
170impl Property for Treble {
171    const KEY: &'static str = "treble";
172}
173
174impl SonosProperty for Treble {
175    const SCOPE: Scope = Scope::Speaker;
176    const SERVICE: Service = Service::RenderingControl;
177
178    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
179        Some(crate::decoder::PropertyChange::Treble(self.clone()))
180    }
181}
182
183impl Treble {
184    pub fn new(value: i8) -> Self {
185        Self(value.clamp(-10, 10))
186    }
187
188    pub fn value(&self) -> i8 {
189        self.0
190    }
191}
192
193/// Loudness compensation setting
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct Loudness(pub bool);
196
197impl Property for Loudness {
198    const KEY: &'static str = "loudness";
199}
200
201impl SonosProperty for Loudness {
202    const SCOPE: Scope = Scope::Speaker;
203    const SERVICE: Service = Service::RenderingControl;
204
205    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
206        Some(crate::decoder::PropertyChange::Loudness(self.clone()))
207    }
208}
209
210impl Loudness {
211    pub fn new(enabled: bool) -> Self {
212        Self(enabled)
213    }
214
215    pub fn is_enabled(&self) -> bool {
216        self.0
217    }
218}
219
220// ============================================================================
221// Group-scoped Properties (from GroupRenderingControl)
222// ============================================================================
223
224/// Group master volume level (0-100)
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226pub struct GroupVolume(pub u16);
227
228impl Property for GroupVolume {
229    const KEY: &'static str = "group_volume";
230}
231
232impl SonosProperty for GroupVolume {
233    const SCOPE: Scope = Scope::Group;
234    const SERVICE: Service = Service::GroupRenderingControl;
235
236    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
237        Some(crate::decoder::PropertyChange::GroupVolume(self.clone()))
238    }
239}
240
241impl GroupVolume {
242    pub fn new(value: u16) -> Self {
243        Self(value.min(100))
244    }
245
246    pub fn value(&self) -> u16 {
247        self.0
248    }
249}
250
251/// Group master mute state
252#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253pub struct GroupMute(pub bool);
254
255impl Property for GroupMute {
256    const KEY: &'static str = "group_mute";
257}
258
259impl SonosProperty for GroupMute {
260    const SCOPE: Scope = Scope::Group;
261    const SERVICE: Service = Service::GroupRenderingControl;
262
263    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
264        Some(crate::decoder::PropertyChange::GroupMute(self.clone()))
265    }
266}
267
268impl GroupMute {
269    pub fn new(muted: bool) -> Self {
270        Self(muted)
271    }
272
273    pub fn is_muted(&self) -> bool {
274        self.0
275    }
276}
277
278/// Whether the group volume can be changed
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct GroupVolumeChangeable(pub bool);
281
282impl Property for GroupVolumeChangeable {
283    const KEY: &'static str = "group_volume_changeable";
284}
285
286impl SonosProperty for GroupVolumeChangeable {
287    const SCOPE: Scope = Scope::Group;
288    const SERVICE: Service = Service::GroupRenderingControl;
289
290    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
291        Some(crate::decoder::PropertyChange::GroupVolumeChangeable(
292            self.clone(),
293        ))
294    }
295}
296
297impl GroupVolumeChangeable {
298    pub fn new(changeable: bool) -> Self {
299        Self(changeable)
300    }
301
302    pub fn is_changeable(&self) -> bool {
303        self.0
304    }
305}
306
307// ============================================================================
308// Speaker-scoped Properties (from AVTransport)
309// ============================================================================
310
311/// Current playback state
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313pub enum PlaybackState {
314    Playing,
315    Paused,
316    Stopped,
317    Transitioning,
318}
319
320impl Property for PlaybackState {
321    const KEY: &'static str = "playback_state";
322}
323
324impl SonosProperty for PlaybackState {
325    const SCOPE: Scope = Scope::Speaker;
326    const SERVICE: Service = Service::AVTransport;
327
328    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
329        Some(crate::decoder::PropertyChange::PlaybackState(self.clone()))
330    }
331}
332
333impl PlaybackState {
334    /// Parse from UPnP transport state string
335    pub fn from_transport_state(state: &str) -> Self {
336        match state.to_uppercase().as_str() {
337            "PLAYING" => PlaybackState::Playing,
338            "PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
339            "STOPPED" => PlaybackState::Stopped,
340            "TRANSITIONING" => PlaybackState::Transitioning,
341            _ => PlaybackState::Stopped,
342        }
343    }
344
345    pub fn is_playing(&self) -> bool {
346        matches!(self, PlaybackState::Playing)
347    }
348
349    pub fn is_paused(&self) -> bool {
350        matches!(self, PlaybackState::Paused)
351    }
352
353    pub fn is_stopped(&self) -> bool {
354        matches!(self, PlaybackState::Stopped)
355    }
356}
357
358/// Current playback position and duration
359#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360pub struct Position {
361    /// Current position in milliseconds
362    pub position_ms: u64,
363    /// Total duration in milliseconds
364    pub duration_ms: u64,
365}
366
367impl Property for Position {
368    const KEY: &'static str = "position";
369}
370
371impl SonosProperty for Position {
372    const SCOPE: Scope = Scope::Speaker;
373    const SERVICE: Service = Service::AVTransport;
374
375    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
376        Some(crate::decoder::PropertyChange::Position(self.clone()))
377    }
378}
379
380impl Position {
381    pub fn new(position_ms: u64, duration_ms: u64) -> Self {
382        Self {
383            position_ms,
384            duration_ms,
385        }
386    }
387
388    /// Get position as a fraction (0.0 to 1.0)
389    pub fn progress(&self) -> f64 {
390        if self.duration_ms == 0 {
391            0.0
392        } else {
393            (self.position_ms as f64) / (self.duration_ms as f64)
394        }
395    }
396
397    /// Parse time string (HH:MM:SS or HH:MM:SS.mmm) to milliseconds
398    pub fn parse_time_to_ms(time_str: &str) -> Option<u64> {
399        if !time_str.contains(':') {
400            return None;
401        }
402
403        let parts: Vec<&str> = time_str.split(':').collect();
404        if parts.len() != 3 {
405            return None;
406        }
407
408        let hours: u64 = parts[0].parse().ok()?;
409        let minutes: u64 = parts[1].parse().ok()?;
410
411        let seconds_parts: Vec<&str> = parts[2].split('.').collect();
412        let seconds: u64 = seconds_parts[0].parse().ok()?;
413        let millis: u64 = seconds_parts
414            .get(1)
415            .and_then(|m| m.parse().ok())
416            .unwrap_or(0);
417
418        Some((hours * 3600 + minutes * 60 + seconds) * 1000 + millis)
419    }
420}
421
422/// Information about the currently playing track
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424pub struct CurrentTrack {
425    pub title: Option<String>,
426    pub artist: Option<String>,
427    pub album: Option<String>,
428    pub album_art_uri: Option<String>,
429    pub uri: Option<String>,
430}
431
432impl Property for CurrentTrack {
433    const KEY: &'static str = "current_track";
434}
435
436impl SonosProperty for CurrentTrack {
437    const SCOPE: Scope = Scope::Speaker;
438    const SERVICE: Service = Service::AVTransport;
439
440    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
441        Some(crate::decoder::PropertyChange::CurrentTrack(self.clone()))
442    }
443}
444
445impl CurrentTrack {
446    pub fn new() -> Self {
447        Self {
448            title: None,
449            artist: None,
450            album: None,
451            album_art_uri: None,
452            uri: None,
453        }
454    }
455
456    /// Check if the track has any meaningful content
457    pub fn is_empty(&self) -> bool {
458        self.title.is_none() && self.artist.is_none() && self.uri.is_none()
459    }
460
461    /// Get a display string for the track
462    pub fn display(&self) -> String {
463        match (&self.artist, &self.title) {
464            (Some(artist), Some(title)) => format!("{artist} - {title}"),
465            (None, Some(title)) => title.clone(),
466            (Some(artist), None) => artist.clone(),
467            (None, None) => "Unknown".to_string(),
468        }
469    }
470}
471
472impl Default for CurrentTrack {
473    fn default() -> Self {
474        Self::new()
475    }
476}
477
478/// Speaker's group membership
479///
480/// Every speaker is always in a group - a single speaker forms a group of one.
481/// The group_id is always present and valid.
482#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483pub struct GroupMembership {
484    /// ID of the group this speaker belongs to (always present)
485    pub group_id: GroupId,
486    /// Whether this speaker is the coordinator (master) of its group
487    pub is_coordinator: bool,
488}
489
490impl Property for GroupMembership {
491    const KEY: &'static str = "group_membership";
492}
493
494impl SonosProperty for GroupMembership {
495    const SCOPE: Scope = Scope::Speaker;
496    const SERVICE: Service = Service::ZoneGroupTopology;
497
498    fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
499        Some(crate::decoder::PropertyChange::GroupMembership(
500            self.clone(),
501        ))
502    }
503}
504
505impl GroupMembership {
506    /// Create a new GroupMembership with the given group ID and coordinator status
507    pub fn new(group_id: GroupId, is_coordinator: bool) -> Self {
508        Self {
509            group_id,
510            is_coordinator,
511        }
512    }
513}
514
515// ============================================================================
516// System-scoped Properties
517// ============================================================================
518
519/// System-wide topology of all speakers and groups
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
521pub struct Topology {
522    pub speakers: Vec<SpeakerInfo>,
523    pub groups: Vec<GroupInfo>,
524}
525
526impl Property for Topology {
527    const KEY: &'static str = "topology";
528}
529
530impl SonosProperty for Topology {
531    const SCOPE: Scope = Scope::System;
532    const SERVICE: Service = Service::ZoneGroupTopology;
533}
534
535impl Topology {
536    pub fn new(speakers: Vec<SpeakerInfo>, groups: Vec<GroupInfo>) -> Self {
537        Self { speakers, groups }
538    }
539
540    pub fn empty() -> Self {
541        Self {
542            speakers: vec![],
543            groups: vec![],
544        }
545    }
546
547    pub fn speaker_count(&self) -> usize {
548        self.speakers.len()
549    }
550
551    pub fn group_count(&self) -> usize {
552        self.groups.len()
553    }
554}
555
556impl Default for Topology {
557    fn default() -> Self {
558        Self::empty()
559    }
560}
561
562/// Group information for topology
563#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
564pub struct GroupInfo {
565    pub id: GroupId,
566    pub coordinator_id: crate::model::SpeakerId,
567    pub member_ids: Vec<crate::model::SpeakerId>,
568}
569
570impl GroupInfo {
571    pub fn new(
572        id: GroupId,
573        coordinator_id: crate::model::SpeakerId,
574        member_ids: Vec<crate::model::SpeakerId>,
575    ) -> Self {
576        Self {
577            id,
578            coordinator_id,
579            member_ids,
580        }
581    }
582
583    pub fn is_standalone(&self) -> bool {
584        self.member_ids.len() == 1
585    }
586}
587
588// ============================================================================
589// Tests
590// ============================================================================
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn test_volume_clamping() {
598        assert_eq!(Volume::new(50).value(), 50);
599        assert_eq!(Volume::new(150).value(), 100);
600        assert_eq!(Volume::new(0).value(), 0);
601    }
602
603    #[test]
604    fn test_bass_clamping() {
605        assert_eq!(Bass::new(0).value(), 0);
606        assert_eq!(Bass::new(-15).value(), -10);
607        assert_eq!(Bass::new(15).value(), 10);
608    }
609
610    #[test]
611    fn test_playback_state_parsing() {
612        assert_eq!(
613            PlaybackState::from_transport_state("PLAYING"),
614            PlaybackState::Playing
615        );
616        assert_eq!(
617            PlaybackState::from_transport_state("PAUSED_PLAYBACK"),
618            PlaybackState::Paused
619        );
620        assert_eq!(
621            PlaybackState::from_transport_state("STOPPED"),
622            PlaybackState::Stopped
623        );
624        assert_eq!(
625            PlaybackState::from_transport_state("unknown"),
626            PlaybackState::Stopped
627        );
628    }
629
630    #[test]
631    fn test_position_progress() {
632        let pos = Position::new(30_000, 180_000); // 30s / 3min
633        assert!((pos.progress() - 0.1667).abs() < 0.001);
634
635        let zero_duration = Position::new(1000, 0);
636        assert_eq!(zero_duration.progress(), 0.0);
637    }
638
639    #[test]
640    fn test_position_time_parsing() {
641        assert_eq!(Position::parse_time_to_ms("0:00:00"), Some(0));
642        assert_eq!(Position::parse_time_to_ms("0:01:00"), Some(60_000));
643        assert_eq!(Position::parse_time_to_ms("1:00:00"), Some(3_600_000));
644        assert_eq!(Position::parse_time_to_ms("0:03:45"), Some(225_000));
645        assert_eq!(Position::parse_time_to_ms("0:03:45.500"), Some(225_500));
646        assert_eq!(Position::parse_time_to_ms("NOT_IMPLEMENTED"), None);
647    }
648
649    #[test]
650    fn test_current_track_display() {
651        let track = CurrentTrack {
652            title: Some("Song".to_string()),
653            artist: Some("Artist".to_string()),
654            album: None,
655            album_art_uri: None,
656            uri: None,
657        };
658        assert_eq!(track.display(), "Artist - Song");
659
660        let title_only = CurrentTrack {
661            title: Some("Song".to_string()),
662            artist: None,
663            album: None,
664            album_art_uri: None,
665            uri: None,
666        };
667        assert_eq!(title_only.display(), "Song");
668    }
669
670    #[test]
671    fn test_property_constants() {
672        assert_eq!(Volume::KEY, "volume");
673        assert_eq!(<Volume as SonosProperty>::SCOPE, Scope::Speaker);
674
675        assert_eq!(Topology::KEY, "topology");
676        assert_eq!(<Topology as SonosProperty>::SCOPE, Scope::System);
677    }
678
679    #[test]
680    fn test_group_volume_clamping() {
681        assert_eq!(GroupVolume::new(50).value(), 50);
682        assert_eq!(GroupVolume::new(200).value(), 100);
683        assert_eq!(GroupVolume::new(0).value(), 0);
684        assert_eq!(GroupVolume::new(100).value(), 100);
685    }
686
687    #[test]
688    fn test_group_volume_property_metadata() {
689        assert_eq!(GroupVolume::KEY, "group_volume");
690        assert_eq!(<GroupVolume as SonosProperty>::SCOPE, Scope::Group);
691        assert_eq!(
692            <GroupVolume as SonosProperty>::SERVICE,
693            Service::GroupRenderingControl
694        );
695    }
696
697    #[test]
698    fn test_group_membership_always_has_valid_group_id() {
699        // GroupMembership always requires a valid GroupId
700        let group_id = GroupId::new("RINCON_12345:1");
701        let membership = GroupMembership::new(group_id.clone(), true);
702
703        // Verify group_id is always present and matches what was provided
704        assert_eq!(membership.group_id, group_id);
705        assert!(!membership.group_id.as_str().is_empty());
706    }
707
708    #[test]
709    fn test_group_membership_is_coordinator_flag() {
710        let group_id = GroupId::new("RINCON_12345:1");
711
712        // Test coordinator
713        let coordinator = GroupMembership::new(group_id.clone(), true);
714        assert!(coordinator.is_coordinator);
715
716        // Test non-coordinator (member)
717        let member = GroupMembership::new(group_id.clone(), false);
718        assert!(!member.is_coordinator);
719    }
720
721    #[test]
722    fn test_group_membership_equality() {
723        let group_id = GroupId::new("RINCON_12345:1");
724
725        let membership1 = GroupMembership::new(group_id.clone(), true);
726        let membership2 = GroupMembership::new(group_id.clone(), true);
727        let membership3 = GroupMembership::new(group_id.clone(), false);
728        let membership4 = GroupMembership::new(GroupId::new("RINCON_67890:1"), true);
729
730        // Same group_id and is_coordinator should be equal
731        assert_eq!(membership1, membership2);
732
733        // Different is_coordinator should not be equal
734        assert_ne!(membership1, membership3);
735
736        // Different group_id should not be equal
737        assert_ne!(membership1, membership4);
738    }
739
740    #[test]
741    fn test_group_membership_property_metadata() {
742        assert_eq!(GroupMembership::KEY, "group_membership");
743        assert_eq!(<GroupMembership as SonosProperty>::SCOPE, Scope::Speaker);
744        assert_eq!(
745            <GroupMembership as SonosProperty>::SERVICE,
746            Service::ZoneGroupTopology
747        );
748    }
749
750    #[test]
751    fn test_group_mute_property_metadata() {
752        assert_eq!(GroupMute::KEY, "group_mute");
753        assert_eq!(<GroupMute as SonosProperty>::SCOPE, Scope::Group);
754        assert_eq!(
755            <GroupMute as SonosProperty>::SERVICE,
756            Service::GroupRenderingControl
757        );
758    }
759
760    #[test]
761    fn test_group_volume_changeable_property_metadata() {
762        assert_eq!(GroupVolumeChangeable::KEY, "group_volume_changeable");
763        assert_eq!(
764            <GroupVolumeChangeable as SonosProperty>::SCOPE,
765            Scope::Group
766        );
767        assert_eq!(
768            <GroupVolumeChangeable as SonosProperty>::SERVICE,
769            Service::GroupRenderingControl
770        );
771    }
772}