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::{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        quick_xml::de::from_str(xml)
95            .map_err(|e| ApiError::ParseError(format!("Failed to parse GroupManagement XML: {e}")))
96    }
97}
98
99/// Parser implementation for GroupManagement events
100pub 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
114/// Create enriched event for sonos-stream integration
115pub 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
128/// Create enriched event with registration ID
129pub 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        // Empty string should not match "1" or "true"
348        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// =============================================================================
370// PROPERTY-BASED TESTS
371// =============================================================================
372
373#[cfg(test)]
374mod property_tests {
375    use super::*;
376    use proptest::prelude::*;
377
378    // =========================================================================
379    // Property 3: Event boolean parsing consistency
380    // =========================================================================
381    // *For any* GroupManagement event XML containing GroupCoordinatorIsLocal or
382    // ResetVolumeAfter with value "1" or "true", parsing SHALL return `true`,
383    // and for value "0" or "false", parsing SHALL return `false`.
384    // **Validates: Requirements 7.1, 7.3**
385    // =========================================================================
386
387    /// Strategy for generating boolean string representations
388    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        /// Feature: group-management, Property 3: Event boolean parsing consistency (GroupCoordinatorIsLocal)
405        #[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        /// Feature: group-management, Property 3: Event boolean parsing consistency (ResetVolumeAfter)
428        #[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}