Skip to main content

sonos_sdk/
group.rs

1//! Group handle for accessing speaker groups
2//!
3//! Provides access to group information and member speakers.
4//! All speakers are always in a group - a single speaker forms a group of one.
5//!
6//! ## Write Operations and State Cache
7//!
8//! Write methods (e.g., `set_volume()`, `set_mute()`) update the state cache
9//! optimistically after the SOAP call succeeds. The cached value may be stale
10//! if the coordinator rejects the command silently, until the next UPnP event
11//! corrects it.
12
13use std::net::IpAddr;
14use std::sync::Arc;
15
16use sonos_api::operation::{ComposableOperation, UPnPOperation, ValidationError};
17use sonos_api::services::av_transport;
18use sonos_api::services::group_rendering_control::{self, SetRelativeGroupVolumeResponse};
19use sonos_api::SonosClient;
20use sonos_state::{GroupId, GroupInfo, GroupMute, GroupVolume, SpeakerId, StateManager};
21
22use crate::property::{
23    GroupContext, GroupMuteHandle, GroupPropertyHandle, GroupVolumeChangeableHandle,
24    GroupVolumeHandle,
25};
26use crate::SdkError;
27use crate::Speaker;
28
29/// Result of a multi-speaker group operation (e.g., `dissolve()`, `create_group()`)
30///
31/// Instead of short-circuiting on the first failure, multi-speaker operations
32/// attempt every speaker and report per-speaker results. This gives callers
33/// full visibility into partial failures.
34#[derive(Debug)]
35pub struct GroupChangeResult {
36    /// Speakers that were successfully changed
37    pub succeeded: Vec<SpeakerId>,
38    /// Speakers that failed, with error descriptions
39    pub failed: Vec<(SpeakerId, SdkError)>,
40}
41
42impl GroupChangeResult {
43    /// Returns `true` if all speakers were changed successfully
44    pub fn is_success(&self) -> bool {
45        self.failed.is_empty()
46    }
47
48    /// Returns `true` if some speakers succeeded and some failed
49    pub fn is_partial(&self) -> bool {
50        !self.succeeded.is_empty() && !self.failed.is_empty()
51    }
52}
53
54/// Group handle with access to coordinator and members
55///
56/// Provides access to group information and member speakers.
57/// All speakers are always in a group - a single speaker forms a group of one.
58///
59/// # Example
60///
61/// ```rust,ignore
62/// // Get all groups
63/// for group in system.groups() {
64///     println!("Group: {} ({} members)", group.id, group.member_count());
65///     
66///     if let Some(coordinator) = group.coordinator() {
67///         println!("  Coordinator: {}", coordinator.name);
68///     }
69///     
70///     for member in group.members() {
71///         let role = if group.is_coordinator(&member.id) { "coordinator" } else { "member" };
72///         println!("  - {} ({})", member.name, role);
73///     }
74/// }
75/// ```
76#[derive(Clone)]
77pub struct Group {
78    /// Unique group identifier
79    pub id: GroupId,
80    /// Coordinator speaker ID
81    pub coordinator_id: SpeakerId,
82    /// All member speaker IDs (including coordinator)
83    pub member_ids: Vec<SpeakerId>,
84
85    // ========================================================================
86    // GroupRenderingControl properties
87    // ========================================================================
88    /// Group volume (0-100)
89    pub volume: GroupVolumeHandle,
90    /// Group mute state
91    pub mute: GroupMuteHandle,
92    /// Whether group volume can be changed (event-only, no fetch)
93    pub volume_changeable: GroupVolumeChangeableHandle,
94
95    // Internal references
96    coordinator_ip: IpAddr,
97    state_manager: Arc<StateManager>,
98    api_client: SonosClient,
99}
100
101impl Group {
102    /// Create a new Group handle from GroupInfo
103    ///
104    /// Returns `None` if the coordinator's IP address cannot be resolved
105    /// (e.g., the coordinator speaker is not registered in state).
106    pub(crate) fn from_info(
107        info: GroupInfo,
108        state_manager: Arc<StateManager>,
109        api_client: SonosClient,
110    ) -> Option<Self> {
111        let coordinator_ip = state_manager.get_speaker_ip(&info.coordinator_id)?;
112
113        let group_context = GroupContext::new(
114            info.id.clone(),
115            info.coordinator_id.clone(),
116            coordinator_ip,
117            Arc::clone(&state_manager),
118            api_client.clone(),
119        );
120
121        Some(Self {
122            id: info.id,
123            coordinator_id: info.coordinator_id,
124            member_ids: info.member_ids,
125            volume: GroupPropertyHandle::new(Arc::clone(&group_context)),
126            mute: GroupPropertyHandle::new(Arc::clone(&group_context)),
127            volume_changeable: GroupPropertyHandle::new(group_context),
128            coordinator_ip,
129            state_manager,
130            api_client,
131        })
132    }
133
134    /// Get the coordinator speaker
135    ///
136    /// Returns the Speaker handle for the group's coordinator.
137    /// Returns `None` if the coordinator speaker is not found in state.
138    ///
139    /// # Example
140    ///
141    /// ```rust,ignore
142    /// if let Some(coordinator) = group.coordinator() {
143    ///     println!("Coordinator: {}", coordinator.name);
144    ///     // Control playback via coordinator
145    ///     let state = coordinator.playback_state.get();
146    /// }
147    /// ```
148    pub fn coordinator(&self) -> Option<Speaker> {
149        let info = self.state_manager.speaker_info(&self.coordinator_id)?;
150        Some(Speaker::new(
151            self.coordinator_id.clone(),
152            info.name,
153            info.ip_address,
154            info.model_name,
155            Arc::clone(&self.state_manager),
156            self.api_client.clone(),
157        ))
158    }
159
160    /// Get all member speakers
161    ///
162    /// Returns Speaker handles for all members in the group, including the coordinator.
163    /// Only returns speakers that are found in state.
164    ///
165    /// # Example
166    ///
167    /// ```rust,ignore
168    /// for member in group.members() {
169    ///     println!("Member: {} ({})", member.name, member.model_name);
170    /// }
171    /// ```
172    pub fn members(&self) -> Vec<Speaker> {
173        self.member_ids
174            .iter()
175            .filter_map(|id| {
176                let info = self.state_manager.speaker_info(id)?;
177                Some(Speaker::new(
178                    id.clone(),
179                    info.name,
180                    info.ip_address,
181                    info.model_name,
182                    Arc::clone(&self.state_manager),
183                    self.api_client.clone(),
184                ))
185            })
186            .collect()
187    }
188
189    /// Get a member speaker by name
190    ///
191    /// Returns `None` if no member with that name exists in this group.
192    ///
193    /// # Example
194    ///
195    /// ```rust,ignore
196    /// let group = sonos.group("Living Room").unwrap();
197    /// if let Some(kitchen) = group.speaker("Kitchen") {
198    ///     println!("Kitchen is in the Living Room group");
199    /// }
200    /// ```
201    pub fn speaker(&self, name: &str) -> Option<Speaker> {
202        self.members()
203            .into_iter()
204            .find(|s| s.name.eq_ignore_ascii_case(name))
205    }
206
207    /// Check if a speaker is the coordinator of this group
208    ///
209    /// # Example
210    ///
211    /// ```rust,ignore
212    /// for member in group.members() {
213    ///     if group.is_coordinator(&member.id) {
214    ///         println!("{} is the coordinator", member.name);
215    ///     }
216    /// }
217    /// ```
218    pub fn is_coordinator(&self, speaker_id: &SpeakerId) -> bool {
219        self.coordinator_id == *speaker_id
220    }
221
222    /// Get the number of members in this group
223    ///
224    /// # Example
225    ///
226    /// ```rust,ignore
227    /// println!("Group has {} members", group.member_count());
228    /// ```
229    pub fn member_count(&self) -> usize {
230        self.member_ids.len()
231    }
232
233    /// Check if this is a standalone group (single speaker)
234    ///
235    /// A standalone group contains only one speaker, which is also the coordinator.
236    ///
237    /// # Example
238    ///
239    /// ```rust,ignore
240    /// if group.is_standalone() {
241    ///     println!("This speaker is not grouped with others");
242    /// }
243    /// ```
244    pub fn is_standalone(&self) -> bool {
245        self.member_ids.len() == 1
246    }
247
248    // ========================================================================
249    // Private helpers
250    // ========================================================================
251
252    /// Execute a UPnP operation against this group's coordinator
253    fn exec<Op: UPnPOperation>(
254        &self,
255        operation: Result<ComposableOperation<Op>, ValidationError>,
256    ) -> Result<Op::Response, SdkError> {
257        let op = operation?;
258        self.api_client
259            .execute_enhanced(&self.coordinator_ip.to_string(), op)
260            .map_err(SdkError::ApiError)
261    }
262
263    // ========================================================================
264    // GroupManagement — Group lifecycle
265    // ========================================================================
266
267    /// Add a speaker to this group
268    ///
269    /// Sends `SetAVTransportURI` to the member speaker with `x-rincon:{coordinator_id}`
270    /// to join the coordinator's audio stream. This is the standard Sonos grouping mechanism.
271    /// After calling this, re-fetch groups via `system.groups()` to see updated membership.
272    pub fn add_speaker(&self, speaker: &Speaker) -> Result<(), SdkError> {
273        if speaker.id == self.coordinator_id {
274            return Err(SdkError::InvalidOperation(
275                "Cannot add coordinator to its own group".to_string(),
276            ));
277        }
278        let rincon_uri = format!("x-rincon:{}", self.coordinator_id.as_str());
279        let op = av_transport::set_av_transport_uri(rincon_uri, String::new()).build()?;
280        self.api_client
281            .execute_enhanced::<av_transport::SetAVTransportURIOperation>(
282                &speaker.ip.to_string(),
283                op,
284            )
285            .map_err(SdkError::ApiError)?;
286        Ok(())
287    }
288
289    /// Remove a speaker from this group
290    ///
291    /// Sends `BecomeCoordinatorOfStandaloneGroup` to the member speaker, causing it
292    /// to leave the group and become standalone. Cannot remove the coordinator.
293    pub fn remove_speaker(&self, speaker: &Speaker) -> Result<(), SdkError> {
294        if speaker.id == self.coordinator_id {
295            return Err(SdkError::InvalidOperation(
296                "Cannot remove coordinator from its own group; use delegate_coordination_to() first".to_string(),
297            ));
298        }
299        let op = av_transport::become_coordinator_of_standalone_group().build()?;
300        self.api_client
301            .execute_enhanced::<av_transport::BecomeCoordinatorOfStandaloneGroupOperation>(
302                &speaker.ip.to_string(),
303                op,
304            )
305            .map_err(SdkError::ApiError)?;
306        Ok(())
307    }
308
309    /// Dissolve this group by removing all non-coordinator members
310    ///
311    /// Attempts to remove every non-coordinator member, even if some fail.
312    /// Returns a [`GroupChangeResult`] showing which speakers were successfully
313    /// removed and which failed. For standalone groups, returns an empty result.
314    pub fn dissolve(&self) -> GroupChangeResult {
315        let mut succeeded = Vec::new();
316        let mut failed = Vec::new();
317
318        for member in self.members() {
319            if !self.is_coordinator(&member.id) {
320                match self.remove_speaker(&member) {
321                    Ok(()) => succeeded.push(member.id.clone()),
322                    Err(e) => failed.push((member.id.clone(), e)),
323                }
324            }
325        }
326
327        GroupChangeResult { succeeded, failed }
328    }
329
330    // ========================================================================
331    // GroupRenderingControl — Volume and mute
332    // ========================================================================
333
334    /// Set group volume (0-100)
335    ///
336    /// Updates the state cache to the new `GroupVolume` on success.
337    pub fn set_volume(&self, volume: u16) -> Result<(), SdkError> {
338        self.exec(group_rendering_control::set_group_volume(volume).build())?;
339        self.state_manager
340            .set_group_property(&self.id, GroupVolume(volume));
341        Ok(())
342    }
343
344    /// Adjust group volume relative to current level
345    ///
346    /// Returns the new absolute volume.
347    pub fn set_relative_volume(
348        &self,
349        adjustment: i16,
350    ) -> Result<SetRelativeGroupVolumeResponse, SdkError> {
351        let response =
352            self.exec(group_rendering_control::set_relative_group_volume(adjustment).build())?;
353        self.state_manager
354            .set_group_property(&self.id, GroupVolume(response.new_volume));
355        Ok(response)
356    }
357
358    /// Set group mute state
359    ///
360    /// Updates the state cache to the new `GroupMute` value on success.
361    pub fn set_mute(&self, muted: bool) -> Result<(), SdkError> {
362        self.exec(group_rendering_control::set_group_mute(muted).build())?;
363        self.state_manager
364            .set_group_property(&self.id, GroupMute(muted));
365        Ok(())
366    }
367
368    /// Snapshot the current group volume (for later restore)
369    pub fn snapshot_volume(&self) -> Result<(), SdkError> {
370        self.exec(group_rendering_control::snapshot_group_volume().build())?;
371        Ok(())
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use sonos_discovery::Device;
379
380    fn create_test_state_manager_with_speakers(
381        speakers: Vec<(&str, &str, &str)>,
382    ) -> Arc<StateManager> {
383        let manager = StateManager::new().unwrap();
384        let devices: Vec<Device> = speakers
385            .into_iter()
386            .map(|(id, name, ip)| Device {
387                id: id.to_string(),
388                name: name.to_string(),
389                room_name: name.to_string(),
390                ip_address: ip.to_string(),
391                port: 1400,
392                model_name: "Sonos One".to_string(),
393            })
394            .collect();
395        manager.add_devices(devices).unwrap();
396        Arc::new(manager)
397    }
398
399    #[test]
400    fn test_group_from_info() {
401        let state_manager = create_test_state_manager_with_speakers(vec![(
402            "RINCON_111",
403            "Living Room",
404            "192.168.1.100",
405        )]);
406        let api_client = SonosClient::new();
407
408        let group_info = GroupInfo::new(
409            GroupId::new("RINCON_111:1"),
410            SpeakerId::new("RINCON_111"),
411            vec![SpeakerId::new("RINCON_111")],
412        );
413
414        let group = Group::from_info(group_info, state_manager, api_client).unwrap();
415
416        assert_eq!(group.id.as_str(), "RINCON_111:1");
417        assert_eq!(group.coordinator_id.as_str(), "RINCON_111");
418        assert_eq!(group.member_ids.len(), 1);
419    }
420
421    #[test]
422    fn test_group_from_info_returns_none_for_unknown_coordinator() {
423        let state_manager = create_test_state_manager_with_speakers(vec![(
424            "RINCON_111",
425            "Living Room",
426            "192.168.1.100",
427        )]);
428        let api_client = SonosClient::new();
429
430        // Coordinator is not a registered speaker
431        let group_info = GroupInfo::new(
432            GroupId::new("RINCON_UNKNOWN:1"),
433            SpeakerId::new("RINCON_UNKNOWN"),
434            vec![SpeakerId::new("RINCON_UNKNOWN")],
435        );
436
437        let group = Group::from_info(group_info, state_manager, api_client);
438        assert!(group.is_none());
439    }
440
441    #[test]
442    fn test_coordinator_returns_correct_speaker() {
443        let state_manager = create_test_state_manager_with_speakers(vec![
444            ("RINCON_111", "Living Room", "192.168.1.100"),
445            ("RINCON_222", "Kitchen", "192.168.1.101"),
446        ]);
447        let api_client = SonosClient::new();
448
449        let group_info = GroupInfo::new(
450            GroupId::new("RINCON_111:1"),
451            SpeakerId::new("RINCON_111"),
452            vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
453        );
454
455        let group = Group::from_info(group_info, state_manager, api_client).unwrap();
456
457        let coordinator = group.coordinator();
458        assert!(coordinator.is_some());
459        let coordinator = coordinator.unwrap();
460        assert_eq!(coordinator.id.as_str(), "RINCON_111");
461        assert_eq!(coordinator.name, "Living Room");
462    }
463
464    #[test]
465    fn test_members_returns_all_members() {
466        let state_manager = create_test_state_manager_with_speakers(vec![
467            ("RINCON_111", "Living Room", "192.168.1.100"),
468            ("RINCON_222", "Kitchen", "192.168.1.101"),
469        ]);
470        let api_client = SonosClient::new();
471
472        let group_info = GroupInfo::new(
473            GroupId::new("RINCON_111:1"),
474            SpeakerId::new("RINCON_111"),
475            vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
476        );
477
478        let group = Group::from_info(group_info, state_manager, api_client).unwrap();
479
480        let members = group.members();
481        assert_eq!(members.len(), 2);
482
483        let member_ids: Vec<_> = members.iter().map(|m| m.id.as_str()).collect();
484        assert!(member_ids.contains(&"RINCON_111"));
485        assert!(member_ids.contains(&"RINCON_222"));
486    }
487
488    #[test]
489    fn test_is_coordinator_returns_correct_values() {
490        let state_manager = create_test_state_manager_with_speakers(vec![
491            ("RINCON_111", "Living Room", "192.168.1.100"),
492            ("RINCON_222", "Kitchen", "192.168.1.101"),
493        ]);
494        let api_client = SonosClient::new();
495
496        let group_info = GroupInfo::new(
497            GroupId::new("RINCON_111:1"),
498            SpeakerId::new("RINCON_111"),
499            vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
500        );
501
502        let group = Group::from_info(group_info, state_manager, api_client).unwrap();
503
504        assert!(group.is_coordinator(&SpeakerId::new("RINCON_111")));
505        assert!(!group.is_coordinator(&SpeakerId::new("RINCON_222")));
506    }
507
508    #[test]
509    fn test_member_count() {
510        let state_manager = create_test_state_manager_with_speakers(vec![
511            ("RINCON_111", "Living Room", "192.168.1.100"),
512            ("RINCON_222", "Kitchen", "192.168.1.101"),
513        ]);
514        let api_client = SonosClient::new();
515
516        // Single member group
517        let single_group = Group::from_info(
518            GroupInfo::new(
519                GroupId::new("RINCON_111:1"),
520                SpeakerId::new("RINCON_111"),
521                vec![SpeakerId::new("RINCON_111")],
522            ),
523            Arc::clone(&state_manager),
524            api_client.clone(),
525        )
526        .unwrap();
527        assert_eq!(single_group.member_count(), 1);
528
529        // Multi-member group
530        let multi_group = Group::from_info(
531            GroupInfo::new(
532                GroupId::new("RINCON_111:1"),
533                SpeakerId::new("RINCON_111"),
534                vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
535            ),
536            state_manager,
537            api_client,
538        )
539        .unwrap();
540        assert_eq!(multi_group.member_count(), 2);
541    }
542
543    #[test]
544    fn test_is_standalone() {
545        let state_manager = create_test_state_manager_with_speakers(vec![
546            ("RINCON_111", "Living Room", "192.168.1.100"),
547            ("RINCON_222", "Kitchen", "192.168.1.101"),
548        ]);
549        let api_client = SonosClient::new();
550
551        // Standalone group
552        let standalone = Group::from_info(
553            GroupInfo::new(
554                GroupId::new("RINCON_111:1"),
555                SpeakerId::new("RINCON_111"),
556                vec![SpeakerId::new("RINCON_111")],
557            ),
558            Arc::clone(&state_manager),
559            api_client.clone(),
560        )
561        .unwrap();
562        assert!(standalone.is_standalone());
563
564        // Non-standalone group
565        let grouped = Group::from_info(
566            GroupInfo::new(
567                GroupId::new("RINCON_111:1"),
568                SpeakerId::new("RINCON_111"),
569                vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
570            ),
571            state_manager,
572            api_client,
573        )
574        .unwrap();
575        assert!(!grouped.is_standalone());
576    }
577
578    #[test]
579    fn test_group_volume_handle_accessible() {
580        let state_manager = create_test_state_manager_with_speakers(vec![(
581            "RINCON_111",
582            "Living Room",
583            "192.168.1.100",
584        )]);
585        let api_client = SonosClient::new();
586
587        let group_info = GroupInfo::new(
588            GroupId::new("RINCON_111:1"),
589            SpeakerId::new("RINCON_111"),
590            vec![SpeakerId::new("RINCON_111")],
591        );
592
593        let group = Group::from_info(group_info, state_manager, api_client).unwrap();
594
595        // Volume handle should exist and return None initially
596        assert!(group.volume.get().is_none());
597        assert_eq!(group.volume.group_id().as_str(), "RINCON_111:1");
598    }
599
600    fn create_test_group() -> Group {
601        let state_manager = create_test_state_manager_with_speakers(vec![(
602            "RINCON_111",
603            "Living Room",
604            "192.168.1.100",
605        )]);
606        let api_client = SonosClient::new();
607
608        let group_info = GroupInfo::new(
609            GroupId::new("RINCON_111:1"),
610            SpeakerId::new("RINCON_111"),
611            vec![SpeakerId::new("RINCON_111")],
612        );
613
614        Group::from_info(group_info, state_manager, api_client).unwrap()
615    }
616
617    #[test]
618    fn test_group_set_volume_rejects_over_100() {
619        let group = create_test_group();
620        let result = group.set_volume(150);
621        assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
622    }
623
624    #[test]
625    fn test_group_action_methods_exist() {
626        fn assert_void(_r: Result<(), SdkError>) {}
627        fn assert_response<T>(_r: Result<T, SdkError>) {}
628
629        let group = create_test_group();
630
631        // These will fail at network level but prove signatures compile
632        assert_void(group.set_volume(50));
633        assert_response::<SetRelativeGroupVolumeResponse>(group.set_relative_volume(5));
634        assert_void(group.set_mute(true));
635        assert_void(group.snapshot_volume());
636    }
637
638    fn create_test_group_with_member() -> (Group, Speaker) {
639        let state_manager = create_test_state_manager_with_speakers(vec![
640            ("RINCON_111", "Living Room", "192.168.1.100"),
641            ("RINCON_222", "Kitchen", "192.168.1.101"),
642        ]);
643        let api_client = SonosClient::new();
644
645        let group_info = GroupInfo::new(
646            GroupId::new("RINCON_111:1"),
647            SpeakerId::new("RINCON_111"),
648            vec![SpeakerId::new("RINCON_111"), SpeakerId::new("RINCON_222")],
649        );
650
651        let group =
652            Group::from_info(group_info, Arc::clone(&state_manager), api_client.clone()).unwrap();
653        let member = Speaker::new(
654            SpeakerId::new("RINCON_222"),
655            "Kitchen".to_string(),
656            "192.168.1.101".parse().unwrap(),
657            "Sonos One".to_string(),
658            state_manager,
659            api_client,
660        );
661
662        (group, member)
663    }
664
665    #[test]
666    fn test_add_speaker_rejects_coordinator_self_add() {
667        let (group, _) = create_test_group_with_member();
668        let coordinator = group.coordinator().unwrap();
669
670        let result = group.add_speaker(&coordinator);
671        assert!(matches!(result, Err(SdkError::InvalidOperation(_))));
672    }
673
674    #[test]
675    fn test_remove_speaker_rejects_coordinator_removal() {
676        let (group, _) = create_test_group_with_member();
677        let coordinator = group.coordinator().unwrap();
678
679        let result = group.remove_speaker(&coordinator);
680        assert!(matches!(result, Err(SdkError::InvalidOperation(_))));
681    }
682
683    #[test]
684    fn test_group_lifecycle_methods_exist() {
685        fn assert_void(_r: Result<(), SdkError>) {}
686        fn assert_change_result(_r: GroupChangeResult) {}
687
688        let (group, member) = create_test_group_with_member();
689
690        // These will fail at network level but prove signatures compile
691        assert_void(group.add_speaker(&member));
692        assert_void(group.remove_speaker(&member));
693        assert_change_result(group.dissolve());
694    }
695
696    #[test]
697    fn test_dissolve_standalone_returns_empty_result() {
698        let group = create_test_group();
699        let result = group.dissolve();
700        assert!(result.is_success());
701        assert!(result.succeeded.is_empty());
702        assert!(result.failed.is_empty());
703    }
704}