1use serde::{Deserialize, Serialize};
10use sonos_api::Service;
11
12use crate::model::{GroupId, SpeakerInfo};
13
14pub use state_store::Property;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Scope {
24 Speaker,
26 Group,
28 System,
30}
31
32pub trait SonosProperty: Property {
53 const SCOPE: Scope;
55
56 const SERVICE: Service;
61
62 fn to_change(&self) -> Option<crate::decoder::PropertyChange> {
77 None
78 }
79}
80
81#[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#[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#[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#[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#[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#[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#[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#[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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360pub struct Position {
361 pub position_ms: u64,
363 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 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 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#[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 pub fn is_empty(&self) -> bool {
458 self.title.is_none() && self.artist.is_none() && self.uri.is_none()
459 }
460
461 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483pub struct GroupMembership {
484 pub group_id: GroupId,
486 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 pub fn new(group_id: GroupId, is_coordinator: bool) -> Self {
508 Self {
509 group_id,
510 is_coordinator,
511 }
512 }
513}
514
515#[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#[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#[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); 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 let group_id = GroupId::new("RINCON_12345:1");
701 let membership = GroupMembership::new(group_id.clone(), true);
702
703 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 let coordinator = GroupMembership::new(group_id.clone(), true);
714 assert!(coordinator.is_coordinator);
715
716 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 assert_eq!(membership1, membership2);
732
733 assert_ne!(membership1, membership3);
735
736 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}