Skip to main content

sonos_sdk/
system.rs

1//! SonosSystem - Main entry point for the SDK
2//!
3//! Provides a sync-first, DOM-like API for controlling Sonos devices.
4
5use 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
20/// Compute the display name for a device.
21///
22/// Prefers `room_name` (user-assigned in the Sonos app, e.g., "Kitchen").
23/// Falls back to `name` (UPnP `friendlyName`) when `room_name` is absent or unknown.
24fn 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
32/// Find a speaker by name with case-insensitive fallback.
33///
34/// Tries an exact O(1) HashMap lookup first, then falls back to
35/// case-insensitive iteration (O(n), typically n < 50).
36fn 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
46/// Main system entry point - provides DOM-like API
47///
48/// SonosSystem is fully synchronous - no async/await required.
49///
50/// # Example
51///
52/// ```rust,ignore
53/// use sonos_sdk::SonosSystem;
54///
55/// fn main() -> Result<(), sonos_sdk::SdkError> {
56///     let system = SonosSystem::new()?;
57///
58///     // Get speaker by name
59///     let speaker = system.speaker("Living Room")
60///         .ok_or_else(|| sonos_sdk::SdkError::SpeakerNotFound("Living Room".to_string()))?;
61///
62///     // Three methods on each property:
63///     let volume = speaker.volume.get();              // Get cached value
64///     let fresh_volume = speaker.volume.fetch()?;     // API call + update cache
65///     let current = speaker.volume.watch()?;          // Start watching for changes
66///
67///     // Iterate over changes
68///     for event in system.iter() {
69///         println!("Property changed: {:?}", event);
70///     }
71///
72///     Ok(())
73/// }
74/// ```
75pub struct SonosSystem {
76    /// State manager for property values.
77    ///
78    /// Also the sole owner of the lazily-created `SonosEventManager`, which it
79    /// holds in a `OnceLock`. `SonosSystem` deliberately keeps no second handle:
80    /// the field that used to sit here claimed to be "kept alive here to prevent
81    /// the Arc from being dropped" but was permanently `None`, because the
82    /// `Arc::try_unwrap` that populated it could never succeed while the
83    /// init closure held the other reference. Since `state_manager` outlives
84    /// every `watch()` anyway, one owner is all that was ever needed.
85    state_manager: Arc<StateManager>,
86
87    /// API client for direct operations
88    api_client: SonosClient,
89
90    /// Speaker handles by name
91    speakers: RwLock<HashMap<String, Speaker>>,
92
93    /// Timestamp of last rediscovery attempt (seconds since UNIX_EPOCH, 0 = never)
94    last_rediscovery: AtomicU64,
95
96    /// When true, this system never touches the network on its own: topology
97    /// prefetch (`ensure_topology`) and lookup-miss rediscovery
98    /// (`try_rediscover`) both become no-ops.
99    ///
100    /// Set by the test constructors only; production paths leave it `false` so
101    /// behavior is unchanged.
102    offline: bool,
103}
104
105const REDISCOVERY_COOLDOWN_SECS: u64 = 30;
106
107impl SonosSystem {
108    /// Create a new SonosSystem with cache-first device discovery (sync)
109    ///
110    /// Discovery strategy:
111    /// 1. Try loading cached devices from disk (~/.cache/sonos/cache.json)
112    /// 2. If cache is fresh (< 24h), use cached devices
113    /// 3. If cache is stale, run SSDP; fall back to stale cache if SSDP finds nothing
114    /// 4. If no cache exists, run SSDP discovery
115    /// 5. If no devices found anywhere, return `Err(SdkError::DiscoveryFailed)`
116    pub fn new() -> Result<Self, SdkError> {
117        let devices = match cache::load() {
118            Some(cached) if !cache::is_stale(&cached) => {
119                // Fresh cache — use directly
120                cached.devices
121            }
122            Some(cached) => {
123                // Stale cache — try SSDP, fall back to stale data
124                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                // No cache — full SSDP discovery
137                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    /// Create a new SonosSystem from pre-discovered devices (sync)
154    ///
155    /// Internal constructor used by `new()` and SDK unit tests.
156    /// Also available publicly when the `test-support` feature is enabled
157    /// (for integration tests and downstream test code).
158    #[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    /// Create a new SonosSystem from pre-discovered devices (sync)
164    ///
165    /// Available publicly for integration tests when `test-support` is enabled.
166    /// Normal consumers should use [`SonosSystem::new()`] instead.
167    #[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    /// Create a SonosSystem from pre-discovered devices WITHOUT any network I/O.
173    ///
174    /// Identical to the normal constructor except that it skips the topology
175    /// prefetch (and the satellite filtering / IP refresh that depend on it),
176    /// and marks the system `offline` so a lookup miss cannot trigger SSDP
177    /// rediscovery.
178    ///
179    /// Exists because the two network paths in the normal constructor
180    /// (topology SOAP poll, rediscovery SSDP) dominate test wall time: each
181    /// unreachable speaker IP costs a 5s connect + 10s read timeout, and a
182    /// single lookup miss costs a 3s SSDP sweep. Tests that only exercise
183    /// in-memory bookkeeping should pay none of that.
184    ///
185    /// Only available when the `test-support` feature is enabled (or when
186    /// compiling this crate's own test harness).
187    #[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        // Prefetch topology before any subscriptions can start.
196        // This ensures group structure is known when the first AVTransport
197        // events arrive, so PerCoordinator suppression/propagation works
198        // from the very first event.
199        system.ensure_topology();
200
201        // Filter satellite speakers (surrounds/subs marked Invisible="1").
202        // Depends on topology having been fetched above.
203        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        // Refresh Speaker handle IPs from state store (topology may have updated them)
212        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    /// Build the in-memory system: state manager, lazy event-init closure,
224    /// API client and Speaker handles. Performs no network I/O.
225    ///
226    /// Shared by [`Self::from_devices_inner`] and [`Self::from_devices_offline`]
227    /// so the Arc wiring below has exactly one definition.
228    ///
229    /// # Why the closure holds a `Weak<StateManager>`
230    ///
231    /// The closure below is *stored on the very `StateManager` it needs to call*
232    /// (`set_event_init` puts it in a `OnceLock` on the manager). Capturing a
233    /// strong `Arc<StateManager>` therefore closed a reference cycle: manager →
234    /// `OnceLock<EventInitFn>` → closure → manager. Neither end could ever reach
235    /// zero, so dropping a `SonosSystem` freed nothing — a measured
236    /// `Arc::strong_count` of 2 after `drop(system)` where 1 was expected. Each
237    /// construction permanently leaked the `StateManager`, its `StateStore`, the
238    /// event-worker thread, the `SonosEventManager` with its tokio runtime, and
239    /// the callback server's UDP/TCP socket.
240    ///
241    /// A `Weak` breaks the cycle without changing the happy path: while the
242    /// system is alive the upgrade always succeeds, and the only way it can fail
243    /// is a `watch()` racing teardown, where doing nothing is exactly right.
244    fn assemble(devices: Vec<Device>, offline: bool) -> Result<Self, SdkError> {
245        // 1. Create shared state FIRST — no event manager yet (lazy init)
246        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        // 2. Build init closure and store on StateManager (single source of truth)
254        let init_fn: EventInitFn = {
255            // Serializes concurrent first-`watch()` calls so at most one
256            // SonosEventManager is ever constructed. `set_event_manager` is
257            // itself idempotent, but without this lock a race would still bind
258            // two callback sockets and spawn two runtimes before one lost.
259            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                    // A failed upgrade means the SonosSystem is being torn down
271                    // while a watch() is in flight. There is nothing left to
272                    // wire an event manager into, so decline quietly rather than
273                    // building a runtime and a socket for a dead system.
274                    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                    // The StateManager owns the only lasting reference, in its
287                    // own OnceLock. SonosSystem deliberately keeps none: a
288                    // second copy of this handle bought nothing and previously
289                    // pretended to be the thing keeping it alive.
290                    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        // 3. Build speakers (init fn is on StateManager — no per-speaker threading needed)
300        let speakers = Self::build_speakers(&devices, &state_manager, &api_client)?;
301
302        // 4. Assemble struct from the SAME Arcs
303        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    /// Create a test SonosSystem with named speakers and no network access.
313    ///
314    /// Builds an in-memory system with synthetic speaker data. No SSDP discovery,
315    /// no event manager socket binding, no cache reads. Speakers get sequential
316    /// IPs starting at `192.168.1.100`.
317    ///
318    /// Only available when the `test-support` feature is enabled.
319    ///
320    /// # Example
321    ///
322    /// ```rust,ignore
323    /// let system = SonosSystem::with_speakers(&["Kitchen", "Bedroom"]);
324    /// assert_eq!(system.speakers().len(), 2);
325    /// assert!(system.speaker("Kitchen").is_some());
326    /// ```
327    #[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    /// Create a test SonosSystem with speakers AND group topology.
363    ///
364    /// Each speaker gets a standalone group (coordinator = self, members = [self]).
365    /// This makes `system.groups()` and `system.group("name")` work in tests.
366    ///
367    /// # Example
368    ///
369    /// ```rust,ignore
370    /// let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
371    /// assert_eq!(system.groups().len(), 2);
372    /// assert!(system.group("Kitchen").is_some());
373    /// ```
374    #[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    /// Build Speaker handles from a list of devices.
395    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    /// Get speaker by name (sync)
430    ///
431    /// If the speaker isn't in the current map, triggers an SSDP
432    /// rediscovery (rate-limited to once per 30s) before returning `None`.
433    ///
434    /// # Example
435    ///
436    /// ```rust,ignore
437    /// let kitchen = sonos.speaker("Kitchen").unwrap();
438    /// kitchen.play()?;
439    /// ```
440    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        // Not found — try rediscovery (cooldown-limited)
448        self.try_rediscover(name);
449        let speakers = self.speakers.read().ok()?;
450        find_speaker_by_name(&speakers, name)
451    }
452
453    /// Get speaker by name (sync)
454    #[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    /// Run SSDP rediscovery with cooldown. Updates internal speaker map and cache.
460    ///
461    /// No-op for offline systems (test constructors) so a lookup miss never
462    /// costs a 3s SSDP sweep.
463    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; // Cooldown period not elapsed
475        }
476        self.last_rediscovery.store(now, Ordering::Relaxed);
477
478        // 1. SSDP runs WITHOUT holding any lock (3s)
479        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        // 2. Register devices with state manager (required for property tracking)
486        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        // 3. Build new Speaker handles (no lock needed)
492        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        // 4. Acquire write lock BRIEFLY for map swap only
502        if let Ok(mut map) = self.speakers.write() {
503            *map = new_speakers;
504        }
505
506        // 5. Save cache (non-fatal on failure)
507        if let Err(e) = cache::save(&devices) {
508            tracing::warn!("Failed to save discovery cache: {}", e);
509        }
510    }
511
512    /// Get all speakers (sync)
513    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    /// Get speaker by ID (sync)
521    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    /// Get speaker by ID (sync)
527    #[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    /// Get all speaker names (sync)
533    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    /// Get the state manager for advanced usage
541    pub fn state_manager(&self) -> &Arc<StateManager> {
542        &self.state_manager
543    }
544
545    /// A non-owning handle to the internal `StateManager`, for leak assertions.
546    ///
547    /// Exists so a test can outlive the system and check that dropping it
548    /// actually freed the manager. `state_manager()` cannot do that job: it
549    /// borrows from `&self`, so nothing observable survives the drop, and
550    /// cloning the `Arc` first would itself keep the manager alive. A `Weak`
551    /// is the only handle that answers "was this really released?".
552    ///
553    /// Only available when the `test-support` feature is enabled (or when
554    /// compiling this crate's own test harness), matching
555    /// [`Self::from_devices_offline`].
556    #[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    /// Get a blocking iterator over property change events
562    ///
563    /// Only emits events for properties that have been `watch()`ed.
564    ///
565    /// # Example
566    ///
567    /// ```rust,ignore
568    /// // First, watch some properties
569    /// speaker.volume.watch()?;
570    /// speaker.playback_state.watch()?;
571    ///
572    /// // Then iterate over changes (blocking)
573    /// for event in system.iter() {
574    ///     println!("Changed: {} on {}", event.property_key, event.speaker_id);
575    /// }
576    /// ```
577    pub fn iter(&self) -> sonos_state::ChangeIterator {
578        self.state_manager.iter()
579    }
580
581    // ========================================================================
582    // Topology Fetch
583    // ========================================================================
584
585    /// Ensure group topology has been fetched.
586    ///
587    /// Tries all known speaker IPs sequentially until one responds with topology.
588    /// Topology data is identical from any speaker, so first success wins.
589    /// Also refreshes speaker IPs and records satellite IDs from the topology.
590    ///
591    /// No-op for offline systems (test constructors), which supply topology
592    /// directly via `state_manager.initialize()` instead of polling speakers.
593    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            // Apply IP updates from topology before initializing groups
621            for (speaker_id, new_ip) in &topology_changes.speaker_ips {
622                self.state_manager.update_speaker_ip(speaker_id, *new_ip);
623            }
624
625            // Build topology with existing speaker data and freshly fetched groups
626            let topology =
627                Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
628            self.state_manager.initialize(topology);
629
630            // Store satellite IDs for later filtering
631            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    // ========================================================================
645    // Group Methods
646    // ========================================================================
647
648    /// Get all current groups (sync)
649    ///
650    /// Returns all groups in the system. Every speaker is always in a group,
651    /// so a single speaker forms a group of one.
652    ///
653    /// # Example
654    ///
655    /// ```rust,ignore
656    /// for group in system.groups() {
657    ///     println!("Group: {} ({} members)", group.id, group.member_count());
658    ///     if let Some(coordinator) = group.coordinator() {
659    ///         println!("  Coordinator: {}", coordinator.name);
660    ///     }
661    /// }
662    /// ```
663    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    /// Get a specific group by ID (sync)
679    ///
680    /// Returns `None` if no group with that ID exists.
681    ///
682    /// # Example
683    ///
684    /// ```rust,ignore
685    /// if let Some(group) = system.group_by_id(&group_id) {
686    ///     println!("Found group with {} members", group.member_count());
687    /// }
688    /// ```
689    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    /// Get a specific group by ID (sync)
700    #[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    /// Get the group a speaker belongs to (sync)
706    ///
707    /// Returns `None` if the speaker is not found or has no group.
708    /// Since all speakers are always in a group, this typically only returns
709    /// `None` if the speaker ID is invalid.
710    ///
711    /// # Example
712    ///
713    /// ```rust,ignore
714    /// if let Some(speaker) = system.speaker("Living Room") {
715    ///     if let Some(group) = system.group_for_speaker(&speaker.id) {
716    ///         println!("{} is in a group with {} speakers",
717    ///             speaker.name, group.member_count());
718    ///     }
719    /// }
720    /// ```
721    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    /// Get the group a speaker belongs to (sync)
732    #[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    /// Get a group by its coordinator speaker name (sync)
741    ///
742    /// Sonos groups don't have independent names — they are identified by the
743    /// coordinator speaker's friendly name. This method matches groups by looking
744    /// up the coordinator's name in the state manager.
745    ///
746    /// Returns `None` if no group's coordinator matches the given name.
747    ///
748    /// # Example
749    ///
750    /// ```rust,ignore
751    /// if let Some(group) = system.group("Living Room") {
752    ///     println!("Found group with {} members", group.member_count());
753    /// }
754    /// ```
755    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    /// Get a group by its coordinator speaker name (sync)
775    #[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    /// Create a new group with the specified coordinator and members
781    ///
782    /// Adds each member speaker to the coordinator's current group.
783    /// Attempts every speaker even if some fail, returning per-speaker results.
784    /// After calling this, re-fetch groups via `groups()` to see the updated topology.
785    ///
786    /// # Example
787    ///
788    /// ```rust,ignore
789    /// let living_room = system.speaker("Living Room").unwrap();
790    /// let kitchen = system.speaker("Kitchen").unwrap();
791    /// let bedroom = system.speaker("Bedroom").unwrap();
792    ///
793    /// let result = system.create_group(&living_room, &[&kitchen, &bedroom])?;
794    /// if !result.is_success() {
795    ///     for (id, err) in &result.failed {
796    ///         eprintln!("Failed to add {}: {}", id, err);
797    ///     }
798    /// }
799    /// ```
800    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    /// Create a test SonosSystem with the given devices.
829    ///
830    /// Uses the offline constructor: no topology SOAP poll, no SSDP
831    /// rediscovery. Tests below supply topology explicitly via
832    /// `state_manager.initialize()`, which is what the online path would have
833    /// fetched anyway.
834    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        // Initialize with topology containing groups
862        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        // Verify groups() returns all groups
879        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        // No topology initialized, so no groups
901        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        // Initialize with topology
919        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        // Verify group_by_id returns the correct group
927        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        // No groups initialized
949        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        // Initialize with a group containing both speakers
978        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        // Verify group_for_speaker returns the correct group for both speakers
990        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        // No groups initialized
1017        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        // Initialize with topology
1036        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        // Verify all three methods return consistent data
1044        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        // All should return the same group
1054        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        // Find by coordinator name
1109        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        // Unknown name returns None
1118        assert!(system.group("Nonexistent").is_none());
1119    }
1120
1121    /// Compile-time assertion that `create_group`'s signature is correct.
1122    ///
1123    /// Never called: `create_group` forwards to `Group::add_speaker`, which
1124    /// would open a real TCP connection and wait out soap-client's 5s connect
1125    /// timeout, yet the assertion is purely about types. Type-checking a
1126    /// never-called function still fails the build if the signature changes, at
1127    /// zero runtime cost.
1128    #[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    /// Guards the whole point of `from_devices_offline`: no network I/O.
1140    ///
1141    /// The device IP is in RFC 5737 TEST-NET-3, which is guaranteed
1142    /// unroutable. If construction ever polls it again, soap-client's 5s
1143    /// connect timeout blows the bound; a lookup miss re-enabling SSDP costs
1144    /// 3s more. A wall-clock bound is the only way to assert absence of I/O
1145    /// without a mock transport.
1146    #[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    /// Dropping a `SonosSystem` must actually free its `StateManager`.
1171    ///
1172    /// The init closure is stored *on* the manager, so capturing a strong
1173    /// `Arc<StateManager>` in it made the manager own a closure that owned the
1174    /// manager. The cycle was invisible from the outside — construction and
1175    /// teardown both "worked" — but every `SonosSystem::new()` permanently
1176    /// leaked the manager, its store, the event-worker thread, the event
1177    /// manager's tokio runtime, and the callback socket. Only a `Weak` that
1178    /// outlives the system can observe the difference.
1179    #[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        // Alive: reachable. The live count is deliberately not asserted — each
1194        // Speaker handle legitimately holds its own Arc, so the number tracks
1195        // the device count rather than anything about the cycle.
1196        assert!(weak.upgrade().is_some());
1197
1198        drop(system);
1199
1200        // Dropped: the system owned the speakers too, so nothing legitimate is
1201        // left holding the manager. A surviving strong reference can only be the
1202        // init closure the manager itself stores.
1203        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    /// The same, but after `watch()` has run the lazy event-manager init, which
1213    /// is the path that actually exercises the closure's capture.
1214    ///
1215    /// Speakers hold `Arc`s to the manager, so the strong count is >1 here; the
1216    /// assertion is the one that matters — once every handle is gone, nothing
1217    /// keeps the manager alive.
1218    #[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            // Runs the init closure. No event manager can bind here (offline
1235            // test host may or may not permit it), so the mode is whatever the
1236            // environment allows — the point is that the closure executed.
1237            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        // Verbose friendlyName should NOT match
1322        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}