1use serde::Deserialize;
7use sonos_api::Service;
8use sonos_stream::events::{
9 AVTransportState, EnrichedEvent, EventData, GroupRenderingControlState, RenderingControlState,
10 ZoneGroupTopologyState,
11};
12use url::{Host, Url};
13
14use std::net::IpAddr;
15
16use crate::model::{GroupId, SpeakerId};
17use crate::property::{
18 Bass, CurrentTrack, GroupInfo, GroupMembership, GroupMute, GroupVolume, GroupVolumeChangeable,
19 Loudness, Mute, PlaybackState, Position, Treble, Volume,
20};
21use crate::state::{StateStore, WriteOutcome, WriteStamp};
22
23#[derive(Debug)]
25pub struct DecodedChanges {
26 pub speaker_id: SpeakerId,
28 pub changes: Vec<PropertyChange>,
30}
31
32#[derive(Debug)]
38pub struct TopologyChanges {
39 pub groups: Vec<GroupInfo>,
41 pub memberships: Vec<(SpeakerId, GroupMembership)>,
43 pub boot_seqs: Vec<(SpeakerId, u32)>,
45 pub speaker_ips: Vec<(SpeakerId, IpAddr)>,
47 pub satellite_ids: Vec<SpeakerId>,
49}
50
51#[derive(Debug, Clone)]
58#[non_exhaustive]
59pub enum PropertyChange {
60 Volume(Volume),
61 Mute(Mute),
62 Bass(Bass),
63 Treble(Treble),
64 Loudness(Loudness),
65 PlaybackState(PlaybackState),
66 Position(Position),
67 CurrentTrack(CurrentTrack),
68 GroupMembership(GroupMembership),
69 GroupVolume(GroupVolume),
70 GroupMute(GroupMute),
71 GroupVolumeChangeable(GroupVolumeChangeable),
72}
73
74impl PropertyChange {
75 pub fn apply(
83 &self,
84 store: &mut StateStore,
85 speaker_id: &SpeakerId,
86 stamp: WriteStamp,
87 ) -> WriteOutcome {
88 match self {
89 PropertyChange::Volume(v) => store.set(speaker_id, v.clone(), stamp),
91 PropertyChange::Mute(v) => store.set(speaker_id, v.clone(), stamp),
92 PropertyChange::Bass(v) => store.set(speaker_id, v.clone(), stamp),
93 PropertyChange::Treble(v) => store.set(speaker_id, v.clone(), stamp),
94 PropertyChange::Loudness(v) => store.set(speaker_id, v.clone(), stamp),
95 PropertyChange::PlaybackState(v) => store.set(speaker_id, v.clone(), stamp),
96 PropertyChange::Position(v) => store.set(speaker_id, v.clone(), stamp),
97 PropertyChange::CurrentTrack(v) => store.set(speaker_id, v.clone(), stamp),
98 PropertyChange::GroupMembership(v) => store.set(speaker_id, v.clone(), stamp),
99 PropertyChange::GroupVolume(v) => {
101 match store.speaker_to_group.get(speaker_id).cloned() {
102 Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
103 None => self.log_unmapped_group_change(speaker_id),
104 }
105 }
106 PropertyChange::GroupMute(v) => match store.speaker_to_group.get(speaker_id).cloned() {
107 Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
108 None => self.log_unmapped_group_change(speaker_id),
109 },
110 PropertyChange::GroupVolumeChangeable(v) => {
111 match store.speaker_to_group.get(speaker_id).cloned() {
112 Some(group_id) => store.set_group(&group_id, v.clone(), stamp),
113 None => self.log_unmapped_group_change(speaker_id),
114 }
115 }
116 }
117 }
118
119 fn log_unmapped_group_change(&self, speaker_id: &SpeakerId) -> WriteOutcome {
129 tracing::warn!(
130 "Dropping group-scoped {} change for {}: speaker has no group mapping \
131 (no topology snapshot applied yet, or group state was cleared)",
132 self.key(),
133 speaker_id.as_str()
134 );
135 WriteOutcome::Unchanged
136 }
137
138 pub fn key(&self) -> &'static str {
140 use crate::property::Property;
141 match self {
142 PropertyChange::Volume(_) => Volume::KEY,
143 PropertyChange::Mute(_) => Mute::KEY,
144 PropertyChange::Bass(_) => Bass::KEY,
145 PropertyChange::Treble(_) => Treble::KEY,
146 PropertyChange::Loudness(_) => Loudness::KEY,
147 PropertyChange::PlaybackState(_) => PlaybackState::KEY,
148 PropertyChange::Position(_) => Position::KEY,
149 PropertyChange::CurrentTrack(_) => CurrentTrack::KEY,
150 PropertyChange::GroupMembership(_) => GroupMembership::KEY,
151 PropertyChange::GroupVolume(_) => GroupVolume::KEY,
152 PropertyChange::GroupMute(_) => GroupMute::KEY,
153 PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::KEY,
154 }
155 }
156
157 pub fn scope(&self) -> crate::property::Scope {
159 use crate::property::SonosProperty;
160 match self {
161 PropertyChange::Volume(_) => Volume::SCOPE,
162 PropertyChange::Mute(_) => Mute::SCOPE,
163 PropertyChange::Bass(_) => Bass::SCOPE,
164 PropertyChange::Treble(_) => Treble::SCOPE,
165 PropertyChange::Loudness(_) => Loudness::SCOPE,
166 PropertyChange::PlaybackState(_) => PlaybackState::SCOPE,
167 PropertyChange::Position(_) => Position::SCOPE,
168 PropertyChange::CurrentTrack(_) => CurrentTrack::SCOPE,
169 PropertyChange::GroupMembership(_) => GroupMembership::SCOPE,
170 PropertyChange::GroupVolume(_) => GroupVolume::SCOPE,
171 PropertyChange::GroupMute(_) => GroupMute::SCOPE,
172 PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SCOPE,
173 }
174 }
175
176 pub fn service(&self) -> Service {
178 use crate::property::SonosProperty;
179 match self {
180 PropertyChange::Volume(_) => Volume::SERVICE,
181 PropertyChange::Mute(_) => Mute::SERVICE,
182 PropertyChange::Bass(_) => Bass::SERVICE,
183 PropertyChange::Treble(_) => Treble::SERVICE,
184 PropertyChange::Loudness(_) => Loudness::SERVICE,
185 PropertyChange::PlaybackState(_) => PlaybackState::SERVICE,
186 PropertyChange::Position(_) => Position::SERVICE,
187 PropertyChange::CurrentTrack(_) => CurrentTrack::SERVICE,
188 PropertyChange::GroupMembership(_) => GroupMembership::SERVICE,
189 PropertyChange::GroupVolume(_) => GroupVolume::SERVICE,
190 PropertyChange::GroupMute(_) => GroupMute::SERVICE,
191 PropertyChange::GroupVolumeChangeable(_) => GroupVolumeChangeable::SERVICE,
192 }
193 }
194}
195
196pub fn decode_event(event: &EnrichedEvent, speaker_id: SpeakerId) -> DecodedChanges {
198 let changes = match &event.event_data {
199 EventData::RenderingControl(rc) => decode_rendering_control(rc),
200 EventData::AVTransport(avt) => decode_av_transport(avt),
201 EventData::ZoneGroupTopology(zgt) => decode_topology(zgt),
202 EventData::DeviceProperties(_) => vec![],
203 EventData::GroupManagement(_) => vec![],
206 EventData::GroupRenderingControl(grc) => decode_group_rendering_control(grc),
207 };
208
209 DecodedChanges {
210 speaker_id,
211 changes,
212 }
213}
214
215fn decode_rendering_control(event: &RenderingControlState) -> Vec<PropertyChange> {
217 let mut changes = vec![];
218
219 if let Some(vol_str) = &event.master_volume {
221 if let Ok(vol) = vol_str.parse::<u8>() {
222 changes.push(PropertyChange::Volume(Volume(vol.min(100))));
223 }
224 }
225
226 if let Some(mute_str) = &event.master_mute {
228 let muted = mute_str == "1" || mute_str.eq_ignore_ascii_case("true");
229 changes.push(PropertyChange::Mute(Mute(muted)));
230 }
231
232 if let Some(bass_str) = &event.bass {
234 if let Ok(bass) = bass_str.parse::<i8>() {
235 changes.push(PropertyChange::Bass(Bass(bass.clamp(-10, 10))));
236 }
237 }
238
239 if let Some(treble_str) = &event.treble {
241 if let Ok(treble) = treble_str.parse::<i8>() {
242 changes.push(PropertyChange::Treble(Treble(treble.clamp(-10, 10))));
243 }
244 }
245
246 if let Some(loudness_str) = &event.loudness {
248 let loudness = loudness_str == "1" || loudness_str.eq_ignore_ascii_case("true");
249 changes.push(PropertyChange::Loudness(Loudness(loudness)));
250 }
251
252 changes
253}
254
255fn decode_av_transport(event: &AVTransportState) -> Vec<PropertyChange> {
257 let mut changes = vec![];
258
259 if let Some(state) = &event.transport_state {
261 let ps = match state.to_uppercase().as_str() {
262 "PLAYING" => PlaybackState::Playing,
263 "PAUSED_PLAYBACK" | "PAUSED" => PlaybackState::Paused,
264 "STOPPED" => PlaybackState::Stopped,
265 _ => PlaybackState::Transitioning,
266 };
267 changes.push(PropertyChange::PlaybackState(ps));
268 }
269
270 if event.rel_time.is_some() || event.track_duration.is_some() {
279 match parse_duration_ms(event.rel_time.as_deref()) {
280 Some(position_ms) => {
281 let duration_ms = parse_duration_ms(event.track_duration.as_deref()).unwrap_or(0);
282 changes.push(PropertyChange::Position(Position {
283 position_ms,
284 duration_ms,
285 }));
286 }
287 None => {
288 tracing::debug!(
289 "Skipping Position update: RelTime {:?} is missing or unparseable",
290 event.rel_time
291 );
292 }
293 }
294 }
295
296 if event.current_track_uri.is_some() || event.track_metadata.is_some() {
298 let (title, artist, album, album_art_uri) =
300 parse_track_metadata(event.track_metadata.as_deref());
301
302 let track = CurrentTrack {
303 title,
304 artist,
305 album,
306 album_art_uri,
307 uri: event.current_track_uri.clone(),
308 };
309 changes.push(PropertyChange::CurrentTrack(track));
310 }
311
312 changes
313}
314
315fn decode_topology(_event: &ZoneGroupTopologyState) -> Vec<PropertyChange> {
320 vec![]
323}
324
325fn decode_group_rendering_control(event: &GroupRenderingControlState) -> Vec<PropertyChange> {
327 let mut changes = vec![];
328
329 if let Some(vol) = event.group_volume {
330 changes.push(PropertyChange::GroupVolume(GroupVolume(vol.min(100))));
331 }
332
333 if let Some(muted) = event.group_mute {
334 changes.push(PropertyChange::GroupMute(GroupMute(muted)));
335 }
336
337 if let Some(changeable) = event.group_volume_changeable {
338 changes.push(PropertyChange::GroupVolumeChangeable(
339 GroupVolumeChangeable(changeable),
340 ));
341 }
342
343 changes
344}
345
346pub fn decode_topology_event(event: &ZoneGroupTopologyState) -> TopologyChanges {
357 let mut groups = Vec::new();
358 let mut memberships = Vec::new();
359 let mut boot_seqs = Vec::new();
360 let mut speaker_ips = Vec::new();
361 let mut satellite_ids = Vec::new();
362
363 for zone_group in &event.zone_groups {
364 let group_id = GroupId::new(&zone_group.id);
365 let coordinator_id = SpeakerId::new(&zone_group.coordinator);
366
367 let member_ids: Vec<SpeakerId> = zone_group
369 .members
370 .iter()
371 .map(|m| SpeakerId::new(&m.uuid))
372 .collect();
373
374 let group_info =
376 GroupInfo::new(group_id.clone(), coordinator_id.clone(), member_ids.clone());
377 groups.push(group_info);
378
379 for member in &zone_group.members {
381 let speaker_id = SpeakerId::new(&member.uuid);
382 let is_coordinator = speaker_id == coordinator_id;
383 let membership = GroupMembership::new(group_id.clone(), is_coordinator);
384 memberships.push((speaker_id.clone(), membership));
385 boot_seqs.push((speaker_id.clone(), member.boot_seq));
386
387 if let Some(ip) = extract_ip_from_location(&member.location) {
388 speaker_ips.push((speaker_id, ip));
389 }
390
391 for sat in &member.satellites {
392 if sat.invisible == "1" {
393 let sat_id = SpeakerId::new(&sat.uuid);
394 satellite_ids.push(sat_id.clone());
395 if let Some(ip) = extract_ip_from_location(&sat.location) {
396 speaker_ips.push((sat_id, ip));
397 }
398 }
399 }
400 }
401 }
402
403 TopologyChanges {
404 groups,
405 memberships,
406 boot_seqs,
407 speaker_ips,
408 satellite_ids,
409 }
410}
411
412fn extract_ip_from_location(location: &str) -> Option<IpAddr> {
424 match Url::parse(location).ok()?.host()? {
425 Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
426 Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
427 Host::Domain(_) => None,
428 }
429}
430
431fn parse_duration_ms(duration: Option<&str>) -> Option<u64> {
433 let d = duration?;
434
435 if d.is_empty() || d == "NOT_IMPLEMENTED" {
437 return None;
438 }
439
440 let parts: Vec<&str> = d.split(':').collect();
441 if parts.len() != 3 {
442 return None;
443 }
444
445 let hours: u64 = parts[0].parse().ok()?;
446 let minutes: u64 = parts[1].parse().ok()?;
447
448 let seconds_parts: Vec<&str> = parts[2].split('.').collect();
450 let seconds: u64 = seconds_parts[0].parse().ok()?;
451 let millis: u64 = seconds_parts
452 .get(1)
453 .and_then(|m| m.parse().ok())
454 .unwrap_or(0);
455
456 hours
461 .checked_mul(3600)?
462 .checked_add(minutes.checked_mul(60)?)?
463 .checked_add(seconds)?
464 .checked_mul(1000)?
465 .checked_add(millis)
466}
467
468#[derive(Debug, Deserialize)]
480struct DidlItem {
481 #[serde(default)]
482 title: Option<String>,
483 #[serde(default)]
484 creator: Option<String>,
485 #[serde(rename = "albumArtist", default)]
486 album_artist: Option<String>,
487 #[serde(default)]
488 album: Option<String>,
489 #[serde(rename = "albumArtURI", default)]
490 album_art_uri: Option<String>,
491}
492
493#[derive(Debug, Deserialize)]
494struct DidlLite {
495 #[serde(rename = "item", default)]
496 items: Vec<DidlItem>,
497}
498
499pub fn parse_track_metadata(
507 metadata: Option<&str>,
508) -> (
509 Option<String>,
510 Option<String>,
511 Option<String>,
512 Option<String>,
513) {
514 let xml = match metadata {
515 Some(m) if !m.is_empty() && m != "NOT_IMPLEMENTED" => m,
516 _ => return (None, None, None, None),
517 };
518
519 let didl = match quick_xml::de::from_str::<DidlLite>(xml) {
530 Ok(didl) => didl,
531 Err(strict_err) => {
532 let repaired = escape_stray_ampersands(xml);
533 match quick_xml::de::from_str::<DidlLite>(&repaired) {
534 Ok(didl) => didl,
535 Err(err) => {
536 tracing::debug!(
537 "Failed to parse DIDL-Lite track metadata: {strict_err}; \
538 retry with escaped ampersands also failed: {err}"
539 );
540 return (None, None, None, None);
541 }
542 }
543 }
544 };
545
546 let Some(item) = didl.items.into_iter().next() else {
547 return (None, None, None, None);
548 };
549
550 let artist = nonempty(item.creator).or_else(|| nonempty(item.album_artist));
553
554 (
555 nonempty(item.title),
556 artist,
557 nonempty(item.album),
558 nonempty(item.album_art_uri),
559 )
560}
561
562fn nonempty(value: Option<String>) -> Option<String> {
564 value.filter(|s| !s.is_empty())
565}
566
567fn escape_stray_ampersands(xml: &str) -> String {
570 let mut out = String::with_capacity(xml.len() + 16);
571 let mut rest = xml;
572
573 while let Some(pos) = rest.find('&') {
574 out.push_str(&rest[..pos]);
575 let tail = &rest[pos..];
576 match character_reference_len(tail) {
577 Some(len) => {
578 out.push_str(&tail[..len]);
579 rest = &tail[len..];
580 }
581 None => {
582 out.push_str("&");
583 rest = &tail[1..];
584 }
585 }
586 }
587
588 out.push_str(rest);
589 out
590}
591
592fn character_reference_len(tail: &str) -> Option<usize> {
596 for named in ["&", "<", ">", """, "'"] {
597 if tail.starts_with(named) {
598 return Some(named.len());
599 }
600 }
601
602 let body = tail.strip_prefix("&#")?;
603 let (digits, radix) = match body.strip_prefix(['x', 'X']) {
604 Some(hex) => (hex, 16),
605 None => (body, 10),
606 };
607 let end = digits.find(';')?;
608 let number = &digits[..end];
609 if number.is_empty() {
610 return None;
611 }
612 let code = u32::from_str_radix(number, radix).ok()?;
613 char::from_u32(code)?;
614
615 Some(tail.len() - digits.len() + end + 1)
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn test_parse_duration_ms() {
625 assert_eq!(parse_duration_ms(Some("0:00:00")), Some(0));
626 assert_eq!(parse_duration_ms(Some("0:01:00")), Some(60_000));
627 assert_eq!(parse_duration_ms(Some("1:00:00")), Some(3_600_000));
628 assert_eq!(parse_duration_ms(Some("0:03:45")), Some(225_000));
629 assert_eq!(parse_duration_ms(Some("0:03:45.500")), Some(225_500));
630 assert_eq!(parse_duration_ms(Some("NOT_IMPLEMENTED")), None);
631 assert_eq!(parse_duration_ms(None), None);
632 assert_eq!(parse_duration_ms(Some("")), None);
633 }
634
635 #[test]
636 fn test_parse_duration_ms_overflow_returns_none() {
637 assert_eq!(parse_duration_ms(Some("18446744073709551615:00:00")), None);
640 assert_eq!(parse_duration_ms(Some("0:0:18446744073709551615")), None);
641 }
642
643 #[test]
644 fn test_decode_av_transport_skips_position_when_rel_time_garbage() {
645 let event = AVTransportState {
648 transport_state: None,
649 transport_status: None,
650 speed: None,
651 current_track_uri: None,
652 track_duration: Some("0:03:45".to_string()),
653 rel_time: Some("garbage".to_string()),
654 abs_time: None,
655 rel_count: None,
656 abs_count: None,
657 play_mode: None,
658 track_metadata: None,
659 next_track_uri: None,
660 next_track_metadata: None,
661 queue_length: None,
662 };
663
664 let changes = decode_av_transport(&event);
665
666 assert!(
667 !changes
668 .iter()
669 .any(|c| matches!(c, PropertyChange::Position(_))),
670 "expected no Position change, got {changes:?}"
671 );
672 }
673
674 #[test]
675 fn test_parse_track_metadata_tolerates_bad_entities() {
676 let xml = r#"<DIDL-Lite><item><dc:title>Rock & Roll Star</dc:title><dc:creator>Oasis</dc:creator></item></DIDL-Lite>"#;
680
681 let (title, artist, album, art) = parse_track_metadata(Some(xml));
682
683 assert_eq!(title, Some("Rock & Roll Star".to_string()));
684 assert_eq!(artist, Some("Oasis".to_string()));
685 assert_eq!((album, art), (None, None));
686
687 assert_eq!(
690 parse_track_metadata(Some("<DIDL-Lite><item><dc:title>Unclosed")),
691 (None, None, None, None)
692 );
693 }
694
695 #[test]
696 fn test_parse_track_metadata_decodes_double_escaped_entity() {
697 let xml =
701 r#"<DIDL-Lite><item><dc:title>&apos;Round Midnight</dc:title></item></DIDL-Lite>"#;
702
703 let (title, ..) = parse_track_metadata(Some(xml));
704
705 assert_eq!(title, Some("'Round Midnight".to_string()));
706 }
707
708 #[test]
709 fn test_extract_ip_from_location_valid() {
710 let ip = extract_ip_from_location("http://192.168.4.200:1400/xml/device_description.xml");
711 assert_eq!(ip, Some("192.168.4.200".parse().unwrap()));
712 }
713
714 #[test]
715 fn test_extract_ip_from_location_no_path() {
716 let ip = extract_ip_from_location("http://10.0.0.1:1400");
717 assert_eq!(ip, Some("10.0.0.1".parse().unwrap()));
718 }
719
720 #[test]
721 fn test_extract_ip_from_location_missing_prefix() {
722 assert_eq!(extract_ip_from_location("192.168.1.1:1400/xml"), None);
723 }
724
725 #[test]
726 fn test_extract_ip_from_location_empty() {
727 assert_eq!(extract_ip_from_location(""), None);
728 }
729
730 #[test]
731 fn test_extract_ip_from_location_malformed() {
732 assert_eq!(extract_ip_from_location("http://not-an-ip:1400/xml"), None);
733 }
734
735 #[test]
736 fn test_decode_topology_extracts_ips_and_satellites() {
737 use sonos_stream::events::{
738 NetworkInfo, SatelliteInfo, ZoneGroupInfo, ZoneGroupMemberInfo, ZoneGroupTopologyState,
739 };
740
741 let event = ZoneGroupTopologyState {
742 zone_groups: vec![ZoneGroupInfo {
743 coordinator: "RINCON_MAIN".to_string(),
744 id: "RINCON_MAIN:1".to_string(),
745 members: vec![ZoneGroupMemberInfo {
746 uuid: "RINCON_MAIN".to_string(),
747 location: "http://192.168.4.100:1400/xml/device_description.xml".to_string(),
748 zone_name: "Living Room".to_string(),
749 software_version: "56.0".to_string(),
750 boot_seq: 42,
751 network_info: NetworkInfo::default(),
752 satellites: vec![SatelliteInfo {
753 uuid: "RINCON_SAT".to_string(),
754 location: "http://192.168.4.101:1400/xml/device_description.xml"
755 .to_string(),
756 zone_name: "Living Room".to_string(),
757 ht_sat_chan_map_set: "".to_string(),
758 invisible: "1".to_string(),
759 }],
760 }],
761 }],
762 vanished_devices: vec![],
763 };
764
765 let changes = decode_topology_event(&event);
766
767 assert_eq!(changes.speaker_ips.len(), 2);
768 assert_eq!(
769 changes.speaker_ips[0],
770 (
771 SpeakerId::new("RINCON_MAIN"),
772 "192.168.4.100".parse().unwrap()
773 )
774 );
775 assert_eq!(
776 changes.speaker_ips[1],
777 (
778 SpeakerId::new("RINCON_SAT"),
779 "192.168.4.101".parse().unwrap()
780 )
781 );
782
783 assert_eq!(changes.satellite_ids.len(), 1);
784 assert_eq!(changes.satellite_ids[0], SpeakerId::new("RINCON_SAT"));
785 }
786
787 #[test]
788 fn test_decode_rendering_control() {
789 let event = RenderingControlState {
790 master_volume: Some("50".to_string()),
791 master_mute: Some("0".to_string()),
792 bass: Some("5".to_string()),
793 treble: Some("-3".to_string()),
794 loudness: Some("1".to_string()),
795 lf_volume: None,
796 rf_volume: None,
797 lf_mute: None,
798 rf_mute: None,
799 balance: None,
800 other_channels: std::collections::HashMap::new(),
801 };
802
803 let changes = decode_rendering_control(&event);
804
805 assert_eq!(changes.len(), 5);
806
807 if let PropertyChange::Volume(v) = &changes[0] {
809 assert_eq!(v.0, 50);
810 } else {
811 panic!("Expected Volume change");
812 }
813
814 if let PropertyChange::Mute(m) = &changes[1] {
816 assert!(!m.0);
817 } else {
818 panic!("Expected Mute change");
819 }
820 }
821
822 #[test]
823 fn test_decode_av_transport() {
824 let event = AVTransportState {
825 transport_state: Some("PLAYING".to_string()),
826 transport_status: None,
827 speed: None,
828 current_track_uri: Some("x-sonos-spotify:track123".to_string()),
829 track_duration: Some("0:03:45".to_string()),
830 rel_time: Some("0:01:30".to_string()),
831 abs_time: None,
832 rel_count: None,
833 abs_count: None,
834 play_mode: None,
835 track_metadata: None,
836 next_track_uri: None,
837 next_track_metadata: None,
838 queue_length: None,
839 };
840
841 let changes = decode_av_transport(&event);
842
843 assert!(changes.len() >= 2);
844
845 if let PropertyChange::PlaybackState(ps) = &changes[0] {
847 assert_eq!(*ps, PlaybackState::Playing);
848 } else {
849 panic!("Expected PlaybackState change");
850 }
851 }
852
853 #[test]
854 fn test_decode_group_rendering_control() {
855 let event = GroupRenderingControlState {
856 group_volume: Some(42),
857 group_mute: Some(false),
858 group_volume_changeable: Some(true),
859 };
860
861 let changes = decode_group_rendering_control(&event);
862
863 assert_eq!(changes.len(), 3);
864
865 if let PropertyChange::GroupVolume(v) = &changes[0] {
866 assert_eq!(v.0, 42);
867 } else {
868 panic!("Expected GroupVolume change");
869 }
870
871 if let PropertyChange::GroupMute(m) = &changes[1] {
872 assert!(!m.0);
873 } else {
874 panic!("Expected GroupMute change");
875 }
876
877 if let PropertyChange::GroupVolumeChangeable(c) = &changes[2] {
878 assert!(c.0);
879 } else {
880 panic!("Expected GroupVolumeChangeable change");
881 }
882 }
883
884 #[test]
885 fn test_decode_group_rendering_control_clamps_volume() {
886 let event = GroupRenderingControlState {
887 group_volume: Some(150),
888 group_mute: None,
889 group_volume_changeable: None,
890 };
891
892 let changes = decode_group_rendering_control(&event);
893
894 assert_eq!(changes.len(), 1);
895
896 if let PropertyChange::GroupVolume(v) = &changes[0] {
897 assert_eq!(v.0, 100);
898 } else {
899 panic!("Expected GroupVolume change");
900 }
901 }
902
903 #[test]
904 fn test_decode_group_rendering_control_no_volume() {
905 let event = GroupRenderingControlState {
906 group_volume: None,
907 group_mute: Some(true),
908 group_volume_changeable: None,
909 };
910
911 let changes = decode_group_rendering_control(&event);
912 assert_eq!(changes.len(), 1);
913
914 if let PropertyChange::GroupMute(m) = &changes[0] {
915 assert!(m.0);
916 } else {
917 panic!("Expected GroupMute change");
918 }
919 }
920
921 #[test]
922 fn test_property_change_key() {
923 use crate::property::Property;
924
925 let vol_change = PropertyChange::Volume(Volume(50));
926 assert_eq!(vol_change.key(), Volume::KEY);
927
928 let mute_change = PropertyChange::Mute(Mute(false));
929 assert_eq!(mute_change.key(), Mute::KEY);
930
931 let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
932 assert_eq!(ps_change.key(), PlaybackState::KEY);
933 }
934
935 #[test]
936 fn test_property_change_service() {
937 use crate::model::GroupId;
938 use crate::property::SonosProperty;
939
940 let vol_change = PropertyChange::Volume(Volume(50));
941 assert_eq!(vol_change.service(), Volume::SERVICE);
942
943 let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
944 assert_eq!(ps_change.service(), PlaybackState::SERVICE);
945
946 let gm_change = PropertyChange::GroupMembership(GroupMembership::new(
947 GroupId::new("RINCON_test:1"),
948 true,
949 ));
950 assert_eq!(gm_change.service(), GroupMembership::SERVICE);
951 }
952
953 #[test]
954 fn test_property_change_scope() {
955 use crate::property::Scope;
956
957 let vol_change = PropertyChange::Volume(Volume(50));
959 assert_eq!(vol_change.scope(), Scope::Speaker);
960
961 let ps_change = PropertyChange::PlaybackState(PlaybackState::Playing);
962 assert_eq!(ps_change.scope(), Scope::Speaker);
963
964 let gv_change = PropertyChange::GroupVolume(GroupVolume(50));
966 assert_eq!(gv_change.scope(), Scope::Group);
967
968 let gm_change = PropertyChange::GroupMute(GroupMute(false));
969 assert_eq!(gm_change.scope(), Scope::Group);
970 }
971
972 use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
977
978 fn make_member(uuid: &str, zone_name: &str) -> ZoneGroupMemberInfo {
980 make_member_with_boot_seq(uuid, zone_name, 0)
981 }
982
983 fn make_member_with_boot_seq(
984 uuid: &str,
985 zone_name: &str,
986 boot_seq: u32,
987 ) -> ZoneGroupMemberInfo {
988 ZoneGroupMemberInfo {
989 uuid: uuid.to_string(),
990 location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
991 zone_name: zone_name.to_string(),
992 software_version: "79.1-56030".to_string(),
993 boot_seq,
994 network_info: NetworkInfo {
995 wireless_mode: "0".to_string(),
996 wifi_enabled: "1".to_string(),
997 eth_link: "1".to_string(),
998 channel_freq: "2412".to_string(),
999 behind_wifi_extender: "0".to_string(),
1000 },
1001 satellites: vec![],
1002 }
1003 }
1004
1005 #[test]
1006 fn test_decode_topology_single_group_one_speaker() {
1007 let event = ZoneGroupTopologyState {
1009 zone_groups: vec![ZoneGroupInfo {
1010 coordinator: "RINCON_111111111111".to_string(),
1011 id: "RINCON_111111111111:0".to_string(),
1012 members: vec![make_member("RINCON_111111111111", "Living Room")],
1013 }],
1014 vanished_devices: vec![],
1015 };
1016
1017 let result = decode_topology_event(&event);
1018
1019 assert_eq!(result.groups.len(), 1);
1021 let group = &result.groups[0];
1022 assert_eq!(group.id.as_str(), "RINCON_111111111111:0");
1023 assert_eq!(group.coordinator_id.as_str(), "RINCON_111111111111");
1024 assert_eq!(group.member_ids.len(), 1);
1025 assert!(group.is_standalone());
1026
1027 assert_eq!(result.memberships.len(), 1);
1029 let (speaker_id, membership) = &result.memberships[0];
1030 assert_eq!(speaker_id.as_str(), "RINCON_111111111111");
1031 assert_eq!(membership.group_id.as_str(), "RINCON_111111111111:0");
1032 assert!(membership.is_coordinator);
1033 }
1034
1035 #[test]
1036 fn test_decode_topology_single_group_multiple_speakers() {
1037 let event = ZoneGroupTopologyState {
1039 zone_groups: vec![ZoneGroupInfo {
1040 coordinator: "RINCON_111111111111".to_string(),
1041 id: "RINCON_111111111111:0".to_string(),
1042 members: vec![
1043 make_member("RINCON_111111111111", "Living Room"),
1044 make_member("RINCON_222222222222", "Kitchen"),
1045 make_member("RINCON_333333333333", "Bedroom"),
1046 ],
1047 }],
1048 vanished_devices: vec![],
1049 };
1050
1051 let result = decode_topology_event(&event);
1052
1053 assert_eq!(result.groups.len(), 1);
1055 let group = &result.groups[0];
1056 assert_eq!(group.member_ids.len(), 3);
1057 assert!(!group.is_standalone());
1058
1059 assert_eq!(result.memberships.len(), 3);
1061
1062 let coordinator_membership = result
1064 .memberships
1065 .iter()
1066 .find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
1067 .map(|(_, m)| m);
1068 assert!(coordinator_membership.is_some());
1069 assert!(coordinator_membership.unwrap().is_coordinator);
1070
1071 let kitchen_membership = result
1073 .memberships
1074 .iter()
1075 .find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
1076 .map(|(_, m)| m);
1077 assert!(kitchen_membership.is_some());
1078 assert!(!kitchen_membership.unwrap().is_coordinator);
1079
1080 let bedroom_membership = result
1081 .memberships
1082 .iter()
1083 .find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
1084 .map(|(_, m)| m);
1085 assert!(bedroom_membership.is_some());
1086 assert!(!bedroom_membership.unwrap().is_coordinator);
1087 }
1088
1089 #[test]
1090 fn test_decode_topology_multiple_groups() {
1091 let event = ZoneGroupTopologyState {
1093 zone_groups: vec![
1094 ZoneGroupInfo {
1095 coordinator: "RINCON_111111111111".to_string(),
1096 id: "RINCON_111111111111:0".to_string(),
1097 members: vec![
1098 make_member("RINCON_111111111111", "Living Room"),
1099 make_member("RINCON_222222222222", "Kitchen"),
1100 ],
1101 },
1102 ZoneGroupInfo {
1103 coordinator: "RINCON_333333333333".to_string(),
1104 id: "RINCON_333333333333:0".to_string(),
1105 members: vec![make_member("RINCON_333333333333", "Bedroom")],
1106 },
1107 ],
1108 vanished_devices: vec![],
1109 };
1110
1111 let result = decode_topology_event(&event);
1112
1113 assert_eq!(result.groups.len(), 2);
1115
1116 let group1 = &result.groups[0];
1118 assert_eq!(group1.id.as_str(), "RINCON_111111111111:0");
1119 assert_eq!(group1.member_ids.len(), 2);
1120
1121 let group2 = &result.groups[1];
1123 assert_eq!(group2.id.as_str(), "RINCON_333333333333:0");
1124 assert_eq!(group2.member_ids.len(), 1);
1125 assert!(group2.is_standalone());
1126
1127 assert_eq!(result.memberships.len(), 3);
1129
1130 let living_room = result
1132 .memberships
1133 .iter()
1134 .find(|(sid, _)| sid.as_str() == "RINCON_111111111111")
1135 .map(|(_, m)| m)
1136 .unwrap();
1137 assert_eq!(living_room.group_id.as_str(), "RINCON_111111111111:0");
1138 assert!(living_room.is_coordinator);
1139
1140 let kitchen = result
1141 .memberships
1142 .iter()
1143 .find(|(sid, _)| sid.as_str() == "RINCON_222222222222")
1144 .map(|(_, m)| m)
1145 .unwrap();
1146 assert_eq!(kitchen.group_id.as_str(), "RINCON_111111111111:0");
1147 assert!(!kitchen.is_coordinator);
1148
1149 let bedroom = result
1150 .memberships
1151 .iter()
1152 .find(|(sid, _)| sid.as_str() == "RINCON_333333333333")
1153 .map(|(_, m)| m)
1154 .unwrap();
1155 assert_eq!(bedroom.group_id.as_str(), "RINCON_333333333333:0");
1156 assert!(bedroom.is_coordinator);
1157 }
1158
1159 #[test]
1160 fn test_decode_topology_empty_event() {
1161 let event = ZoneGroupTopologyState {
1163 zone_groups: vec![],
1164 vanished_devices: vec![],
1165 };
1166
1167 let result = decode_topology_event(&event);
1168
1169 assert!(result.groups.is_empty());
1170 assert!(result.memberships.is_empty());
1171 }
1172
1173 #[test]
1174 fn test_decode_topology_extracts_boot_seq_values() {
1175 let event = ZoneGroupTopologyState {
1176 zone_groups: vec![ZoneGroupInfo {
1177 coordinator: "RINCON_111111111111".to_string(),
1178 id: "RINCON_111111111111:0".to_string(),
1179 members: vec![
1180 make_member_with_boot_seq("RINCON_111111111111", "Living Room", 42),
1181 make_member_with_boot_seq("RINCON_222222222222", "Kitchen", 17),
1182 ],
1183 }],
1184 vanished_devices: vec![],
1185 };
1186
1187 let result = decode_topology_event(&event);
1188
1189 assert_eq!(result.boot_seqs.len(), 2);
1190
1191 let boot_seq_111 = result
1192 .boot_seqs
1193 .iter()
1194 .find(|(id, _)| id.as_str() == "RINCON_111111111111")
1195 .map(|(_, bs)| *bs);
1196 assert_eq!(boot_seq_111, Some(42));
1197
1198 let boot_seq_222 = result
1199 .boot_seqs
1200 .iter()
1201 .find(|(id, _)| id.as_str() == "RINCON_222222222222")
1202 .map(|(_, bs)| *bs);
1203 assert_eq!(boot_seq_222, Some(17));
1204 }
1205
1206 #[test]
1207 fn test_decode_topology_boot_seq_defaults_to_zero() {
1208 let event = ZoneGroupTopologyState {
1210 zone_groups: vec![ZoneGroupInfo {
1211 coordinator: "RINCON_111111111111".to_string(),
1212 id: "RINCON_111111111111:0".to_string(),
1213 members: vec![make_member("RINCON_111111111111", "Living Room")],
1214 }],
1215 vanished_devices: vec![],
1216 };
1217
1218 let result = decode_topology_event(&event);
1219
1220 assert_eq!(result.boot_seqs.len(), 1);
1221 assert_eq!(result.boot_seqs[0].1, 0);
1222 }
1223}
1224
1225#[cfg(test)]
1230mod property_tests {
1231 use super::*;
1232 use proptest::prelude::*;
1233 use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
1234
1235 fn speaker_uuid_strategy() -> impl Strategy<Value = String> {
1237 "[A-F0-9]{12}".prop_map(|s| format!("RINCON_{s}"))
1238 }
1239
1240 fn zone_group_member_strategy() -> impl Strategy<Value = ZoneGroupMemberInfo> {
1242 (speaker_uuid_strategy(), "[A-Za-z ]{3,15}").prop_map(|(uuid, zone_name)| {
1243 ZoneGroupMemberInfo {
1244 uuid,
1245 location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
1246 zone_name: zone_name.trim().to_string(),
1247 software_version: "79.1-56030".to_string(),
1248 boot_seq: 0,
1249 network_info: NetworkInfo {
1250 wireless_mode: "0".to_string(),
1251 wifi_enabled: "1".to_string(),
1252 eth_link: "1".to_string(),
1253 channel_freq: "2412".to_string(),
1254 behind_wifi_extender: "0".to_string(),
1255 },
1256 satellites: vec![],
1257 }
1258 })
1259 }
1260
1261 fn zone_group_strategy() -> impl Strategy<Value = ZoneGroupInfo> {
1263 proptest::collection::vec(zone_group_member_strategy(), 1..=5).prop_flat_map(|members| {
1264 let coordinator = members[0].uuid.clone();
1266 let group_id = format!("{coordinator}:0");
1267 Just(ZoneGroupInfo {
1268 coordinator,
1269 id: group_id,
1270 members,
1271 })
1272 })
1273 }
1274
1275 fn topology_event_strategy() -> impl Strategy<Value = ZoneGroupTopologyState> {
1277 proptest::collection::vec(zone_group_strategy(), 1..=3).prop_map(|zone_groups| {
1278 ZoneGroupTopologyState {
1279 zone_groups,
1280 vanished_devices: vec![],
1281 }
1282 })
1283 }
1284
1285 proptest! {
1286 #![proptest_config(ProptestConfig::with_cases(100))]
1287
1288 #[test]
1294 fn prop_topology_event_processing_round_trip(event in topology_event_strategy()) {
1295 let result = decode_topology_event(&event);
1296
1297 prop_assert_eq!(
1299 result.groups.len(),
1300 event.zone_groups.len(),
1301 "Number of groups should match number of zone groups in event"
1302 );
1303
1304 let total_members: usize = event.zone_groups.iter()
1306 .map(|zg| zg.members.len())
1307 .sum();
1308 prop_assert_eq!(
1309 result.memberships.len(),
1310 total_members,
1311 "Number of memberships should match total members across all groups"
1312 );
1313
1314 for (group_info, zone_group) in result.groups.iter().zip(event.zone_groups.iter()) {
1316 prop_assert_eq!(
1317 group_info.id.as_str(),
1318 &zone_group.id,
1319 "GroupInfo ID should match zone group ID"
1320 );
1321 prop_assert_eq!(
1322 group_info.coordinator_id.as_str(),
1323 &zone_group.coordinator,
1324 "GroupInfo coordinator should match zone group coordinator"
1325 );
1326 prop_assert_eq!(
1327 group_info.member_ids.len(),
1328 zone_group.members.len(),
1329 "GroupInfo member count should match zone group member count"
1330 );
1331 }
1332
1333 for zone_group in &event.zone_groups {
1335 for member in &zone_group.members {
1336 let membership = result.memberships.iter()
1337 .find(|(sid, _)| sid.as_str() == member.uuid)
1338 .map(|(_, m)| m);
1339
1340 prop_assert!(
1341 membership.is_some(),
1342 "Each member should have a GroupMembership"
1343 );
1344
1345 let membership = membership.unwrap();
1346 prop_assert_eq!(
1347 membership.group_id.as_str(),
1348 &zone_group.id,
1349 "GroupMembership.group_id should match zone group ID"
1350 );
1351
1352 let is_coordinator = member.uuid == zone_group.coordinator;
1353 prop_assert_eq!(
1354 membership.is_coordinator,
1355 is_coordinator,
1356 "is_coordinator should be true only for the coordinator"
1357 );
1358 }
1359 }
1360 }
1361
1362 #[test]
1365 fn prop_coordinator_always_in_members(event in topology_event_strategy()) {
1366 let result = decode_topology_event(&event);
1367
1368 for group_info in &result.groups {
1369 prop_assert!(
1370 group_info.member_ids.contains(&group_info.coordinator_id),
1371 "Coordinator should always be in member_ids"
1372 );
1373 }
1374 }
1375
1376 #[test]
1379 fn prop_exactly_one_coordinator_per_group(event in topology_event_strategy()) {
1380 let result = decode_topology_event(&event);
1381
1382 for group_info in &result.groups {
1383 let coordinator_count = result.memberships.iter()
1384 .filter(|(sid, membership)| {
1385 group_info.member_ids.contains(sid) && membership.is_coordinator
1386 })
1387 .count();
1388
1389 prop_assert_eq!(
1390 coordinator_count,
1391 1,
1392 "Each group should have exactly one coordinator"
1393 );
1394 }
1395 }
1396 }
1397}