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 let system = 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 system.ensure_topology();
220
221 let satellite_ids = system.state_manager.get_satellite_ids();
223 if !satellite_ids.is_empty() {
224 if let Ok(mut speakers) = system.speakers.write() {
225 speakers.retain(|_name, speaker| !satellite_ids.contains(&speaker.id));
226 }
227 tracing::debug!("Filtered {} satellite speakers", satellite_ids.len());
228 }
229
230 if let Ok(mut speakers) = system.speakers.write() {
232 for speaker in speakers.values_mut() {
233 if let Some(info) = system.state_manager.speaker_info(&speaker.id) {
234 speaker.ip = info.ip_address;
235 }
236 }
237 }
238
239 Ok(system)
240 }
241
242 #[cfg(feature = "test-support")]
258 pub fn with_speakers(names: &[&str]) -> Self {
259 let devices: Vec<Device> = names
260 .iter()
261 .enumerate()
262 .map(|(i, name)| Device {
263 id: format!("RINCON_{i:03}"),
264 name: name.to_string(),
265 room_name: name.to_string(),
266 ip_address: format!("192.168.1.{}", 100 + i),
267 port: 1400,
268 model_name: "Sonos One".to_string(),
269 })
270 .collect();
271
272 let state_manager =
273 Arc::new(StateManager::new().expect("StateManager::new() should not fail"));
274
275 state_manager
276 .add_devices(devices.clone())
277 .expect("add_devices should not fail with valid test data");
278
279 let api_client = SonosClient::new();
280 let speakers = Self::build_speakers(&devices, &state_manager, &api_client)
281 .expect("build_speakers should not fail with valid test data");
282
283 Self {
284 state_manager,
285 event_manager: Mutex::new(None),
286 api_client,
287 speakers: RwLock::new(speakers),
288 last_rediscovery: AtomicU64::new(0),
289 }
290 }
291
292 #[cfg(feature = "test-support")]
305 pub fn with_groups(names: &[&str]) -> Self {
306 let system = Self::with_speakers(names);
307
308 let groups: Vec<GroupInfo> = names
309 .iter()
310 .enumerate()
311 .map(|(i, _name)| {
312 let speaker_id = SpeakerId::new(format!("RINCON_{i:03}"));
313 let group_id = GroupId::new(format!("RINCON_{i:03}:1"));
314 GroupInfo::new(group_id, speaker_id.clone(), vec![speaker_id])
315 })
316 .collect();
317
318 let topology = Topology::new(system.state_manager.speaker_infos(), groups);
319 system.state_manager.initialize(topology);
320
321 system
322 }
323
324 fn build_speakers(
326 devices: &[Device],
327 state_manager: &Arc<StateManager>,
328 api_client: &SonosClient,
329 ) -> Result<HashMap<String, Speaker>, SdkError> {
330 let mut speakers = HashMap::new();
331 for device in devices {
332 let speaker_id = SpeakerId::new(&device.id);
333 let ip = device
334 .ip_address
335 .parse()
336 .map_err(|_| SdkError::InvalidIpAddress)?;
337
338 let name = display_name(device);
339 let speaker = Speaker::new(
340 speaker_id,
341 name.clone(),
342 ip,
343 device.model_name.clone(),
344 Arc::clone(state_manager),
345 api_client.clone(),
346 );
347
348 if speakers.contains_key(&name) {
349 tracing::warn!(
350 "duplicate speaker name \"{}\", keeping last discovered",
351 name
352 );
353 }
354 speakers.insert(name, speaker);
355 }
356 Ok(speakers)
357 }
358
359 pub fn speaker(&self, name: &str) -> Option<Speaker> {
371 {
372 let speakers = self.speakers.read().ok()?;
373 if let Some(speaker) = find_speaker_by_name(&speakers, name) {
374 return Some(speaker);
375 }
376 }
377 self.try_rediscover(name);
379 let speakers = self.speakers.read().ok()?;
380 find_speaker_by_name(&speakers, name)
381 }
382
383 #[deprecated(since = "0.2.0", note = "renamed to `speaker()`")]
385 pub fn get_speaker_by_name(&self, name: &str) -> Option<Speaker> {
386 self.speaker(name)
387 }
388
389 fn try_rediscover(&self, name: &str) {
391 let now = std::time::SystemTime::now()
392 .duration_since(std::time::UNIX_EPOCH)
393 .unwrap_or_default()
394 .as_secs();
395 let last = self.last_rediscovery.load(Ordering::Relaxed);
396 if last > 0 && now - last < REDISCOVERY_COOLDOWN_SECS {
397 return; }
399 self.last_rediscovery.store(now, Ordering::Relaxed);
400
401 tracing::info!("speaker '{}' not found, running auto-rediscovery...", name);
403 let devices = sonos_discovery::get_with_timeout(Duration::from_secs(3));
404 if devices.is_empty() {
405 return;
406 }
407
408 if let Err(e) = self.state_manager.add_devices(devices.clone()) {
410 tracing::warn!("Failed to register rediscovered devices: {}", e);
411 return;
412 }
413
414 let new_speakers =
416 match Self::build_speakers(&devices, &self.state_manager, &self.api_client) {
417 Ok(s) => s,
418 Err(e) => {
419 tracing::warn!("Failed to build speakers from rediscovery: {}", e);
420 return;
421 }
422 };
423
424 if let Ok(mut map) = self.speakers.write() {
426 *map = new_speakers;
427 }
428
429 if let Err(e) = cache::save(&devices) {
431 tracing::warn!("Failed to save discovery cache: {}", e);
432 }
433 }
434
435 pub fn speakers(&self) -> Vec<Speaker> {
437 self.speakers
438 .read()
439 .map(|s| s.values().cloned().collect())
440 .unwrap_or_default()
441 }
442
443 pub fn speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
445 let speakers = self.speakers.read().ok()?;
446 speakers.values().find(|s| s.id == *speaker_id).cloned()
447 }
448
449 #[deprecated(since = "0.2.0", note = "renamed to `speaker_by_id()`")]
451 pub fn get_speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
452 self.speaker_by_id(speaker_id)
453 }
454
455 pub fn speaker_names(&self) -> Vec<String> {
457 self.speakers
458 .read()
459 .map(|s| s.keys().cloned().collect())
460 .unwrap_or_default()
461 }
462
463 pub fn state_manager(&self) -> &Arc<StateManager> {
465 &self.state_manager
466 }
467
468 pub fn iter(&self) -> sonos_state::ChangeIterator {
485 self.state_manager.iter()
486 }
487
488 fn ensure_topology(&self) {
498 if self.state_manager.group_count() > 0 {
499 return;
500 }
501
502 let speaker_ips: Vec<String> = {
503 let speakers = match self.speakers.read() {
504 Ok(s) => s,
505 Err(_) => return,
506 };
507 speakers.values().map(|s| s.ip.to_string()).collect()
508 };
509
510 for speaker_ip in &speaker_ips {
511 let topology_state = match sonos_api::services::zone_group_topology::state::poll(
512 &self.api_client,
513 speaker_ip,
514 ) {
515 Ok(state) => state,
516 Err(e) => {
517 tracing::debug!("Topology fetch failed for {}: {}", speaker_ip, e);
518 continue;
519 }
520 };
521
522 let topology_changes = sonos_state::decode_topology_event(&topology_state);
523
524 for (speaker_id, new_ip) in &topology_changes.speaker_ips {
526 self.state_manager.update_speaker_ip(speaker_id, *new_ip);
527 }
528
529 let topology =
531 Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
532 self.state_manager.initialize(topology);
533
534 self.state_manager
536 .set_satellite_ids(topology_changes.satellite_ids);
537
538 tracing::debug!(
539 "Fetched zone group topology on-demand ({} groups)",
540 self.state_manager.group_count()
541 );
542 return;
543 }
544
545 tracing::warn!("ensure_topology: no speakers responded");
546 }
547
548 pub fn groups(&self) -> Vec<Group> {
568 self.ensure_topology();
569 self.state_manager
570 .groups()
571 .into_iter()
572 .filter_map(|info| {
573 Group::from_info(
574 info,
575 Arc::clone(&self.state_manager),
576 self.api_client.clone(),
577 )
578 })
579 .collect()
580 }
581
582 pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
594 self.ensure_topology();
595 let info = self.state_manager.get_group(group_id)?;
596 Group::from_info(
597 info,
598 Arc::clone(&self.state_manager),
599 self.api_client.clone(),
600 )
601 }
602
603 #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
605 pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
606 self.group_by_id(group_id)
607 }
608
609 pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
626 self.ensure_topology();
627 let info = self.state_manager.get_group_for_speaker(speaker_id)?;
628 Group::from_info(
629 info,
630 Arc::clone(&self.state_manager),
631 self.api_client.clone(),
632 )
633 }
634
635 #[deprecated(
637 since = "0.2.0",
638 note = "use `speaker.group()` or `group_for_speaker()` instead"
639 )]
640 pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
641 self.group_for_speaker(speaker_id)
642 }
643
644 pub fn group(&self, name: &str) -> Option<Group> {
660 self.ensure_topology();
661 self.state_manager
662 .groups()
663 .into_iter()
664 .find(|info| {
665 self.state_manager
666 .speaker_info(&info.coordinator_id)
667 .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
668 })
669 .and_then(|info| {
670 Group::from_info(
671 info,
672 Arc::clone(&self.state_manager),
673 self.api_client.clone(),
674 )
675 })
676 }
677
678 #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
680 pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
681 self.group(name)
682 }
683
684 pub fn create_group(
705 &self,
706 coordinator: &Speaker,
707 members: &[&Speaker],
708 ) -> Result<crate::group::GroupChangeResult, SdkError> {
709 let coord_group = self
710 .group_for_speaker(&coordinator.id)
711 .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
712
713 let mut succeeded = Vec::new();
714 let mut failed = Vec::new();
715
716 for member in members {
717 match coord_group.add_speaker(member) {
718 Ok(()) => succeeded.push(member.id.clone()),
719 Err(e) => failed.push((member.id.clone(), e)),
720 }
721 }
722
723 Ok(crate::group::GroupChangeResult { succeeded, failed })
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use sonos_state::GroupInfo;
731
732 fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
738 SonosSystem::from_discovered_devices(devices)
739 }
740
741 #[test]
742 fn test_groups_returns_all_groups() {
743 let devices = vec![
744 Device {
745 id: "RINCON_111".to_string(),
746 name: "Living Room".to_string(),
747 room_name: "Living Room".to_string(),
748 ip_address: "192.168.1.100".to_string(),
749 port: 1400,
750 model_name: "Sonos One".to_string(),
751 },
752 Device {
753 id: "RINCON_222".to_string(),
754 name: "Kitchen".to_string(),
755 room_name: "Kitchen".to_string(),
756 ip_address: "192.168.1.101".to_string(),
757 port: 1400,
758 model_name: "Sonos One".to_string(),
759 },
760 ];
761
762 let system = create_test_system(devices).unwrap();
763
764 let speaker1 = SpeakerId::new("RINCON_111");
766 let speaker2 = SpeakerId::new("RINCON_222");
767 let group1 = GroupInfo::new(
768 GroupId::new("RINCON_111:1"),
769 speaker1.clone(),
770 vec![speaker1.clone()],
771 );
772 let group2 = GroupInfo::new(
773 GroupId::new("RINCON_222:1"),
774 speaker2.clone(),
775 vec![speaker2.clone()],
776 );
777
778 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
779 system.state_manager.initialize(topology);
780
781 let groups = system.groups();
783 assert_eq!(groups.len(), 2);
784
785 let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
786 assert!(group_ids.contains(&"RINCON_111:1".to_string()));
787 assert!(group_ids.contains(&"RINCON_222:1".to_string()));
788 }
789
790 #[test]
791 fn test_groups_returns_empty_when_no_groups() {
792 let devices = vec![Device {
793 id: "RINCON_111".to_string(),
794 name: "Living Room".to_string(),
795 room_name: "Living Room".to_string(),
796 ip_address: "192.168.1.100".to_string(),
797 port: 1400,
798 model_name: "Sonos One".to_string(),
799 }];
800
801 let system = create_test_system(devices).unwrap();
802
803 let groups = system.groups();
805 assert!(groups.is_empty());
806 }
807
808 #[test]
809 fn test_group_by_id_returns_correct_group() {
810 let devices = vec![Device {
811 id: "RINCON_111".to_string(),
812 name: "Living Room".to_string(),
813 room_name: "Living Room".to_string(),
814 ip_address: "192.168.1.100".to_string(),
815 port: 1400,
816 model_name: "Sonos One".to_string(),
817 }];
818
819 let system = create_test_system(devices).unwrap();
820
821 let speaker = SpeakerId::new("RINCON_111");
823 let group_id = GroupId::new("RINCON_111:1");
824 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
825
826 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
827 system.state_manager.initialize(topology);
828
829 let found = system.group_by_id(&group_id);
831 assert!(found.is_some());
832 let found = found.unwrap();
833 assert_eq!(found.id.as_str(), "RINCON_111:1");
834 assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
835 assert_eq!(found.member_ids.len(), 1);
836 }
837
838 #[test]
839 fn test_group_by_id_returns_none_for_unknown() {
840 let devices = vec![Device {
841 id: "RINCON_111".to_string(),
842 name: "Living Room".to_string(),
843 room_name: "Living Room".to_string(),
844 ip_address: "192.168.1.100".to_string(),
845 port: 1400,
846 model_name: "Sonos One".to_string(),
847 }];
848
849 let system = create_test_system(devices).unwrap();
850
851 let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
853 let found = system.group_by_id(&unknown_id);
854 assert!(found.is_none());
855 }
856
857 #[test]
858 fn test_group_for_speaker_returns_correct_group() {
859 let devices = vec![
860 Device {
861 id: "RINCON_111".to_string(),
862 name: "Living Room".to_string(),
863 room_name: "Living Room".to_string(),
864 ip_address: "192.168.1.100".to_string(),
865 port: 1400,
866 model_name: "Sonos One".to_string(),
867 },
868 Device {
869 id: "RINCON_222".to_string(),
870 name: "Kitchen".to_string(),
871 room_name: "Kitchen".to_string(),
872 ip_address: "192.168.1.101".to_string(),
873 port: 1400,
874 model_name: "Sonos One".to_string(),
875 },
876 ];
877
878 let system = create_test_system(devices).unwrap();
879
880 let speaker1 = SpeakerId::new("RINCON_111");
882 let speaker2 = SpeakerId::new("RINCON_222");
883 let group = GroupInfo::new(
884 GroupId::new("RINCON_111:1"),
885 speaker1.clone(),
886 vec![speaker1.clone(), speaker2.clone()],
887 );
888
889 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
890 system.state_manager.initialize(topology);
891
892 let found1 = system.group_for_speaker(&speaker1);
894 assert!(found1.is_some());
895 let found1 = found1.unwrap();
896 assert_eq!(found1.id.as_str(), "RINCON_111:1");
897 assert_eq!(found1.member_ids.len(), 2);
898
899 let found2 = system.group_for_speaker(&speaker2);
900 assert!(found2.is_some());
901 let found2 = found2.unwrap();
902 assert_eq!(found2.id.as_str(), "RINCON_111:1");
903 assert_eq!(found2.member_ids.len(), 2);
904 }
905
906 #[test]
907 fn test_group_for_speaker_returns_none_for_unknown() {
908 let devices = vec![Device {
909 id: "RINCON_111".to_string(),
910 name: "Living Room".to_string(),
911 room_name: "Living Room".to_string(),
912 ip_address: "192.168.1.100".to_string(),
913 port: 1400,
914 model_name: "Sonos One".to_string(),
915 }];
916
917 let system = create_test_system(devices).unwrap();
918
919 let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
921 let found = system.group_for_speaker(&unknown_speaker);
922 assert!(found.is_none());
923 }
924
925 #[test]
926 fn test_group_methods_consistency() {
927 let devices = vec![Device {
928 id: "RINCON_111".to_string(),
929 name: "Living Room".to_string(),
930 room_name: "Living Room".to_string(),
931 ip_address: "192.168.1.100".to_string(),
932 port: 1400,
933 model_name: "Sonos One".to_string(),
934 }];
935
936 let system = create_test_system(devices).unwrap();
937
938 let speaker = SpeakerId::new("RINCON_111");
940 let group_id = GroupId::new("RINCON_111:1");
941 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
942
943 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
944 system.state_manager.initialize(topology);
945
946 let groups = system.groups();
948 assert_eq!(groups.len(), 1);
949
950 let by_id = system.group_by_id(&group_id);
951 assert!(by_id.is_some());
952
953 let by_speaker = system.group_for_speaker(&speaker);
954 assert!(by_speaker.is_some());
955
956 assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
958 assert_eq!(
959 groups[0].id.as_str(),
960 by_speaker.as_ref().unwrap().id.as_str()
961 );
962 assert_eq!(
963 groups[0].coordinator_id.as_str(),
964 by_id.as_ref().unwrap().coordinator_id.as_str()
965 );
966 assert_eq!(
967 groups[0].coordinator_id.as_str(),
968 by_speaker.as_ref().unwrap().coordinator_id.as_str()
969 );
970 }
971
972 #[test]
973 fn test_group_by_name_returns_correct_group() {
974 let devices = vec![
975 Device {
976 id: "RINCON_111".to_string(),
977 name: "Living Room".to_string(),
978 room_name: "Living Room".to_string(),
979 ip_address: "192.168.1.100".to_string(),
980 port: 1400,
981 model_name: "Sonos One".to_string(),
982 },
983 Device {
984 id: "RINCON_222".to_string(),
985 name: "Kitchen".to_string(),
986 room_name: "Kitchen".to_string(),
987 ip_address: "192.168.1.101".to_string(),
988 port: 1400,
989 model_name: "Sonos One".to_string(),
990 },
991 ];
992
993 let system = create_test_system(devices).unwrap();
994
995 let speaker1 = SpeakerId::new("RINCON_111");
996 let speaker2 = SpeakerId::new("RINCON_222");
997 let group1 = GroupInfo::new(
998 GroupId::new("RINCON_111:1"),
999 speaker1.clone(),
1000 vec![speaker1.clone()],
1001 );
1002 let group2 = GroupInfo::new(
1003 GroupId::new("RINCON_222:1"),
1004 speaker2.clone(),
1005 vec![speaker2.clone()],
1006 );
1007
1008 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
1009 system.state_manager.initialize(topology);
1010
1011 let found = system.group("Living Room");
1013 assert!(found.is_some());
1014 assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
1015
1016 let found = system.group("Kitchen");
1017 assert!(found.is_some());
1018 assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
1019
1020 assert!(system.group("Nonexistent").is_none());
1022 }
1023
1024 #[test]
1025 fn test_create_group_method_exists() {
1026 fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1028
1029 let devices = vec![
1030 Device {
1031 id: "RINCON_111".to_string(),
1032 name: "Living Room".to_string(),
1033 room_name: "Living Room".to_string(),
1034 ip_address: "192.168.1.100".to_string(),
1035 port: 1400,
1036 model_name: "Sonos One".to_string(),
1037 },
1038 Device {
1039 id: "RINCON_222".to_string(),
1040 name: "Kitchen".to_string(),
1041 room_name: "Kitchen".to_string(),
1042 ip_address: "192.168.1.101".to_string(),
1043 port: 1400,
1044 model_name: "Sonos One".to_string(),
1045 },
1046 ];
1047
1048 let system = create_test_system(devices).unwrap();
1049
1050 let speaker1 = SpeakerId::new("RINCON_111");
1052 let speaker2 = SpeakerId::new("RINCON_222");
1053 let group = GroupInfo::new(
1054 GroupId::new("RINCON_111:1"),
1055 speaker1.clone(),
1056 vec![speaker1.clone()],
1057 );
1058 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1059 system.state_manager.initialize(topology);
1060
1061 let coordinator = system.speaker_by_id(&speaker1).unwrap();
1062 let member = system.speaker_by_id(&speaker2).unwrap();
1063
1064 assert_change_result(system.create_group(&coordinator, &[&member]));
1066 }
1067
1068 #[test]
1069 fn test_display_name_prefers_room_name() {
1070 let device = Device {
1071 id: "RINCON_111".to_string(),
1072 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1073 room_name: "Kitchen".to_string(),
1074 ip_address: "192.168.1.100".to_string(),
1075 port: 1400,
1076 model_name: "Sonos One".to_string(),
1077 };
1078 assert_eq!(display_name(&device), "Kitchen");
1079 }
1080
1081 #[test]
1082 fn test_display_name_falls_back_to_friendly_name() {
1083 let device = Device {
1084 id: "RINCON_111".to_string(),
1085 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1086 room_name: "Unknown".to_string(),
1087 ip_address: "192.168.1.100".to_string(),
1088 port: 1400,
1089 model_name: "Sonos One".to_string(),
1090 };
1091 assert_eq!(
1092 display_name(&device),
1093 "192.168.1.100 - Sonos One - RINCON_111"
1094 );
1095
1096 let device_empty = Device {
1097 id: "RINCON_222".to_string(),
1098 name: "192.168.1.101 - Sonos One".to_string(),
1099 room_name: "".to_string(),
1100 ip_address: "192.168.1.101".to_string(),
1101 port: 1400,
1102 model_name: "Sonos One".to_string(),
1103 };
1104 assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1105 }
1106
1107 #[test]
1108 fn test_speaker_lookup_case_insensitive() {
1109 let devices = vec![Device {
1110 id: "RINCON_111".to_string(),
1111 name: "Kitchen".to_string(),
1112 room_name: "Kitchen".to_string(),
1113 ip_address: "192.168.1.100".to_string(),
1114 port: 1400,
1115 model_name: "Sonos One".to_string(),
1116 }];
1117 let system = create_test_system(devices).unwrap();
1118 assert!(system.speaker("Kitchen").is_some());
1119 assert!(system.speaker("kitchen").is_some());
1120 assert!(system.speaker("KITCHEN").is_some());
1121 assert!(system.speaker("Nonexistent").is_none());
1122 }
1123
1124 #[test]
1125 fn test_speaker_uses_room_name() {
1126 let devices = vec![Device {
1127 id: "RINCON_111".to_string(),
1128 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1129 room_name: "Kitchen".to_string(),
1130 ip_address: "192.168.1.100".to_string(),
1131 port: 1400,
1132 model_name: "Sonos One".to_string(),
1133 }];
1134
1135 let system = create_test_system(devices).unwrap();
1136 let spk = system.speaker("Kitchen");
1137 assert!(spk.is_some());
1138 assert_eq!(spk.unwrap().name, "Kitchen");
1139
1140 assert!(system
1142 .speaker("192.168.1.100 - Sonos One - RINCON_111")
1143 .is_none());
1144 }
1145
1146 #[test]
1147 fn test_group_lookup_case_insensitive() {
1148 let devices = vec![Device {
1149 id: "RINCON_111".to_string(),
1150 name: "Living Room".to_string(),
1151 room_name: "Living Room".to_string(),
1152 ip_address: "192.168.1.100".to_string(),
1153 port: 1400,
1154 model_name: "Sonos One".to_string(),
1155 }];
1156
1157 let system = create_test_system(devices).unwrap();
1158
1159 let speaker = SpeakerId::new("RINCON_111");
1160 let group = GroupInfo::new(
1161 GroupId::new("RINCON_111:1"),
1162 speaker.clone(),
1163 vec![speaker.clone()],
1164 );
1165
1166 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1167 system.state_manager.initialize(topology);
1168
1169 assert!(system.group("Living Room").is_some());
1170 assert!(system.group("living room").is_some());
1171 assert!(system.group("LIVING ROOM").is_some());
1172 assert!(system.group("Nonexistent").is_none());
1173 }
1174}