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 {
578 self.state_manager.iter()
579 }
580
581 fn ensure_topology(&self) {
594 if self.offline || self.state_manager.group_count() > 0 {
595 return;
596 }
597
598 let speaker_ips: Vec<String> = {
599 let speakers = match self.speakers.read() {
600 Ok(s) => s,
601 Err(_) => return,
602 };
603 speakers.values().map(|s| s.ip.to_string()).collect()
604 };
605
606 for speaker_ip in &speaker_ips {
607 let topology_state = match sonos_api::services::zone_group_topology::state::poll(
608 &self.api_client,
609 speaker_ip,
610 ) {
611 Ok(state) => state,
612 Err(e) => {
613 tracing::debug!("Topology fetch failed for {}: {}", speaker_ip, e);
614 continue;
615 }
616 };
617
618 let topology_changes = sonos_state::decode_topology_event(&topology_state);
619
620 for (speaker_id, new_ip) in &topology_changes.speaker_ips {
622 self.state_manager.update_speaker_ip(speaker_id, *new_ip);
623 }
624
625 let topology =
627 Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
628 self.state_manager.initialize(topology);
629
630 self.state_manager
632 .set_satellite_ids(topology_changes.satellite_ids);
633
634 tracing::debug!(
635 "Fetched zone group topology on-demand ({} groups)",
636 self.state_manager.group_count()
637 );
638 return;
639 }
640
641 tracing::warn!("ensure_topology: no speakers responded");
642 }
643
644 pub fn groups(&self) -> Vec<Group> {
664 self.ensure_topology();
665 self.state_manager
666 .groups()
667 .into_iter()
668 .filter_map(|info| {
669 Group::from_info(
670 info,
671 Arc::clone(&self.state_manager),
672 self.api_client.clone(),
673 )
674 })
675 .collect()
676 }
677
678 pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
690 self.ensure_topology();
691 let info = self.state_manager.get_group(group_id)?;
692 Group::from_info(
693 info,
694 Arc::clone(&self.state_manager),
695 self.api_client.clone(),
696 )
697 }
698
699 #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
701 pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
702 self.group_by_id(group_id)
703 }
704
705 pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
722 self.ensure_topology();
723 let info = self.state_manager.get_group_for_speaker(speaker_id)?;
724 Group::from_info(
725 info,
726 Arc::clone(&self.state_manager),
727 self.api_client.clone(),
728 )
729 }
730
731 #[deprecated(
733 since = "0.2.0",
734 note = "use `speaker.group()` or `group_for_speaker()` instead"
735 )]
736 pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
737 self.group_for_speaker(speaker_id)
738 }
739
740 pub fn group(&self, name: &str) -> Option<Group> {
756 self.ensure_topology();
757 self.state_manager
758 .groups()
759 .into_iter()
760 .find(|info| {
761 self.state_manager
762 .speaker_info(&info.coordinator_id)
763 .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
764 })
765 .and_then(|info| {
766 Group::from_info(
767 info,
768 Arc::clone(&self.state_manager),
769 self.api_client.clone(),
770 )
771 })
772 }
773
774 #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
776 pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
777 self.group(name)
778 }
779
780 pub fn create_group(
801 &self,
802 coordinator: &Speaker,
803 members: &[&Speaker],
804 ) -> Result<crate::group::GroupChangeResult, SdkError> {
805 let coord_group = self
806 .group_for_speaker(&coordinator.id)
807 .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
808
809 let mut succeeded = Vec::new();
810 let mut failed = Vec::new();
811
812 for member in members {
813 match coord_group.add_speaker(member) {
814 Ok(()) => succeeded.push(member.id.clone()),
815 Err(e) => failed.push((member.id.clone(), e)),
816 }
817 }
818
819 Ok(crate::group::GroupChangeResult { succeeded, failed })
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use sonos_state::GroupInfo;
827
828 fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
835 SonosSystem::from_devices_offline(devices)
836 }
837
838 #[test]
839 fn test_groups_returns_all_groups() {
840 let devices = vec![
841 Device {
842 id: "RINCON_111".to_string(),
843 name: "Living Room".to_string(),
844 room_name: "Living Room".to_string(),
845 ip_address: "192.168.1.100".to_string(),
846 port: 1400,
847 model_name: "Sonos One".to_string(),
848 },
849 Device {
850 id: "RINCON_222".to_string(),
851 name: "Kitchen".to_string(),
852 room_name: "Kitchen".to_string(),
853 ip_address: "192.168.1.101".to_string(),
854 port: 1400,
855 model_name: "Sonos One".to_string(),
856 },
857 ];
858
859 let system = create_test_system(devices).unwrap();
860
861 let speaker1 = SpeakerId::new("RINCON_111");
863 let speaker2 = SpeakerId::new("RINCON_222");
864 let group1 = GroupInfo::new(
865 GroupId::new("RINCON_111:1"),
866 speaker1.clone(),
867 vec![speaker1.clone()],
868 );
869 let group2 = GroupInfo::new(
870 GroupId::new("RINCON_222:1"),
871 speaker2.clone(),
872 vec![speaker2.clone()],
873 );
874
875 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
876 system.state_manager.initialize(topology);
877
878 let groups = system.groups();
880 assert_eq!(groups.len(), 2);
881
882 let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
883 assert!(group_ids.contains(&"RINCON_111:1".to_string()));
884 assert!(group_ids.contains(&"RINCON_222:1".to_string()));
885 }
886
887 #[test]
888 fn test_groups_returns_empty_when_no_groups() {
889 let devices = vec![Device {
890 id: "RINCON_111".to_string(),
891 name: "Living Room".to_string(),
892 room_name: "Living Room".to_string(),
893 ip_address: "192.168.1.100".to_string(),
894 port: 1400,
895 model_name: "Sonos One".to_string(),
896 }];
897
898 let system = create_test_system(devices).unwrap();
899
900 let groups = system.groups();
902 assert!(groups.is_empty());
903 }
904
905 #[test]
906 fn test_group_by_id_returns_correct_group() {
907 let devices = vec![Device {
908 id: "RINCON_111".to_string(),
909 name: "Living Room".to_string(),
910 room_name: "Living Room".to_string(),
911 ip_address: "192.168.1.100".to_string(),
912 port: 1400,
913 model_name: "Sonos One".to_string(),
914 }];
915
916 let system = create_test_system(devices).unwrap();
917
918 let speaker = SpeakerId::new("RINCON_111");
920 let group_id = GroupId::new("RINCON_111:1");
921 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
922
923 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
924 system.state_manager.initialize(topology);
925
926 let found = system.group_by_id(&group_id);
928 assert!(found.is_some());
929 let found = found.unwrap();
930 assert_eq!(found.id.as_str(), "RINCON_111:1");
931 assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
932 assert_eq!(found.member_ids.len(), 1);
933 }
934
935 #[test]
936 fn test_group_by_id_returns_none_for_unknown() {
937 let devices = vec![Device {
938 id: "RINCON_111".to_string(),
939 name: "Living Room".to_string(),
940 room_name: "Living Room".to_string(),
941 ip_address: "192.168.1.100".to_string(),
942 port: 1400,
943 model_name: "Sonos One".to_string(),
944 }];
945
946 let system = create_test_system(devices).unwrap();
947
948 let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
950 let found = system.group_by_id(&unknown_id);
951 assert!(found.is_none());
952 }
953
954 #[test]
955 fn test_group_for_speaker_returns_correct_group() {
956 let devices = vec![
957 Device {
958 id: "RINCON_111".to_string(),
959 name: "Living Room".to_string(),
960 room_name: "Living Room".to_string(),
961 ip_address: "192.168.1.100".to_string(),
962 port: 1400,
963 model_name: "Sonos One".to_string(),
964 },
965 Device {
966 id: "RINCON_222".to_string(),
967 name: "Kitchen".to_string(),
968 room_name: "Kitchen".to_string(),
969 ip_address: "192.168.1.101".to_string(),
970 port: 1400,
971 model_name: "Sonos One".to_string(),
972 },
973 ];
974
975 let system = create_test_system(devices).unwrap();
976
977 let speaker1 = SpeakerId::new("RINCON_111");
979 let speaker2 = SpeakerId::new("RINCON_222");
980 let group = GroupInfo::new(
981 GroupId::new("RINCON_111:1"),
982 speaker1.clone(),
983 vec![speaker1.clone(), speaker2.clone()],
984 );
985
986 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
987 system.state_manager.initialize(topology);
988
989 let found1 = system.group_for_speaker(&speaker1);
991 assert!(found1.is_some());
992 let found1 = found1.unwrap();
993 assert_eq!(found1.id.as_str(), "RINCON_111:1");
994 assert_eq!(found1.member_ids.len(), 2);
995
996 let found2 = system.group_for_speaker(&speaker2);
997 assert!(found2.is_some());
998 let found2 = found2.unwrap();
999 assert_eq!(found2.id.as_str(), "RINCON_111:1");
1000 assert_eq!(found2.member_ids.len(), 2);
1001 }
1002
1003 #[test]
1004 fn test_group_for_speaker_returns_none_for_unknown() {
1005 let devices = vec![Device {
1006 id: "RINCON_111".to_string(),
1007 name: "Living Room".to_string(),
1008 room_name: "Living Room".to_string(),
1009 ip_address: "192.168.1.100".to_string(),
1010 port: 1400,
1011 model_name: "Sonos One".to_string(),
1012 }];
1013
1014 let system = create_test_system(devices).unwrap();
1015
1016 let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1018 let found = system.group_for_speaker(&unknown_speaker);
1019 assert!(found.is_none());
1020 }
1021
1022 #[test]
1023 fn test_group_methods_consistency() {
1024 let devices = vec![Device {
1025 id: "RINCON_111".to_string(),
1026 name: "Living Room".to_string(),
1027 room_name: "Living Room".to_string(),
1028 ip_address: "192.168.1.100".to_string(),
1029 port: 1400,
1030 model_name: "Sonos One".to_string(),
1031 }];
1032
1033 let system = create_test_system(devices).unwrap();
1034
1035 let speaker = SpeakerId::new("RINCON_111");
1037 let group_id = GroupId::new("RINCON_111:1");
1038 let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1039
1040 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1041 system.state_manager.initialize(topology);
1042
1043 let groups = system.groups();
1045 assert_eq!(groups.len(), 1);
1046
1047 let by_id = system.group_by_id(&group_id);
1048 assert!(by_id.is_some());
1049
1050 let by_speaker = system.group_for_speaker(&speaker);
1051 assert!(by_speaker.is_some());
1052
1053 assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
1055 assert_eq!(
1056 groups[0].id.as_str(),
1057 by_speaker.as_ref().unwrap().id.as_str()
1058 );
1059 assert_eq!(
1060 groups[0].coordinator_id.as_str(),
1061 by_id.as_ref().unwrap().coordinator_id.as_str()
1062 );
1063 assert_eq!(
1064 groups[0].coordinator_id.as_str(),
1065 by_speaker.as_ref().unwrap().coordinator_id.as_str()
1066 );
1067 }
1068
1069 #[test]
1070 fn test_group_by_name_returns_correct_group() {
1071 let devices = vec![
1072 Device {
1073 id: "RINCON_111".to_string(),
1074 name: "Living Room".to_string(),
1075 room_name: "Living Room".to_string(),
1076 ip_address: "192.168.1.100".to_string(),
1077 port: 1400,
1078 model_name: "Sonos One".to_string(),
1079 },
1080 Device {
1081 id: "RINCON_222".to_string(),
1082 name: "Kitchen".to_string(),
1083 room_name: "Kitchen".to_string(),
1084 ip_address: "192.168.1.101".to_string(),
1085 port: 1400,
1086 model_name: "Sonos One".to_string(),
1087 },
1088 ];
1089
1090 let system = create_test_system(devices).unwrap();
1091
1092 let speaker1 = SpeakerId::new("RINCON_111");
1093 let speaker2 = SpeakerId::new("RINCON_222");
1094 let group1 = GroupInfo::new(
1095 GroupId::new("RINCON_111:1"),
1096 speaker1.clone(),
1097 vec![speaker1.clone()],
1098 );
1099 let group2 = GroupInfo::new(
1100 GroupId::new("RINCON_222:1"),
1101 speaker2.clone(),
1102 vec![speaker2.clone()],
1103 );
1104
1105 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
1106 system.state_manager.initialize(topology);
1107
1108 let found = system.group("Living Room");
1110 assert!(found.is_some());
1111 assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
1112
1113 let found = system.group("Kitchen");
1114 assert!(found.is_some());
1115 assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
1116
1117 assert!(system.group("Nonexistent").is_none());
1119 }
1120
1121 #[allow(dead_code)]
1129 fn _assert_create_group_signature(
1130 system: &SonosSystem,
1131 coordinator: &Speaker,
1132 member: &Speaker,
1133 ) {
1134 fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1135
1136 assert_change_result(system.create_group(coordinator, &[member]));
1137 }
1138
1139 #[test]
1147 fn test_from_devices_offline_makes_no_network_calls() {
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: "203.0.113.1".to_string(),
1153 port: 1400,
1154 model_name: "Sonos One".to_string(),
1155 }];
1156
1157 let start = std::time::Instant::now();
1158 let system = SonosSystem::from_devices_offline(devices).unwrap();
1159 assert!(system.speaker("Living Room").is_some());
1160 assert!(system.speaker("Nonexistent").is_none());
1161 assert!(system.groups().is_empty());
1162 let elapsed = start.elapsed();
1163
1164 assert!(
1165 elapsed < Duration::from_millis(500),
1166 "offline construction and lookups should not touch the network, took {elapsed:?}"
1167 );
1168 }
1169
1170 #[test]
1180 fn test_dropping_system_releases_state_manager() {
1181 let devices = vec![Device {
1182 id: "RINCON_111".to_string(),
1183 name: "Living Room".to_string(),
1184 room_name: "Living Room".to_string(),
1185 ip_address: "203.0.113.1".to_string(),
1186 port: 1400,
1187 model_name: "Sonos One".to_string(),
1188 }];
1189
1190 let system = SonosSystem::from_devices_offline(devices).unwrap();
1191 let weak = system.state_manager_weak();
1192
1193 assert!(weak.upgrade().is_some());
1197
1198 drop(system);
1199
1200 assert_eq!(
1204 weak.strong_count(),
1205 0,
1206 "StateManager outlived its SonosSystem — the event-init closure is \
1207 holding a strong Arc to the manager that stores it"
1208 );
1209 assert!(weak.upgrade().is_none());
1210 }
1211
1212 #[test]
1219 fn test_dropping_system_after_watch_releases_state_manager() {
1220 let devices = vec![Device {
1221 id: "RINCON_111".to_string(),
1222 name: "Living Room".to_string(),
1223 room_name: "Living Room".to_string(),
1224 ip_address: "203.0.113.1".to_string(),
1225 port: 1400,
1226 model_name: "Sonos One".to_string(),
1227 }];
1228
1229 let system = SonosSystem::from_devices_offline(devices).unwrap();
1230 let weak = system.state_manager_weak();
1231
1232 {
1233 let speaker = system.speaker("Living Room").unwrap();
1234 let _watch = speaker.volume.watch().unwrap();
1238 }
1239
1240 drop(system);
1241
1242 assert!(
1243 weak.upgrade().is_none(),
1244 "StateManager outlived its SonosSystem after watch() ran the lazy \
1245 event-init closure"
1246 );
1247 }
1248
1249 #[test]
1250 fn test_display_name_prefers_room_name() {
1251 let device = Device {
1252 id: "RINCON_111".to_string(),
1253 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1254 room_name: "Kitchen".to_string(),
1255 ip_address: "192.168.1.100".to_string(),
1256 port: 1400,
1257 model_name: "Sonos One".to_string(),
1258 };
1259 assert_eq!(display_name(&device), "Kitchen");
1260 }
1261
1262 #[test]
1263 fn test_display_name_falls_back_to_friendly_name() {
1264 let device = Device {
1265 id: "RINCON_111".to_string(),
1266 name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1267 room_name: "Unknown".to_string(),
1268 ip_address: "192.168.1.100".to_string(),
1269 port: 1400,
1270 model_name: "Sonos One".to_string(),
1271 };
1272 assert_eq!(
1273 display_name(&device),
1274 "192.168.1.100 - Sonos One - RINCON_111"
1275 );
1276
1277 let device_empty = Device {
1278 id: "RINCON_222".to_string(),
1279 name: "192.168.1.101 - Sonos One".to_string(),
1280 room_name: "".to_string(),
1281 ip_address: "192.168.1.101".to_string(),
1282 port: 1400,
1283 model_name: "Sonos One".to_string(),
1284 };
1285 assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1286 }
1287
1288 #[test]
1289 fn test_speaker_lookup_case_insensitive() {
1290 let devices = vec![Device {
1291 id: "RINCON_111".to_string(),
1292 name: "Kitchen".to_string(),
1293 room_name: "Kitchen".to_string(),
1294 ip_address: "192.168.1.100".to_string(),
1295 port: 1400,
1296 model_name: "Sonos One".to_string(),
1297 }];
1298 let system = create_test_system(devices).unwrap();
1299 assert!(system.speaker("Kitchen").is_some());
1300 assert!(system.speaker("kitchen").is_some());
1301 assert!(system.speaker("KITCHEN").is_some());
1302 assert!(system.speaker("Nonexistent").is_none());
1303 }
1304
1305 #[test]
1306 fn test_speaker_uses_room_name() {
1307 let devices = vec![Device {
1308 id: "RINCON_111".to_string(),
1309 name: "192.168.1.100 - Sonos One - RINCON_111".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
1316 let system = create_test_system(devices).unwrap();
1317 let spk = system.speaker("Kitchen");
1318 assert!(spk.is_some());
1319 assert_eq!(spk.unwrap().name, "Kitchen");
1320
1321 assert!(system
1323 .speaker("192.168.1.100 - Sonos One - RINCON_111")
1324 .is_none());
1325 }
1326
1327 #[test]
1328 fn test_group_lookup_case_insensitive() {
1329 let devices = vec![Device {
1330 id: "RINCON_111".to_string(),
1331 name: "Living Room".to_string(),
1332 room_name: "Living Room".to_string(),
1333 ip_address: "192.168.1.100".to_string(),
1334 port: 1400,
1335 model_name: "Sonos One".to_string(),
1336 }];
1337
1338 let system = create_test_system(devices).unwrap();
1339
1340 let speaker = SpeakerId::new("RINCON_111");
1341 let group = GroupInfo::new(
1342 GroupId::new("RINCON_111:1"),
1343 speaker.clone(),
1344 vec![speaker.clone()],
1345 );
1346
1347 let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1348 system.state_manager.initialize(topology);
1349
1350 assert!(system.group("Living Room").is_some());
1351 assert!(system.group("living room").is_some());
1352 assert!(system.group("LIVING ROOM").is_some());
1353 assert!(system.group("Nonexistent").is_none());
1354 }
1355}