Skip to main content

sonos_api/services/group_management/
operations.rs

1//! GroupManagement service operations
2//!
3//! This module provides operations for managing speaker group membership
4//! on Sonos speaker groups. All operations should be sent to the group coordinator only.
5//!
6//! # Operations
7//! - `add_member` - Add a speaker to the group
8//! - `remove_member` - Remove a speaker from the group
9//! - `report_track_buffering_result` - Report track buffering status
10//! - `set_source_area_ids` - Set source area identifiers
11
12use crate::operation::parse_sonos_bool;
13use crate::{define_upnp_operation, Validate};
14use paste::paste;
15use serde::{Deserialize, Serialize};
16
17// =============================================================================
18// ADD MEMBER OPERATION (Manual implementation due to boolean response field)
19// =============================================================================
20
21/// Request to add a member to the group
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct AddMemberOperationRequest {
24    /// The member ID (RINCON format UUID) of the speaker to add
25    pub member_id: String,
26    /// The boot sequence number of the speaker
27    pub boot_seq: u32,
28}
29
30impl Validate for AddMemberOperationRequest {}
31
32/// Response from adding a member to the group
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
34pub struct AddMemberResponse {
35    /// Current transport settings for the group
36    pub current_transport_settings: String,
37    /// Current URI being played
38    pub current_uri: String,
39    /// UUID of the group that was joined
40    pub group_uuid_joined: String,
41    /// Whether to reset volume after joining
42    pub reset_volume_after: bool,
43    /// Volume AV transport URI
44    pub volume_av_transport_uri: String,
45}
46
47/// Operation to add a member to a speaker group
48pub struct AddMemberOperation;
49
50impl crate::operation::UPnPOperation for AddMemberOperation {
51    type Request = AddMemberOperationRequest;
52    type Response = AddMemberResponse;
53
54    const SERVICE: crate::service::Service = crate::service::Service::GroupManagement;
55    const ACTION: &'static str = "AddMember";
56
57    fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
58        <Self::Request as Validate>::validate(request, crate::operation::ValidationLevel::Basic)?;
59        Ok(format!(
60            "<InstanceID>0</InstanceID><MemberID>{}</MemberID><BootSeq>{}</BootSeq>",
61            crate::operation::xml_escape(&request.member_id),
62            request.boot_seq
63        ))
64    }
65
66    fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, crate::error::ApiError> {
67        let current_transport_settings = xml
68            .get_child("CurrentTransportSettings")
69            .and_then(|e| e.get_text())
70            .map(|s| s.to_string())
71            .unwrap_or_default();
72
73        let current_uri = xml
74            .get_child("CurrentURI")
75            .and_then(|e| e.get_text())
76            .map(|s| s.to_string())
77            .unwrap_or_default();
78
79        let group_uuid_joined = xml
80            .get_child("GroupUUIDJoined")
81            .and_then(|e| e.get_text())
82            .map(|s| s.to_string())
83            .unwrap_or_default();
84
85        let reset_volume_after = parse_sonos_bool(xml, "ResetVolumeAfter");
86
87        let volume_av_transport_uri = xml
88            .get_child("VolumeAVTransportURI")
89            .and_then(|e| e.get_text())
90            .map(|s| s.to_string())
91            .unwrap_or_default();
92
93        Ok(AddMemberResponse {
94            current_transport_settings,
95            current_uri,
96            group_uuid_joined,
97            reset_volume_after,
98            volume_av_transport_uri,
99        })
100    }
101}
102
103/// Create an AddMember operation builder
104pub fn add_member_operation(
105    member_id: String,
106    boot_seq: u32,
107) -> crate::operation::OperationBuilder<AddMemberOperation> {
108    let request = AddMemberOperationRequest {
109        member_id,
110        boot_seq,
111    };
112    crate::operation::OperationBuilder::new(request)
113}
114
115// =============================================================================
116// REMOVE MEMBER OPERATION
117// =============================================================================
118
119define_upnp_operation! {
120    operation: RemoveMemberOperation,
121    action: "RemoveMember",
122    service: GroupManagement,
123    request: {
124        member_id: String,
125    },
126    response: (),
127    payload: |req| {
128        format!("<InstanceID>{}</InstanceID><MemberID>{}</MemberID>",
129            req.instance_id,
130            crate::operation::xml_escape(&req.member_id))
131    },
132    parse: |_xml| Ok(()),
133}
134
135impl Validate for RemoveMemberOperationRequest {}
136
137// =============================================================================
138// REPORT TRACK BUFFERING RESULT OPERATION
139// =============================================================================
140
141define_upnp_operation! {
142    operation: ReportTrackBufferingResultOperation,
143    action: "ReportTrackBufferingResult",
144    service: GroupManagement,
145    request: {
146        member_id: String,
147        result_code: i32,
148    },
149    response: (),
150    payload: |req| {
151        format!(
152            "<InstanceID>{}</InstanceID><MemberID>{}</MemberID><ResultCode>{}</ResultCode>",
153            req.instance_id,
154            crate::operation::xml_escape(&req.member_id),
155            req.result_code
156        )
157    },
158    parse: |_xml| Ok(()),
159}
160
161impl Validate for ReportTrackBufferingResultOperationRequest {}
162
163// =============================================================================
164// SET SOURCE AREA IDS OPERATION
165// =============================================================================
166
167define_upnp_operation! {
168    operation: SetSourceAreaIdsOperation,
169    action: "SetSourceAreaIds",
170    service: GroupManagement,
171    request: {
172        desired_source_area_ids: String,
173    },
174    response: (),
175    payload: |req| {
176        format!(
177            "<InstanceID>{}</InstanceID><DesiredSourceAreaIds>{}</DesiredSourceAreaIds>",
178            req.instance_id,
179            crate::operation::xml_escape(&req.desired_source_area_ids)
180        )
181    },
182    parse: |_xml| Ok(()),
183}
184
185impl Validate for SetSourceAreaIdsOperationRequest {}
186
187// =============================================================================
188// LEGACY ALIASES
189// =============================================================================
190
191pub use add_member_operation as add_member;
192pub use remove_member_operation as remove_member;
193pub use report_track_buffering_result_operation as report_track_buffering_result;
194pub use set_source_area_ids_operation as set_source_area_ids;
195
196// =============================================================================
197// TESTS
198// =============================================================================
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::operation::UPnPOperation;
204
205    // --- AddMember Tests ---
206
207    #[test]
208    fn test_add_member_builder() {
209        let op = add_member_operation("RINCON_123".to_string(), 42)
210            .build()
211            .unwrap();
212        assert_eq!(op.request().member_id, "RINCON_123");
213        assert_eq!(op.request().boot_seq, 42);
214        assert_eq!(op.metadata().action, "AddMember");
215        assert_eq!(op.metadata().service, "GroupManagement");
216    }
217
218    #[test]
219    fn test_add_member_payload() {
220        let request = AddMemberOperationRequest {
221            member_id: "RINCON_ABC123".to_string(),
222            boot_seq: 100,
223        };
224        let payload = AddMemberOperation::build_payload(&request).unwrap();
225        assert!(payload.contains("<InstanceID>0</InstanceID>"));
226        assert!(payload.contains("<MemberID>RINCON_ABC123</MemberID>"));
227        assert!(payload.contains("<BootSeq>100</BootSeq>"));
228    }
229
230    #[test]
231    fn test_add_member_payload_escapes_xml_special_chars() {
232        let request = AddMemberOperationRequest {
233            member_id: "RINCON_123</MemberID><BootSeq>999</BootSeq><Foo>bar".to_string(),
234            boot_seq: 42,
235        };
236        let payload = AddMemberOperation::build_payload(&request).unwrap();
237        // Should not contain unescaped injection
238        assert!(!payload.contains("</MemberID><BootSeq>999"));
239        assert!(payload.contains("&lt;/MemberID&gt;"));
240        assert!(payload.contains("<BootSeq>42</BootSeq>"));
241    }
242
243    #[test]
244    fn test_add_member_response_parsing_reset_volume_true() {
245        let xml_str = r#"<AddMemberResponse>
246            <CurrentTransportSettings>settings</CurrentTransportSettings>
247            <CurrentURI>x-rincon:RINCON_123</CurrentURI>
248            <GroupUUIDJoined>group-uuid-123</GroupUUIDJoined>
249            <ResetVolumeAfter>1</ResetVolumeAfter>
250            <VolumeAVTransportURI>x-rincon:RINCON_456</VolumeAVTransportURI>
251        </AddMemberResponse>"#;
252        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
253        let response = AddMemberOperation::parse_response(&xml).unwrap();
254
255        assert_eq!(response.current_transport_settings, "settings");
256        assert_eq!(response.current_uri, "x-rincon:RINCON_123");
257        assert_eq!(response.group_uuid_joined, "group-uuid-123");
258        assert!(response.reset_volume_after);
259        assert_eq!(response.volume_av_transport_uri, "x-rincon:RINCON_456");
260    }
261
262    #[test]
263    fn test_add_member_response_parsing_reset_volume_false() {
264        let xml_str = r#"<AddMemberResponse>
265            <CurrentTransportSettings></CurrentTransportSettings>
266            <CurrentURI></CurrentURI>
267            <GroupUUIDJoined></GroupUUIDJoined>
268            <ResetVolumeAfter>0</ResetVolumeAfter>
269            <VolumeAVTransportURI></VolumeAVTransportURI>
270        </AddMemberResponse>"#;
271        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
272        let response = AddMemberOperation::parse_response(&xml).unwrap();
273
274        assert!(!response.reset_volume_after);
275    }
276
277    // --- RemoveMember Tests ---
278
279    #[test]
280    fn test_remove_member_builder() {
281        let op = remove_member_operation("RINCON_456".to_string())
282            .build()
283            .unwrap();
284        assert_eq!(op.request().member_id, "RINCON_456");
285        assert_eq!(op.metadata().action, "RemoveMember");
286        assert_eq!(op.metadata().service, "GroupManagement");
287    }
288
289    #[test]
290    fn test_remove_member_payload() {
291        let request = RemoveMemberOperationRequest {
292            member_id: "RINCON_XYZ".to_string(),
293            instance_id: 0,
294        };
295        let payload = RemoveMemberOperation::build_payload(&request).unwrap();
296        assert!(payload.contains("<MemberID>RINCON_XYZ</MemberID>"));
297        assert!(payload.contains("<InstanceID>0</InstanceID>"));
298    }
299
300    // --- ReportTrackBufferingResult Tests ---
301
302    #[test]
303    fn test_report_track_buffering_result_builder() {
304        let op = report_track_buffering_result_operation("RINCON_789".to_string(), 0)
305            .build()
306            .unwrap();
307        assert_eq!(op.request().member_id, "RINCON_789");
308        assert_eq!(op.request().result_code, 0);
309        assert_eq!(op.metadata().action, "ReportTrackBufferingResult");
310        assert_eq!(op.metadata().service, "GroupManagement");
311    }
312
313    #[test]
314    fn test_report_track_buffering_result_payload() {
315        let request = ReportTrackBufferingResultOperationRequest {
316            member_id: "RINCON_ABC".to_string(),
317            result_code: -1,
318            instance_id: 0,
319        };
320        let payload = ReportTrackBufferingResultOperation::build_payload(&request).unwrap();
321        assert!(payload.contains("<MemberID>RINCON_ABC</MemberID>"));
322        assert!(payload.contains("<ResultCode>-1</ResultCode>"));
323    }
324
325    // --- SetSourceAreaIds Tests ---
326
327    #[test]
328    fn test_set_source_area_ids_builder() {
329        let op = set_source_area_ids_operation("area1,area2".to_string())
330            .build()
331            .unwrap();
332        assert_eq!(op.request().desired_source_area_ids, "area1,area2");
333        assert_eq!(op.metadata().action, "SetSourceAreaIds");
334        assert_eq!(op.metadata().service, "GroupManagement");
335    }
336
337    #[test]
338    fn test_set_source_area_ids_payload() {
339        let request = SetSourceAreaIdsOperationRequest {
340            desired_source_area_ids: "source-area-123".to_string(),
341            instance_id: 0,
342        };
343        let payload = SetSourceAreaIdsOperation::build_payload(&request).unwrap();
344        assert!(payload.contains("<DesiredSourceAreaIds>source-area-123</DesiredSourceAreaIds>"));
345    }
346
347    // --- SERVICE constant test ---
348
349    #[test]
350    fn test_service_constant() {
351        assert_eq!(
352            AddMemberOperation::SERVICE,
353            crate::service::Service::GroupManagement
354        );
355        assert_eq!(
356            RemoveMemberOperation::SERVICE,
357            crate::service::Service::GroupManagement
358        );
359        assert_eq!(
360            ReportTrackBufferingResultOperation::SERVICE,
361            crate::service::Service::GroupManagement
362        );
363        assert_eq!(
364            SetSourceAreaIdsOperation::SERVICE,
365            crate::service::Service::GroupManagement
366        );
367    }
368}
369
370// =============================================================================
371// PROPERTY-BASED TESTS
372// =============================================================================
373
374#[cfg(test)]
375mod property_tests {
376    use super::*;
377    use crate::operation::{UPnPOperation, ValidationLevel};
378    use proptest::prelude::*;
379
380    // =========================================================================
381    // Property 1: AddMember boolean response parsing
382    // =========================================================================
383    // *For any* AddMember XML response containing ResetVolumeAfter with value "1",
384    // parsing SHALL return `reset_volume_after: true`, and for value "0",
385    // parsing SHALL return `reset_volume_after: false`.
386    // **Validates: Requirements 1.5**
387    // =========================================================================
388
389    proptest! {
390        #![proptest_config(ProptestConfig::with_cases(100))]
391
392        /// Feature: group-management, Property 1: AddMember boolean response parsing
393        #[test]
394        fn prop_add_member_bool_parsing(reset_vol in proptest::bool::ANY) {
395            let xml_value = if reset_vol { "1" } else { "0" };
396            let xml_str = format!(r#"<AddMemberResponse>
397                <CurrentTransportSettings>test-settings</CurrentTransportSettings>
398                <CurrentURI>x-rincon:RINCON_TEST</CurrentURI>
399                <GroupUUIDJoined>test-group-uuid</GroupUUIDJoined>
400                <ResetVolumeAfter>{xml_value}</ResetVolumeAfter>
401                <VolumeAVTransportURI>x-rincon:RINCON_VOL</VolumeAVTransportURI>
402            </AddMemberResponse>"#);
403
404            let xml = xmltree::Element::parse(xml_str.as_bytes())
405                .expect("XML should parse successfully");
406            let response = AddMemberOperation::parse_response(&xml)
407                .expect("Response parsing should succeed");
408
409            prop_assert_eq!(
410                response.reset_volume_after,
411                reset_vol,
412                "ResetVolumeAfter '{}' should parse to {}",
413                xml_value,
414                reset_vol
415            );
416        }
417    }
418
419    // =========================================================================
420    // Property 2: Void operations always pass validation
421    // =========================================================================
422    // *For any* RemoveMemberOperationRequest, ReportTrackBufferingResultOperationRequest,
423    // or SetSourceAreaIdsOperationRequest with valid string/integer field values,
424    // validation SHALL succeed (return Ok).
425    // **Validates: Requirements 2.4, 3.4, 4.4**
426    // =========================================================================
427
428    /// Strategy for generating arbitrary member IDs
429    fn member_id_strategy() -> impl Strategy<Value = String> {
430        prop::string::string_regex("[A-Za-z0-9_-]{0,50}").unwrap()
431    }
432
433    /// Strategy for generating arbitrary source area IDs
434    fn source_area_ids_strategy() -> impl Strategy<Value = String> {
435        prop::string::string_regex("[A-Za-z0-9,_-]{0,100}").unwrap()
436    }
437
438    proptest! {
439        #![proptest_config(ProptestConfig::with_cases(100))]
440
441        /// Feature: group-management, Property 2: Void operations always pass validation (RemoveMember)
442        #[test]
443        fn prop_remove_member_validation_passes(member_id in member_id_strategy()) {
444            let request = RemoveMemberOperationRequest {
445                member_id,
446                instance_id: 0,
447            };
448            let result = <RemoveMemberOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
449            prop_assert!(
450                result.is_ok(),
451                "RemoveMember validation should always pass, got: {:?}",
452                result
453            );
454        }
455
456        /// Feature: group-management, Property 2: Void operations always pass validation (ReportTrackBufferingResult)
457        #[test]
458        fn prop_report_track_buffering_result_validation_passes(
459            member_id in member_id_strategy(),
460            result_code in prop::num::i32::ANY,
461        ) {
462            let request = ReportTrackBufferingResultOperationRequest {
463                member_id,
464                result_code,
465                instance_id: 0,
466            };
467            let result = <ReportTrackBufferingResultOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
468            prop_assert!(
469                result.is_ok(),
470                "ReportTrackBufferingResult validation should always pass, got: {:?}",
471                result
472            );
473        }
474
475        /// Feature: group-management, Property 2: Void operations always pass validation (SetSourceAreaIds)
476        #[test]
477        fn prop_set_source_area_ids_validation_passes(
478            desired_source_area_ids in source_area_ids_strategy(),
479        ) {
480            let request = SetSourceAreaIdsOperationRequest {
481                desired_source_area_ids,
482                instance_id: 0,
483            };
484            let result = <SetSourceAreaIdsOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
485            prop_assert!(
486                result.is_ok(),
487                "SetSourceAreaIds validation should always pass, got: {:?}",
488                result
489            );
490        }
491    }
492}