Skip to main content

sonos_api/services/zone_group_topology/
events.rs

1//! ZoneGroupTopology service event types and parsing
2//!
3//! Provides direct serde-based XML parsing with no business logic,
4//! replicating exactly what Sonos produces for sonos-stream consumption.
5
6use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8
9use crate::events::{xml_utils, EnrichedEvent, EventParser, EventSource};
10use crate::{ApiError, Result, Service};
11
12/// Minimal ZoneGroupTopology event - direct serde mapping from UPnP event XML
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename = "propertyset")]
15pub struct ZoneGroupTopologyEvent {
16    /// Multiple property elements can exist in a single event
17    #[serde(rename = "property", default)]
18    properties: Vec<ZoneGroupTopologyProperty>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct ZoneGroupTopologyProperty {
23    #[serde(
24        rename = "ZoneGroupState",
25        default,
26        deserialize_with = "xml_utils::deserialize_zone_group_state"
27    )]
28    zone_group_state: Option<ZoneGroupState>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32struct ZoneGroupState {
33    #[serde(rename = "ZoneGroups")]
34    zone_groups: ZoneGroups,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct ZoneGroups {
39    #[serde(rename = "ZoneGroup", default)]
40    zone_groups: Vec<ZoneGroup>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44struct ZoneGroup {
45    #[serde(rename = "@Coordinator")]
46    coordinator: String,
47
48    #[serde(rename = "@ID")]
49    id: String,
50
51    #[serde(rename = "ZoneGroupMember", default)]
52    members: Vec<ZoneGroupMember>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56struct ZoneGroupMember {
57    #[serde(rename = "@UUID")]
58    uuid: String,
59
60    #[serde(rename = "@Location")]
61    location: String,
62
63    #[serde(rename = "@ZoneName")]
64    zone_name: String,
65
66    #[serde(rename = "@SoftwareVersion", default)]
67    software_version: Option<String>,
68
69    #[serde(rename = "@WirelessMode", default)]
70    wireless_mode: Option<String>,
71
72    #[serde(rename = "@WifiEnabled", default)]
73    wifi_enabled: Option<String>,
74
75    #[serde(rename = "@EthLink", default)]
76    eth_link: Option<String>,
77
78    #[serde(rename = "@ChannelFreq", default)]
79    channel_freq: Option<String>,
80
81    #[serde(rename = "@BehindWifiExtender", default)]
82    behind_wifi_extender: Option<String>,
83
84    #[serde(rename = "@HTSatChanMapSet", default)]
85    ht_sat_chan_map_set: Option<String>,
86
87    #[serde(rename = "@Icon", default)]
88    icon: Option<String>,
89
90    #[serde(rename = "@Invisible", default)]
91    invisible: Option<String>,
92
93    #[serde(rename = "@IsZoneBridge", default)]
94    is_zone_bridge: Option<String>,
95
96    #[serde(rename = "@BootSeq", default)]
97    boot_seq: Option<String>,
98
99    #[serde(rename = "@TVConfigurationError", default)]
100    tv_configuration_error: Option<String>,
101
102    #[serde(rename = "@HdmiCecAvailable", default)]
103    hdmi_cec_available: Option<String>,
104
105    #[serde(rename = "@HasConfiguredSSID", default)]
106    has_configured_ssid: Option<String>,
107
108    #[serde(rename = "@MicEnabled", default)]
109    mic_enabled: Option<String>,
110
111    #[serde(rename = "@AirPlayEnabled", default)]
112    airplay_enabled: Option<String>,
113
114    #[serde(rename = "@IdleState", default)]
115    idle_state: Option<String>,
116
117    #[serde(rename = "@MoreInfo", default)]
118    more_info: Option<String>,
119
120    /// Nested satellite speakers (for home theater setups with sub/surrounds)
121    #[serde(rename = "Satellite", default)]
122    satellites: Vec<Satellite>,
123}
124
125/// A satellite speaker in a home theater setup (subwoofer, surround speakers)
126#[derive(Debug, Clone, Serialize, Deserialize)]
127struct Satellite {
128    #[serde(rename = "@UUID")]
129    uuid: String,
130
131    #[serde(rename = "@Location", default)]
132    location: Option<String>,
133
134    #[serde(rename = "@ZoneName", default)]
135    zone_name: Option<String>,
136
137    #[serde(rename = "@HTSatChanMapSet", default)]
138    ht_sat_chan_map_set: Option<String>,
139
140    #[serde(rename = "@Invisible", default)]
141    invisible: Option<String>,
142}
143
144/// Information about a single zone group (public interface for sonos-stream)
145#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
146pub struct ZoneGroupInfo {
147    pub coordinator: String,
148    pub id: String,
149    pub members: Vec<ZoneGroupMemberInfo>,
150}
151
152/// Information about a speaker in a zone group (public interface for sonos-stream)
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct ZoneGroupMemberInfo {
155    pub uuid: String,
156    pub location: String,
157    pub zone_name: String,
158    pub software_version: String,
159    pub boot_seq: u32,
160    pub network_info: NetworkInfo,
161    pub satellites: Vec<SatelliteInfo>,
162}
163
164/// Network configuration information for a speaker
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166pub struct NetworkInfo {
167    pub wireless_mode: String,
168    pub wifi_enabled: String,
169    pub eth_link: String,
170    pub channel_freq: String,
171    pub behind_wifi_extender: String,
172}
173
174impl Default for NetworkInfo {
175    fn default() -> Self {
176        Self {
177            wireless_mode: "0".to_string(),
178            wifi_enabled: "0".to_string(),
179            eth_link: "0".to_string(),
180            channel_freq: "0".to_string(),
181            behind_wifi_extender: "0".to_string(),
182        }
183    }
184}
185
186/// Information about a satellite speaker
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
188pub struct SatelliteInfo {
189    pub uuid: String,
190    pub location: String,
191    pub zone_name: String,
192    pub ht_sat_chan_map_set: String,
193    pub invisible: String,
194}
195
196/// Parse raw ZoneGroupState XML into ZoneGroupInfo structs.
197///
198/// Shared by UPnP event processing and polling for parity.
199/// The XML should be the inner `<ZoneGroupState>` content, e.g. from `GetZoneGroupState` response.
200pub fn parse_zone_group_state_xml(raw_xml: &str) -> Result<Vec<ZoneGroupInfo>> {
201    let clean_xml = xml_utils::strip_namespaces(raw_xml);
202    let state: ZoneGroupState = quick_xml::de::from_str(&clean_xml)
203        .map_err(|e| ApiError::ParseError(format!("ZoneGroupState parse error: {e}")))?;
204    Ok(convert_zone_groups(&state))
205}
206
207/// Convert parsed private ZoneGroupState to public ZoneGroupInfo types.
208fn convert_zone_groups(zone_group_state: &ZoneGroupState) -> Vec<ZoneGroupInfo> {
209    zone_group_state
210        .zone_groups
211        .zone_groups
212        .iter()
213        .map(|group| ZoneGroupInfo {
214            coordinator: group.coordinator.clone(),
215            id: group.id.clone(),
216            members: group
217                .members
218                .iter()
219                .map(|member| ZoneGroupMemberInfo {
220                    uuid: member.uuid.clone(),
221                    location: member.location.clone(),
222                    zone_name: member.zone_name.clone(),
223                    software_version: member.software_version.clone().unwrap_or_default(),
224                    boot_seq: member
225                        .boot_seq
226                        .as_deref()
227                        .and_then(|s| s.parse::<u32>().ok())
228                        .unwrap_or(0),
229                    network_info: NetworkInfo {
230                        wireless_mode: member.wireless_mode.clone().unwrap_or_default(),
231                        wifi_enabled: member.wifi_enabled.clone().unwrap_or_default(),
232                        eth_link: member.eth_link.clone().unwrap_or_default(),
233                        channel_freq: member.channel_freq.clone().unwrap_or_default(),
234                        behind_wifi_extender: member
235                            .behind_wifi_extender
236                            .clone()
237                            .unwrap_or_default(),
238                    },
239                    satellites: member
240                        .satellites
241                        .iter()
242                        .map(|sat| SatelliteInfo {
243                            uuid: sat.uuid.clone(),
244                            location: sat.location.clone().unwrap_or_default(),
245                            zone_name: sat.zone_name.clone().unwrap_or_default(),
246                            ht_sat_chan_map_set: sat
247                                .ht_sat_chan_map_set
248                                .clone()
249                                .unwrap_or_default(),
250                            invisible: sat.invisible.clone().unwrap_or_default(),
251                        })
252                        .collect(),
253                })
254                .collect(),
255        })
256        .collect()
257}
258
259impl ZoneGroupTopologyEvent {
260    /// Get zone groups from the topology event
261    pub fn zone_groups(&self) -> Vec<ZoneGroupInfo> {
262        let zone_group_state = self
263            .properties
264            .iter()
265            .find_map(|p| p.zone_group_state.as_ref());
266
267        if let Some(state) = zone_group_state {
268            convert_zone_groups(state)
269        } else {
270            Vec::new()
271        }
272    }
273
274    /// Convert parsed UPnP event to canonical state representation.
275    pub fn into_state(&self) -> super::state::ZoneGroupTopologyState {
276        super::state::ZoneGroupTopologyState {
277            zone_groups: self.zone_groups(),
278            vanished_devices: self.vanished_devices(),
279        }
280    }
281
282    /// Get vanished devices from the topology event
283    pub fn vanished_devices(&self) -> Vec<String> {
284        Vec::new() // Simplified for now
285    }
286
287    /// Parse from UPnP event XML using serde
288    pub fn from_xml(xml: &str) -> Result<Self> {
289        let clean_xml = xml_utils::strip_namespaces(xml);
290        quick_xml::de::from_str(&clean_xml).map_err(|e| {
291            ApiError::ParseError(format!("Failed to parse ZoneGroupTopology XML: {e}"))
292        })
293    }
294}
295
296/// Minimal parser implementation
297pub struct ZoneGroupTopologyEventParser;
298
299impl EventParser for ZoneGroupTopologyEventParser {
300    type EventData = ZoneGroupTopologyEvent;
301
302    fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
303        ZoneGroupTopologyEvent::from_xml(xml)
304    }
305
306    fn service_type(&self) -> Service {
307        Service::ZoneGroupTopology
308    }
309}
310
311/// Create enriched event for sonos-stream integration
312pub fn create_enriched_event(
313    speaker_ip: IpAddr,
314    event_source: EventSource,
315    event_data: ZoneGroupTopologyEvent,
316) -> EnrichedEvent<ZoneGroupTopologyEvent> {
317    EnrichedEvent::new(
318        speaker_ip,
319        Service::ZoneGroupTopology,
320        event_source,
321        event_data,
322    )
323}
324
325/// Create enriched event with registration ID
326pub fn create_enriched_event_with_registration_id(
327    registration_id: u64,
328    speaker_ip: IpAddr,
329    event_source: EventSource,
330    event_data: ZoneGroupTopologyEvent,
331) -> EnrichedEvent<ZoneGroupTopologyEvent> {
332    EnrichedEvent::with_registration_id(
333        registration_id,
334        speaker_ip,
335        Service::ZoneGroupTopology,
336        event_source,
337        event_data,
338    )
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_zone_group_topology_parser_service_type() {
347        let parser = ZoneGroupTopologyEventParser;
348        assert_eq!(parser.service_type(), Service::ZoneGroupTopology);
349    }
350
351    #[test]
352    fn test_zone_group_topology_event_creation() {
353        let member = ZoneGroupMemberInfo {
354            uuid: "RINCON_123456789".to_string(),
355            location: "http://192.168.1.100:1400/xml/device_description.xml".to_string(),
356            zone_name: "Living Room".to_string(),
357            software_version: "56.0-76060".to_string(),
358            boot_seq: 0,
359            network_info: NetworkInfo {
360                wireless_mode: "0".to_string(),
361                wifi_enabled: "1".to_string(),
362                eth_link: "1".to_string(),
363                channel_freq: "2412".to_string(),
364                behind_wifi_extender: "0".to_string(),
365            },
366            satellites: Vec::new(),
367        };
368
369        let zone_group = ZoneGroupInfo {
370            coordinator: "RINCON_123456789".to_string(),
371            id: "RINCON_123456789:0".to_string(),
372            members: vec![member],
373        };
374
375        let event_data = ZoneGroupState {
376            zone_groups: ZoneGroups {
377                zone_groups: vec![ZoneGroup {
378                    coordinator: zone_group.coordinator.clone(),
379                    id: zone_group.id.clone(),
380                    members: Vec::new(),
381                }],
382            },
383        };
384
385        let event = ZoneGroupTopologyEvent {
386            properties: vec![ZoneGroupTopologyProperty {
387                zone_group_state: Some(event_data),
388            }],
389        };
390
391        let zone_groups = event.zone_groups();
392        assert_eq!(zone_groups.len(), 1);
393        assert_eq!(zone_groups[0].coordinator, "RINCON_123456789");
394    }
395
396    #[test]
397    fn test_enriched_event_creation() {
398        let ip: IpAddr = "192.168.1.100".parse().unwrap();
399        let source = EventSource::UPnPNotification {
400            subscription_id: "uuid:123".to_string(),
401        };
402        let event_data = ZoneGroupTopologyEvent {
403            properties: vec![ZoneGroupTopologyProperty {
404                zone_group_state: None,
405            }],
406        };
407
408        let enriched = create_enriched_event(ip, source, event_data);
409
410        assert_eq!(enriched.speaker_ip, ip);
411        assert_eq!(enriched.service, Service::ZoneGroupTopology);
412        assert!(enriched.registration_id.is_none());
413    }
414
415    #[test]
416    fn test_enriched_event_with_registration_id() {
417        let ip: IpAddr = "192.168.1.100".parse().unwrap();
418        let source = EventSource::UPnPNotification {
419            subscription_id: "uuid:123".to_string(),
420        };
421        let event_data = ZoneGroupTopologyEvent {
422            properties: vec![ZoneGroupTopologyProperty {
423                zone_group_state: None,
424            }],
425        };
426
427        let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
428
429        assert_eq!(enriched.registration_id, Some(42));
430    }
431}
432#[cfg(test)]
433mod xml_parsing_tests {
434    use super::*;
435
436    #[test]
437    fn test_multi_property_event() {
438        // Real Sonos events can have multiple <e:property> elements
439        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
440<e:property>
441<ZoneGroupState>&lt;ZoneGroupState&gt;&lt;ZoneGroups&gt;&lt;ZoneGroup Coordinator="RINCON_5CAAFDAE58BD01400" ID="RINCON_5CAAFDAE58BD01400:0"&gt;&lt;ZoneGroupMember UUID="RINCON_5CAAFDAE58BD01400" Location="http://192.168.1.100:1400/xml/device_description.xml" ZoneName="Living Room"/&gt;&lt;/ZoneGroup&gt;&lt;/ZoneGroups&gt;&lt;/ZoneGroupState&gt;</ZoneGroupState>
442</e:property>
443<e:property>
444<ThirdPartyMediaServersX></ThirdPartyMediaServersX>
445</e:property>
446</e:propertyset>"#;
447
448        let result = ZoneGroupTopologyEvent::from_xml(xml);
449        assert!(
450            result.is_ok(),
451            "Failed to parse multi-property event: {result:?}"
452        );
453
454        let event = result.unwrap();
455        let zone_groups = event.zone_groups();
456        assert_eq!(zone_groups.len(), 1);
457        assert_eq!(zone_groups[0].members[0].zone_name, "Living Room");
458    }
459
460    #[test]
461    fn test_empty_zone_group_state() {
462        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
463<e:property>
464<ZoneGroupState></ZoneGroupState>
465</e:property>
466</e:propertyset>"#;
467
468        let result = ZoneGroupTopologyEvent::from_xml(xml);
469        assert!(
470            result.is_ok(),
471            "Failed with empty ZoneGroupState: {result:?}"
472        );
473
474        let event = result.unwrap();
475        assert!(event.zone_groups().is_empty());
476    }
477
478    #[test]
479    fn test_non_zone_group_state_property() {
480        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
481<e:property>
482<ThirdPartyMediaServersX></ThirdPartyMediaServersX>
483</e:property>
484</e:propertyset>"#;
485
486        let result = ZoneGroupTopologyEvent::from_xml(xml);
487        assert!(result.is_ok());
488
489        let event = result.unwrap();
490        assert!(event.zone_groups().is_empty());
491    }
492
493    #[test]
494    fn test_home_theater_with_satellites() {
495        // Test with nested Satellite elements inside ZoneGroupMember (common in Sonos home theater setups)
496        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
497<e:property>
498<ZoneGroupState>&lt;ZoneGroupState&gt;&lt;ZoneGroups&gt;&lt;ZoneGroup Coordinator=&quot;RINCON_123&quot; ID=&quot;RINCON_123:0&quot;&gt;&lt;ZoneGroupMember UUID=&quot;RINCON_123&quot; Location=&quot;http://192.168.1.100:1400/xml/device_description.xml&quot; ZoneName=&quot;Living Room&quot;&gt;&lt;Satellite UUID=&quot;RINCON_456&quot; Location=&quot;http://192.168.1.101:1400/xml/device_description.xml&quot; ZoneName=&quot;Sub&quot;/&gt;&lt;/ZoneGroupMember&gt;&lt;/ZoneGroup&gt;&lt;/ZoneGroups&gt;&lt;/ZoneGroupState&gt;</ZoneGroupState>
499</e:property>
500</e:propertyset>"#;
501
502        let result = ZoneGroupTopologyEvent::from_xml(xml);
503        assert!(result.is_ok(), "Failed with satellites: {result:?}");
504
505        let event = result.unwrap();
506        let zone_groups = event.zone_groups();
507        assert_eq!(zone_groups.len(), 1);
508        assert_eq!(zone_groups[0].members.len(), 1);
509        assert_eq!(zone_groups[0].members[0].satellites.len(), 1);
510        assert_eq!(zone_groups[0].members[0].satellites[0].uuid, "RINCON_456");
511    }
512
513    #[test]
514    fn test_into_state_maps_zone_groups() {
515        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
516<e:property>
517<ZoneGroupState>&lt;ZoneGroupState&gt;&lt;ZoneGroups&gt;&lt;ZoneGroup Coordinator=&quot;RINCON_123&quot; ID=&quot;RINCON_123:0&quot;&gt;&lt;ZoneGroupMember UUID=&quot;RINCON_123&quot; Location=&quot;http://192.168.1.100:1400/xml/device_description.xml&quot; ZoneName=&quot;Living Room&quot;/&gt;&lt;/ZoneGroup&gt;&lt;/ZoneGroups&gt;&lt;/ZoneGroupState&gt;</ZoneGroupState>
518</e:property>
519</e:propertyset>"#;
520
521        let event = ZoneGroupTopologyEvent::from_xml(xml).unwrap();
522        let state = event.into_state();
523
524        assert_eq!(state.zone_groups.len(), 1);
525        assert_eq!(state.zone_groups[0].coordinator, "RINCON_123");
526        assert_eq!(state.zone_groups[0].members.len(), 1);
527    }
528
529    #[test]
530    fn test_parse_zone_group_state_xml_standalone() {
531        let zone_group_state_xml = r#"<ZoneGroupState>
532            <ZoneGroups>
533                <ZoneGroup Coordinator="RINCON_111" ID="RINCON_111:0">
534                    <ZoneGroupMember UUID="RINCON_111" Location="http://192.168.1.100:1400/xml/device_description.xml" ZoneName="Living Room"/>
535                    <ZoneGroupMember UUID="RINCON_222" Location="http://192.168.1.101:1400/xml/device_description.xml" ZoneName="Kitchen"/>
536                </ZoneGroup>
537            </ZoneGroups>
538        </ZoneGroupState>"#;
539
540        let groups = parse_zone_group_state_xml(zone_group_state_xml).unwrap();
541
542        assert_eq!(groups.len(), 1);
543        assert_eq!(groups[0].coordinator, "RINCON_111");
544        assert_eq!(groups[0].id, "RINCON_111:0");
545        assert_eq!(groups[0].members.len(), 2);
546        assert_eq!(groups[0].members[0].zone_name, "Living Room");
547        assert_eq!(groups[0].members[1].zone_name, "Kitchen");
548    }
549}