Skip to main content

sonos_state/
decoder.rs

1//! Event decoder - converts EnrichedEvent to typed property changes
2//!
3//! This module decodes raw events from sonos-stream into typed property
4//! changes that can be applied to the StateStore.
5
6use 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/// Decoded changes from a single event
22#[derive(Debug)]
23pub struct DecodedChanges {
24    /// Speaker ID the changes apply to
25    pub speaker_id: SpeakerId,
26    /// List of property changes
27    pub changes: Vec<PropertyChange>,
28}
29
30/// Changes extracted from a ZoneGroupTopology event
31///
32/// This struct contains the complete topology update including:
33/// - All groups with their coordinator and members
34/// - GroupMembership for each speaker in the topology
35#[derive(Debug)]
36pub struct TopologyChanges {
37    /// Updated group information
38    pub groups: Vec<GroupInfo>,
39    /// Updated speaker memberships: (speaker_id, membership)
40    pub memberships: Vec<(SpeakerId, GroupMembership)>,
41    /// Boot sequence numbers per speaker (for GroupManagement AddMember)
42    pub boot_seqs: Vec<(SpeakerId, u32)>,
43    /// Current IPs extracted from topology location URLs
44    pub speaker_ips: Vec<(SpeakerId, IpAddr)>,
45    /// Speakers marked Invisible="1" (satellites: surrounds, subs)
46    pub satellite_ids: Vec<SpeakerId>,
47}
48
49/// A single property change
50///
51/// `#[non_exhaustive]` because this enum is public API (re-exported from
52/// `sonos-sdk`) and gains a variant every time a property is added. Without it,
53/// every consumer `match` would be exhaustive-by-default and adding a property
54/// would be a breaking change; consumers must include a `_ =>` arm.
55#[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    /// Apply this change to the store using scope-based routing
74    ///
75    /// Speaker-scoped properties are stored in `speaker_props`,
76    /// group-scoped properties resolve speaker→group and store in `group_props`.
77    ///
78    /// Returns the [`WriteOutcome`] — `Changed` only if the value actually
79    /// changed *and* `stamp` was not older than the stored observation.
80    pub fn apply(
81        &self,
82        store: &mut StateStore,
83        speaker_id: &SpeakerId,
84        stamp: WriteStamp,
85    ) -> WriteOutcome {
86        match self {
87            // Speaker-scoped properties
88            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            // Group-scoped properties: resolve speaker→group, store in group_props
98            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    /// Report a group-scoped change that could not be stored because the speaker
118    /// has no `speaker_to_group` mapping yet.
119    ///
120    /// This used to be a bare `else { false }`. It is a legitimate state — a
121    /// GroupRenderingControl event can arrive before the first topology snapshot
122    /// — but it is also what a wiped `speaker_to_group` map looks like, so it
123    /// must be observable rather than silent. Always reports `Unchanged`
124    /// (nothing was stored, so nothing changed) rather than `Stale` — the write
125    /// was not rejected for being out of order, it had nowhere to go.
126    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    /// Get the property key for this change
137    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    /// Get the scope of this property
156    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    /// Get the service this property belongs to
175    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
194/// Decode an enriched event into typed property changes
195pub 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        // GroupManagement is action-only; group changes surface via ZoneGroupTopology events.
202        // No user-facing properties to decode.
203        EventData::GroupManagement(_) => vec![],
204        EventData::GroupRenderingControl(grc) => decode_group_rendering_control(grc),
205    };
206
207    DecodedChanges {
208        speaker_id,
209        changes,
210    }
211}
212
213/// Decode RenderingControl event data
214fn decode_rendering_control(event: &RenderingControlState) -> Vec<PropertyChange> {
215    let mut changes = vec![];
216
217    // Volume
218    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    // Mute
225    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    // Bass
231    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    // Treble
238    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    // Loudness
245    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
253/// Decode AVTransport event data
254fn decode_av_transport(event: &AVTransportState) -> Vec<PropertyChange> {
255    let mut changes = vec![];
256
257    // Playback state
258    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    // Position
269    //
270    // Only emit when `rel_time` actually parses. The previous `unwrap_or(0)`
271    // turned an unparseable or missing position into 0:00, which a consumer
272    // cannot tell apart from a track that genuinely just started — so a garbage
273    // value would visibly rewind the playhead. `track_duration` keeps its
274    // `unwrap_or(0)` because "unknown duration" is a real, common state for
275    // live streams and 0 is how the SDK already represents it.
276    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    // CurrentTrack
295    if event.current_track_uri.is_some() || event.track_metadata.is_some() {
296        // Parse metadata if available (track_metadata is raw XML, need to parse it)
297        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
313/// Decode ZoneGroupTopology event data into property changes
314///
315/// Note: This returns an empty Vec because topology changes are handled
316/// specially via `decode_topology_event()` which returns `TopologyChanges`.
317fn decode_topology(_event: &ZoneGroupTopologyState) -> Vec<PropertyChange> {
318    // Topology events are handled specially via decode_topology_event()
319    // which returns TopologyChanges instead of PropertyChange
320    vec![]
321}
322
323/// Decode GroupRenderingControl event data
324fn 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
344/// Decode a ZoneGroupTopology event into TopologyChanges
345///
346/// This extracts group information and speaker memberships from the topology event.
347/// Each zone group becomes a GroupInfo, and each member gets a GroupMembership.
348///
349/// # Arguments
350/// * `event` - The ZoneGroupTopology event to decode
351///
352/// # Returns
353/// TopologyChanges containing all groups and speaker memberships
354pub 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        // Collect all member IDs
366        let member_ids: Vec<SpeakerId> = zone_group
367            .members
368            .iter()
369            .map(|m| SpeakerId::new(&m.uuid))
370            .collect();
371
372        // Create GroupInfo for this zone group
373        let group_info =
374            GroupInfo::new(group_id.clone(), coordinator_id.clone(), member_ids.clone());
375        groups.push(group_info);
376
377        // Create GroupMembership and extract boot_seq for each member
378        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
417/// Parse duration string (HH:MM:SS or H:MM:SS) to milliseconds
418fn parse_duration_ms(duration: Option<&str>) -> Option<u64> {
419    let d = duration?;
420
421    // Handle NOT_IMPLEMENTED or empty strings
422    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    // Handle potential milliseconds in seconds part (HH:MM:SS.mmm)
435    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    // Checked arithmetic throughout: the components come straight off the wire,
443    // so a device (or a forged event) can supply values near `u64::MAX` that
444    // overflow the multiply. Unchecked math panics in debug and silently wraps
445    // to a nonsense position in release; `None` correctly means "unparseable".
446    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
454/// Parse DIDL-Lite track metadata XML
455pub 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    // Simple XML extraction (could use quick-xml for more robust parsing)
469    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
478/// Extract content from an XML element (simple regex-free implementation)
479pub 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    // Unescape basic XML entities
489    let unescaped = content
490        .replace("&lt;", "<")
491        .replace("&gt;", ">")
492        .replace("&amp;", "&")
493        .replace("&apos;", "'")
494        .replace("&quot;", "\"");
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        // Components near u64::MAX overflow the seconds→millis multiply.
522        // Unchecked arithmetic would panic in debug and wrap in release.
523        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        // Garbage RelTime must not silently become position 0:00, which is
530        // indistinguishable from a track that just started.
531        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        // Check volume
673        if let PropertyChange::Volume(v) = &changes[0] {
674            assert_eq!(v.0, 50);
675        } else {
676            panic!("Expected Volume change");
677        }
678
679        // Check mute
680        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        // Check playback state
711        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        // Speaker-scoped properties
823        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        // Group-scoped properties
830        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    // ========================================================================
838    // Unit Tests for decode_topology_event
839    // ========================================================================
840
841    use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
842
843    /// Helper to create a ZoneGroupMemberInfo for testing
844    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        // Single speaker in a standalone group
873        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        // Should have 1 group
885        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        // Should have 1 membership
893        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        // Group with 3 speakers: coordinator + 2 members
903        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        // Should have 1 group with 3 members
919        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        // Should have 3 memberships
925        assert_eq!(result.memberships.len(), 3);
926
927        // Check coordinator membership
928        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        // Check non-coordinator memberships
937        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        // Two separate groups
957        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        // Should have 2 groups
979        assert_eq!(result.groups.len(), 2);
980
981        // First group: 2 members
982        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        // Second group: 1 member (standalone)
987        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        // Should have 3 total memberships
993        assert_eq!(result.memberships.len(), 3);
994
995        // Verify each speaker has correct group_id
996        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        // Empty topology (no groups)
1027        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        // make_member() uses boot_seq: 0
1074        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// ============================================================================
1091// Property-Based Tests for Topology Decoding
1092// ============================================================================
1093
1094#[cfg(test)]
1095mod property_tests {
1096    use super::*;
1097    use proptest::prelude::*;
1098    use sonos_stream::events::types::{NetworkInfo, ZoneGroupInfo, ZoneGroupMemberInfo};
1099
1100    /// Strategy for generating valid RINCON-style speaker UUIDs
1101    fn speaker_uuid_strategy() -> impl Strategy<Value = String> {
1102        "[A-F0-9]{12}".prop_map(|s| format!("RINCON_{s}"))
1103    }
1104
1105    /// Strategy for generating a zone group member
1106    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    /// Strategy for generating a zone group with 1-5 members
1127    fn zone_group_strategy() -> impl Strategy<Value = ZoneGroupInfo> {
1128        proptest::collection::vec(zone_group_member_strategy(), 1..=5).prop_flat_map(|members| {
1129            // First member is always the coordinator
1130            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    /// Strategy for generating a topology event with 1-3 groups
1141    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        /// *For any* valid ZoneGroupTopology event containing zone groups, after processing:
1154        /// - Each zone group in the event corresponds to a GroupInfo in the result
1155        /// - Each member in each zone group has a GroupMembership in the result
1156        /// - The GroupMembership.group_id matches the zone group's ID
1157        /// - The GroupMembership.is_coordinator is true only for the coordinator
1158        #[test]
1159        fn prop_topology_event_processing_round_trip(event in topology_event_strategy()) {
1160            let result = decode_topology_event(&event);
1161
1162            // Property: Each zone group in the event corresponds to a GroupInfo
1163            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            // Property: Each member has a GroupMembership
1170            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            // Property: GroupInfo matches zone group data
1180            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            // Property: GroupMembership.group_id matches and is_coordinator is correct
1199            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        /// *For any* decoded topology, the coordinator_id should always be present
1228        /// in the member_ids list.
1229        #[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        /// *For any* decoded topology, each group should have exactly one member
1242        /// marked as coordinator in the memberships.
1243        #[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}