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