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    /// Each call returns an **independent** iterator, and every iterator
566    /// receives every event. Two event loops — say a UI thread and a logger —
567    /// therefore both see the whole stream instead of splitting it between them.
568    ///
569    /// An iterator only receives events emitted *after* it was created, so take
570    /// it before the writes you want to observe. For current state rather than
571    /// changes, use `speaker.volume.get()` and friends.
572    ///
573    /// Each iterator owns an unbounded queue: a slow consumer never loses an
574    /// event and never blocks a fast one, but one that never drains will grow.
575    /// Drop iterators you no longer read.
576    ///
577    /// # Example
578    ///
579    /// ```rust,ignore
580    /// // First, watch some properties
581    /// speaker.volume.watch()?;
582    /// speaker.playback_state.watch()?;
583    ///
584    /// // Then iterate over changes (blocking). Each event carries the new
585    /// // value, so draining a backlog shows every value the property passed
586    /// // through rather than the latest one repeated.
587    /// for event in system.iter() {
588    ///     match &event.change {
589    ///         PropertyChange::Volume(v) => println!("volume -> {}%", v.value()),
590    ///         other => println!("{} changed on {}", other.key(), event.speaker_id),
591    ///     }
592    /// }
593    /// ```
594    pub fn iter(&self) -> sonos_state::ChangeIterator {
595        self.state_manager.iter()
596    }
597
598    // ========================================================================
599    // Topology Fetch
600    // ========================================================================
601
602    /// Ensure group topology has been fetched.
603    ///
604    /// Tries all known speaker IPs sequentially until one responds with topology.
605    /// Topology data is identical from any speaker, so first success wins.
606    /// Also refreshes speaker IPs and records satellite IDs from the topology.
607    ///
608    /// No-op for offline systems (test constructors), which supply topology
609    /// directly via `state_manager.initialize()` instead of polling speakers.
610    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            // Apply IP updates from topology before initializing groups
638            for (speaker_id, new_ip) in &topology_changes.speaker_ips {
639                self.state_manager.update_speaker_ip(speaker_id, *new_ip);
640            }
641
642            // Build topology with existing speaker data and freshly fetched groups
643            let topology =
644                Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
645            self.state_manager.initialize(topology);
646
647            // Store satellite IDs for later filtering
648            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    // ========================================================================
662    // Group Methods
663    // ========================================================================
664
665    /// Get all current groups (sync)
666    ///
667    /// Returns all groups in the system. Every speaker is always in a group,
668    /// so a single speaker forms a group of one.
669    ///
670    /// # Example
671    ///
672    /// ```rust,ignore
673    /// for group in system.groups() {
674    ///     println!("Group: {} ({} members)", group.id, group.member_count());
675    ///     if let Some(coordinator) = group.coordinator() {
676    ///         println!("  Coordinator: {}", coordinator.name);
677    ///     }
678    /// }
679    /// ```
680    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    /// Get a specific group by ID (sync)
696    ///
697    /// Returns `None` if no group with that ID exists.
698    ///
699    /// # Example
700    ///
701    /// ```rust,ignore
702    /// if let Some(group) = system.group_by_id(&group_id) {
703    ///     println!("Found group with {} members", group.member_count());
704    /// }
705    /// ```
706    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    /// Get a specific group by ID (sync)
717    #[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    /// Get the group a speaker belongs to (sync)
723    ///
724    /// Returns `None` if the speaker is not found or has no group.
725    /// Since all speakers are always in a group, this typically only returns
726    /// `None` if the speaker ID is invalid.
727    ///
728    /// # Example
729    ///
730    /// ```rust,ignore
731    /// if let Some(speaker) = system.speaker("Living Room") {
732    ///     if let Some(group) = system.group_for_speaker(&speaker.id) {
733    ///         println!("{} is in a group with {} speakers",
734    ///             speaker.name, group.member_count());
735    ///     }
736    /// }
737    /// ```
738    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    /// Get the group a speaker belongs to (sync)
749    #[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    /// Get a group by its coordinator speaker name (sync)
758    ///
759    /// Sonos groups don't have independent names — they are identified by the
760    /// coordinator speaker's friendly name. This method matches groups by looking
761    /// up the coordinator's name in the state manager.
762    ///
763    /// Returns `None` if no group's coordinator matches the given name.
764    ///
765    /// # Example
766    ///
767    /// ```rust,ignore
768    /// if let Some(group) = system.group("Living Room") {
769    ///     println!("Found group with {} members", group.member_count());
770    /// }
771    /// ```
772    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    /// Get a group by its coordinator speaker name (sync)
792    #[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    /// Create a new group with the specified coordinator and members
798    ///
799    /// Adds each member speaker to the coordinator's current group.
800    /// Attempts every speaker even if some fail, returning per-speaker results.
801    /// After calling this, re-fetch groups via `groups()` to see the updated topology.
802    ///
803    /// # Example
804    ///
805    /// ```rust,ignore
806    /// let living_room = system.speaker("Living Room").unwrap();
807    /// let kitchen = system.speaker("Kitchen").unwrap();
808    /// let bedroom = system.speaker("Bedroom").unwrap();
809    ///
810    /// let result = system.create_group(&living_room, &[&kitchen, &bedroom])?;
811    /// if !result.is_success() {
812    ///     for (id, err) in &result.failed {
813    ///         eprintln!("Failed to add {}: {}", id, err);
814    ///     }
815    /// }
816    /// ```
817    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    /// Create a test SonosSystem with the given devices.
846    ///
847    /// Uses the offline constructor: no topology SOAP poll, no SSDP
848    /// rediscovery. Tests below supply topology explicitly via
849    /// `state_manager.initialize()`, which is what the online path would have
850    /// fetched anyway.
851    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        // Initialize with topology containing groups
879        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        // Verify groups() returns all groups
896        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        // No topology initialized, so no groups
918        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        // Initialize with topology
936        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        // Verify group_by_id returns the correct group
944        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        // No groups initialized
966        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        // Initialize with a group containing both speakers
995        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        // Verify group_for_speaker returns the correct group for both speakers
1007        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        // No groups initialized
1034        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        // Initialize with topology
1053        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        // Verify all three methods return consistent data
1061        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        // All should return the same group
1071        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        // Find by coordinator name
1126        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        // Unknown name returns None
1135        assert!(system.group("Nonexistent").is_none());
1136    }
1137
1138    /// Compile-time assertion that `create_group`'s signature is correct.
1139    ///
1140    /// Never called: `create_group` forwards to `Group::add_speaker`, which
1141    /// would open a real TCP connection and wait out soap-client's 5s connect
1142    /// timeout, yet the assertion is purely about types. Type-checking a
1143    /// never-called function still fails the build if the signature changes, at
1144    /// zero runtime cost.
1145    #[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    /// Guards the whole point of `from_devices_offline`: no network I/O.
1157    ///
1158    /// The device IP is in RFC 5737 TEST-NET-3, which is guaranteed
1159    /// unroutable. If construction ever polls it again, soap-client's 5s
1160    /// connect timeout blows the bound; a lookup miss re-enabling SSDP costs
1161    /// 3s more. A wall-clock bound is the only way to assert absence of I/O
1162    /// without a mock transport.
1163    #[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    /// Dropping a `SonosSystem` must actually free its `StateManager`.
1188    ///
1189    /// The init closure is stored *on* the manager, so capturing a strong
1190    /// `Arc<StateManager>` in it made the manager own a closure that owned the
1191    /// manager. The cycle was invisible from the outside — construction and
1192    /// teardown both "worked" — but every `SonosSystem::new()` permanently
1193    /// leaked the manager, its store, the event-worker thread, the event
1194    /// manager's tokio runtime, and the callback socket. Only a `Weak` that
1195    /// outlives the system can observe the difference.
1196    #[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        // Alive: reachable. The live count is deliberately not asserted — each
1211        // Speaker handle legitimately holds its own Arc, so the number tracks
1212        // the device count rather than anything about the cycle.
1213        assert!(weak.upgrade().is_some());
1214
1215        drop(system);
1216
1217        // Dropped: the system owned the speakers too, so nothing legitimate is
1218        // left holding the manager. A surviving strong reference can only be the
1219        // init closure the manager itself stores.
1220        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    /// The same, but after `watch()` has run the lazy event-manager init, which
1230    /// is the path that actually exercises the closure's capture.
1231    ///
1232    /// Speakers hold `Arc`s to the manager, so the strong count is >1 here; the
1233    /// assertion is the one that matters — once every handle is gone, nothing
1234    /// keeps the manager alive.
1235    #[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            // Runs the init closure. No event manager can bind here (offline
1252            // test host may or may not permit it), so the mode is whatever the
1253            // environment allows — the point is that the closure executed.
1254            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        // Verbose friendlyName should NOT match
1339        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}