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