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, response_string};
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: &str) -> Result<Self::Response, crate::error::ApiError> {
67        Ok(AddMemberResponse {
68            current_transport_settings: response_string(xml, "CurrentTransportSettings"),
69            current_uri: response_string(xml, "CurrentURI"),
70            group_uuid_joined: response_string(xml, "GroupUUIDJoined"),
71            reset_volume_after: parse_sonos_bool(xml, "ResetVolumeAfter"),
72            volume_av_transport_uri: response_string(xml, "VolumeAVTransportURI"),
73        })
74    }
75}
76
77/// Create an AddMember operation builder
78pub fn add_member_operation(
79    member_id: String,
80    boot_seq: u32,
81) -> crate::operation::OperationBuilder<AddMemberOperation> {
82    let request = AddMemberOperationRequest {
83        member_id,
84        boot_seq,
85    };
86    crate::operation::OperationBuilder::new(request)
87}
88
89// =============================================================================
90// REMOVE MEMBER OPERATION
91// =============================================================================
92
93define_upnp_operation! {
94    operation: RemoveMemberOperation,
95    action: "RemoveMember",
96    service: GroupManagement,
97    request: {
98        member_id: String,
99    },
100    response: (),
101    payload: |req| {
102        format!("<InstanceID>{}</InstanceID><MemberID>{}</MemberID>",
103            req.instance_id,
104            crate::operation::xml_escape(&req.member_id))
105    },
106    parse: |_xml| Ok(()),
107}
108
109impl Validate for RemoveMemberOperationRequest {}
110
111// =============================================================================
112// REPORT TRACK BUFFERING RESULT OPERATION
113// =============================================================================
114
115define_upnp_operation! {
116    operation: ReportTrackBufferingResultOperation,
117    action: "ReportTrackBufferingResult",
118    service: GroupManagement,
119    request: {
120        member_id: String,
121        result_code: i32,
122    },
123    response: (),
124    payload: |req| {
125        format!(
126            "<InstanceID>{}</InstanceID><MemberID>{}</MemberID><ResultCode>{}</ResultCode>",
127            req.instance_id,
128            crate::operation::xml_escape(&req.member_id),
129            req.result_code
130        )
131    },
132    parse: |_xml| Ok(()),
133}
134
135impl Validate for ReportTrackBufferingResultOperationRequest {}
136
137// =============================================================================
138// SET SOURCE AREA IDS OPERATION
139// =============================================================================
140
141define_upnp_operation! {
142    operation: SetSourceAreaIdsOperation,
143    action: "SetSourceAreaIds",
144    service: GroupManagement,
145    request: {
146        desired_source_area_ids: String,
147    },
148    response: (),
149    payload: |req| {
150        format!(
151            "<InstanceID>{}</InstanceID><DesiredSourceAreaIds>{}</DesiredSourceAreaIds>",
152            req.instance_id,
153            crate::operation::xml_escape(&req.desired_source_area_ids)
154        )
155    },
156    parse: |_xml| Ok(()),
157}
158
159impl Validate for SetSourceAreaIdsOperationRequest {}
160
161// =============================================================================
162// LEGACY ALIASES
163// =============================================================================
164
165pub use add_member_operation as add_member;
166pub use remove_member_operation as remove_member;
167pub use report_track_buffering_result_operation as report_track_buffering_result;
168pub use set_source_area_ids_operation as set_source_area_ids;
169
170// =============================================================================
171// TESTS
172// =============================================================================
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::operation::UPnPOperation;
178
179    // --- AddMember Tests ---
180
181    #[test]
182    fn test_add_member_builder() {
183        let op = add_member_operation("RINCON_123".to_string(), 42)
184            .build()
185            .unwrap();
186        assert_eq!(op.request().member_id, "RINCON_123");
187        assert_eq!(op.request().boot_seq, 42);
188        assert_eq!(op.metadata().action, "AddMember");
189        assert_eq!(op.metadata().service, "GroupManagement");
190    }
191
192    #[test]
193    fn test_add_member_payload() {
194        let request = AddMemberOperationRequest {
195            member_id: "RINCON_ABC123".to_string(),
196            boot_seq: 100,
197        };
198        let payload = AddMemberOperation::build_payload(&request).unwrap();
199        assert!(payload.contains("<InstanceID>0</InstanceID>"));
200        assert!(payload.contains("<MemberID>RINCON_ABC123</MemberID>"));
201        assert!(payload.contains("<BootSeq>100</BootSeq>"));
202    }
203
204    #[test]
205    fn test_add_member_payload_escapes_xml_special_chars() {
206        let request = AddMemberOperationRequest {
207            member_id: "RINCON_123</MemberID><BootSeq>999</BootSeq><Foo>bar".to_string(),
208            boot_seq: 42,
209        };
210        let payload = AddMemberOperation::build_payload(&request).unwrap();
211        // Should not contain unescaped injection
212        assert!(!payload.contains("</MemberID><BootSeq>999"));
213        assert!(payload.contains("&lt;/MemberID&gt;"));
214        assert!(payload.contains("<BootSeq>42</BootSeq>"));
215    }
216
217    #[test]
218    fn test_add_member_response_parsing_reset_volume_true() {
219        let xml_str = r#"<AddMemberResponse>
220            <CurrentTransportSettings>settings</CurrentTransportSettings>
221            <CurrentURI>x-rincon:RINCON_123</CurrentURI>
222            <GroupUUIDJoined>group-uuid-123</GroupUUIDJoined>
223            <ResetVolumeAfter>1</ResetVolumeAfter>
224            <VolumeAVTransportURI>x-rincon:RINCON_456</VolumeAVTransportURI>
225        </AddMemberResponse>"#;
226        let response = AddMemberOperation::parse_response(xml_str).unwrap();
227
228        assert_eq!(response.current_transport_settings, "settings");
229        assert_eq!(response.current_uri, "x-rincon:RINCON_123");
230        assert_eq!(response.group_uuid_joined, "group-uuid-123");
231        assert!(response.reset_volume_after);
232        assert_eq!(response.volume_av_transport_uri, "x-rincon:RINCON_456");
233    }
234
235    #[test]
236    fn test_add_member_response_parsing_reset_volume_false() {
237        let xml_str = r#"<AddMemberResponse>
238            <CurrentTransportSettings></CurrentTransportSettings>
239            <CurrentURI></CurrentURI>
240            <GroupUUIDJoined></GroupUUIDJoined>
241            <ResetVolumeAfter>0</ResetVolumeAfter>
242            <VolumeAVTransportURI></VolumeAVTransportURI>
243        </AddMemberResponse>"#;
244        let response = AddMemberOperation::parse_response(xml_str).unwrap();
245
246        assert!(!response.reset_volume_after);
247    }
248
249    // --- RemoveMember Tests ---
250
251    #[test]
252    fn test_remove_member_builder() {
253        let op = remove_member_operation("RINCON_456".to_string())
254            .build()
255            .unwrap();
256        assert_eq!(op.request().member_id, "RINCON_456");
257        assert_eq!(op.metadata().action, "RemoveMember");
258        assert_eq!(op.metadata().service, "GroupManagement");
259    }
260
261    #[test]
262    fn test_remove_member_payload() {
263        let request = RemoveMemberOperationRequest {
264            member_id: "RINCON_XYZ".to_string(),
265            instance_id: 0,
266        };
267        let payload = RemoveMemberOperation::build_payload(&request).unwrap();
268        assert!(payload.contains("<MemberID>RINCON_XYZ</MemberID>"));
269        assert!(payload.contains("<InstanceID>0</InstanceID>"));
270    }
271
272    // --- ReportTrackBufferingResult Tests ---
273
274    #[test]
275    fn test_report_track_buffering_result_builder() {
276        let op = report_track_buffering_result_operation("RINCON_789".to_string(), 0)
277            .build()
278            .unwrap();
279        assert_eq!(op.request().member_id, "RINCON_789");
280        assert_eq!(op.request().result_code, 0);
281        assert_eq!(op.metadata().action, "ReportTrackBufferingResult");
282        assert_eq!(op.metadata().service, "GroupManagement");
283    }
284
285    #[test]
286    fn test_report_track_buffering_result_payload() {
287        let request = ReportTrackBufferingResultOperationRequest {
288            member_id: "RINCON_ABC".to_string(),
289            result_code: -1,
290            instance_id: 0,
291        };
292        let payload = ReportTrackBufferingResultOperation::build_payload(&request).unwrap();
293        assert!(payload.contains("<MemberID>RINCON_ABC</MemberID>"));
294        assert!(payload.contains("<ResultCode>-1</ResultCode>"));
295    }
296
297    // --- SetSourceAreaIds Tests ---
298
299    #[test]
300    fn test_set_source_area_ids_builder() {
301        let op = set_source_area_ids_operation("area1,area2".to_string())
302            .build()
303            .unwrap();
304        assert_eq!(op.request().desired_source_area_ids, "area1,area2");
305        assert_eq!(op.metadata().action, "SetSourceAreaIds");
306        assert_eq!(op.metadata().service, "GroupManagement");
307    }
308
309    #[test]
310    fn test_set_source_area_ids_payload() {
311        let request = SetSourceAreaIdsOperationRequest {
312            desired_source_area_ids: "source-area-123".to_string(),
313            instance_id: 0,
314        };
315        let payload = SetSourceAreaIdsOperation::build_payload(&request).unwrap();
316        assert!(payload.contains("<DesiredSourceAreaIds>source-area-123</DesiredSourceAreaIds>"));
317    }
318
319    // --- SERVICE constant test ---
320
321    #[test]
322    fn test_service_constant() {
323        assert_eq!(
324            AddMemberOperation::SERVICE,
325            crate::service::Service::GroupManagement
326        );
327        assert_eq!(
328            RemoveMemberOperation::SERVICE,
329            crate::service::Service::GroupManagement
330        );
331        assert_eq!(
332            ReportTrackBufferingResultOperation::SERVICE,
333            crate::service::Service::GroupManagement
334        );
335        assert_eq!(
336            SetSourceAreaIdsOperation::SERVICE,
337            crate::service::Service::GroupManagement
338        );
339    }
340}
341
342// =============================================================================
343// PROPERTY-BASED TESTS
344// =============================================================================
345
346#[cfg(test)]
347mod property_tests {
348    use super::*;
349    use crate::operation::{UPnPOperation, ValidationLevel};
350    use proptest::prelude::*;
351
352    // =========================================================================
353    // Property 1: AddMember boolean response parsing
354    // =========================================================================
355    // *For any* AddMember XML response containing ResetVolumeAfter with value "1",
356    // parsing SHALL return `reset_volume_after: true`, and for value "0",
357    // parsing SHALL return `reset_volume_after: false`.
358    // **Validates: Requirements 1.5**
359    // =========================================================================
360
361    proptest! {
362        #![proptest_config(ProptestConfig::with_cases(100))]
363
364        /// Feature: group-management, Property 1: AddMember boolean response parsing
365        #[test]
366        fn prop_add_member_bool_parsing(reset_vol in proptest::bool::ANY) {
367            let xml_value = if reset_vol { "1" } else { "0" };
368            let xml_str = format!(r#"<AddMemberResponse>
369                <CurrentTransportSettings>test-settings</CurrentTransportSettings>
370                <CurrentURI>x-rincon:RINCON_TEST</CurrentURI>
371                <GroupUUIDJoined>test-group-uuid</GroupUUIDJoined>
372                <ResetVolumeAfter>{xml_value}</ResetVolumeAfter>
373                <VolumeAVTransportURI>x-rincon:RINCON_VOL</VolumeAVTransportURI>
374            </AddMemberResponse>"#);
375
376            let response = AddMemberOperation::parse_response(&xml_str)
377                .expect("Response parsing should succeed");
378
379            prop_assert_eq!(
380                response.reset_volume_after,
381                reset_vol,
382                "ResetVolumeAfter '{}' should parse to {}",
383                xml_value,
384                reset_vol
385            );
386        }
387    }
388
389    // =========================================================================
390    // Property 2: Void operations always pass validation
391    // =========================================================================
392    // *For any* RemoveMemberOperationRequest, ReportTrackBufferingResultOperationRequest,
393    // or SetSourceAreaIdsOperationRequest with valid string/integer field values,
394    // validation SHALL succeed (return Ok).
395    // **Validates: Requirements 2.4, 3.4, 4.4**
396    // =========================================================================
397
398    /// Strategy for generating arbitrary member IDs
399    fn member_id_strategy() -> impl Strategy<Value = String> {
400        prop::string::string_regex("[A-Za-z0-9_-]{0,50}").unwrap()
401    }
402
403    /// Strategy for generating arbitrary source area IDs
404    fn source_area_ids_strategy() -> impl Strategy<Value = String> {
405        prop::string::string_regex("[A-Za-z0-9,_-]{0,100}").unwrap()
406    }
407
408    proptest! {
409        #![proptest_config(ProptestConfig::with_cases(100))]
410
411        /// Feature: group-management, Property 2: Void operations always pass validation (RemoveMember)
412        #[test]
413        fn prop_remove_member_validation_passes(member_id in member_id_strategy()) {
414            let request = RemoveMemberOperationRequest {
415                member_id,
416                instance_id: 0,
417            };
418            let result = <RemoveMemberOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
419            prop_assert!(
420                result.is_ok(),
421                "RemoveMember validation should always pass, got: {:?}",
422                result
423            );
424        }
425
426        /// Feature: group-management, Property 2: Void operations always pass validation (ReportTrackBufferingResult)
427        #[test]
428        fn prop_report_track_buffering_result_validation_passes(
429            member_id in member_id_strategy(),
430            result_code in prop::num::i32::ANY,
431        ) {
432            let request = ReportTrackBufferingResultOperationRequest {
433                member_id,
434                result_code,
435                instance_id: 0,
436            };
437            let result = <ReportTrackBufferingResultOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
438            prop_assert!(
439                result.is_ok(),
440                "ReportTrackBufferingResult validation should always pass, got: {:?}",
441                result
442            );
443        }
444
445        /// Feature: group-management, Property 2: Void operations always pass validation (SetSourceAreaIds)
446        #[test]
447        fn prop_set_source_area_ids_validation_passes(
448            desired_source_area_ids in source_area_ids_strategy(),
449        ) {
450            let request = SetSourceAreaIdsOperationRequest {
451                desired_source_area_ids,
452                instance_id: 0,
453            };
454            let result = <SetSourceAreaIdsOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
455            prop_assert!(
456                result.is_ok(),
457                "SetSourceAreaIds validation should always pass, got: {:?}",
458                result
459            );
460        }
461    }
462}