Skip to main content

sonos_api/services/group_management/
events.rs

1//! GroupManagement 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/// GroupManagement event - direct serde mapping from UPnP event XML
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename = "propertyset")]
15pub struct GroupManagementEvent {
16    /// Multiple property elements can exist in a single event
17    #[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    /// Get whether this speaker is the group coordinator
41    ///
42    /// Returns `true` if the value is "1" or "true" (case-insensitive)
43    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    /// Get the local group UUID
51    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    /// Get whether to reset volume after group changes
58    ///
59    /// Returns `true` if the value is "1" or "true" (case-insensitive)
60    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    /// Get the virtual line-in group ID
68    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    /// Get the volume AV transport URI
75    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    /// Convert parsed UPnP event to canonical state representation.
82    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    /// Parse from UPnP event XML using serde
93    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
100/// Parser implementation for GroupManagement events
101pub 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
115/// Create enriched event for sonos-stream integration
116pub 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
129/// Create enriched event with registration ID
130pub 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        // Empty string should not match "1" or "true"
349        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// =============================================================================
371// PROPERTY-BASED TESTS
372// =============================================================================
373
374#[cfg(test)]
375mod property_tests {
376    use super::*;
377    use proptest::prelude::*;
378
379    // =========================================================================
380    // Property 3: Event boolean parsing consistency
381    // =========================================================================
382    // *For any* GroupManagement event XML containing GroupCoordinatorIsLocal or
383    // ResetVolumeAfter with value "1" or "true", parsing SHALL return `true`,
384    // and for value "0" or "false", parsing SHALL return `false`.
385    // **Validates: Requirements 7.1, 7.3**
386    // =========================================================================
387
388    /// Strategy for generating boolean string representations
389    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        /// Feature: group-management, Property 3: Event boolean parsing consistency (GroupCoordinatorIsLocal)
406        #[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        /// Feature: group-management, Property 3: Event boolean parsing consistency (ResetVolumeAfter)
429        #[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}