1use 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#[derive(Debug)]
35pub struct GroupChangeResult {
36 pub succeeded: Vec<SpeakerId>,
38 pub failed: Vec<(SpeakerId, SdkError)>,
40}
41
42impl GroupChangeResult {
43 pub fn is_success(&self) -> bool {
45 self.failed.is_empty()
46 }
47
48 pub fn is_partial(&self) -> bool {
50 !self.succeeded.is_empty() && !self.failed.is_empty()
51 }
52}
53
54#[derive(Clone)]
77pub struct Group {
78 pub id: GroupId,
80 pub coordinator_id: SpeakerId,
82 pub member_ids: Vec<SpeakerId>,
84
85 pub volume: GroupVolumeHandle,
90 pub mute: GroupMuteHandle,
92 pub volume_changeable: GroupVolumeChangeableHandle,
94
95 coordinator_ip: IpAddr,
97 state_manager: Arc<StateManager>,
98 api_client: SonosClient,
99}
100
101impl Group {
102 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 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 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 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 pub fn is_coordinator(&self, speaker_id: &SpeakerId) -> bool {
219 self.coordinator_id == *speaker_id
220 }
221
222 pub fn member_count(&self) -> usize {
230 self.member_ids.len()
231 }
232
233 pub fn is_standalone(&self) -> bool {
245 self.member_ids.len() == 1
246 }
247
248 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}