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