1use sonos_api::Service;
7use sonos_stream::events::{
8 AVTransportState, EnrichedEvent, EventData, GroupRenderingControlState, RenderingControlState,
9 ZoneGroupTopologyState,
10};
11
12use std::net::IpAddr;
13
14use crate::model::{GroupId, SpeakerId};
15use crate::property::{
16 Bass, CurrentTrack, GroupInfo, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
17 Loudness, Mute, PlaybackState, Position, Treble, Volume,
18};
19use crate::state::{StateStore, WriteOutcome, WriteStamp};
20
21#[derive(Debug)]
23pub struct DecodedChanges {
24 pub speaker_id: SpeakerId,
26 pub changes: Vec<PropertyChange>,
28}
29
30#[derive(Debug)]
36pub struct TopologyChanges {
37 pub groups: Vec<GroupInfo>,
39 pub memberships: Vec<(SpeakerId, GroupMembership)>,
41 pub boot_seqs: Vec<(SpeakerId, u32)>,
43 pub speaker_ips: Vec<(SpeakerId, IpAddr)>,
45 pub satellite_ids: Vec<SpeakerId>,
47}
48
49#[derive(Debug, Clone)]
56#[non_exhaustive]
57pub enum PropertyChange {
58 Volume(Volume),
59 Mute(Mute),
60 Bass(Bass),
61 Treble(Treble),
62 Loudness(Loudness),
63 PlaybackState(PlaybackState),
64 Position(Position),
65 CurrentTrack(CurrentTrack),
66 GroupMembership(GroupMembership),
67 GroupVolume(GroupVolume),
68 GroupMute(GroupMute),
69 GroupVolumeChangeable(GroupVolumeChangeable),
70}
71
72impl PropertyChange {
73 pub fn apply(
81 &self,
82 store: &mut StateStore,
83 speaker_id: &SpeakerId,
84 stamp: WriteStamp,
85 ) -> WriteOutcome {
86 match self {
87 PropertyChange::Volume(v) => store.set(speaker_id, v.clone(), stamp),
89 PropertyChange::Mute(v) => store.set(speaker_id, v.clone(), stamp),
90 PropertyChange::Bass(v) => store.set(speaker_id, v.clone(), stamp),
91 PropertyChange::Treble(v) => store.set(speaker_id, v.clone(), stamp),
92 PropertyChange::Loudness(v) => store.set(speaker_id, v.clone(), stamp),
93 PropertyChange::PlaybackState(v) => store.set(speaker_id, v.clone(), stamp),
94 PropertyChange::Position(v) => store.set(speaker_id, v.clone(), stamp),
95 PropertyChange::CurrentTrack(v) => store.set(speaker_id, v.clone(), stamp),
96 PropertyChange::GroupMembership(v) => store.set(speaker_id, v.clone(), stamp),
97 PropertyChange::GroupVolume(v) => {
99 match store.speaker_to_group.get(speaker_id).cloned() {
100 Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
101 None => self.log_unmapped_group_change(speaker_id),
102 }
103 }
104 PropertyChange::GroupMute(v) => match store.speaker_to_group.get(speaker_id).cloned() {
105 Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
106 None => self.log_unmapped_group_change(speaker_id),
107 },
108 PropertyChange::GroupVolumeChangeable(v) => {
109 match store.speaker_to_group.get(speaker_id).cloned() {
110 Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
111 None => self.log_unmapped_group_change(speaker_id),
112 }
113 }
114 }
115 }
116
117 fn log_unmapped_group_change(&self, speaker_id: &SpeakerId) -> WriteOutcome {
127 tracing::warn!(
128 "Dropping group-scoped {} change for {}: speaker has no group mapping \
129 (no topology snapshot applied yet, or group state was cleared)",
130 self.key(),
131 speaker_id.as_str()
132 );
133 WriteOutcome::Unchanged
134 }
135
136 pub fn key(&self) -> &'static str {
138 use crate::property::Property;
139 match self {
140 PropertyChange::Volume(_) => Volume::KEY,
141 PropertyChange::Mute(_) => Mute::KEY,
142 PropertyChange::Bass(_) => Bass::KEY,
143 PropertyChange::Treble(_) => Treble::KEY,
144 PropertyChange::Loudness(_) => Loudness::KEY,
145 PropertyChange::PlaybackState(_) => PlaybackState::KEY,
146 PropertyChange::Position(_) => Position::KEY,
147 PropertyChange::CurrentTrack(_) => CurrentTrack::KEY,
148 PropertyChange::GroupMembership(_) => GroupMembership::KEY,
149 PropertyChange::GroupVolume(_) => GroupVolume::KEY,
150 PropertyChange::GroupMute(_) => GroupMute::KEY,
151 PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::KEY,
152 }
153 }
154
155 pub fn scope(&self) -> crate::property::Scope {
157 use crate::property::SonosProperty;
158 match self {
159 PropertyChange::Volume(_) => Volume::SCOPE,
160 PropertyChange::Mute(_) => Mute::SCOPE,
161 PropertyChange::Bass(_) => Bass::SCOPE,
162 PropertyChange::Treble(_) => Treble::SCOPE,
163 PropertyChange::Loudness(_) => Loudness::SCOPE,
164 PropertyChange::PlaybackState(_) => PlaybackState::SCOPE,
165 PropertyChange::Position(_) => Position::SCOPE,
166 PropertyChange::CurrentTrack(_) => CurrentTrack::SCOPE,
167 PropertyChange::GroupMembership(_) => GroupMembership::SCOPE,
168 PropertyChange::GroupVolume(_) => GroupVolume::SCOPE,
169 PropertyChange::GroupMute(_) => GroupMute::SCOPE,
170 PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SCOPE,
171 }
172 }
173
174 pub fn service(&self) -> Service {
176 use crate::property::SonosProperty;
177 match self {
178 PropertyChange::Volume(_) => Volume::SERVICE,
179 PropertyChange::Mute(_) => Mute::SERVICE,
180 PropertyChange::Bass(_) => Bass::SERVICE,
181 PropertyChange::Treble(_) => Treble::SERVICE,
182 PropertyChange::Loudness(_) => Loudness::SERVICE,
183 PropertyChange::PlaybackState(_) => PlaybackState::SERVICE,
184 PropertyChange::Position(_) => Position::SERVICE,
185 PropertyChange::CurrentTrack(_) => CurrentTrack::SERVICE,
186 PropertyChange::GroupMembership(_) => GroupMembership::SERVICE,
187 PropertyChange::GroupVolume(_) => GroupVolume::SERVICE,
188 PropertyChange::GroupMute(_) => GroupMute::SERVICE,
189 PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SERVICE,
190 }
191 }
192}
193
194pub fn decode_event(event: &EnrichedEvent, speaker_id: SpeakerId) -> DecodedChanges {
196 let changes = match &event.event_data {
197 EventData::RenderingControl(rc) => decode_rendering_control(rc),
198 EventData::AVTransport(avt) => decode_av_transport(avt),
199 EventData::ZoneGroupTopology(zgt) => decode_topology(zgt),
200 EventData::DeviceProperties(_) => vec![],
201 EventData::GroupManagement(_) => vec![],
204 EventData::GroupRenderingControl(grc) => decode_group_rendering_control(grc),
205 };
206
207 DecodedChanges {
208 speaker_id,
209 changes,
210 }
211}
212
213fn decode_rendering_control(event: &RenderingControlState) -> Vec<PropertyChange> {
215 let mut changes = vec![];
216
217 if let Some(vol_str) = &event.master_volume {
219 if let Ok(vol) = vol_str.parse::<u8>() {
220 changes.push(PropertyChange::Volume(Volume(vol.min(100))));
221 }
222 }
223
224 if let Some(mute_str) = &event.master_mute {
226 let muted = mute_str == "1" || mute_str.eq_ignore_ascii_case("true");
227 changes.push(PropertyChange::Mute(Mute(muted)));
228 }
229
230 if let Some(bass_str) = &event.bass {
232 if let Ok(bass) = bass_str.parse::<i8>() {
233 changes.push(PropertyChange::Bass(Bass(bass.clamp(-10, 10))));
234 }
235 }
236
237 if let Some(treble_str) = &event.treble {
239 if let Ok(treble) = treble_str.parse::<i8>() {
240 changes.push(PropertyChange::Treble(Treble(treble.clamp(-10, 10))));
241 }
242 }
243
244 if let Some(loudness_str) = &event.loudness {
246 let loudness = loudness_str == "1" || loudness_str.eq_ignore_ascii_case("true");
247 changes.push(PropertyChange::Loudness(Loudness(loudness)));
248 }
249
250 changes
251}
252
253fn decode_av_transport(event: &AVTransportState) -> Vec<PropertyChange> {
255 let mut changes = vec![];
256
257 if let Some(state) = &event.transport_state {
259 let ps = match state.to_uppercase().as_str() {
260 "PLAYING" => PlaybackState::Playing,
261 "PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
262 "STOPPED" => PlaybackState::Stopped,
263 _ => PlaybackState::Transitioning,
264 };
265 changes.push(PropertyChange::PlaybackState(ps));
266 }
267
268 if event.rel_time.is_some() || event.track_duration.is_some() {
277 match parse_duration_ms(event.rel_time.as_deref()) {
278 Some(position_ms) => {
279 let duration_ms = parse_duration_ms(event.track_duration.as_deref()).unwrap_or(0);
280 changes.push(PropertyChange::Position(Position {
281 position_ms,
282 duration_ms,
283 }));
284 }
285 None => {
286 tracing::debug!(
287 "Skipping Position update: RelTime {:?} is missing or unparseable",
288 event.rel_time
289 );
290 }
291 }
292 }
293
294 if event.current_track_uri.is_some() || event.track_metadata.is_some() {
296 let (title, artist, album, album_art_uri) =
298 parse_track_metadata(event.track_metadata.as_deref());
299
300 let track = CurrentTrack {
301 title,
302 artist,
303 album,
304 album_art_uri,
305 uri: event.current_track_uri.clone(),
306 };
307 changes.push(PropertyChange::CurrentTrack(track));
308 }
309
310 changes
311}
312
313fn decode_topology(_event: &ZoneGroupTopologyState) -> Vec<PropertyChange> {
318 vec![]
321}
322
323fn decode_group_rendering_control(event: &GroupRenderingControlState) -> Vec<PropertyChange> {
325 let mut changes = vec![];
326
327 if let Some(vol) = event.group_volume {
328 changes.push(PropertyChange::GroupVolume(GroupVolume(vol.min(100))));
329 }
330
331 if let Some(muted) = event.group_mute {
332 changes.push(PropertyChange::GroupMute(GroupMute(muted)));
333 }
334
335 if let Some(changeable) = event.group_volume_changeable {
336 changes.push(PropertyChange::GroupVolumeChangeable(
337 GroupVolumeChangeable(changeable),
338 ));
339 }
340
341 changes
342}
343
344pub fn decode_topology_event(event: &ZoneGroupTopologyState) -> TopologyChanges {
355 let mut groups = Vec::new();
356 let mut memberships = Vec::new();
357 let mut boot_seqs = Vec::new();
358 let mut speaker_ips = Vec::new();
359 let mut satellite_ids = Vec::new();
360
361 for zone_group in &event.zone_groups {
362 let group_id = GroupId::new(&zone_group.id);
363 let coordinator_id = SpeakerId::new(&zone_group.coordinator);
364
365 let member_ids: Vec<SpeakerId> = zone_group
367 .members
368 .iter()
369 .map(|m| SpeakerId::new(&m.uuid))
370 .collect();
371
372 let group_info =
374 GroupInfo::new(group_id.clone(), coordinator_id.clone(), member_ids.clone());
375 groups.push(group_info);
376
377 for member in &zone_group.members {
379 let speaker_id = SpeakerId::new(&member.uuid);
380 let is_coordinator = speaker_id == coordinator_id;
381 let membership = GroupMembership::new(group_id.clone(), is_coordinator);
382 memberships.push((speaker_id.clone(), membership));
383 boot_seqs.push((speaker_id.clone(), member.boot_seq));
384
385 if let Some(ip) = extract_ip_from_location(&member.location) {
386 speaker_ips.push((speaker_id, ip));
387 }
388
389 for sat in &member.satellites {
390 if sat.invisible == "1" {
391 let sat_id = SpeakerId::new(&sat.uuid);
392 satellite_ids.push(sat_id.clone());
393 if let Some(ip) = extract_ip_from_location(&sat.location) {
394 speaker_ips.push((sat_id, ip));
395 }
396 }
397 }
398 }
399 }
400
401 TopologyChanges {
402 groups,
403 memberships,
404 boot_seqs,
405 speaker_ips,
406 satellite_ids,
407 }
408}
409
410fn extract_ip_from_location(location: &str) -> Option<IpAddr> {
411 let url_part = location.strip_prefix("http://")?;
412 let host_port = url_part.split('/').next()?;
413 let host = host_port.split(':').next()?;
414 host.parse().ok()
415}
416
417fn parse_duration_ms(duration: Option<&str>) -> Option<u64> {
419 let d = duration?;
420
421 if d.is_empty() || d == "NOT_IMPLEMENTED" {
423 return None;
424 }
425
426 let parts: Vec<&str> = d.split(':').collect();
427 if parts.len() != 3 {
428 return None;
429 }
430
431 let hours: u64 = parts[0].parse().ok()?;
432 let minutes: u64 = parts[1].parse().ok()?;
433
434 let seconds_parts: Vec<&str> = parts[2].split('.').collect();
436 let seconds: u64 = seconds_parts[0].parse().ok()?;
437 let millis: u64 = seconds_parts
438 .get(1)
439 .and_then(|m| m.parse().ok())
440 .unwrap_or(0);
441
442 hours
447 .checked_mul(3600)?
448 .checked_add(minutes.checked_mul(60)?)?
449 .checked_add(seconds)?
450 .checked_mul(1000)?
451 .checked_add(millis)
452}
453
454pub fn parse_track_metadata(
456 metadata: Option<&str>,
457) -> (
458 Option<String>,
459 Option<String>,
460 Option<String>,
461 Option<String>,
462) {
463 let xml = match metadata {
464 Some(m) if !m.is_empty() && m != "NOT_IMPLEMENTED" => m,
465 _ => return (None, None, None, None),
466 };
467
468 let title = extract_xml_element(xml, "dc:title");
470 let artist = extract_xml_element(xml, "dc:creator")
471 .or_else(|| extract_xml_element(xml, "r:albumArtist"));
472 let album = extract_xml_element(xml, "upnp:album");
473 let album_art_uri = extract_xml_element(xml, "upnp:albumArtURI");
474
475 (title, artist, album, album_art_uri)
476}
477
478pub fn extract_xml_element(xml: &str, element: &str) -> Option<String> {
480 let start_tag = format!("<{element}>");
481 let end_tag = format!("</{element}>");
482
483 let start_idx = xml.find(&start_tag)? + start_tag.len();
484 let end_idx = xml[start_idx..].find(&end_tag)? + start_idx;
485
486 let content = &xml[start_idx..end_idx];
487
488 let unescaped = content
490 .replace("<", "<")
491 .replace(">", ">")
492 .replace("&", "&")
493 .replace("'", "'")
494 .replace(""", "\"");
495
496 if unescaped.is_empty() {
497 None
498 } else {
499 Some(unescaped)
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_parse_duration_ms() {
509 assert_eq!(parse_duration_ms(Some("0:00:00")), Some(0));
510 assert_eq!(parse_duration_ms(Some("0:01:00")), Some(60_000));
511 assert_eq!(parse_duration_ms(Some("1:00:00")), Some(3_600_000));
512 assert_eq!(parse_duration_ms(Some("0:03:45")), Some(225_000));
513 assert_eq!(parse_duration_ms(Some("0:03:45.500")), Some(225_500));
514 assert_eq!(parse_duration_ms(Some("NOT_IMPLEMENTED")), None);
515 assert_eq!(parse_duration_ms(None), None);
516 assert_eq!(parse_duration_ms(Some("")), None);
517 }
518
519 #[test]
520 fn test_parse_duration_ms_overflow_returns_none() {
521 assert_eq!(parse_duration_ms(Some("18446744073709551615:00:00")), None);
524 assert_eq!(parse_duration_ms(Some("0:0:18446744073709551615")), None);
525 }
526
527 #[test]
528 fn test_decode_av_transport_skips_position_when_rel_time_garbage() {
529 let event = AVTransportState {
532 transport_state: None,
533 transport_status: None,
534 speed: None,
535 current_track_uri: None,
536 track_duration: Some("0:03:45".to_string()),
537 rel_time: Some("garbage".to_string()),
538 abs_time: None,
539 rel_count: None,
540 abs_count: None,
541 play_mode: None,
542 track_metadata: None,
543 next_track_uri: None,
544 next_track_metadata: None,
545 queue_length: None,
546 };
547
548 let changes = decode_av_transport(&event);
549
550 assert!(
551 !changes
552 .iter()
553 .any(|c| matches!(c, PropertyChange::Position(_))),
554 "expected no Position change, got {changes:?}"
555 );
556 }
557
558 #[test]
559 fn test_extract_xml_element() {
560 let xml = r#"<DIDL-Lite><item><dc:title>Test Song</dc:title><dc:creator>Artist Name</dc:creator></item></DIDL-Lite>"#;
561
562 assert_eq!(
563 extract_xml_element(xml, "dc:title"),
564 Some("Test Song".to_string())
565 );
566 assert_eq!(
567 extract_xml_element(xml, "dc:creator"),
568 Some("Artist Name".to_string())
569 );
570 assert_eq!(extract_xml_element(xml, "upnp:album"), None);
571 }
572
573 #[test]
574 fn test_extract_ip_from_location_valid() {
575 let ip = extract_ip_from_location("http://192.168.4.200:1400/xml/device_description.xml");
576 assert_eq!(ip, Some("192.168.4.200".parse().unwrap()));
577 }
578
579 #[test]
580 fn test_extract_ip_from_location_no_path() {
581 let ip = extract_ip_from_location("http://10.0.0.1:1400");
582 assert_eq!(ip, Some("10.0.0.1".parse().unwrap()));
583 }
584
585 #[test]
586 fn test_extract_ip_from_location_missing_prefix() {
587 assert_eq!(extract_ip_from_location("192.168.1.1:1400/xml"), None);
588 }
589
590 #[test]
591 fn test_extract_ip_from_location_empty() {
592 assert_eq!(extract_ip_from_location(""), None);
593 }
594
595 #[test]
596 fn test_extract_ip_from_location_malformed() {
597 assert_eq!(extract_ip_from_location("http://not-an-ip:1400/xml"), None);
598 }
599
600 #[test]
601 fn test_decode_topology_extracts_ips_and_satellites() {
602 use sonos_stream::events::{
603 NetworkInfo, SatelliteInfo, ZoneGroupInfo, ZoneGroupMemberInfo, ZoneGroupTopologyState,
604 };
605
606 let event = ZoneGroupTopologyState {
607 zone_groups: vec![ZoneGroupInfo {
608 coordinator: "RINCON_MAIN".to_string(),
609 id: "RINCON_MAIN:1".to_string(),
610 members: vec![ZoneGroupMemberInfo {
611 uuid: "RINCON_MAIN".to_string(),
612 location: "http://192.168.4.100:1400/xml/device_description.xml".to_string(),
613 zone_name: "Living Room".to_string(),
614 software_version: "56.0".to_string(),
615 boot_seq: 42,
616 network_info: NetworkInfo::default(),
617 satellites: vec![SatelliteInfo {
618 uuid: "RINCON_SAT".to_string(),
619 location: "http://192.168.4.101:1400/xml/device_description.xml"
620 .to_string(),
621 zone_name: "Living Room".to_string(),
622 ht_sat_chan_map_set: "".to_string(),
623 invisible: "1".to_string(),
624 }],
625 }],
626 }],
627 vanished_devices: vec![],
628 };
629
630 let changes = decode_topology_event(&event);
631
632 assert_eq!(changes.speaker_ips.len(), 2);
633 assert_eq!(
634 changes.speaker_ips[0],
635 (
636 SpeakerId::new("RINCON_MAIN"),
637 "192.168.4.100".parse().unwrap()
638 )
639 );
640 assert_eq!(
641 changes.speaker_ips[1],
642 (
643 SpeakerId::new("RINCON_SAT"),
644 "192.168.4.101".parse().unwrap()
645 )
646 );
647
648 assert_eq!(changes.satellite_ids.len(), 1);
649 assert_eq!(changes.satellite_ids[0], SpeakerId::new("RINCON_SAT"));
650 }
651
652 #[test]
653 fn test_decode_rendering_control() {
654 let event = RenderingControlState {
655 master_volume: Some("50".to_string()),
656 master_mute: Some("0".to_string()),
657 bass: Some("5".to_string()),
658 treble: Some("-3".to_string()),
659 loudness: Some("1".to_string()),
660 lf_volume: None,
661 rf_volume: None,
662 lf_mute: None,
663 rf_mute: None,
664 balance: None,
665 other_channels: std::collections::HashMap::new(),
666 };
667
668 let changes = decode_rendering_control(&event);
669
670 assert_eq!(changes.len(), 5);
671
672 if let PropertyChange::Volume(v) = &changes[0] {
674 assert_eq!(v.0, 50);
675 } else {
676 panic!("Expected Volume change");
677 }
678
679 if let PropertyChange::Mute(m) = &changes[1] {
681 assert!(!m.0);
682 } else {
683 panic!("Expected Mute change");
684 }
685 }
686
687 #[test]
688 fn test_decode_av_transport() {
689 let event = AVTransportState {
690 transport_state: Some("PLAYING".to_string()),
691 transport_status: None,
692 speed: None,
693 current_track_uri: Some("x-sonos-spotify:track123".to_string()),
694 track_duration: Some("0:03:45".to_string()),
695 rel_time: Some("0:01:30".to_string()),
696 abs_time: None,
697 rel_count: None,
698 abs_count: None,
699 play_mode: None,
700 track_metadata: None,
701 next_track_uri: None,
702 next_track_metadata: None,
703 queue_length: None,
704 };
705
706 let changes = decode_av_transport(&event);
707
708 assert!(changes.len() >= 2);
709
710 if let PropertyChange::PlaybackState(ps) = &changes[0] {
712 assert_eq!(*ps, PlaybackState::Playing);
713 } else {
714 panic!("Expected PlaybackState change");
715 }
716 }
717
718 #[test]
719 fn test_decode_group_rendering_control() {
720 let event = GroupRenderingControlState {
721 group_volume: Some(42),
722 group_mute: Some(false),
723 group_volume_changeable: Some(true),
724 };
725
726 let changes = decode_group_rendering_control(&event);
727
728 assert_eq!(changes.len(), 3);
729
730 if let PropertyChange::GroupVolume(v) = &changes[0] {
731 assert_eq!(v.0, 42);
732 } else {
733 panic!("Expected GroupVolume change");
734 }
735
736 if let PropertyChange::GroupMute(m) = &changes[1] {
737 assert!(!m.0);
738 } else {
739 panic!("Expected GroupMute change");
740 }
741
742 if let PropertyChange::GroupVolumeChangeable(c) = &changes[2] {
743 assert!(c.0);
744 } else {
745 panic!("Expected GroupVolumeChangeable change");
746 }
747 }
748
749 #[test]
750 fn test_decode_group_rendering_control_clamps_volume() {
751 let event = GroupRenderingControlState {
752 group_volume: Some(150),
753 group_mute: None,
754 group_volume_changeable: None,
755 };
756
757 let changes = decode_group_rendering_control(&event);
758
759 assert_eq!(changes.len(), 1);
760
761 if let PropertyChange::GroupVolume(v) = &changes[0] {
762 assert_eq!(v.0, 100);
763 } else {
764 panic!("Expected GroupVolume change");
765 }
766 }
767
768 #[test]
769 fn test_decode_group_rendering_control_no_volume() {
770 let event = GroupRenderingControlState {
771 group_volume: None,
772 group_mute: Some(true),
773 group_volume_changeable: None,
774 };
775
776 let changes = decode_group_rendering_control(&event);
777 assert_eq!(changes.len(), 1);
778
779 if let PropertyChange::GroupMute(m) = &changes[0] {
780 assert!(m.0);
781 } else {
782 panic!("Expected GroupMute change");
783 }
784 }
785
786 #[test]
787 fn test_property_change_key() {
788 use crate::property::Property;
789
790 let vol_change = PropertyChange::Volume(Volume(50));
791 assert_eq!(vol_change.key(), Volume::KEY);
792
793 let mute_change = PropertyChange::Mute(Mute(false));
794 assert_eq!(mute_change.key(), Mute::KEY);
795
796 let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
797 assert_eq!(ps_change.key(), PlaybackState::KEY);
798 }
799
800 #[test]
801 fn test_property_change_service() {
802 use crate::model::GroupId;
803 use crate::property::SonosProperty;
804
805 let vol_change = PropertyChange::Volume(Volume(50));
806 assert_eq!(vol_change.service(), Volume::SERVICE);
807
808 let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
809 assert_eq!(ps_change.service(), PlaybackState::SERVICE);
810
811 let gm_change = PropertyChange::GroupMembership(GroupMembership::new(
812 GroupId::new("RINCON_test:1"),
813 true,
814 ));
815 assert_eq!(gm_change.service(), GroupMembership::SERVICE);
816 }
817
818 #[test]
819 fn test_property_change_scope() {
820 use crate::property::Scope;
821
822 let vol_change = PropertyChange::Volume(Volume(50));
824 assert_eq!(vol_change.scope(), Scope::Speaker);
825
826 let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
827 assert_eq!(ps_change.scope(), Scope::Speaker);
828
829 let gv_change = PropertyChange::GroupVolume(GroupVolume(50));
831 assert_eq!(gv_change.scope(), Scope::Group);
832
833 let gm_change = PropertyChange::GroupMute(GroupMute(false));
834 assert_eq!(gm_change.scope(), Scope::Group);
835 }
836
837 use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
842
843 fn make_member(uuid: &str, zone_name: &str) -> ZoneGroupMemberInfo {
845 make_member_with_boot_seq(uuid, zone_name, 0)
846 }
847
848 fn make_member_with_boot_seq(
849 uuid: &str,
850 zone_name: &str,
851 boot_seq: u32,
852 ) -> ZoneGroupMemberInfo {
853 ZoneGroupMemberInfo {
854 uuid: uuid.to_string(),
855 location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
856 zone_name: zone_name.to_string(),
857 software_version: "79.1-56030".to_string(),
858 boot_seq,
859 network_info: NetworkInfo {
860 wireless_mode: "0".to_string(),
861 wifi_enabled: "1".to_string(),
862 eth_link: "1".to_string(),
863 channel_freq: "2412".to_string(),
864 behind_wifi_extender: "0".to_string(),
865 },
866 satellites: vec![],
867 }
868 }
869
870 #[test]
871 fn test_decode_topology_single_group_one_speaker() {
872 let event = ZoneGroupTopologyState {
874 zone_groups: vec![ZoneGroupInfo {
875 coordinator: "RINCON_111111111111".to_string(),
876 id: "RINCON_111111111111:0".to_string(),
877 members: vec![make_member("RINCON_111111111111", "Living Room")],
878 }],
879 vanished_devices: vec![],
880 };
881
882 let result = decode_topology_event(&event);
883
884 assert_eq!(result.groups.len(), 1);
886 let group = &result.groups[0];
887 assert_eq!(group.id.as_str(), "RINCON_111111111111:0");
888 assert_eq!(group.coordinator_id.as_str(), "RINCON_111111111111");
889 assert_eq!(group.member_ids.len(), 1);
890 assert!(group.is_standalone());
891
892 assert_eq!(result.memberships.len(), 1);
894 let (speaker_id, membership) = &result.memberships[0];
895 assert_eq!(speaker_id.as_str(), "RINCON_111111111111");
896 assert_eq!(membership.group_id.as_str(), "RINCON_111111111111:0");
897 assert!(membership.is_coordinator);
898 }
899
900 #[test]
901 fn test_decode_topology_single_group_multiple_speakers() {
902 let event = ZoneGroupTopologyState {
904 zone_groups: vec![ZoneGroupInfo {
905 coordinator: "RINCON_111111111111".to_string(),
906 id: "RINCON_111111111111:0".to_string(),
907 members: vec![
908 make_member("RINCON_111111111111", "Living Room"),
909 make_member("RINCON_222222222222", "Kitchen"),
910 make_member("RINCON_333333333333", "Bedroom"),
911 ],
912 }],
913 vanished_devices: vec![],
914 };
915
916 let result = decode_topology_event(&event);
917
918 assert_eq!(result.groups.len(), 1);
920 let group = &result.groups[0];
921 assert_eq!(group.member_ids.len(), 3);
922 assert!(!group.is_standalone());
923
924 assert_eq!(result.memberships.len(), 3);
926
927 let coordinator_membership = result
929 .memberships
930 .iter()
931 .find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
932 .map(|(_, m)| m);
933 assert!(coordinator_membership.is_some());
934 assert!(coordinator_membership.unwrap().is_coordinator);
935
936 let kitchen_membership = result
938 .memberships
939 .iter()
940 .find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
941 .map(|(_, m)| m);
942 assert!(kitchen_membership.is_some());
943 assert!(!kitchen_membership.unwrap().is_coordinator);
944
945 let bedroom_membership = result
946 .memberships
947 .iter()
948 .find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
949 .map(|(_, m)| m);
950 assert!(bedroom_membership.is_some());
951 assert!(!bedroom_membership.unwrap().is_coordinator);
952 }
953
954 #[test]
955 fn test_decode_topology_multiple_groups() {
956 let event = ZoneGroupTopologyState {
958 zone_groups: vec![
959 ZoneGroupInfo {
960 coordinator: "RINCON_111111111111".to_string(),
961 id: "RINCON_111111111111:0".to_string(),
962 members: vec![
963 make_member("RINCON_111111111111", "Living Room"),
964 make_member("RINCON_222222222222", "Kitchen"),
965 ],
966 },
967 ZoneGroupInfo {
968 coordinator: "RINCON_333333333333".to_string(),
969 id: "RINCON_333333333333:0".to_string(),
970 members: vec![make_member("RINCON_333333333333", "Bedroom")],
971 },
972 ],
973 vanished_devices: vec![],
974 };
975
976 let result = decode_topology_event(&event);
977
978 assert_eq!(result.groups.len(), 2);
980
981 let group1 = &result.groups[0];
983 assert_eq!(group1.id.as_str(), "RINCON_111111111111:0");
984 assert_eq!(group1.member_ids.len(), 2);
985
986 let group2 = &result.groups[1];
988 assert_eq!(group2.id.as_str(), "RINCON_333333333333:0");
989 assert_eq!(group2.member_ids.len(), 1);
990 assert!(group2.is_standalone());
991
992 assert_eq!(result.memberships.len(), 3);
994
995 let living_room = result
997 .memberships
998 .iter()
999 .find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
1000 .map(|(_, m)| m)
1001 .unwrap();
1002 assert_eq!(living_room.group_id.as_str(), "RINCON_111111111111:0");
1003 assert!(living_room.is_coordinator);
1004
1005 let kitchen = result
1006 .memberships
1007 .iter()
1008 .find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
1009 .map(|(_, m)| m)
1010 .unwrap();
1011 assert_eq!(kitchen.group_id.as_str(), "RINCON_111111111111:0");
1012 assert!(!kitchen.is_coordinator);
1013
1014 let bedroom = result
1015 .memberships
1016 .iter()
1017 .find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
1018 .map(|(_, m)| m)
1019 .unwrap();
1020 assert_eq!(bedroom.group_id.as_str(), "RINCON_333333333333:0");
1021 assert!(bedroom.is_coordinator);
1022 }
1023
1024 #[test]
1025 fn test_decode_topology_empty_event() {
1026 let event = ZoneGroupTopologyState {
1028 zone_groups: vec![],
1029 vanished_devices: vec![],
1030 };
1031
1032 let result = decode_topology_event(&event);
1033
1034 assert!(result.groups.is_empty());
1035 assert!(result.memberships.is_empty());
1036 }
1037
1038 #[test]
1039 fn test_decode_topology_extracts_boot_seq_values() {
1040 let event = ZoneGroupTopologyState {
1041 zone_groups: vec![ZoneGroupInfo {
1042 coordinator: "RINCON_111111111111".to_string(),
1043 id: "RINCON_111111111111:0".to_string(),
1044 members: vec![
1045 make_member_with_boot_seq("RINCON_111111111111", "Living Room", 42),
1046 make_member_with_boot_seq("RINCON_222222222222", "Kitchen", 17),
1047 ],
1048 }],
1049 vanished_devices: vec![],
1050 };
1051
1052 let result = decode_topology_event(&event);
1053
1054 assert_eq!(result.boot_seqs.len(), 2);
1055
1056 let boot_seq_111 = result
1057 .boot_seqs
1058 .iter()
1059 .find(|(id, _)| id.as_str() == "RINCON_111111111111")
1060 .map(|(_, bs)| *bs);
1061 assert_eq!(boot_seq_111, Some(42));
1062
1063 let boot_seq_222 = result
1064 .boot_seqs
1065 .iter()
1066 .find(|(id, _)| id.as_str() == "RINCON_222222222222")
1067 .map(|(_, bs)| *bs);
1068 assert_eq!(boot_seq_222, Some(17));
1069 }
1070
1071 #[test]
1072 fn test_decode_topology_boot_seq_defaults_to_zero() {
1073 let event = ZoneGroupTopologyState {
1075 zone_groups: vec![ZoneGroupInfo {
1076 coordinator: "RINCON_111111111111".to_string(),
1077 id: "RINCON_111111111111:0".to_string(),
1078 members: vec![make_member("RINCON_111111111111", "Living Room")],
1079 }],
1080 vanished_devices: vec![],
1081 };
1082
1083 let result = decode_topology_event(&event);
1084
1085 assert_eq!(result.boot_seqs.len(), 1);
1086 assert_eq!(result.boot_seqs[0].1, 0);
1087 }
1088}
1089
1090#[cfg(test)]
1095mod property_tests {
1096 use super::*;
1097 use proptest::prelude::*;
1098 use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
1099
1100 fn speaker_uuid_strategy() -> impl Strategy<Value = String> {
1102 "[A-F0-9]{12}".prop_map(|s| format!("RINCON_{s}"))
1103 }
1104
1105 fn zone_group_member_strategy() -> impl Strategy<Value = ZoneGroupMemberInfo> {
1107 (speaker_uuid_strategy(), "[A-Za-z ]{3,15}").prop_map(|(uuid, zone_name)| {
1108 ZoneGroupMemberInfo {
1109 uuid,
1110 location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
1111 zone_name: zone_name.trim().to_string(),
1112 software_version: "79.1-56030".to_string(),
1113 boot_seq: 0,
1114 network_info: NetworkInfo {
1115 wireless_mode: "0".to_string(),
1116 wifi_enabled: "1".to_string(),
1117 eth_link: "1".to_string(),
1118 channel_freq: "2412".to_string(),
1119 behind_wifi_extender: "0".to_string(),
1120 },
1121 satellites: vec![],
1122 }
1123 })
1124 }
1125
1126 fn zone_group_strategy() -> impl Strategy<Value = ZoneGroupInfo> {
1128 proptest::collection::vec(zone_group_member_strategy(), 1..=5).prop_flat_map(|members| {
1129 let coordinator = members[0].uuid.clone();
1131 let group_id = format!("{coordinator}:0");
1132 Just(ZoneGroupInfo {
1133 coordinator,
1134 id: group_id,
1135 members,
1136 })
1137 })
1138 }
1139
1140 fn topology_event_strategy() -> impl Strategy<Value = ZoneGroupTopologyState> {
1142 proptest::collection::vec(zone_group_strategy(), 1..=3).prop_map(|zone_groups| {
1143 ZoneGroupTopologyState {
1144 zone_groups,
1145 vanished_devices: vec![],
1146 }
1147 })
1148 }
1149
1150 proptest! {
1151 #![proptest_config(ProptestConfig::with_cases(100))]
1152
1153 #[test]
1159 fn prop_topology_event_processing_round_trip(event in topology_event_strategy()) {
1160 let result = decode_topology_event(&event);
1161
1162 prop_assert_eq!(
1164 result.groups.len(),
1165 event.zone_groups.len(),
1166 "Number of groups should match number of zone groups in event"
1167 );
1168
1169 let total_members: usize = event.zone_groups.iter()
1171 .map(|zg| zg.members.len())
1172 .sum();
1173 prop_assert_eq!(
1174 result.memberships.len(),
1175 total_members,
1176 "Number of memberships should match total members across all groups"
1177 );
1178
1179 for (group_info, zone_group) in result.groups.iter().zip(event.zone_groups.iter()) {
1181 prop_assert_eq!(
1182 group_info.id.as_str(),
1183 &zone_group.id,
1184 "GroupInfo ID should match zone group ID"
1185 );
1186 prop_assert_eq!(
1187 group_info.coordinator_id.as_str(),
1188 &zone_group.coordinator,
1189 "GroupInfo coordinator should match zone group coordinator"
1190 );
1191 prop_assert_eq!(
1192 group_info.member_ids.len(),
1193 zone_group.members.len(),
1194 "GroupInfo member count should match zone group member count"
1195 );
1196 }
1197
1198 for zone_group in &event.zone_groups {
1200 for member in &zone_group.members {
1201 let membership = result.memberships.iter()
1202 .find(|(sid, _)| sid.as_str() == member.uuid)
1203 .map(|(_, m)| m);
1204
1205 prop_assert!(
1206 membership.is_some(),
1207 "Each member should have a GroupMembership"
1208 );
1209
1210 let membership = membership.unwrap();
1211 prop_assert_eq!(
1212 membership.group_id.as_str(),
1213 &zone_group.id,
1214 "GroupMembership.group_id should match zone group ID"
1215 );
1216
1217 let is_coordinator = member.uuid == zone_group.coordinator;
1218 prop_assert_eq!(
1219 membership.is_coordinator,
1220 is_coordinator,
1221 "is_coordinator should be true only for the coordinator"
1222 );
1223 }
1224 }
1225 }
1226
1227 #[test]
1230 fn prop_coordinator_always_in_members(event in topology_event_strategy()) {
1231 let result = decode_topology_event(&event);
1232
1233 for group_info in &result.groups {
1234 prop_assert!(
1235 group_info.member_ids.contains(&group_info.coordinator_id),
1236 "Coordinator should always be in member_ids"
1237 );
1238 }
1239 }
1240
1241 #[test]
1244 fn prop_exactly_one_coordinator_per_group(event in topology_event_strategy()) {
1245 let result = decode_topology_event(&event);
1246
1247 for group_info in &result.groups {
1248 let coordinator_count = result.memberships.iter()
1249 .filter(|(sid, membership)| {
1250 group_info.member_ids.contains(sid) && membership.is_coordinator
1251 })
1252 .count();
1253
1254 prop_assert_eq!(
1255 coordinator_count,
1256 1,
1257 "Each group should have exactly one coordinator"
1258 );
1259 }
1260 }
1261 }
1262}