1use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8
9use crate::events::{xml_utils, EnrichedEvent, EventParser, EventSource};
10use crate::{ApiError, Result, Service};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename = "propertyset")]
15pub struct ZoneGroupTopologyEvent {
16 #[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 #[serde(rename = "Satellite", default)]
122 satellites: Vec<Satellite>,
123}
124
125#[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#[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#[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#[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#[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
196pub 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
207fn 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 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 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 pub fn vanished_devices(&self) -> Vec<String> {
284 Vec::new() }
286
287 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
296pub 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
311pub 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
325pub 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 let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
440<e:property>
441<ZoneGroupState><ZoneGroupState><ZoneGroups><ZoneGroup Coordinator="RINCON_5CAAFDAE58BD01400" ID="RINCON_5CAAFDAE58BD01400:0"><ZoneGroupMember UUID="RINCON_5CAAFDAE58BD01400" Location="http://192.168.1.100:1400/xml/device_description.xml" ZoneName="Living Room"/></ZoneGroup></ZoneGroups></ZoneGroupState></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 let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
497<e:property>
498<ZoneGroupState><ZoneGroupState><ZoneGroups><ZoneGroup Coordinator="RINCON_123" ID="RINCON_123:0"><ZoneGroupMember UUID="RINCON_123" Location="http://192.168.1.100:1400/xml/device_description.xml" ZoneName="Living Room"><Satellite UUID="RINCON_456" Location="http://192.168.1.101:1400/xml/device_description.xml" ZoneName="Sub"/></ZoneGroupMember></ZoneGroup></ZoneGroups></ZoneGroupState></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><ZoneGroupState><ZoneGroups><ZoneGroup Coordinator="RINCON_123" ID="RINCON_123:0"><ZoneGroupMember UUID="RINCON_123" Location="http://192.168.1.100:1400/xml/device_description.xml" ZoneName="Living Room"/></ZoneGroup></ZoneGroups></ZoneGroupState></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}