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: &str) -> 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 response = GetGroupMuteOperation::parse_response(xml_str).unwrap();
324        assert!(response.current_mute);
325    }
326
327    #[test]
328    fn test_get_group_mute_parse_response_false() {
329        let xml_str =
330            r#"<GetGroupMuteResponse><CurrentMute>0</CurrentMute></GetGroupMuteResponse>"#;
331        let response = GetGroupMuteOperation::parse_response(xml_str).unwrap();
332        assert!(!response.current_mute);
333    }
334
335    #[test]
336    fn test_set_group_mute_payload_true() {
337        let request = SetGroupMuteOperationRequest {
338            instance_id: 0,
339            desired_mute: true,
340        };
341        let payload = SetGroupMuteOperation::build_payload(&request).unwrap();
342        assert!(payload.contains("<InstanceID>0</InstanceID>"));
343        assert!(payload.contains("<DesiredMute>1</DesiredMute>"));
344    }
345
346    #[test]
347    fn test_set_group_mute_payload_false() {
348        let request = SetGroupMuteOperationRequest {
349            instance_id: 0,
350            desired_mute: false,
351        };
352        let payload = SetGroupMuteOperation::build_payload(&request).unwrap();
353        assert!(payload.contains("<InstanceID>0</InstanceID>"));
354        assert!(payload.contains("<DesiredMute>0</DesiredMute>"));
355    }
356
357    #[test]
358    fn test_snapshot_group_volume_payload() {
359        let request = SnapshotGroupVolumeOperationRequest { instance_id: 0 };
360        let payload = SnapshotGroupVolumeOperation::build_payload(&request).unwrap();
361        assert_eq!(payload, "<InstanceID>0</InstanceID>");
362    }
363
364    #[test]
365    fn test_set_group_volume_rejects_over_100() {
366        let request = SetGroupVolumeOperationRequest {
367            instance_id: 0,
368            desired_volume: 101,
369        };
370        assert!(request.validate_basic().is_err());
371    }
372
373    #[test]
374    fn test_set_group_volume_accepts_boundary_values() {
375        // Test volume = 0
376        let request = SetGroupVolumeOperationRequest {
377            instance_id: 0,
378            desired_volume: 0,
379        };
380        assert!(request.validate_basic().is_ok());
381
382        // Test volume = 100
383        let request = SetGroupVolumeOperationRequest {
384            instance_id: 0,
385            desired_volume: 100,
386        };
387        assert!(request.validate_basic().is_ok());
388    }
389
390    #[test]
391    fn test_set_relative_group_volume_rejects_under_minus_100() {
392        let request = SetRelativeGroupVolumeOperationRequest {
393            instance_id: 0,
394            adjustment: -101,
395        };
396        assert!(request.validate_basic().is_err());
397    }
398
399    #[test]
400    fn test_set_relative_group_volume_rejects_over_100() {
401        let request = SetRelativeGroupVolumeOperationRequest {
402            instance_id: 0,
403            adjustment: 101,
404        };
405        assert!(request.validate_basic().is_err());
406    }
407
408    #[test]
409    fn test_set_relative_group_volume_accepts_boundary_values() {
410        // Test adjustment = -100
411        let request = SetRelativeGroupVolumeOperationRequest {
412            instance_id: 0,
413            adjustment: -100,
414        };
415        assert!(request.validate_basic().is_ok());
416
417        // Test adjustment = 0
418        let request = SetRelativeGroupVolumeOperationRequest {
419            instance_id: 0,
420            adjustment: 0,
421        };
422        assert!(request.validate_basic().is_ok());
423
424        // Test adjustment = 100
425        let request = SetRelativeGroupVolumeOperationRequest {
426            instance_id: 0,
427            adjustment: 100,
428        };
429        assert!(request.validate_basic().is_ok());
430    }
431
432    #[test]
433    fn test_service_constant() {
434        assert_eq!(SERVICE, crate::Service::GroupRenderingControl);
435    }
436
437    #[test]
438    fn test_subscribe_function_signature() {
439        let client = crate::SonosClient::new();
440        // Verify subscribe function has correct signature (compiles)
441        let _subscribe_fn = || subscribe(&client, "192.168.1.100", "http://callback.url");
442    }
443
444    #[test]
445    fn test_subscribe_with_timeout_function_signature() {
446        let client = crate::SonosClient::new();
447        // Verify subscribe_with_timeout function has correct signature (compiles)
448        let _subscribe_fn =
449            || subscribe_with_timeout(&client, "192.168.1.100", "http://callback.url", 3600);
450    }
451}