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
14#[cfg(feature = "test-support")]
15use sonos_state::GroupInfo;
16use sonos_state::{EventInitFn, GroupId, SpeakerId, StateManager, Topology};
17
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>,
86
87 api_client: SonosClient,
89
90 speakers: RwLock<HashMap<String, Speaker>>,
92
93 last_rediscovery: AtomicU64,
95
96 offline: bool,
103}
104
105const REDISCOVERY_COOLDOWN_SECS: u64 = 30;
106
107impl SonosSystem {
108 pub fn new() -> Result<Self, SdkError> {
117 let devices = match cache::load() {
118 Some(cached) if !cache::is_stale(&cached) => {
119 cached.devices
121 }
122 Some(cached) => {
123 let fresh = sonos_discovery::get_with_timeout(Duration::from_secs(3));
125 if fresh.is_empty() {
126 tracing::warn!("Cache is stale and SSDP found no devices; using stale cache");
127 cached.devices
128 } else {
129 if let Err(e) = cache::save(&fresh) {
130 tracing::warn!("Failed to save discovery cache: {}", e);
131 }
132 fresh
133 }
134 }
135 None => {
136 let fresh = sonos_discovery::get_with_timeout(Duration::from_secs(3));
138 if fresh.is_empty() {
139 return Err(SdkError::DiscoveryFailed(
140 "no Sonos devices found on the network".to_string(),
141 ));
142 }
143 if let Err(e) = cache::save(&fresh) {
144 tracing::warn!("Failed to save discovery cache: {}", e);
145 }
146 fresh
147 }
148 };
149
150 Self::from_discovered_devices(devices)
151 }
152
153 #[cfg(not(feature = "test-support"))]
159 pub(crate) fn from_discovered_devices(devices: Vec<Device>) -> Result<Self, SdkError> {
160 Self::from_devices_inner(devices)
161 }
162
163 #[cfg(feature = "test-support")]
168 pub fn from_discovered_devices(devices: Vec<Device>) -> Result<Self, SdkError> {
169 Self::from_devices_inner(devices)
170 }
171
172 #[cfg(any(feature = "test-support", test))]
188 pub fn from_devices_offline(devices: Vec<Device>) -> Result<Self, SdkError> {
189 Self::assemble(devices, true)
190 }
191
192 fn from_devices_inner(devices: Vec<Device>) -> Result<Self, SdkError> {
193 let system = Self::assemble(devices, false)?;
194
195 system.ensure_topology();
200
201 let satellite_ids = system.state_manager.get_satellite_ids();
204 if !satellite_ids.is_empty() {
205 if let Ok(mut speakers) = system.speakers.write() {
206 speakers.retain(|_name, speaker| !satellite_ids.contains(&speaker.id));
207 }
208 tracing::debug!("Filtered {} satellite speakers", satellite_ids.len());
209 }
210
211 if let Ok(mut speakers) = system.speakers.write() {
213 for speaker in speakers.values_mut() {
214 if let Some(info) = system.state_manager.speaker_info(&speaker.id) {
215 speaker.ip = info.ip_address;
216 }
217 }
218 }
219
220 Ok(system)
221 }
222
223 fn assemble(devices: Vec<Device>, offline: bool) -> Result<Self, SdkError> {
245 let state_manager = Arc::new(StateManager::new().map_err(SdkError::StateError)?);
247 state_manager
248 .add_devices(devices.clone())
249 .map_err(SdkError::StateError)?;
250
251 let api_client = SonosClient::new();
252
253 let init_fn: EventInitFn = {
255 let init_lock: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
260 let weak_sm = Arc::downgrade(&state_manager);
261 Arc::new(
262 move || -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
263 let mut initialized = init_lock.lock().map_err(|_| SdkError::LockPoisoned)?;
264 if *initialized {
265 tracing::trace!(
266 "Event manager init closure called but already initialized"
267 );
268 return Ok(());
269 }
270 let Some(sm) = weak_sm.upgrade() else {
275 tracing::debug!(
276 "Event manager init skipped: SonosSystem has already been dropped"
277 );
278 return Ok(());
279 };
280 tracing::info!("Lazy-initializing event manager (first watch() call)");
281 let em = Arc::new(SonosEventManager::new().map_err(|e| {
282 tracing::error!("Failed to create SonosEventManager: {}", e);
283 SdkError::EventManager(e.to_string())
284 })?);
285 tracing::debug!("SonosEventManager created, wiring into StateManager");
286 sm.set_event_manager(em).map_err(SdkError::StateError)?;
291 *initialized = true;
292 tracing::info!("Event manager initialization complete");
293 Ok(())
294 },
295 )
296 };
297 state_manager.set_event_init(init_fn);
298
299 let speakers = Self::build_speakers(&devices, &state_manager, &api_client)?;
301
302 Ok(Self {
304 state_manager,
305 api_client,
306 speakers: RwLock::new(speakers),
307 last_rediscovery: AtomicU64::new(0),
308 offline,
309 })
310 }
311
312 #[cfg(feature = "test-support")]
328 pub fn with_speakers(names: &[&str]) -> Self {
329 let devices: Vec<Device> = names
330 .iter()
331 .enumerate()
332 .map(|(i, name)| Device {
333 id: format!("RINCON_{i:03}"),
334 name: name.to_string(),
335 room_name: name.to_string(),
336 ip_address: format!("192.168.1.{}", 100 + i),
337 port: 1400,
338 model_name: "Sonos One".to_string(),
339 })
340 .collect();
341
342 let state_manager =
343 Arc::new(StateManager::new().expect("StateManager::new() should not fail"));
344
345 state_manager
346 .add_devices(devices.clone())
347 .expect("add_devices should not fail with valid test data");
348
349 let api_client = SonosClient::new();
350 let speakers = Self::build_speakers(&devices, &state_manager, &api_client)
351 .expect("build_speakers should not fail with valid test data");
352
353 Self {
354 state_manager,
355 api_client,
356 speakers: RwLock::new(speakers),
357 last_rediscovery: AtomicU64::new(0),
358 offline: true,
359 }
360 }
361
362 #[cfg(feature = "test-support")]
375 pub fn with_groups(names: &[&str]) -> Self {
376 let system = Self::with_speakers(names);
377
378 let groups: Vec<GroupInfo> = names
379 .iter()
380 .enumerate()
381 .map(|(i, _name)| {
382 let speaker_id = SpeakerId::new(format!("RINCON_{i:03}"));
383 let group_id = GroupId::new(format!("RINCON_{i:03}:1"));
384 GroupInfo::new(group_id, speaker_id.clone(), vec![speaker_id])
385 })
386 .collect();
387
388 let topology = Topology::new(system.state_manager.speaker_infos(), groups);
389 system.state_manager.initialize(topology);
390
391 system
392 }
393
394 fn build_speakers(
396 devices: &[Device],
397 state_manager: &Arc<StateManager>,
398 api_client: &SonosClient,
399 ) -> Result<HashMap<String, Speaker>, SdkError> {
400 let mut speakers = HashMap::new();
401 for device in devices {
402 let speaker_id = SpeakerId::new(&device.id);
403 let ip = device
404 .ip_address
405 .parse()
406 .map_err(|_| SdkError::InvalidIpAddress)?;
407
408 let name = display_name(device);
409 let speaker = Speaker::new(
410 speaker_id,
411 name.clone(),
412 ip,
413 device.model_name.clone(),
414 Arc::clone(state_manager),
415 api_client.clone(),
416 );
417
418 if speakers.contains_key(&name) {
419 tracing::warn!(
420 "duplicate speaker name \"{}\", keeping last discovered",
421 name
422 );
423 }
424 speakers.insert(name, speaker);
425 }
426 Ok(speakers)
427 }
428
429 pub fn speaker(&self, name: &str) -> Option<Speaker> {
441 {
442 let speakers = self.speakers.read().ok()?;
443 if let Some(speaker) = find_speaker_by_name(&speakers, name) {
444 return Some(speaker);
445 }
446 }
447 self.try_rediscover(name);
449 let speakers = self.speakers.read().ok()?;
450 find_speaker_by_name(&speakers, name)
451 }
452
453 #[deprecated(since = "0.2.0", note = "renamed to `speaker()`")]
455 pub fn get_speaker_by_name(&self, name: &str) -> Option<Speaker> {
456 self.speaker(name)
457 }
458
459 fn try_rediscover(&self, name: &str) {
464 if self.offline {
465 return;
466 }
467
468 let now = std::time::SystemTime::now()
469 .duration_since(std::time::UNIX_EPOCH)
470 .unwrap_or_default()
471 .as_secs();
472 let last = self.last_rediscovery.load(Ordering::Relaxed);
473 if last > 0 && now - last < REDISCOVERY_COOLDOWN_SECS {
474 return; }
476 self.last_rediscovery.store(now, Ordering::Relaxed);
477
478 tracing::info!("speaker '{}' not found, running auto-rediscovery...", name);
480 let devices = sonos_discovery::get_with_timeout(Duration::from_secs(3));
481 if devices.is_empty() {
482 return;
483 }
484
485 if let Err(e) = self.state_manager.add_devices(devices.clone()) {
487 tracing::warn!("Failed to register rediscovered devices: {}", e);
488 return;
489 }
490
491 let new_speakers =
493 match Self::build_speakers(&devices, &self.state_manager, &self.api_client) {
494 Ok(s) => s,
495 Err(e) => {
496 tracing::warn!("Failed to build speakers from rediscovery: {}", e);
497 return;
498 }
499 };
500
501 if let Ok(mut map) = self.speakers.write() {
503 *map = new_speakers;
504 }
505
506 if let Err(e) = cache::save(&devices) {
508 tracing::warn!("Failed to save discovery cache: {}", e);
509 }
510 }
511
512 pub fn speakers(&self) -> Vec<Speaker> {
514 self.speakers
515 .read()
516 .map(|s| s.values().cloned().collect())
517 .unwrap_or_default()
518 }
519
520 pub fn speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
522 let speakers = self.speakers.read().ok()?;
523 speakers.values().find(|s| s.id == *speaker_id).cloned()
524 }
525
526 #[deprecated(since = "0.2.0", note = "renamed to `speaker_by_id()`")]
528 pub fn get_speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
529 self.speaker_by_id(speaker_id)
530 }
531
532 pub fn speaker_names(&self) -> Vec<String> {
534 self.speakers
535 .read()
536 .map(|s| s.keys().cloned().collect())
537 .unwrap_or_default()
538 }
539
540 pub fn state_manager(&self) -> &Arc<StateManager> {
542 &self.state_manager
543 }
544
545 #[cfg(any(feature = "test-support", test))]
557 pub fn state_manager_weak(&self) -> std::sync::Weak<StateManager> {
558 Arc::downgrade(&self.state_manager)
559 }
560
561 pub fn iter(&self) -> sonos_state::ChangeIterator {
595 self.state_manager.iter()
596 }
597
598 fn ensure_topology(&self) {
611 if self.offline || self.state_manager.group_count() > 0 {
612 return;
613 }
614
615 let speaker_ips: Vec<String> = {
616 let speakers = match self.speakers.read() {
617 Ok(s) => s,
618 Err(_) => return,
619 };
620 speakers.values().map(|s| s.ip.to_string()).collect()
621 };
622
623 for speaker_ip in &speaker_ips {
624 let topology_state = match sonos_api::services::zone_group_topology::state::poll(
625 &self.api_client,
626 speaker_ip,
627 ) {
628 Ok(state) => state,
629 Err(e) => {
630 tracing::debug!("Topology fetch failed for {}: {}", speaker_ip, e);
631 continue;
632 }
633 };
634
635 let topology_changes = sonos_state::decode_topology_event(&topology_state);
636
637 for (speaker_id, new_ip) in &topology_changes.speaker_ips {
639 self.state_manager.update_speaker_ip(speaker_id, *new_ip);
640 }
641
642 let topology =
644 Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
645 self.state_manager.initialize(topology);
646
647 self.state_manager
649 .set_satellite_ids(topology_changes.satellite_ids);
650
651 tracing::debug!(
652 "Fetched zone group topology on-demand ({} groups)",
653 self.state_manager.group_count()
654 );
655 return;
656 }
657
658 tracing::warn!("ensure_topology: no speakers responded");
659 }
660
661 pub fn groups(&self) -> Vec<Group> {
681 self.ensure_topology();
682 self.state_manager
683 .groups()
684 .into_iter()
685 .filter_map(|info| {
686 Group::from_info(
687 info,
688 Arc::clone(&self.state_manager),
689 self.api_client.clone(),
690 )
691 })
692 .collect()
693 }
694
695 pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
707 self.ensure_topology();
708 let info = self.state_manager.get_group(group_id)?;
709 Group::from_info(
710 info,
711 Arc::clone(&self.state_manager),
712 self.api_client.clone(),
713 )
714 }
715
716 #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
718 pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
719 self.group_by_id(group_id)
720 }
721
722 pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
739 self.ensure_topology();
740 let info = self.state_manager.get_group_for_speaker(speaker_id)?;
741 Group::from_info(
742 info,
743 Arc::clone(&self.state_manager),
744 self.api_client.clone(),
745 )
746 }
747
748 #[deprecated(
750 since = "0.2.0",
751 note = "use `speaker.group()` or `group_for_speaker()` instead"
752 )]
753 pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
754 self.group_for_speaker(speaker_id)
755 }
756
757 pub fn group(&self, name: &str) -> Option<Group> {
773 self.ensure_topology();
774 self.state_manager
775 .groups()
776 .into_iter()
777 .find(|info| {
778 self.state_manager
779 .speaker_info(&info.coordinator_id)
780 .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
781 })
782 .and_then(|info| {
783 Group::from_info(
784 info,
785 Arc::clone(&self.state_manager),
786 self.api_client.clone(),
787 )
788 })
789 }
790
791 #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
793 pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
794 self.group(name)
795 }
796
797 pub fn create_group(
818 &self,
819 coordinator: &Speaker,
820 members: &[&Speaker],
821 ) -> Result<crate::group::GroupChangeResult, SdkError> {
822 let coord_group = self
823 .group_for_speaker(&coordinator.id)
824 .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
825
826 let mut succeeded = Vec::new();
827 let mut failed = Vec::new();
828
829 for member in members {
830 match coord_group.add_speaker(member) {
831 Ok(()) => succeeded.push(member.id.clone()),
832 Err(e) => failed.push((member.id.clone(), e)),
833 }
834 }
835
836 Ok(crate::group::GroupChangeResult { succeeded, failed })
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use sonos_state::GroupInfo;
844
845 fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
852 SonosSystem::from_devices_offline(devices)
853 }
854
855 #[test]
856 fn test_groups_returns_all_groups() {
857 let devices = vec![
858 Device {
859 id: "RINCON_111".to_string(),
860 name: "Living Room".to_string(),
861 room_name: "Living Room".to_string(),
862 ip_address: "192.168.1.100".to_string(),
863 port: 1400,
864 model_name: "Sonos One".to_string(),
865 },
866 Device {
867 id: "RINCON_222".to_string(),
868 name: "Kitchen".to_string(),
869 room_name: "Kitchen".to_string(),
870 ip_address: "192.168.1.101".to_string(),
871 port: 1400,
872 model_name: "Sonos One".to_string(),
873 },
874 ];
875
876 let system = create_test_system(devices).unwrap();
877
878 let speaker1 = SpeakerId::new("RINCON_111");
880 let speaker2 = SpeakerId::new("RINCON_222");
881 let group1 = GroupInfo::new(
882 GroupId::new("RINCON_111:1"),
883 speaker1.clone(),
884 vec![speaker1.clone()],
885 );
886 let group2 = GroupInfo::new(
887 GroupId::new("RINCON_222:1"),
888 speaker2.clone(),
889 vec![speaker2.clone()],
890 );
891
892 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
893 system.state_manager.initialize(topology);
894
895 let groups = system.groups();
897 assert_eq!(groups.len(), 2);
898
899 let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
900 assert!(group_ids.contains(&"RINCON_111:1".to_string()));
901 assert!(group_ids.contains(&"RINCON_222:1".to_string()));
902 }
903
904 #[test]
905 fn test_groups_returns_empty_when_no_groups() {
906 let devices = vec![Device {
907 id: "RINCON_111".to_string(),
908 name: "Living Room".to_string(),
909 room_name: "Living Room".to_string(),
910 ip_address: "192.168.1.100".to_string(),
911 port: 1400,
912 model_name: "Sonos One".to_string(),
913 }];
914
915 let system = create_test_system(devices).unwrap();
916
917 let groups = system.groups();
919 assert!(groups.is_empty());
920 }
921
922 #[test]
923 fn test_group_by_id_returns_correct_group() {
924 let devices = vec![Device {
925 id: "RINCON_111".to_string(),
926 name: "Living Room".to_string(),
927 room_name: "Living Room".to_string(),
928 ip_address: "192.168.1.100".to_string(),
929 port: 1400,
930 model_name: "Sonos One".to_string(),
931 }];
932
933 let system = create_test_system(devices).unwrap();
934
935 let speaker = SpeakerId::new("RINCON_111");
937 let group_id = GroupId::new("RINCON_111:1");
938 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
939
940 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
941 system.state_manager.initialize(topology);
942
943 let found = system.group_by_id(&group_id);
945 assert!(found.is_some());
946 let found = found.unwrap();
947 assert_eq!(found.id.as_str(), "RINCON_111:1");
948 assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
949 assert_eq!(found.member_ids.len(), 1);
950 }
951
952 #[test]
953 fn test_group_by_id_returns_none_for_unknown() {
954 let devices = vec![Device {
955 id: "RINCON_111".to_string(),
956 name: "Living Room".to_string(),
957 room_name: "Living Room".to_string(),
958 ip_address: "192.168.1.100".to_string(),
959 port: 1400,
960 model_name: "Sonos One".to_string(),
961 }];
962
963 let system = create_test_system(devices).unwrap();
964
965 let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
967 let found = system.group_by_id(&unknown_id);
968 assert!(found.is_none());
969 }
970
971 #[test]
972 fn test_group_for_speaker_returns_correct_group() {
973 let devices = vec![
974 Device {
975 id: "RINCON_111".to_string(),
976 name: "Living Room".to_string(),
977 room_name: "Living Room".to_string(),
978 ip_address: "192.168.1.100".to_string(),
979 port: 1400,
980 model_name: "Sonos One".to_string(),
981 },
982 Device {
983 id: "RINCON_222".to_string(),
984 name: "Kitchen".to_string(),
985 room_name: "Kitchen".to_string(),
986 ip_address: "192.168.1.101".to_string(),
987 port: 1400,
988 model_name: "Sonos One".to_string(),
989 },
990 ];
991
992 let system = create_test_system(devices).unwrap();
993
994 let speaker1 = SpeakerId::new("RINCON_111");
996 let speaker2 = SpeakerId::new("RINCON_222");
997 let group = GroupInfo::new(
998 GroupId::new("RINCON_111:1"),
999 speaker1.clone(),
1000 vec![speaker1.clone(), speaker2.clone()],
1001 );
1002
1003 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1004 system.state_manager.initialize(topology);
1005
1006 let found1 = system.group_for_speaker(&speaker1);
1008 assert!(found1.is_some());
1009 let found1 = found1.unwrap();
1010 assert_eq!(found1.id.as_str(), "RINCON_111:1");
1011 assert_eq!(found1.member_ids.len(), 2);
1012
1013 let found2 = system.group_for_speaker(&speaker2);
1014 assert!(found2.is_some());
1015 let found2 = found2.unwrap();
1016 assert_eq!(found2.id.as_str(), "RINCON_111:1");
1017 assert_eq!(found2.member_ids.len(), 2);
1018 }
1019
1020 #[test]
1021 fn test_group_for_speaker_returns_none_for_unknown() {
1022 let devices = vec![Device {
1023 id: "RINCON_111".to_string(),
1024 name: "Living Room".to_string(),
1025 room_name: "Living Room".to_string(),
1026 ip_address: "192.168.1.100".to_string(),
1027 port: 1400,
1028 model_name: "Sonos One".to_string(),
1029 }];
1030
1031 let system = create_test_system(devices).unwrap();
1032
1033 let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1035 let found = system.group_for_speaker(&unknown_speaker);
1036 assert!(found.is_none());
1037 }
1038
1039 #[test]
1040 fn test_group_methods_consistency() {
1041 let devices = vec![Device {
1042 id: "RINCON_111".to_string(),
1043 name: "Living Room".to_string(),
1044 room_name: "Living Room".to_string(),
1045 ip_address: "192.168.1.100".to_string(),
1046 port: 1400,
1047 model_name: "Sonos One".to_string(),
1048 }];
1049
1050 let system = create_test_system(devices).unwrap();
1051
1052 let speaker = SpeakerId::new("RINCON_111");
1054 let group_id = GroupId::new("RINCON_111:1");
1055 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1056
1057 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1058 system.state_manager.initialize(topology);
1059
1060 let groups = system.groups();
1062 assert_eq!(groups.len(), 1);
1063
1064 let by_id = system.group_by_id(&group_id);
1065 assert!(by_id.is_some());
1066
1067 let by_speaker = system.group_for_speaker(&speaker);
1068 assert!(by_speaker.is_some());
1069
1070 assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
1072 assert_eq!(
1073 groups[0].id.as_str(),
1074 by_speaker.as_ref().unwrap().id.as_str()
1075 );
1076 assert_eq!(
1077 groups[0].coordinator_id.as_str(),
1078 by_id.as_ref().unwrap().coordinator_id.as_str()
1079 );
1080 assert_eq!(
1081 groups[0].coordinator_id.as_str(),
1082 by_speaker.as_ref().unwrap().coordinator_id.as_str()
1083 );
1084 }
1085
1086 #[test]
1087 fn test_group_by_name_returns_correct_group() {
1088 let devices = vec![
1089 Device {
1090 id: "RINCON_111".to_string(),
1091 name: "Living Room".to_string(),
1092 room_name: "Living Room".to_string(),
1093 ip_address: "192.168.1.100".to_string(),
1094 port: 1400,
1095 model_name: "Sonos One".to_string(),
1096 },
1097 Device {
1098 id: "RINCON_222".to_string(),
1099 name: "Kitchen".to_string(),
1100 room_name: "Kitchen".to_string(),
1101 ip_address: "192.168.1.101".to_string(),
1102 port: 1400,
1103 model_name: "Sonos One".to_string(),
1104 },
1105 ];
1106
1107 let system = create_test_system(devices).unwrap();
1108
1109 let speaker1 = SpeakerId::new("RINCON_111");
1110 let speaker2 = SpeakerId::new("RINCON_222");
1111 let group1 = GroupInfo::new(
1112 GroupId::new("RINCON_111:1"),
1113 speaker1.clone(),
1114 vec![speaker1.clone()],
1115 );
1116 let group2 = GroupInfo::new(
1117 GroupId::new("RINCON_222:1"),
1118 speaker2.clone(),
1119 vec![speaker2.clone()],
1120 );
1121
1122 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
1123 system.state_manager.initialize(topology);
1124
1125 let found = system.group("Living Room");
1127 assert!(found.is_some());
1128 assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
1129
1130 let found = system.group("Kitchen");
1131 assert!(found.is_some());
1132 assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
1133
1134 assert!(system.group("Nonexistent").is_none());
1136 }
1137
1138 #[allow(dead_code)]
1146 fn _assert_create_group_signature(
1147 system: &SonosSystem,
1148 coordinator: &Speaker,
1149 member: &Speaker,
1150 ) {
1151 fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1152
1153 assert_change_result(system.create_group(coordinator, &[member]));
1154 }
1155
1156 #[test]
1164 fn test_from_devices_offline_makes_no_network_calls() {
1165 let devices = vec![Device {
1166 id: "RINCON_111".to_string(),
1167 name: "Living Room".to_string(),
1168 room_name: "Living Room".to_string(),
1169 ip_address: "203.0.113.1".to_string(),
1170 port: 1400,
1171 model_name: "Sonos One".to_string(),
1172 }];
1173
1174 let start = std::time::Instant::now();
1175 let system = SonosSystem::from_devices_offline(devices).unwrap();
1176 assert!(system.speaker("Living Room").is_some());
1177 assert!(system.speaker("Nonexistent").is_none());
1178 assert!(system.groups().is_empty());
1179 let elapsed = start.elapsed();
1180
1181 assert!(
1182 elapsed < Duration::from_millis(500),
1183 "offline construction and lookups should not touch the network, took {elapsed:?}"
1184 );
1185 }
1186
1187 #[test]
1197 fn test_dropping_system_releases_state_manager() {
1198 let devices = vec![Device {
1199 id: "RINCON_111".to_string(),
1200 name: "Living Room".to_string(),
1201 room_name: "Living Room".to_string(),
1202 ip_address: "203.0.113.1".to_string(),
1203 port: 1400,
1204 model_name: "Sonos One".to_string(),
1205 }];
1206
1207 let system = SonosSystem::from_devices_offline(devices).unwrap();
1208 let weak = system.state_manager_weak();
1209
1210 assert!(weak.upgrade().is_some());
1214
1215 drop(system);
1216
1217 assert_eq!(
1221 weak.strong_count(),
1222 0,
1223 "StateManager outlived its SonosSystem — the event-init closure is \
1224 holding a strong Arc to the manager that stores it"
1225 );
1226 assert!(weak.upgrade().is_none());
1227 }
1228
1229 #[test]
1236 fn test_dropping_system_after_watch_releases_state_manager() {
1237 let devices = vec![Device {
1238 id: "RINCON_111".to_string(),
1239 name: "Living Room".to_string(),
1240 room_name: "Living Room".to_string(),
1241 ip_address: "203.0.113.1".to_string(),
1242 port: 1400,
1243 model_name: "Sonos One".to_string(),
1244 }];
1245
1246 let system = SonosSystem::from_devices_offline(devices).unwrap();
1247 let weak = system.state_manager_weak();
1248
1249 {
1250 let speaker = system.speaker("Living Room").unwrap();
1251 let _watch = speaker.volume.watch().unwrap();
1255 }
1256
1257 drop(system);
1258
1259 assert!(
1260 weak.upgrade().is_none(),
1261 "StateManager outlived its SonosSystem after watch() ran the lazy \
1262 event-init closure"
1263 );
1264 }
1265
1266 #[test]
1267 fn test_display_name_prefers_room_name() {
1268 let device = Device {
1269 id: "RINCON_111".to_string(),
1270 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1271 room_name: "Kitchen".to_string(),
1272 ip_address: "192.168.1.100".to_string(),
1273 port: 1400,
1274 model_name: "Sonos One".to_string(),
1275 };
1276 assert_eq!(display_name(&device), "Kitchen");
1277 }
1278
1279 #[test]
1280 fn test_display_name_falls_back_to_friendly_name() {
1281 let device = Device {
1282 id: "RINCON_111".to_string(),
1283 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1284 room_name: "Unknown".to_string(),
1285 ip_address: "192.168.1.100".to_string(),
1286 port: 1400,
1287 model_name: "Sonos One".to_string(),
1288 };
1289 assert_eq!(
1290 display_name(&device),
1291 "192.168.1.100 - Sonos One - RINCON_111"
1292 );
1293
1294 let device_empty = Device {
1295 id: "RINCON_222".to_string(),
1296 name: "192.168.1.101 - Sonos One".to_string(),
1297 room_name: "".to_string(),
1298 ip_address: "192.168.1.101".to_string(),
1299 port: 1400,
1300 model_name: "Sonos One".to_string(),
1301 };
1302 assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1303 }
1304
1305 #[test]
1306 fn test_speaker_lookup_case_insensitive() {
1307 let devices = vec![Device {
1308 id: "RINCON_111".to_string(),
1309 name: "Kitchen".to_string(),
1310 room_name: "Kitchen".to_string(),
1311 ip_address: "192.168.1.100".to_string(),
1312 port: 1400,
1313 model_name: "Sonos One".to_string(),
1314 }];
1315 let system = create_test_system(devices).unwrap();
1316 assert!(system.speaker("Kitchen").is_some());
1317 assert!(system.speaker("kitchen").is_some());
1318 assert!(system.speaker("KITCHEN").is_some());
1319 assert!(system.speaker("Nonexistent").is_none());
1320 }
1321
1322 #[test]
1323 fn test_speaker_uses_room_name() {
1324 let devices = vec![Device {
1325 id: "RINCON_111".to_string(),
1326 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1327 room_name: "Kitchen".to_string(),
1328 ip_address: "192.168.1.100".to_string(),
1329 port: 1400,
1330 model_name: "Sonos One".to_string(),
1331 }];
1332
1333 let system = create_test_system(devices).unwrap();
1334 let spk = system.speaker("Kitchen");
1335 assert!(spk.is_some());
1336 assert_eq!(spk.unwrap().name, "Kitchen");
1337
1338 assert!(system
1340 .speaker("192.168.1.100 - Sonos One - RINCON_111")
1341 .is_none());
1342 }
1343
1344 #[test]
1345 fn test_group_lookup_case_insensitive() {
1346 let devices = vec![Device {
1347 id: "RINCON_111".to_string(),
1348 name: "Living Room".to_string(),
1349 room_name: "Living Room".to_string(),
1350 ip_address: "192.168.1.100".to_string(),
1351 port: 1400,
1352 model_name: "Sonos One".to_string(),
1353 }];
1354
1355 let system = create_test_system(devices).unwrap();
1356
1357 let speaker = SpeakerId::new("RINCON_111");
1358 let group = GroupInfo::new(
1359 GroupId::new("RINCON_111:1"),
1360 speaker.clone(),
1361 vec![speaker.clone()],
1362 );
1363
1364 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1365 system.state_manager.initialize(topology);
1366
1367 assert!(system.group("Living Room").is_some());
1368 assert!(system.group("living room").is_some());
1369 assert!(system.group("LIVING ROOM").is_some());
1370 assert!(system.group("Nonexistent").is_none());
1371 }
1372}