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 GroupManagementEvent {
16 #[serde(rename = "property", default)]
18 properties: Vec<GroupManagementProperty>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct GroupManagementProperty {
23 #[serde(rename = "GroupCoordinatorIsLocal", default)]
24 group_coordinator_is_local: Option<String>,
25
26 #[serde(rename = "LocalGroupUUID", default)]
27 local_group_uuid: Option<String>,
28
29 #[serde(rename = "ResetVolumeAfter", default)]
30 reset_volume_after: Option<String>,
31
32 #[serde(rename = "VirtualLineInGroupID", default)]
33 virtual_line_in_group_id: Option<String>,
34
35 #[serde(rename = "VolumeAVTransportURI", default)]
36 volume_av_transport_uri: Option<String>,
37}
38
39impl GroupManagementEvent {
40 pub fn group_coordinator_is_local(&self) -> Option<bool> {
44 self.properties
45 .iter()
46 .find_map(|p| p.group_coordinator_is_local.as_ref())
47 .map(|s| s == "1" || s.to_lowercase() == "true")
48 }
49
50 pub fn local_group_uuid(&self) -> Option<String> {
52 self.properties
53 .iter()
54 .find_map(|p| p.local_group_uuid.clone())
55 }
56
57 pub fn reset_volume_after(&self) -> Option<bool> {
61 self.properties
62 .iter()
63 .find_map(|p| p.reset_volume_after.as_ref())
64 .map(|s| s == "1" || s.to_lowercase() == "true")
65 }
66
67 pub fn virtual_line_in_group_id(&self) -> Option<String> {
69 self.properties
70 .iter()
71 .find_map(|p| p.virtual_line_in_group_id.clone())
72 }
73
74 pub fn volume_av_transport_uri(&self) -> Option<String> {
76 self.properties
77 .iter()
78 .find_map(|p| p.volume_av_transport_uri.clone())
79 }
80
81 pub fn into_state(&self) -> super::state::GroupManagementState {
83 super::state::GroupManagementState {
84 group_coordinator_is_local: self.group_coordinator_is_local(),
85 local_group_uuid: self.local_group_uuid(),
86 reset_volume_after: self.reset_volume_after(),
87 virtual_line_in_group_id: self.virtual_line_in_group_id(),
88 volume_av_transport_uri: self.volume_av_transport_uri(),
89 }
90 }
91
92 pub fn from_xml(xml: &str) -> Result<Self> {
94 let clean_xml = xml_utils::strip_namespaces(xml);
95 quick_xml::de::from_str(&clean_xml)
96 .map_err(|e| ApiError::ParseError(format!("Failed to parse GroupManagement XML: {e}")))
97 }
98}
99
100pub struct GroupManagementEventParser;
102
103impl EventParser for GroupManagementEventParser {
104 type EventData = GroupManagementEvent;
105
106 fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
107 GroupManagementEvent::from_xml(xml)
108 }
109
110 fn service_type(&self) -> Service {
111 Service::GroupManagement
112 }
113}
114
115pub fn create_enriched_event(
117 speaker_ip: IpAddr,
118 event_source: EventSource,
119 event_data: GroupManagementEvent,
120) -> EnrichedEvent<GroupManagementEvent> {
121 EnrichedEvent::new(
122 speaker_ip,
123 Service::GroupManagement,
124 event_source,
125 event_data,
126 )
127}
128
129pub fn create_enriched_event_with_registration_id(
131 registration_id: u64,
132 speaker_ip: IpAddr,
133 event_source: EventSource,
134 event_data: GroupManagementEvent,
135) -> EnrichedEvent<GroupManagementEvent> {
136 EnrichedEvent::with_registration_id(
137 registration_id,
138 speaker_ip,
139 Service::GroupManagement,
140 event_source,
141 event_data,
142 )
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_group_management_parser_service_type() {
151 let parser = GroupManagementEventParser;
152 assert_eq!(parser.service_type(), Service::GroupManagement);
153 }
154
155 #[test]
156 fn test_group_management_event_creation() {
157 let event = GroupManagementEvent {
158 properties: vec![GroupManagementProperty {
159 group_coordinator_is_local: Some("1".to_string()),
160 local_group_uuid: Some("RINCON_123456789:0".to_string()),
161 reset_volume_after: Some("0".to_string()),
162 virtual_line_in_group_id: Some("".to_string()),
163 volume_av_transport_uri: Some("".to_string()),
164 }],
165 };
166
167 assert_eq!(event.group_coordinator_is_local(), Some(true));
168 assert_eq!(
169 event.local_group_uuid(),
170 Some("RINCON_123456789:0".to_string())
171 );
172 assert_eq!(event.reset_volume_after(), Some(false));
173 }
174
175 #[test]
176 fn test_boolean_parsing_with_1_and_0() {
177 let event = GroupManagementEvent {
178 properties: vec![GroupManagementProperty {
179 group_coordinator_is_local: Some("1".to_string()),
180 local_group_uuid: None,
181 reset_volume_after: Some("0".to_string()),
182 virtual_line_in_group_id: None,
183 volume_av_transport_uri: None,
184 }],
185 };
186
187 assert_eq!(event.group_coordinator_is_local(), Some(true));
188 assert_eq!(event.reset_volume_after(), Some(false));
189 }
190
191 #[test]
192 fn test_boolean_parsing_with_true_and_false() {
193 let event = GroupManagementEvent {
194 properties: vec![GroupManagementProperty {
195 group_coordinator_is_local: Some("true".to_string()),
196 local_group_uuid: None,
197 reset_volume_after: Some("false".to_string()),
198 virtual_line_in_group_id: None,
199 volume_av_transport_uri: None,
200 }],
201 };
202
203 assert_eq!(event.group_coordinator_is_local(), Some(true));
204 assert_eq!(event.reset_volume_after(), Some(false));
205 }
206
207 #[test]
208 fn test_boolean_parsing_case_insensitive() {
209 let event = GroupManagementEvent {
210 properties: vec![GroupManagementProperty {
211 group_coordinator_is_local: Some("TRUE".to_string()),
212 local_group_uuid: None,
213 reset_volume_after: Some("True".to_string()),
214 virtual_line_in_group_id: None,
215 volume_av_transport_uri: None,
216 }],
217 };
218
219 assert_eq!(event.group_coordinator_is_local(), Some(true));
220 assert_eq!(event.reset_volume_after(), Some(true));
221 }
222
223 #[test]
224 fn test_enriched_event_creation() {
225 let ip: IpAddr = "192.168.1.100".parse().unwrap();
226 let source = EventSource::UPnPNotification {
227 subscription_id: "uuid:123".to_string(),
228 };
229 let event_data = GroupManagementEvent {
230 properties: vec![GroupManagementProperty {
231 group_coordinator_is_local: Some("1".to_string()),
232 local_group_uuid: None,
233 reset_volume_after: None,
234 virtual_line_in_group_id: None,
235 volume_av_transport_uri: None,
236 }],
237 };
238
239 let enriched = create_enriched_event(ip, source, event_data);
240
241 assert_eq!(enriched.speaker_ip, ip);
242 assert_eq!(enriched.service, Service::GroupManagement);
243 assert!(enriched.registration_id.is_none());
244 }
245
246 #[test]
247 fn test_enriched_event_with_registration_id() {
248 let ip: IpAddr = "192.168.1.100".parse().unwrap();
249 let source = EventSource::UPnPNotification {
250 subscription_id: "uuid:123".to_string(),
251 };
252 let event_data = GroupManagementEvent {
253 properties: vec![GroupManagementProperty {
254 group_coordinator_is_local: None,
255 local_group_uuid: None,
256 reset_volume_after: None,
257 virtual_line_in_group_id: None,
258 volume_av_transport_uri: None,
259 }],
260 };
261
262 let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
263
264 assert_eq!(enriched.registration_id, Some(42));
265 }
266
267 #[test]
268 fn test_basic_xml_parsing() {
269 let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
270 <e:property>
271 <GroupCoordinatorIsLocal>1</GroupCoordinatorIsLocal>
272 </e:property>
273 <e:property>
274 <LocalGroupUUID>RINCON_123456789:0</LocalGroupUUID>
275 </e:property>
276 <e:property>
277 <ResetVolumeAfter>0</ResetVolumeAfter>
278 </e:property>
279 </e:propertyset>"#;
280
281 let result = GroupManagementEvent::from_xml(xml);
282 assert!(
283 result.is_ok(),
284 "Failed to parse GroupManagement XML: {result:?}"
285 );
286
287 let event = result.unwrap();
288 assert_eq!(event.group_coordinator_is_local(), Some(true));
289 assert_eq!(
290 event.local_group_uuid(),
291 Some("RINCON_123456789:0".to_string())
292 );
293 assert_eq!(event.reset_volume_after(), Some(false));
294 }
295
296 #[test]
297 fn test_xml_parsing_all_fields() {
298 let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
299 <e:property>
300 <GroupCoordinatorIsLocal>1</GroupCoordinatorIsLocal>
301 </e:property>
302 <e:property>
303 <LocalGroupUUID>RINCON_123456789:0</LocalGroupUUID>
304 </e:property>
305 <e:property>
306 <ResetVolumeAfter>1</ResetVolumeAfter>
307 </e:property>
308 <e:property>
309 <VirtualLineInGroupID>virtual-group-123</VirtualLineInGroupID>
310 </e:property>
311 <e:property>
312 <VolumeAVTransportURI>x-rincon:RINCON_123456789</VolumeAVTransportURI>
313 </e:property>
314 </e:propertyset>"#;
315
316 let result = GroupManagementEvent::from_xml(xml);
317 assert!(result.is_ok(), "Failed to parse: {result:?}");
318
319 let event = result.unwrap();
320 assert_eq!(event.group_coordinator_is_local(), Some(true));
321 assert_eq!(
322 event.local_group_uuid(),
323 Some("RINCON_123456789:0".to_string())
324 );
325 assert_eq!(event.reset_volume_after(), Some(true));
326 assert_eq!(
327 event.virtual_line_in_group_id(),
328 Some("virtual-group-123".to_string())
329 );
330 assert_eq!(
331 event.volume_av_transport_uri(),
332 Some("x-rincon:RINCON_123456789".to_string())
333 );
334 }
335
336 #[test]
337 fn test_empty_properties() {
338 let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
339 <e:property>
340 <GroupCoordinatorIsLocal></GroupCoordinatorIsLocal>
341 </e:property>
342 </e:propertyset>"#;
343
344 let result = GroupManagementEvent::from_xml(xml);
345 assert!(result.is_ok());
346
347 let event = result.unwrap();
348 assert_eq!(event.group_coordinator_is_local(), Some(false));
350 }
351
352 #[test]
353 fn test_missing_properties() {
354 let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
355 <e:property>
356 <LocalGroupUUID>RINCON_123:0</LocalGroupUUID>
357 </e:property>
358 </e:propertyset>"#;
359
360 let result = GroupManagementEvent::from_xml(xml);
361 assert!(result.is_ok());
362
363 let event = result.unwrap();
364 assert_eq!(event.group_coordinator_is_local(), None);
365 assert_eq!(event.local_group_uuid(), Some("RINCON_123:0".to_string()));
366 assert_eq!(event.reset_volume_after(), None);
367 }
368}
369
370#[cfg(test)]
375mod property_tests {
376 use super::*;
377 use proptest::prelude::*;
378
379 fn bool_string_strategy() -> impl Strategy<Value = (String, bool)> {
390 prop_oneof![
391 Just(("1".to_string(), true)),
392 Just(("0".to_string(), false)),
393 Just(("true".to_string(), true)),
394 Just(("false".to_string(), false)),
395 Just(("TRUE".to_string(), true)),
396 Just(("FALSE".to_string(), false)),
397 Just(("True".to_string(), true)),
398 Just(("False".to_string(), false)),
399 ]
400 }
401
402 proptest! {
403 #![proptest_config(ProptestConfig::with_cases(100))]
404
405 #[test]
407 fn prop_event_group_coordinator_is_local_parsing((bool_str, expected) in bool_string_strategy()) {
408 let event = GroupManagementEvent {
409 properties: vec![GroupManagementProperty {
410 group_coordinator_is_local: Some(bool_str.clone()),
411 local_group_uuid: None,
412 reset_volume_after: None,
413 virtual_line_in_group_id: None,
414 volume_av_transport_uri: None,
415 }],
416 };
417
418 let result = event.group_coordinator_is_local();
419 prop_assert_eq!(
420 result,
421 Some(expected),
422 "GroupCoordinatorIsLocal '{}' should parse to {}",
423 bool_str,
424 expected
425 );
426 }
427
428 #[test]
430 fn prop_event_reset_volume_after_parsing((bool_str, expected) in bool_string_strategy()) {
431 let event = GroupManagementEvent {
432 properties: vec![GroupManagementProperty {
433 group_coordinator_is_local: None,
434 local_group_uuid: None,
435 reset_volume_after: Some(bool_str.clone()),
436 virtual_line_in_group_id: None,
437 volume_av_transport_uri: None,
438 }],
439 };
440
441 let result = event.reset_volume_after();
442 prop_assert_eq!(
443 result,
444 Some(expected),
445 "ResetVolumeAfter '{}' should parse to {}",
446 bool_str,
447 expected
448 );
449 }
450 }
451
452 #[test]
453 fn test_into_state_maps_all_fields() {
454 let event = GroupManagementEvent {
455 properties: vec![GroupManagementProperty {
456 group_coordinator_is_local: Some("true".to_string()),
457 local_group_uuid: Some("RINCON_111:1".to_string()),
458 reset_volume_after: Some("1".to_string()),
459 virtual_line_in_group_id: Some("vline123".to_string()),
460 volume_av_transport_uri: Some("x-rincon:RINCON_111".to_string()),
461 }],
462 };
463
464 let state = event.into_state();
465
466 assert_eq!(state.group_coordinator_is_local, Some(true));
467 assert_eq!(state.local_group_uuid, Some("RINCON_111:1".to_string()));
468 assert_eq!(state.reset_volume_after, Some(true));
469 assert_eq!(state.virtual_line_in_group_id, Some("vline123".to_string()));
470 assert_eq!(
471 state.volume_av_transport_uri,
472 Some("x-rincon:RINCON_111".to_string())
473 );
474 }
475}