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
63#[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#[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#[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#[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#[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#[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#[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#[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#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
304pub struct Position {
305 pub position_ms: u64,
307 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 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 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#[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 pub fn is_empty(&self) -> bool {
394 self.title.is_none() && self.artist.is_none() && self.uri.is_none()
395 }
396
397 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
419pub struct GroupMembership {
420 pub group_id: GroupId,
422 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 pub fn new(group_id: GroupId, is_coordinator: bool) -> Self {
438 Self {
439 group_id,
440 is_coordinator,
441 }
442 }
443}
444
445#[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#[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#[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); 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 let group_id = GroupId::new("RINCON_12345:1");
631 let membership = GroupMembership::new(group_id.clone(), true);
632
633 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 let coordinator = GroupMembership::new(group_id.clone(), true);
644 assert!(coordinator.is_coordinator);
645
646 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 assert_eq!(membership1, membership2);
662
663 assert_ne!(membership1, membership3);
665
666 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}