1use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex, RwLock};
8use std::time::Duration;
9
10use sonos_api::SonosClient;
11use sonos_discovery::{self, Device};
12use sonos_event_manager::SonosEventManager;
13#[cfg(feature = "test-support")]
14use sonos_state::GroupInfo;
15use sonos_state::{GroupId, SpeakerId, StateManager, Topology};
16
17use crate::property::EventInitFn;
18use crate::{cache, Group, SdkError, Speaker};
19
20fn display_name(device: &Device) -> String {
25 if device.room_name.is_empty() || device.room_name == "Unknown" {
26 device.name.clone()
27 } else {
28 device.room_name.clone()
29 }
30}
31
32fn find_speaker_by_name(speakers: &HashMap<String, Speaker>, name: &str) -> Option<Speaker> {
37 if let Some(speaker) = speakers.get(name) {
38 return Some(speaker.clone());
39 }
40 speakers
41 .values()
42 .find(|s| s.name.eq_ignore_ascii_case(name))
43 .cloned()
44}
45
46pub struct SonosSystem {
76 state_manager: Arc<StateManager>,
78
79 #[allow(dead_code)]
83 event_manager: Mutex<Option<Arc<SonosEventManager>>>,
84
85 api_client: SonosClient,
87
88 speakers: RwLock<HashMap<String, Speaker>>,
90
91 last_rediscovery: AtomicU64,
93}
94
95const REDISCOVERY_COOLDOWN_SECS: u64 = 30;
96
97impl SonosSystem {
98 pub fn new() -> Result<Self, SdkError> {
107 let devices = match cache::load() {
108 Some(cached) if !cache::is_stale(&cached) => {
109 cached.devices
111 }
112 Some(cached) => {
113 let fresh = sonos_discovery::get_with_timeout(Duration::from_secs(3));
115 if fresh.is_empty() {
116 tracing::warn!("Cache is stale and SSDP found no devices; using stale cache");
117 cached.devices
118 } else {
119 if let Err(e) = cache::save(&fresh) {
120 tracing::warn!("Failed to save discovery cache: {}", e);
121 }
122 fresh
123 }
124 }
125 None => {
126 let fresh = sonos_discovery::get_with_timeout(Duration::from_secs(3));
128 if fresh.is_empty() {
129 return Err(SdkError::DiscoveryFailed(
130 "no Sonos devices found on the network".to_string(),
131 ));
132 }
133 if let Err(e) = cache::save(&fresh) {
134 tracing::warn!("Failed to save discovery cache: {}", e);
135 }
136 fresh
137 }
138 };
139
140 Self::from_discovered_devices(devices)
141 }
142
143 #[cfg(not(feature = "test-support"))]
149 pub(crate) fn from_discovered_devices(devices: Vec<Device>) -> Result<Self, SdkError> {
150 Self::from_devices_inner(devices)
151 }
152
153 #[cfg(feature = "test-support")]
158 pub fn from_discovered_devices(devices: Vec<Device>) -> Result<Self, SdkError> {
159 Self::from_devices_inner(devices)
160 }
161
162 fn from_devices_inner(devices: Vec<Device>) -> Result<Self, SdkError> {
163 let state_manager = Arc::new(StateManager::new().map_err(SdkError::StateError)?);
165 state_manager
166 .add_devices(devices.clone())
167 .map_err(SdkError::StateError)?;
168
169 let api_client = SonosClient::new();
170 let event_manager: Arc<Mutex<Option<Arc<SonosEventManager>>>> = Arc::new(Mutex::new(None));
171
172 let init_fn: EventInitFn = {
174 let em_mutex = Arc::clone(&event_manager);
175 let sm = Arc::clone(&state_manager);
176 Arc::new(move || {
177 let mut guard = em_mutex.lock().map_err(|_| SdkError::LockPoisoned)?;
178 if guard.is_some() {
179 return Ok(());
180 }
181 let em = Arc::new(
182 SonosEventManager::new().map_err(|e| SdkError::EventManager(e.to_string()))?,
183 );
184 sm.set_event_manager(Arc::clone(&em))
185 .map_err(SdkError::StateError)?;
186 *guard = Some(em);
187 Ok(())
188 })
189 };
190
191 let speakers =
193 Self::build_speakers_with_init(&devices, &state_manager, &api_client, Some(&init_fn))?;
194
195 Ok(Self {
197 state_manager,
198 event_manager: Arc::try_unwrap(event_manager).unwrap_or_else(|arc| {
199 let inner = arc.lock().unwrap().clone();
200 Mutex::new(inner)
201 }),
202 api_client,
203 speakers: RwLock::new(speakers),
204 last_rediscovery: AtomicU64::new(0),
205 })
206 }
207
208 #[cfg(feature = "test-support")]
224 pub fn with_speakers(names: &[&str]) -> Self {
225 let devices: Vec<Device> = names
226 .iter()
227 .enumerate()
228 .map(|(i, name)| Device {
229 id: format!("RINCON_{i:03}"),
230 name: name.to_string(),
231 room_name: name.to_string(),
232 ip_address: format!("192.168.1.{}", 100 + i),
233 port: 1400,
234 model_name: "Sonos One".to_string(),
235 })
236 .collect();
237
238 let state_manager =
239 Arc::new(StateManager::new().expect("StateManager::new() should not fail"));
240
241 state_manager
242 .add_devices(devices.clone())
243 .expect("add_devices should not fail with valid test data");
244
245 let api_client = SonosClient::new();
246 let speakers = Self::build_speakers_with_init(&devices, &state_manager, &api_client, None)
247 .expect("build_speakers should not fail with valid test data");
248
249 Self {
250 state_manager,
251 event_manager: Mutex::new(None),
252 api_client,
253 speakers: RwLock::new(speakers),
254 last_rediscovery: AtomicU64::new(0),
255 }
256 }
257
258 #[cfg(feature = "test-support")]
271 pub fn with_groups(names: &[&str]) -> Self {
272 let system = Self::with_speakers(names);
273
274 let groups: Vec<GroupInfo> = names
275 .iter()
276 .enumerate()
277 .map(|(i, _name)| {
278 let speaker_id = SpeakerId::new(format!("RINCON_{i:03}"));
279 let group_id = GroupId::new(format!("RINCON_{i:03}:1"));
280 GroupInfo::new(group_id, speaker_id.clone(), vec![speaker_id])
281 })
282 .collect();
283
284 let topology = Topology::new(system.state_manager.speaker_infos(), groups);
285 system.state_manager.initialize(topology);
286
287 system
288 }
289
290 fn build_speakers_with_init(
295 devices: &[Device],
296 state_manager: &Arc<StateManager>,
297 api_client: &SonosClient,
298 event_init: Option<&EventInitFn>,
299 ) -> Result<HashMap<String, Speaker>, SdkError> {
300 let mut speakers = HashMap::new();
301 for device in devices {
302 let speaker_id = SpeakerId::new(&device.id);
303 let ip = device
304 .ip_address
305 .parse()
306 .map_err(|_| SdkError::InvalidIpAddress)?;
307
308 let name = display_name(device);
309 let speaker = Speaker::new_with_event_init(
310 speaker_id,
311 name.clone(),
312 ip,
313 device.model_name.clone(),
314 Arc::clone(state_manager),
315 api_client.clone(),
316 event_init.cloned(),
317 );
318
319 if speakers.contains_key(&name) {
320 tracing::warn!(
321 "duplicate speaker name \"{}\", keeping last discovered",
322 name
323 );
324 }
325 speakers.insert(name, speaker);
326 }
327 Ok(speakers)
328 }
329
330 pub fn speaker(&self, name: &str) -> Option<Speaker> {
342 {
343 let speakers = self.speakers.read().ok()?;
344 if let Some(speaker) = find_speaker_by_name(&speakers, name) {
345 return Some(speaker);
346 }
347 }
348 self.try_rediscover(name);
350 let speakers = self.speakers.read().ok()?;
351 find_speaker_by_name(&speakers, name)
352 }
353
354 #[deprecated(since = "0.2.0", note = "renamed to `speaker()`")]
356 pub fn get_speaker_by_name(&self, name: &str) -> Option<Speaker> {
357 self.speaker(name)
358 }
359
360 fn try_rediscover(&self, name: &str) {
362 let now = std::time::SystemTime::now()
363 .duration_since(std::time::UNIX_EPOCH)
364 .unwrap_or_default()
365 .as_secs();
366 let last = self.last_rediscovery.load(Ordering::Relaxed);
367 if last > 0 && now - last < REDISCOVERY_COOLDOWN_SECS {
368 return; }
370 self.last_rediscovery.store(now, Ordering::Relaxed);
371
372 tracing::info!("speaker '{}' not found, running auto-rediscovery...", name);
374 let devices = sonos_discovery::get_with_timeout(Duration::from_secs(3));
375 if devices.is_empty() {
376 return;
377 }
378
379 if let Err(e) = self.state_manager.add_devices(devices.clone()) {
381 tracing::warn!("Failed to register rediscovered devices: {}", e);
382 return;
383 }
384
385 let new_speakers = match Self::build_speakers_with_init(
387 &devices,
388 &self.state_manager,
389 &self.api_client,
390 None,
391 ) {
392 Ok(s) => s,
393 Err(e) => {
394 tracing::warn!("Failed to build speakers from rediscovery: {}", e);
395 return;
396 }
397 };
398
399 if let Ok(mut map) = self.speakers.write() {
401 *map = new_speakers;
402 }
403
404 if let Err(e) = cache::save(&devices) {
406 tracing::warn!("Failed to save discovery cache: {}", e);
407 }
408 }
409
410 pub fn speakers(&self) -> Vec<Speaker> {
412 self.speakers
413 .read()
414 .map(|s| s.values().cloned().collect())
415 .unwrap_or_default()
416 }
417
418 pub fn speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
420 let speakers = self.speakers.read().ok()?;
421 speakers.values().find(|s| s.id == *speaker_id).cloned()
422 }
423
424 #[deprecated(since = "0.2.0", note = "renamed to `speaker_by_id()`")]
426 pub fn get_speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
427 self.speaker_by_id(speaker_id)
428 }
429
430 pub fn speaker_names(&self) -> Vec<String> {
432 self.speakers
433 .read()
434 .map(|s| s.keys().cloned().collect())
435 .unwrap_or_default()
436 }
437
438 pub fn state_manager(&self) -> &Arc<StateManager> {
440 &self.state_manager
441 }
442
443 pub fn iter(&self) -> sonos_state::ChangeIterator {
460 self.state_manager.iter()
461 }
462
463 fn ensure_topology(&self) {
475 if self.state_manager.group_count() > 0 {
477 return;
478 }
479
480 let speaker_ip = {
482 let speakers = match self.speakers.read() {
483 Ok(s) => s,
484 Err(_) => return,
485 };
486 match speakers.values().next() {
487 Some(speaker) => speaker.ip.to_string(),
488 None => return,
489 }
490 };
491
492 let topology_state = match sonos_api::services::zone_group_topology::state::poll(
494 &self.api_client,
495 &speaker_ip,
496 ) {
497 Ok(state) => state,
498 Err(e) => {
499 tracing::warn!(
500 "Failed to fetch zone group topology from {}: {}",
501 speaker_ip,
502 e
503 );
504 return;
505 }
506 };
507
508 let topology_changes = sonos_state::decode_topology_event(&topology_state);
510
511 let topology = Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
513 self.state_manager.initialize(topology);
514
515 tracing::debug!(
516 "Fetched zone group topology on-demand ({} groups)",
517 self.state_manager.group_count()
518 );
519 }
520
521 pub fn groups(&self) -> Vec<Group> {
541 self.ensure_topology();
542 self.state_manager
543 .groups()
544 .into_iter()
545 .filter_map(|info| {
546 Group::from_info(
547 info,
548 Arc::clone(&self.state_manager),
549 self.api_client.clone(),
550 )
551 })
552 .collect()
553 }
554
555 pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
567 self.ensure_topology();
568 let info = self.state_manager.get_group(group_id)?;
569 Group::from_info(
570 info,
571 Arc::clone(&self.state_manager),
572 self.api_client.clone(),
573 )
574 }
575
576 #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
578 pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
579 self.group_by_id(group_id)
580 }
581
582 pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
599 self.ensure_topology();
600 let info = self.state_manager.get_group_for_speaker(speaker_id)?;
601 Group::from_info(
602 info,
603 Arc::clone(&self.state_manager),
604 self.api_client.clone(),
605 )
606 }
607
608 #[deprecated(
610 since = "0.2.0",
611 note = "use `speaker.group()` or `group_for_speaker()` instead"
612 )]
613 pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
614 self.group_for_speaker(speaker_id)
615 }
616
617 pub fn group(&self, name: &str) -> Option<Group> {
633 self.ensure_topology();
634 self.state_manager
635 .groups()
636 .into_iter()
637 .find(|info| {
638 self.state_manager
639 .speaker_info(&info.coordinator_id)
640 .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
641 })
642 .and_then(|info| {
643 Group::from_info(
644 info,
645 Arc::clone(&self.state_manager),
646 self.api_client.clone(),
647 )
648 })
649 }
650
651 #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
653 pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
654 self.group(name)
655 }
656
657 pub fn create_group(
678 &self,
679 coordinator: &Speaker,
680 members: &[&Speaker],
681 ) -> Result<crate::group::GroupChangeResult, SdkError> {
682 let coord_group = self
683 .group_for_speaker(&coordinator.id)
684 .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
685
686 let mut succeeded = Vec::new();
687 let mut failed = Vec::new();
688
689 for member in members {
690 match coord_group.add_speaker(member) {
691 Ok(()) => succeeded.push(member.id.clone()),
692 Err(e) => failed.push((member.id.clone(), e)),
693 }
694 }
695
696 Ok(crate::group::GroupChangeResult { succeeded, failed })
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703 use sonos_state::GroupInfo;
704
705 fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
711 SonosSystem::from_discovered_devices(devices)
712 }
713
714 #[test]
715 fn test_groups_returns_all_groups() {
716 let devices = vec![
717 Device {
718 id: "RINCON_111".to_string(),
719 name: "Living Room".to_string(),
720 room_name: "Living Room".to_string(),
721 ip_address: "192.168.1.100".to_string(),
722 port: 1400,
723 model_name: "Sonos One".to_string(),
724 },
725 Device {
726 id: "RINCON_222".to_string(),
727 name: "Kitchen".to_string(),
728 room_name: "Kitchen".to_string(),
729 ip_address: "192.168.1.101".to_string(),
730 port: 1400,
731 model_name: "Sonos One".to_string(),
732 },
733 ];
734
735 let system = create_test_system(devices).unwrap();
736
737 let speaker1 = SpeakerId::new("RINCON_111");
739 let speaker2 = SpeakerId::new("RINCON_222");
740 let group1 = GroupInfo::new(
741 GroupId::new("RINCON_111:1"),
742 speaker1.clone(),
743 vec![speaker1.clone()],
744 );
745 let group2 = GroupInfo::new(
746 GroupId::new("RINCON_222:1"),
747 speaker2.clone(),
748 vec![speaker2.clone()],
749 );
750
751 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
752 system.state_manager.initialize(topology);
753
754 let groups = system.groups();
756 assert_eq!(groups.len(), 2);
757
758 let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
759 assert!(group_ids.contains(&"RINCON_111:1".to_string()));
760 assert!(group_ids.contains(&"RINCON_222:1".to_string()));
761 }
762
763 #[test]
764 fn test_groups_returns_empty_when_no_groups() {
765 let devices = vec![Device {
766 id: "RINCON_111".to_string(),
767 name: "Living Room".to_string(),
768 room_name: "Living Room".to_string(),
769 ip_address: "192.168.1.100".to_string(),
770 port: 1400,
771 model_name: "Sonos One".to_string(),
772 }];
773
774 let system = create_test_system(devices).unwrap();
775
776 let groups = system.groups();
778 assert!(groups.is_empty());
779 }
780
781 #[test]
782 fn test_group_by_id_returns_correct_group() {
783 let devices = vec![Device {
784 id: "RINCON_111".to_string(),
785 name: "Living Room".to_string(),
786 room_name: "Living Room".to_string(),
787 ip_address: "192.168.1.100".to_string(),
788 port: 1400,
789 model_name: "Sonos One".to_string(),
790 }];
791
792 let system = create_test_system(devices).unwrap();
793
794 let speaker = SpeakerId::new("RINCON_111");
796 let group_id = GroupId::new("RINCON_111:1");
797 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
798
799 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
800 system.state_manager.initialize(topology);
801
802 let found = system.group_by_id(&group_id);
804 assert!(found.is_some());
805 let found = found.unwrap();
806 assert_eq!(found.id.as_str(), "RINCON_111:1");
807 assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
808 assert_eq!(found.member_ids.len(), 1);
809 }
810
811 #[test]
812 fn test_group_by_id_returns_none_for_unknown() {
813 let devices = vec![Device {
814 id: "RINCON_111".to_string(),
815 name: "Living Room".to_string(),
816 room_name: "Living Room".to_string(),
817 ip_address: "192.168.1.100".to_string(),
818 port: 1400,
819 model_name: "Sonos One".to_string(),
820 }];
821
822 let system = create_test_system(devices).unwrap();
823
824 let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
826 let found = system.group_by_id(&unknown_id);
827 assert!(found.is_none());
828 }
829
830 #[test]
831 fn test_group_for_speaker_returns_correct_group() {
832 let devices = vec![
833 Device {
834 id: "RINCON_111".to_string(),
835 name: "Living Room".to_string(),
836 room_name: "Living Room".to_string(),
837 ip_address: "192.168.1.100".to_string(),
838 port: 1400,
839 model_name: "Sonos One".to_string(),
840 },
841 Device {
842 id: "RINCON_222".to_string(),
843 name: "Kitchen".to_string(),
844 room_name: "Kitchen".to_string(),
845 ip_address: "192.168.1.101".to_string(),
846 port: 1400,
847 model_name: "Sonos One".to_string(),
848 },
849 ];
850
851 let system = create_test_system(devices).unwrap();
852
853 let speaker1 = SpeakerId::new("RINCON_111");
855 let speaker2 = SpeakerId::new("RINCON_222");
856 let group = GroupInfo::new(
857 GroupId::new("RINCON_111:1"),
858 speaker1.clone(),
859 vec![speaker1.clone(), speaker2.clone()],
860 );
861
862 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
863 system.state_manager.initialize(topology);
864
865 let found1 = system.group_for_speaker(&speaker1);
867 assert!(found1.is_some());
868 let found1 = found1.unwrap();
869 assert_eq!(found1.id.as_str(), "RINCON_111:1");
870 assert_eq!(found1.member_ids.len(), 2);
871
872 let found2 = system.group_for_speaker(&speaker2);
873 assert!(found2.is_some());
874 let found2 = found2.unwrap();
875 assert_eq!(found2.id.as_str(), "RINCON_111:1");
876 assert_eq!(found2.member_ids.len(), 2);
877 }
878
879 #[test]
880 fn test_group_for_speaker_returns_none_for_unknown() {
881 let devices = vec![Device {
882 id: "RINCON_111".to_string(),
883 name: "Living Room".to_string(),
884 room_name: "Living Room".to_string(),
885 ip_address: "192.168.1.100".to_string(),
886 port: 1400,
887 model_name: "Sonos One".to_string(),
888 }];
889
890 let system = create_test_system(devices).unwrap();
891
892 let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
894 let found = system.group_for_speaker(&unknown_speaker);
895 assert!(found.is_none());
896 }
897
898 #[test]
899 fn test_group_methods_consistency() {
900 let devices = vec![Device {
901 id: "RINCON_111".to_string(),
902 name: "Living Room".to_string(),
903 room_name: "Living Room".to_string(),
904 ip_address: "192.168.1.100".to_string(),
905 port: 1400,
906 model_name: "Sonos One".to_string(),
907 }];
908
909 let system = create_test_system(devices).unwrap();
910
911 let speaker = SpeakerId::new("RINCON_111");
913 let group_id = GroupId::new("RINCON_111:1");
914 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
915
916 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
917 system.state_manager.initialize(topology);
918
919 let groups = system.groups();
921 assert_eq!(groups.len(), 1);
922
923 let by_id = system.group_by_id(&group_id);
924 assert!(by_id.is_some());
925
926 let by_speaker = system.group_for_speaker(&speaker);
927 assert!(by_speaker.is_some());
928
929 assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
931 assert_eq!(
932 groups[0].id.as_str(),
933 by_speaker.as_ref().unwrap().id.as_str()
934 );
935 assert_eq!(
936 groups[0].coordinator_id.as_str(),
937 by_id.as_ref().unwrap().coordinator_id.as_str()
938 );
939 assert_eq!(
940 groups[0].coordinator_id.as_str(),
941 by_speaker.as_ref().unwrap().coordinator_id.as_str()
942 );
943 }
944
945 #[test]
946 fn test_group_by_name_returns_correct_group() {
947 let devices = vec![
948 Device {
949 id: "RINCON_111".to_string(),
950 name: "Living Room".to_string(),
951 room_name: "Living Room".to_string(),
952 ip_address: "192.168.1.100".to_string(),
953 port: 1400,
954 model_name: "Sonos One".to_string(),
955 },
956 Device {
957 id: "RINCON_222".to_string(),
958 name: "Kitchen".to_string(),
959 room_name: "Kitchen".to_string(),
960 ip_address: "192.168.1.101".to_string(),
961 port: 1400,
962 model_name: "Sonos One".to_string(),
963 },
964 ];
965
966 let system = create_test_system(devices).unwrap();
967
968 let speaker1 = SpeakerId::new("RINCON_111");
969 let speaker2 = SpeakerId::new("RINCON_222");
970 let group1 = GroupInfo::new(
971 GroupId::new("RINCON_111:1"),
972 speaker1.clone(),
973 vec![speaker1.clone()],
974 );
975 let group2 = GroupInfo::new(
976 GroupId::new("RINCON_222:1"),
977 speaker2.clone(),
978 vec![speaker2.clone()],
979 );
980
981 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
982 system.state_manager.initialize(topology);
983
984 let found = system.group("Living Room");
986 assert!(found.is_some());
987 assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
988
989 let found = system.group("Kitchen");
990 assert!(found.is_some());
991 assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
992
993 assert!(system.group("Nonexistent").is_none());
995 }
996
997 #[test]
998 fn test_create_group_method_exists() {
999 fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1001
1002 let devices = vec![
1003 Device {
1004 id: "RINCON_111".to_string(),
1005 name: "Living Room".to_string(),
1006 room_name: "Living Room".to_string(),
1007 ip_address: "192.168.1.100".to_string(),
1008 port: 1400,
1009 model_name: "Sonos One".to_string(),
1010 },
1011 Device {
1012 id: "RINCON_222".to_string(),
1013 name: "Kitchen".to_string(),
1014 room_name: "Kitchen".to_string(),
1015 ip_address: "192.168.1.101".to_string(),
1016 port: 1400,
1017 model_name: "Sonos One".to_string(),
1018 },
1019 ];
1020
1021 let system = create_test_system(devices).unwrap();
1022
1023 let speaker1 = SpeakerId::new("RINCON_111");
1025 let speaker2 = SpeakerId::new("RINCON_222");
1026 let group = GroupInfo::new(
1027 GroupId::new("RINCON_111:1"),
1028 speaker1.clone(),
1029 vec![speaker1.clone()],
1030 );
1031 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1032 system.state_manager.initialize(topology);
1033
1034 let coordinator = system.speaker_by_id(&speaker1).unwrap();
1035 let member = system.speaker_by_id(&speaker2).unwrap();
1036
1037 assert_change_result(system.create_group(&coordinator, &[&member]));
1039 }
1040
1041 #[test]
1042 fn test_display_name_prefers_room_name() {
1043 let device = Device {
1044 id: "RINCON_111".to_string(),
1045 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1046 room_name: "Kitchen".to_string(),
1047 ip_address: "192.168.1.100".to_string(),
1048 port: 1400,
1049 model_name: "Sonos One".to_string(),
1050 };
1051 assert_eq!(display_name(&device), "Kitchen");
1052 }
1053
1054 #[test]
1055 fn test_display_name_falls_back_to_friendly_name() {
1056 let device = Device {
1057 id: "RINCON_111".to_string(),
1058 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1059 room_name: "Unknown".to_string(),
1060 ip_address: "192.168.1.100".to_string(),
1061 port: 1400,
1062 model_name: "Sonos One".to_string(),
1063 };
1064 assert_eq!(
1065 display_name(&device),
1066 "192.168.1.100 - Sonos One - RINCON_111"
1067 );
1068
1069 let device_empty = Device {
1070 id: "RINCON_222".to_string(),
1071 name: "192.168.1.101 - Sonos One".to_string(),
1072 room_name: "".to_string(),
1073 ip_address: "192.168.1.101".to_string(),
1074 port: 1400,
1075 model_name: "Sonos One".to_string(),
1076 };
1077 assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1078 }
1079
1080 #[test]
1081 fn test_speaker_lookup_case_insensitive() {
1082 let devices = vec![Device {
1083 id: "RINCON_111".to_string(),
1084 name: "Kitchen".to_string(),
1085 room_name: "Kitchen".to_string(),
1086 ip_address: "192.168.1.100".to_string(),
1087 port: 1400,
1088 model_name: "Sonos One".to_string(),
1089 }];
1090 let system = create_test_system(devices).unwrap();
1091 assert!(system.speaker("Kitchen").is_some());
1092 assert!(system.speaker("kitchen").is_some());
1093 assert!(system.speaker("KITCHEN").is_some());
1094 assert!(system.speaker("Nonexistent").is_none());
1095 }
1096
1097 #[test]
1098 fn test_speaker_uses_room_name() {
1099 let devices = vec![Device {
1100 id: "RINCON_111".to_string(),
1101 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1102 room_name: "Kitchen".to_string(),
1103 ip_address: "192.168.1.100".to_string(),
1104 port: 1400,
1105 model_name: "Sonos One".to_string(),
1106 }];
1107
1108 let system = create_test_system(devices).unwrap();
1109 let spk = system.speaker("Kitchen");
1110 assert!(spk.is_some());
1111 assert_eq!(spk.unwrap().name, "Kitchen");
1112
1113 assert!(system
1115 .speaker("192.168.1.100 - Sonos One - RINCON_111")
1116 .is_none());
1117 }
1118
1119 #[test]
1120 fn test_group_lookup_case_insensitive() {
1121 let devices = vec![Device {
1122 id: "RINCON_111".to_string(),
1123 name: "Living Room".to_string(),
1124 room_name: "Living Room".to_string(),
1125 ip_address: "192.168.1.100".to_string(),
1126 port: 1400,
1127 model_name: "Sonos One".to_string(),
1128 }];
1129
1130 let system = create_test_system(devices).unwrap();
1131
1132 let speaker = SpeakerId::new("RINCON_111");
1133 let group = GroupInfo::new(
1134 GroupId::new("RINCON_111:1"),
1135 speaker.clone(),
1136 vec![speaker.clone()],
1137 );
1138
1139 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1140 system.state_manager.initialize(topology);
1141
1142 assert!(system.group("Living Room").is_some());
1143 assert!(system.group("living room").is_some());
1144 assert!(system.group("LIVING ROOM").is_some());
1145 assert!(system.group("Nonexistent").is_none());
1146 }
1147}