Skip to main content

sonos_api/services/group_rendering_control/
operations.rs

1//! GroupRenderingControl service operations
2//!
3//! This module provides operations for controlling group-wide audio rendering settings
4//! on Sonos speaker groups. All operations should be sent to the group coordinator only.
5//!
6//! # Operations
7//! - `get_group_volume` - Get the current group volume level
8//! - `set_group_volume` - Set the group volume level (0-100)
9//! - `set_relative_group_volume` - Adjust group volume relatively (-100 to +100)
10//! - `get_group_mute` - Get the current group mute state
11//! - `set_group_mute` - Set the group mute state
12//! - `snapshot_group_volume` - Snapshot volume ratios for proportional changes
13
14use crate::operation::parse_sonos_bool;
15use crate::{define_operation_with_response, define_upnp_operation, Validate};
16use paste::paste;
17
18// =============================================================================
19// GET GROUP VOLUME
20// =============================================================================
21
22define_operation_with_response! {
23    operation: GetGroupVolumeOperation,
24    action: "GetGroupVolume",
25    service: GroupRenderingControl,
26    request: {},
27    response: GetGroupVolumeResponse {
28        current_volume: u16,
29    },
30    xml_mapping: {
31        current_volume: "CurrentVolume",
32    },
33}
34
35impl Validate for GetGroupVolumeOperationRequest {}
36
37pub use get_group_volume_operation as get_group_volume;
38
39// =============================================================================
40// SET GROUP VOLUME
41// =============================================================================
42
43define_upnp_operation! {
44    operation: SetGroupVolumeOperation,
45    action: "SetGroupVolume",
46    service: GroupRenderingControl,
47    request: {
48        desired_volume: u16,
49    },
50    response: (),
51    payload: |req| {
52        format!(
53            "<InstanceID>{}</InstanceID><DesiredVolume>{}</DesiredVolume>",
54            req.instance_id, req.desired_volume
55        )
56    },
57    parse: |_xml| Ok(()),
58}
59
60impl Validate for SetGroupVolumeOperationRequest {
61    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
62        if self.desired_volume > 100 {
63            return Err(crate::operation::ValidationError::range_error(
64                "desired_volume",
65                0,
66                100,
67                self.desired_volume,
68            ));
69        }
70        Ok(())
71    }
72}
73
74pub use set_group_volume_operation as set_group_volume;
75
76// =============================================================================
77// SET RELATIVE GROUP VOLUME
78// =============================================================================
79
80define_operation_with_response! {
81    operation: SetRelativeGroupVolumeOperation,
82    action: "SetRelativeGroupVolume",
83    service: GroupRenderingControl,
84    request: {
85        adjustment: i16,
86    },
87    response: SetRelativeGroupVolumeResponse {
88        new_volume: u16,
89    },
90    xml_mapping: {
91        new_volume: "NewVolume",
92    },
93}
94
95impl Validate for SetRelativeGroupVolumeOperationRequest {
96    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
97        if self.adjustment < -100 || self.adjustment > 100 {
98            return Err(crate::operation::ValidationError::range_error(
99                "adjustment",
100                -100,
101                100,
102                self.adjustment,
103            ));
104        }
105        Ok(())
106    }
107}
108
109pub use set_relative_group_volume_operation as set_relative_group_volume;
110
111// =============================================================================
112// GET GROUP MUTE
113// =============================================================================
114
115// Manual implementation because Sonos returns "0"/"1" for bools, not "true"/"false",
116// and the define_operation_with_response! macro's .parse::<bool>() only handles "true"/"false".
117#[derive(serde::Serialize, Clone, Debug, PartialEq)]
118pub struct GetGroupMuteOperationRequest {
119    pub instance_id: u32,
120}
121
122#[derive(serde::Deserialize, Debug, Clone, PartialEq)]
123pub struct GetGroupMuteResponse {
124    pub current_mute: bool,
125}
126
127pub struct GetGroupMuteOperation;
128
129impl crate::operation::UPnPOperation for GetGroupMuteOperation {
130    type Request = GetGroupMuteOperationRequest;
131    type Response = GetGroupMuteResponse;
132
133    const SERVICE: crate::service::Service = crate::service::Service::GroupRenderingControl;
134    const ACTION: &'static str = "GetGroupMute";
135
136    fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
137        request.validate(crate::operation::ValidationLevel::Basic)?;
138        Ok(format!("<InstanceID>{}</InstanceID>", request.instance_id))
139    }
140
141    fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, crate::error::ApiError> {
142        Ok(GetGroupMuteResponse {
143            current_mute: parse_sonos_bool(xml, "CurrentMute"),
144        })
145    }
146}
147
148pub fn get_group_mute_operation() -> crate::operation::OperationBuilder<GetGroupMuteOperation> {
149    let request = GetGroupMuteOperationRequest { instance_id: 0 };
150    crate::operation::OperationBuilder::new(request)
151}
152
153impl Validate for GetGroupMuteOperationRequest {}
154
155pub use get_group_mute_operation as get_group_mute;
156
157// =============================================================================
158// SET GROUP MUTE
159// =============================================================================
160
161define_upnp_operation! {
162    operation: SetGroupMuteOperation,
163    action: "SetGroupMute",
164    service: GroupRenderingControl,
165    request: {
166        desired_mute: bool,
167    },
168    response: (),
169    payload: |req| {
170        format!(
171            "<InstanceID>{}</InstanceID><DesiredMute>{}</DesiredMute>",
172            req.instance_id,
173            if req.desired_mute { "1" } else { "0" }
174        )
175    },
176    parse: |_xml| Ok(()),
177}
178
179impl Validate for SetGroupMuteOperationRequest {}
180
181pub use set_group_mute_operation as set_group_mute;
182
183// =============================================================================
184// SNAPSHOT GROUP VOLUME
185// =============================================================================
186
187define_upnp_operation! {
188    operation: SnapshotGroupVolumeOperation,
189    action: "SnapshotGroupVolume",
190    service: GroupRenderingControl,
191    request: {},
192    response: (),
193    payload: |req| {
194        format!("<InstanceID>{}</InstanceID>", req.instance_id)
195    },
196    parse: |_xml| Ok(()),
197}
198
199impl Validate for SnapshotGroupVolumeOperationRequest {}
200
201pub use snapshot_group_volume_operation as snapshot_group_volume;
202
203// =============================================================================
204// SERVICE CONSTANT AND SUBSCRIPTION HELPERS
205// =============================================================================
206
207/// Service identifier for GroupRenderingControl
208pub const SERVICE: crate::Service = crate::Service::GroupRenderingControl;
209
210/// Subscribe to GroupRenderingControl events
211pub fn subscribe(
212    client: &crate::SonosClient,
213    ip: &str,
214    callback_url: &str,
215) -> crate::Result<crate::ManagedSubscription> {
216    client.subscribe(ip, SERVICE, callback_url)
217}
218
219/// Subscribe to GroupRenderingControl events with custom timeout
220pub fn subscribe_with_timeout(
221    client: &crate::SonosClient,
222    ip: &str,
223    callback_url: &str,
224    timeout_seconds: u32,
225) -> crate::Result<crate::ManagedSubscription> {
226    client.subscribe_with_timeout(ip, SERVICE, callback_url, timeout_seconds)
227}
228
229// =============================================================================
230// TESTS
231// =============================================================================
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::operation::UPnPOperation;
237
238    #[test]
239    fn test_get_group_volume_builder() {
240        let op = get_group_volume().build().unwrap();
241        assert_eq!(op.metadata().action, "GetGroupVolume");
242        assert_eq!(op.request().instance_id, 0);
243    }
244
245    #[test]
246    fn test_set_group_volume_builder() {
247        let op = set_group_volume(75).build().unwrap();
248        assert_eq!(op.request().desired_volume, 75);
249        assert_eq!(op.request().instance_id, 0);
250        assert_eq!(op.metadata().action, "SetGroupVolume");
251    }
252
253    #[test]
254    fn test_set_relative_group_volume_builder() {
255        let op = set_relative_group_volume(10).build().unwrap();
256        assert_eq!(op.request().adjustment, 10);
257        assert_eq!(op.request().instance_id, 0);
258        assert_eq!(op.metadata().action, "SetRelativeGroupVolume");
259    }
260
261    #[test]
262    fn test_get_group_mute_builder() {
263        let op = get_group_mute().build().unwrap();
264        assert_eq!(op.metadata().action, "GetGroupMute");
265        assert_eq!(op.request().instance_id, 0);
266    }
267
268    #[test]
269    fn test_set_group_mute_builder() {
270        let op = set_group_mute(true).build().unwrap();
271        assert!(op.request().desired_mute);
272        assert_eq!(op.request().instance_id, 0);
273        assert_eq!(op.metadata().action, "SetGroupMute");
274    }
275
276    #[test]
277    fn test_snapshot_group_volume_builder() {
278        let op = snapshot_group_volume().build().unwrap();
279        assert_eq!(op.metadata().action, "SnapshotGroupVolume");
280        assert_eq!(op.request().instance_id, 0);
281    }
282
283    #[test]
284    fn test_get_group_volume_payload() {
285        let request = GetGroupVolumeOperationRequest { instance_id: 0 };
286        let payload = GetGroupVolumeOperation::build_payload(&request).unwrap();
287        assert_eq!(payload, "<InstanceID>0</InstanceID>");
288    }
289
290    #[test]
291    fn test_set_group_volume_payload() {
292        let request = SetGroupVolumeOperationRequest {
293            instance_id: 0,
294            desired_volume: 75,
295        };
296        let payload = SetGroupVolumeOperation::build_payload(&request).unwrap();
297        assert!(payload.contains("<InstanceID>0</InstanceID>"));
298        assert!(payload.contains("<DesiredVolume>75</DesiredVolume>"));
299    }
300
301    #[test]
302    fn test_set_relative_group_volume_payload() {
303        let request = SetRelativeGroupVolumeOperationRequest {
304            instance_id: 0,
305            adjustment: -25,
306        };
307        let payload = SetRelativeGroupVolumeOperation::build_payload(&request).unwrap();
308        assert!(payload.contains("<InstanceID>0</InstanceID>"));
309        assert!(payload.contains("<Adjustment>-25</Adjustment>"));
310    }
311
312    #[test]
313    fn test_get_group_mute_payload() {
314        let request = GetGroupMuteOperationRequest { instance_id: 0 };
315        let payload = GetGroupMuteOperation::build_payload(&request).unwrap();
316        assert_eq!(payload, "<InstanceID>0</InstanceID>");
317    }
318
319    #[test]
320    fn test_get_group_mute_parse_response_true() {
321        let xml_str =
322            r#"<GetGroupMuteResponse><CurrentMute>1</CurrentMute></GetGroupMuteResponse>"#;
323        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
324        let response = GetGroupMuteOperation::parse_response(&xml).unwrap();
325        assert!(response.current_mute);
326    }
327
328    #[test]
329    fn test_get_group_mute_parse_response_false() {
330        let xml_str =
331            r#"<GetGroupMuteResponse><CurrentMute>0</CurrentMute></GetGroupMuteResponse>"#;
332        let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
333        let response = GetGroupMuteOperation::parse_response(&xml).unwrap();
334        assert!(!response.current_mute);
335    }
336
337    #[test]
338    fn test_set_group_mute_payload_true() {
339        let request = SetGroupMuteOperationRequest {
340            instance_id: 0,
341            desired_mute: true,
342        };
343        let payload = SetGroupMuteOperation::build_payload(&request).unwrap();
344        assert!(payload.contains("<InstanceID>0</InstanceID>"));
345        assert!(payload.contains("<DesiredMute>1</DesiredMute>"));
346    }
347
348    #[test]
349    fn test_set_group_mute_payload_false() {
350        let request = SetGroupMuteOperationRequest {
351            instance_id: 0,
352            desired_mute: false,
353        };
354        let payload = SetGroupMuteOperation::build_payload(&request).unwrap();
355        assert!(payload.contains("<InstanceID>0</InstanceID>"));
356        assert!(payload.contains("<DesiredMute>0</DesiredMute>"));
357    }
358
359    #[test]
360    fn test_snapshot_group_volume_payload() {
361        let request = SnapshotGroupVolumeOperationRequest { instance_id: 0 };
362        let payload = SnapshotGroupVolumeOperation::build_payload(&request).unwrap();
363        assert_eq!(payload, "<InstanceID>0</InstanceID>");
364    }
365
366    #[test]
367    fn test_set_group_volume_rejects_over_100() {
368        let request = SetGroupVolumeOperationRequest {
369            instance_id: 0,
370            desired_volume: 101,
371        };
372        assert!(request.validate_basic().is_err());
373    }
374
375    #[test]
376    fn test_set_group_volume_accepts_boundary_values() {
377        // Test volume = 0
378        let request = SetGroupVolumeOperationRequest {
379            instance_id: 0,
380            desired_volume: 0,
381        };
382        assert!(request.validate_basic().is_ok());
383
384        // Test volume = 100
385        let request = SetGroupVolumeOperationRequest {
386            instance_id: 0,
387            desired_volume: 100,
388        };
389        assert!(request.validate_basic().is_ok());
390    }
391
392    #[test]
393    fn test_set_relative_group_volume_rejects_under_minus_100() {
394        let request = SetRelativeGroupVolumeOperationRequest {
395            instance_id: 0,
396            adjustment: -101,
397        };
398        assert!(request.validate_basic().is_err());
399    }
400
401    #[test]
402    fn test_set_relative_group_volume_rejects_over_100() {
403        let request = SetRelativeGroupVolumeOperationRequest {
404            instance_id: 0,
405            adjustment: 101,
406        };
407        assert!(request.validate_basic().is_err());
408    }
409
410    #[test]
411    fn test_set_relative_group_volume_accepts_boundary_values() {
412        // Test adjustment = -100
413        let request = SetRelativeGroupVolumeOperationRequest {
414            instance_id: 0,
415            adjustment: -100,
416        };
417        assert!(request.validate_basic().is_ok());
418
419        // Test adjustment = 0
420        let request = SetRelativeGroupVolumeOperationRequest {
421            instance_id: 0,
422            adjustment: 0,
423        };
424        assert!(request.validate_basic().is_ok());
425
426        // Test adjustment = 100
427        let request = SetRelativeGroupVolumeOperationRequest {
428            instance_id: 0,
429            adjustment: 100,
430        };
431        assert!(request.validate_basic().is_ok());
432    }
433
434    #[test]
435    fn test_service_constant() {
436        assert_eq!(SERVICE, crate::Service::GroupRenderingControl);
437    }
438
439    #[test]
440    fn test_subscribe_function_signature() {
441        let client = crate::SonosClient::new();
442        // Verify subscribe function has correct signature (compiles)
443        let _subscribe_fn = || subscribe(&client, "192.168.1.100", "http://callback.url");
444    }
445
446    #[test]
447    fn test_subscribe_with_timeout_function_signature() {
448        let client = crate::SonosClient::new();
449        // Verify subscribe_with_timeout function has correct signature (compiles)
450        let _subscribe_fn =
451            || subscribe_with_timeout(&client, "192.168.1.100", "http://callback.url", 3600);
452    }
453}