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, HashSet};
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    /// Runs the same construction sequence as the normal constructor, except
175    /// that the topology *poll* is skipped and the system is marked `offline` so
176    /// a lookup miss cannot trigger SSDP rediscovery. The topology-dependent
177    /// steps still execute; with no topology they simply have nothing to do
178    /// (the satellite set is empty, and no IPs have changed).
179    ///
180    /// Use [`Self::from_devices_offline_with_topology`] to supply the topology
181    /// the poll would have returned.
182    ///
183    /// Exists because the two network paths in the normal constructor
184    /// (topology SOAP poll, rediscovery SSDP) dominate test wall time: each
185    /// unreachable speaker IP costs a 5s connect + 10s read timeout, and a
186    /// single lookup miss costs a 3s SSDP sweep. Tests that only exercise
187    /// in-memory bookkeeping should pay none of that.
188    ///
189    /// Only available when the `test-support` feature is enabled (or when
190    /// compiling this crate's own test harness).
191    #[cfg(any(feature = "test-support", test))]
192    pub fn from_devices_offline(devices: Vec<Device>) -> Result<Self, SdkError> {
193        Self::construct(devices, true, |_| {})
194    }
195
196    /// Construct offline, but inject the topology `ensure_topology` would have
197    /// polled, so the post-topology construction steps still run.
198    ///
199    /// `seed` is called after the state manager exists and before the
200    /// satellite-aware re-key, which is exactly the window `ensure_topology`
201    /// occupies in production. It is the only way a test can exercise satellite
202    /// filtering — the behavior is defined entirely by topology, and topology
203    /// otherwise arrives only over the network.
204    ///
205    /// Tests go through [`Self::construct`] rather than poking the speaker map
206    /// afterwards on purpose: filtering that runs in the wrong *order* is the
207    /// entire bug, so a test that reproduces the steps itself could not detect
208    /// the production sequence regressing.
209    #[cfg(any(feature = "test-support", test))]
210    pub fn from_devices_offline_with_topology(
211        devices: Vec<Device>,
212        seed: impl FnOnce(&Self),
213    ) -> Result<Self, SdkError> {
214        Self::construct(devices, true, seed)
215    }
216
217    /// Construct offline with an explicit group topology.
218    ///
219    /// The `seed` closure of [`Self::from_devices_offline_with_topology`] receives
220    /// a `&Self` whose `state_manager` is private to this crate, so a downstream
221    /// crate cannot actually seed anything through it — and
222    /// [`Self::with_groups`] only ever builds single-member groups. That left
223    /// multi-member topology unreachable from outside, so a consumer could not
224    /// test behaviour that depends on group size or coordinator identity.
225    ///
226    /// Takes the groups directly and applies them in the same window
227    /// `ensure_topology` occupies in production, so the topology-dependent
228    /// construction steps run in their real order.
229    ///
230    /// ```no_run
231    /// # use sonos_sdk::SonosSystem;
232    /// # use sonos_sdk::{GroupId, SpeakerId};
233    /// # use sonos_sdk::sonos_discovery::Device;
234    /// # fn f(devices: Vec<Device>) -> Result<(), sonos_sdk::SdkError> {
235    /// let system = SonosSystem::from_devices_offline_with_groups(
236    ///     devices,
237    ///     vec![(
238    ///         GroupId::new("RINCON_000:1"),
239    ///         SpeakerId::new("RINCON_000"),
240    ///         vec![SpeakerId::new("RINCON_000"), SpeakerId::new("RINCON_001")],
241    ///     )],
242    /// )?;
243    /// # Ok(())
244    /// # }
245    /// ```
246    ///
247    /// Only available with the `test-support` feature.
248    #[cfg(any(feature = "test-support", test))]
249    pub fn from_devices_offline_with_groups(
250        devices: Vec<Device>,
251        groups: Vec<(sonos_state::GroupId, SpeakerId, Vec<SpeakerId>)>,
252    ) -> Result<Self, SdkError> {
253        Self::construct(devices, true, |system| {
254            let infos: Vec<sonos_state::GroupInfo> = groups
255                .into_iter()
256                .map(|(id, coordinator, members)| {
257                    sonos_state::GroupInfo::new(id, coordinator, members)
258                })
259                .collect();
260            let topology = sonos_state::Topology::new(system.state_manager.speaker_infos(), infos);
261            system.state_manager.initialize(topology);
262        })
263    }
264
265    fn from_devices_inner(devices: Vec<Device>) -> Result<Self, SdkError> {
266        Self::construct(devices, false, |_| {})
267    }
268
269    /// The single construction sequence: in-memory wiring, topology, then the
270    /// steps that depend on topology.
271    ///
272    /// Production and the offline test constructors share this body so the
273    /// *order* of the topology-dependent steps has exactly one definition.
274    fn construct(
275        devices: Vec<Device>,
276        offline: bool,
277        seed: impl FnOnce(&Self),
278    ) -> Result<Self, SdkError> {
279        let system = Self::assemble(devices.clone(), offline)?;
280
281        // Tests inject the topology that `ensure_topology` would have fetched.
282        seed(&system);
283
284        // Prefetch topology before any subscriptions can start.
285        // This ensures group structure is known when the first AVTransport
286        // events arrive, so PerCoordinator suppression/propagation works
287        // from the very first event.
288        // No-op when offline, or when `seed` already supplied groups.
289        system.ensure_topology();
290
291        // Re-key the speaker map now that satellites are known.
292        system.rebuild_speakers_excluding_satellites(&devices)?;
293
294        // Refresh Speaker handle IPs from state store (topology may have updated them)
295        if let Ok(mut speakers) = system.speakers.write() {
296            for speaker in speakers.values_mut() {
297                if let Some(info) = system.state_manager.speaker_info(&speaker.id) {
298                    speaker.ip = info.ip_address;
299                }
300            }
301        }
302
303        Ok(system)
304    }
305
306    /// Build the in-memory system: state manager, lazy event-init closure,
307    /// API client and Speaker handles. Performs no network I/O.
308    ///
309    /// Shared by [`Self::from_devices_inner`] and [`Self::from_devices_offline`]
310    /// so the Arc wiring below has exactly one definition.
311    ///
312    /// # Why the closure holds a `Weak<StateManager>`
313    ///
314    /// The closure below is *stored on the very `StateManager` it needs to call*
315    /// (`set_event_init` puts it in a `OnceLock` on the manager). Capturing a
316    /// strong `Arc<StateManager>` therefore closed a reference cycle: manager →
317    /// `OnceLock<EventInitFn>` → closure → manager. Neither end could ever reach
318    /// zero, so dropping a `SonosSystem` freed nothing — a measured
319    /// `Arc::strong_count` of 2 after `drop(system)` where 1 was expected. Each
320    /// construction permanently leaked the `StateManager`, its `StateStore`, the
321    /// event-worker thread, the `SonosEventManager` with its tokio runtime, and
322    /// the callback server's UDP/TCP socket.
323    ///
324    /// A `Weak` breaks the cycle without changing the happy path: while the
325    /// system is alive the upgrade always succeeds, and the only way it can fail
326    /// is a `watch()` racing teardown, where doing nothing is exactly right.
327    fn assemble(devices: Vec<Device>, offline: bool) -> Result<Self, SdkError> {
328        // 1. Create shared state FIRST — no event manager yet (lazy init)
329        let state_manager = Arc::new(StateManager::new().map_err(SdkError::StateError)?);
330        state_manager
331            .add_devices(devices.clone())
332            .map_err(SdkError::StateError)?;
333
334        let api_client = SonosClient::new();
335
336        // 2. Build init closure and store on StateManager (single source of truth)
337        let init_fn: EventInitFn = {
338            // Serializes concurrent first-`watch()` calls so at most one
339            // SonosEventManager is ever constructed. `set_event_manager` is
340            // itself idempotent, but without this lock a race would still bind
341            // two callback sockets and spawn two runtimes before one lost.
342            let init_lock: Arc<Mutex<bool>> = Arc::new(Mutex::new(false));
343            let weak_sm = Arc::downgrade(&state_manager);
344            Arc::new(
345                move || -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
346                    let mut initialized = init_lock.lock().map_err(|_| SdkError::LockPoisoned)?;
347                    if *initialized {
348                        tracing::trace!(
349                            "Event manager init closure called but already initialized"
350                        );
351                        return Ok(());
352                    }
353                    // A failed upgrade means the SonosSystem is being torn down
354                    // while a watch() is in flight. There is nothing left to
355                    // wire an event manager into, so decline quietly rather than
356                    // building a runtime and a socket for a dead system.
357                    let Some(sm) = weak_sm.upgrade() else {
358                        tracing::debug!(
359                            "Event manager init skipped: SonosSystem has already been dropped"
360                        );
361                        return Ok(());
362                    };
363                    tracing::info!("Lazy-initializing event manager (first watch() call)");
364                    let em = Arc::new(SonosEventManager::new().map_err(|e| {
365                        tracing::error!("Failed to create SonosEventManager: {}", e);
366                        SdkError::EventManager(e.to_string())
367                    })?);
368                    tracing::debug!("SonosEventManager created, wiring into StateManager");
369                    // The StateManager owns the only lasting reference, in its
370                    // own OnceLock. SonosSystem deliberately keeps none: a
371                    // second copy of this handle bought nothing and previously
372                    // pretended to be the thing keeping it alive.
373                    sm.set_event_manager(em).map_err(SdkError::StateError)?;
374                    *initialized = true;
375                    tracing::info!("Event manager initialization complete");
376                    Ok(())
377                },
378            )
379        };
380        state_manager.set_event_init(init_fn);
381
382        // 3. Build speakers (init fn is on StateManager — no per-speaker threading needed).
383        //
384        // No topology has been fetched yet, so satellite identity is unknown and
385        // every device is a candidate. `from_devices_inner` rebuilds this map
386        // once `ensure_topology` has run; the offline constructors have no
387        // topology to consult and keep this provisional map as final.
388        let speakers =
389            Self::build_speakers(&devices, &HashSet::new(), &state_manager, &api_client)?;
390
391        // 4. Assemble struct from the SAME Arcs
392        Ok(Self {
393            state_manager,
394            api_client,
395            speakers: RwLock::new(speakers),
396            last_rediscovery: AtomicU64::new(0),
397            offline,
398        })
399    }
400
401    /// Create a test SonosSystem with named speakers and no network access.
402    ///
403    /// Builds an in-memory system with synthetic speaker data. No SSDP discovery,
404    /// no event manager socket binding, no cache reads. Speakers get sequential
405    /// IPs starting at `192.168.1.100`.
406    ///
407    /// Only available when the `test-support` feature is enabled.
408    ///
409    /// # Example
410    ///
411    /// ```rust,ignore
412    /// let system = SonosSystem::with_speakers(&["Kitchen", "Bedroom"]);
413    /// assert_eq!(system.speakers().len(), 2);
414    /// assert!(system.speaker("Kitchen").is_some());
415    /// ```
416    #[cfg(feature = "test-support")]
417    pub fn with_speakers(names: &[&str]) -> Self {
418        let devices: Vec<Device> = names
419            .iter()
420            .enumerate()
421            .map(|(i, name)| Device {
422                id: format!("RINCON_{i:03}"),
423                name: name.to_string(),
424                room_name: name.to_string(),
425                ip_address: format!("192.168.1.{}", 100 + i),
426                port: 1400,
427                model_name: "Sonos One".to_string(),
428            })
429            .collect();
430
431        let state_manager =
432            Arc::new(StateManager::new().expect("StateManager::new() should not fail"));
433
434        state_manager
435            .add_devices(devices.clone())
436            .expect("add_devices should not fail with valid test data");
437
438        let api_client = SonosClient::new();
439        let speakers = Self::build_speakers(&devices, &HashSet::new(), &state_manager, &api_client)
440            .expect("build_speakers should not fail with valid test data");
441
442        Self {
443            state_manager,
444            api_client,
445            speakers: RwLock::new(speakers),
446            last_rediscovery: AtomicU64::new(0),
447            offline: true,
448        }
449    }
450
451    /// Create a test SonosSystem with speakers AND group topology.
452    ///
453    /// Each speaker gets a standalone group (coordinator = self, members = [self]).
454    /// This makes `system.groups()` and `system.group("name")` work in tests.
455    ///
456    /// # Example
457    ///
458    /// ```rust,ignore
459    /// let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
460    /// assert_eq!(system.groups().len(), 2);
461    /// assert!(system.group("Kitchen").is_some());
462    /// ```
463    #[cfg(feature = "test-support")]
464    pub fn with_groups(names: &[&str]) -> Self {
465        let system = Self::with_speakers(names);
466
467        let groups: Vec<GroupInfo> = names
468            .iter()
469            .enumerate()
470            .map(|(i, _name)| {
471                let speaker_id = SpeakerId::new(format!("RINCON_{i:03}"));
472                let group_id = GroupId::new(format!("RINCON_{i:03}:1"));
473                GroupInfo::new(group_id, speaker_id.clone(), vec![speaker_id])
474            })
475            .collect();
476
477        let topology = Topology::new(system.state_manager.speaker_infos(), groups);
478        system.state_manager.initialize(topology);
479
480        system
481    }
482
483    /// Re-key the name-keyed speaker map with satellite devices excluded.
484    ///
485    /// Satellite identity comes from topology, which is only available *after*
486    /// `ensure_topology()`; the map key comes from the device list, which is
487    /// available from the start. This method is the one place both facts are
488    /// known, so it is where the map can first be built correctly — which is why
489    /// it rebuilds from `devices` instead of filtering the provisional map.
490    ///
491    /// Filtering the provisional map in place was the bug. A bonded home theater
492    /// is one visible coordinator plus N invisible satellites, all reporting the
493    /// same `room_name`, so all of them hash to one key and only one survives
494    /// insertion. When the survivor was a satellite, the filter then deleted it
495    /// and the whole room disappeared — the controllable coordinator having
496    /// already been overwritten. Rebuilding with satellites skipped up front
497    /// means the coordinator is the only candidate for the key.
498    ///
499    /// No-op when no satellites are known, which keeps the offline constructors
500    /// (no topology fetch, empty satellite set) on their existing behavior.
501    fn rebuild_speakers_excluding_satellites(&self, devices: &[Device]) -> Result<(), SdkError> {
502        let satellite_ids: HashSet<SpeakerId> =
503            self.state_manager.get_satellite_ids().into_iter().collect();
504        if satellite_ids.is_empty() {
505            return Ok(());
506        }
507
508        let rebuilt = Self::build_speakers(
509            devices,
510            &satellite_ids,
511            &self.state_manager,
512            &self.api_client,
513        )?;
514        if let Ok(mut speakers) = self.speakers.write() {
515            *speakers = rebuilt;
516        }
517        tracing::debug!("Excluded {} satellite speakers", satellite_ids.len());
518        Ok(())
519    }
520
521    /// Build the name-keyed Speaker map from a list of devices.
522    ///
523    /// `satellite_ids` are devices marked `Invisible="1"` in the topology
524    /// (home-theater surrounds and subs). They are skipped **before** insertion,
525    /// not filtered afterwards, because insertion is what collides: every device
526    /// in a bonded set reports the same `room_name`, so all of them produce the
527    /// same map key. Filtering after the fact can only inspect whichever device
528    /// happened to win that collision, and if the winner was a satellite the
529    /// entire room is deleted along with it. Skipping first guarantees the
530    /// visible coordinator — the one that accepts playback and volume commands —
531    /// is the device that reaches the map.
532    ///
533    /// Pass an empty set when satellite identity is not yet known; every device
534    /// is then a candidate, which is the pre-topology status quo.
535    ///
536    /// Two *genuinely visible* devices sharing a room name remain a real
537    /// conflict. Sonos itself prevents this in the app, so it means unusual
538    /// state (a rename mid-discovery, a stale cache entry for a replaced unit).
539    /// Rather than silently discarding one, both are kept: the first-seen device
540    /// holds the plain room name and later ones are suffixed with their speaker
541    /// ID, so `speaker("Basement")` stays stable and
542    /// `speaker("Basement (RINCON_2)")` reaches the other. Nothing is lost, and
543    /// `speakers()` / `speaker_by_id()` see the true device count.
544    fn build_speakers(
545        devices: &[Device],
546        satellite_ids: &HashSet<SpeakerId>,
547        state_manager: &Arc<StateManager>,
548        api_client: &SonosClient,
549    ) -> Result<HashMap<String, Speaker>, SdkError> {
550        let mut speakers = HashMap::new();
551        for device in devices {
552            let speaker_id = SpeakerId::new(&device.id);
553
554            // Skip satellites before they can claim the name key.
555            if satellite_ids.contains(&speaker_id) {
556                tracing::debug!(
557                    "skipping satellite speaker {} in room \"{}\"",
558                    device.id,
559                    display_name(device)
560                );
561                continue;
562            }
563
564            let ip = device
565                .ip_address
566                .parse()
567                .map_err(|_| SdkError::InvalidIpAddress)?;
568
569            let base_name = display_name(device);
570            let key = if speakers.contains_key(&base_name) {
571                let disambiguated = format!("{base_name} ({})", device.id);
572                tracing::warn!(
573                    "two visible speakers report the name \"{}\"; registering the second as \"{}\"",
574                    base_name,
575                    disambiguated
576                );
577                disambiguated
578            } else {
579                base_name
580            };
581
582            let speaker = Speaker::new(
583                speaker_id,
584                key.clone(),
585                ip,
586                device.model_name.clone(),
587                Arc::clone(state_manager),
588                api_client.clone(),
589            );
590
591            speakers.insert(key, speaker);
592        }
593        Ok(speakers)
594    }
595
596    /// Get speaker by name (sync)
597    ///
598    /// If the speaker isn't in the current map, triggers an SSDP
599    /// rediscovery (rate-limited to once per 30s) before returning `None`.
600    ///
601    /// # Example
602    ///
603    /// ```rust,ignore
604    /// let kitchen = sonos.speaker("Kitchen").unwrap();
605    /// kitchen.play()?;
606    /// ```
607    pub fn speaker(&self, name: &str) -> Option<Speaker> {
608        {
609            let speakers = self.speakers.read().ok()?;
610            if let Some(speaker) = find_speaker_by_name(&speakers, name) {
611                return Some(speaker);
612            }
613        }
614        // Not found — try rediscovery (cooldown-limited)
615        self.try_rediscover(name);
616        let speakers = self.speakers.read().ok()?;
617        find_speaker_by_name(&speakers, name)
618    }
619
620    /// Get speaker by name (sync)
621    #[deprecated(since = "0.2.0", note = "renamed to `speaker()`")]
622    pub fn get_speaker_by_name(&self, name: &str) -> Option<Speaker> {
623        self.speaker(name)
624    }
625
626    /// Run SSDP rediscovery with cooldown. Updates internal speaker map and cache.
627    ///
628    /// No-op for offline systems (test constructors) so a lookup miss never
629    /// costs a 3s SSDP sweep.
630    fn try_rediscover(&self, name: &str) {
631        if self.offline {
632            return;
633        }
634
635        let now = std::time::SystemTime::now()
636            .duration_since(std::time::UNIX_EPOCH)
637            .unwrap_or_default()
638            .as_secs();
639        let last = self.last_rediscovery.load(Ordering::Relaxed);
640        if last > 0 && now - last < REDISCOVERY_COOLDOWN_SECS {
641            return; // Cooldown period not elapsed
642        }
643        self.last_rediscovery.store(now, Ordering::Relaxed);
644
645        // 1. SSDP runs WITHOUT holding any lock (3s)
646        tracing::info!("speaker '{}' not found, running auto-rediscovery...", name);
647        let devices = sonos_discovery::get_with_timeout(Duration::from_secs(3));
648        if devices.is_empty() {
649            return;
650        }
651
652        // 2. Register devices with state manager (required for property tracking)
653        if let Err(e) = self.state_manager.add_devices(devices.clone()) {
654            tracing::warn!("Failed to register rediscovered devices: {}", e);
655            return;
656        }
657
658        // 3. Build new Speaker handles (no lock needed).
659        //
660        // Satellites must be excluded here too: this map *replaces* the one
661        // built at construction, so rebuilding without the exclusion would
662        // resurrect every filtered surround and re-lose its room to the name
663        // collision. Any topology already fetched is reused; before the first
664        // fetch the set is empty, which is the same state construction starts in.
665        let satellite_ids: HashSet<SpeakerId> =
666            self.state_manager.get_satellite_ids().into_iter().collect();
667        let new_speakers = match Self::build_speakers(
668            &devices,
669            &satellite_ids,
670            &self.state_manager,
671            &self.api_client,
672        ) {
673            Ok(s) => s,
674            Err(e) => {
675                tracing::warn!("Failed to build speakers from rediscovery: {}", e);
676                return;
677            }
678        };
679
680        // 4. Acquire write lock BRIEFLY for map swap only
681        if let Ok(mut map) = self.speakers.write() {
682            *map = new_speakers;
683        }
684
685        // 5. Save cache (non-fatal on failure)
686        if let Err(e) = cache::save(&devices) {
687            tracing::warn!("Failed to save discovery cache: {}", e);
688        }
689    }
690
691    /// Get all speakers (sync)
692    pub fn speakers(&self) -> Vec<Speaker> {
693        self.speakers
694            .read()
695            .map(|s| s.values().cloned().collect())
696            .unwrap_or_default()
697    }
698
699    /// Get speaker by ID (sync)
700    pub fn speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
701        let speakers = self.speakers.read().ok()?;
702        speakers.values().find(|s| s.id == *speaker_id).cloned()
703    }
704
705    /// Get speaker by ID (sync)
706    #[deprecated(since = "0.2.0", note = "renamed to `speaker_by_id()`")]
707    pub fn get_speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
708        self.speaker_by_id(speaker_id)
709    }
710
711    /// Get all speaker names (sync)
712    pub fn speaker_names(&self) -> Vec<String> {
713        self.speakers
714            .read()
715            .map(|s| s.keys().cloned().collect())
716            .unwrap_or_default()
717    }
718
719    /// Get the state manager for advanced usage
720    pub fn state_manager(&self) -> &Arc<StateManager> {
721        &self.state_manager
722    }
723
724    /// A non-owning handle to the internal `StateManager`, for leak assertions.
725    ///
726    /// Exists so a test can outlive the system and check that dropping it
727    /// actually freed the manager. `state_manager()` cannot do that job: it
728    /// borrows from `&self`, so nothing observable survives the drop, and
729    /// cloning the `Arc` first would itself keep the manager alive. A `Weak`
730    /// is the only handle that answers "was this really released?".
731    ///
732    /// Only available when the `test-support` feature is enabled (or when
733    /// compiling this crate's own test harness), matching
734    /// [`Self::from_devices_offline`].
735    #[cfg(any(feature = "test-support", test))]
736    pub fn state_manager_weak(&self) -> std::sync::Weak<StateManager> {
737        Arc::downgrade(&self.state_manager)
738    }
739
740    /// Get a blocking iterator over property change events
741    ///
742    /// Only emits events for properties that have been `watch()`ed.
743    ///
744    /// Each call returns an **independent** iterator, and every iterator
745    /// receives every event. Two event loops — say a UI thread and a logger —
746    /// therefore both see the whole stream instead of splitting it between them.
747    ///
748    /// An iterator only receives events emitted *after* it was created, so take
749    /// it before the writes you want to observe. For current state rather than
750    /// changes, use `speaker.volume.get()` and friends.
751    ///
752    /// Each iterator owns an unbounded queue: a slow consumer never loses an
753    /// event and never blocks a fast one, but one that never drains will grow.
754    /// Drop iterators you no longer read.
755    ///
756    /// # Example
757    ///
758    /// ```rust,ignore
759    /// // First, watch some properties
760    /// speaker.volume.watch()?;
761    /// speaker.playback_state.watch()?;
762    ///
763    /// // Then iterate over changes (blocking). Each event carries the new
764    /// // value, so draining a backlog shows every value the property passed
765    /// // through rather than the latest one repeated.
766    /// for event in system.iter() {
767    ///     match &event.change {
768    ///         PropertyChange::Volume(v) => println!("volume -> {}%", v.value()),
769    ///         other => println!("{} changed on {}", other.key(), event.speaker_id),
770    ///     }
771    /// }
772    /// ```
773    pub fn iter(&self) -> sonos_state::ChangeIterator {
774        self.state_manager.iter()
775    }
776
777    // ========================================================================
778    // Topology Fetch
779    // ========================================================================
780
781    /// Ensure group topology has been fetched.
782    ///
783    /// Tries all known speaker IPs sequentially until one responds with topology.
784    /// Topology data is identical from any speaker, so first success wins.
785    /// Also refreshes speaker IPs and records satellite IDs from the topology.
786    ///
787    /// No-op for offline systems (test constructors), which supply topology
788    /// directly via `state_manager.initialize()` instead of polling speakers.
789    fn ensure_topology(&self) {
790        if self.offline || self.state_manager.group_count() > 0 {
791            return;
792        }
793
794        let speaker_ips: Vec<String> = {
795            let speakers = match self.speakers.read() {
796                Ok(s) => s,
797                Err(_) => return,
798            };
799            speakers.values().map(|s| s.ip.to_string()).collect()
800        };
801
802        for speaker_ip in &speaker_ips {
803            let topology_state = match sonos_api::services::zone_group_topology::state::poll(
804                &self.api_client,
805                speaker_ip,
806            ) {
807                Ok(state) => state,
808                Err(e) => {
809                    tracing::debug!("Topology fetch failed for {}: {}", speaker_ip, e);
810                    continue;
811                }
812            };
813
814            let topology_changes = sonos_state::decode_topology_event(&topology_state);
815
816            // Apply IP updates from topology before initializing groups
817            for (speaker_id, new_ip) in &topology_changes.speaker_ips {
818                self.state_manager.update_speaker_ip(speaker_id, *new_ip);
819            }
820
821            // Build topology with existing speaker data and freshly fetched groups
822            let topology =
823                Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
824            self.state_manager.initialize(topology);
825
826            // Store satellite IDs for later filtering
827            self.state_manager
828                .set_satellite_ids(topology_changes.satellite_ids);
829
830            tracing::debug!(
831                "Fetched zone group topology on-demand ({} groups)",
832                self.state_manager.group_count()
833            );
834            return;
835        }
836
837        tracing::warn!("ensure_topology: no speakers responded");
838    }
839
840    // ========================================================================
841    // Group Methods
842    // ========================================================================
843
844    /// Get all current groups (sync)
845    ///
846    /// Returns all groups in the system. Every speaker is always in a group,
847    /// so a single speaker forms a group of one.
848    ///
849    /// # Example
850    ///
851    /// ```rust,ignore
852    /// for group in system.groups() {
853    ///     println!("Group: {} ({} members)", group.id, group.member_count());
854    ///     if let Some(coordinator) = group.coordinator() {
855    ///         println!("  Coordinator: {}", coordinator.name);
856    ///     }
857    /// }
858    /// ```
859    pub fn groups(&self) -> Vec<Group> {
860        self.ensure_topology();
861        self.state_manager
862            .groups()
863            .into_iter()
864            .filter_map(|info| {
865                Group::from_info(
866                    info,
867                    Arc::clone(&self.state_manager),
868                    self.api_client.clone(),
869                )
870            })
871            .collect()
872    }
873
874    /// Get a specific group by ID (sync)
875    ///
876    /// Returns `None` if no group with that ID exists.
877    ///
878    /// # Example
879    ///
880    /// ```rust,ignore
881    /// if let Some(group) = system.group_by_id(&group_id) {
882    ///     println!("Found group with {} members", group.member_count());
883    /// }
884    /// ```
885    pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
886        self.ensure_topology();
887        let info = self.state_manager.get_group(group_id)?;
888        Group::from_info(
889            info,
890            Arc::clone(&self.state_manager),
891            self.api_client.clone(),
892        )
893    }
894
895    /// Get a specific group by ID (sync)
896    #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
897    pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
898        self.group_by_id(group_id)
899    }
900
901    /// Get the group a speaker belongs to (sync)
902    ///
903    /// Returns `None` if the speaker is not found or has no group.
904    /// Since all speakers are always in a group, this typically only returns
905    /// `None` if the speaker ID is invalid.
906    ///
907    /// # Example
908    ///
909    /// ```rust,ignore
910    /// if let Some(speaker) = system.speaker("Living Room") {
911    ///     if let Some(group) = system.group_for_speaker(&speaker.id) {
912    ///         println!("{} is in a group with {} speakers",
913    ///             speaker.name, group.member_count());
914    ///     }
915    /// }
916    /// ```
917    pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
918        self.ensure_topology();
919        let info = self.state_manager.get_group_for_speaker(speaker_id)?;
920        Group::from_info(
921            info,
922            Arc::clone(&self.state_manager),
923            self.api_client.clone(),
924        )
925    }
926
927    /// Get the group a speaker belongs to (sync)
928    #[deprecated(
929        since = "0.2.0",
930        note = "use `speaker.group()` or `group_for_speaker()` instead"
931    )]
932    pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
933        self.group_for_speaker(speaker_id)
934    }
935
936    /// Get a group by its coordinator speaker name (sync)
937    ///
938    /// Sonos groups don't have independent names — they are identified by the
939    /// coordinator speaker's friendly name. This method matches groups by looking
940    /// up the coordinator's name in the state manager.
941    ///
942    /// Returns `None` if no group's coordinator matches the given name.
943    ///
944    /// # Example
945    ///
946    /// ```rust,ignore
947    /// if let Some(group) = system.group("Living Room") {
948    ///     println!("Found group with {} members", group.member_count());
949    /// }
950    /// ```
951    pub fn group(&self, name: &str) -> Option<Group> {
952        self.ensure_topology();
953        self.state_manager
954            .groups()
955            .into_iter()
956            .find(|info| {
957                self.state_manager
958                    .speaker_info(&info.coordinator_id)
959                    .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
960            })
961            .and_then(|info| {
962                Group::from_info(
963                    info,
964                    Arc::clone(&self.state_manager),
965                    self.api_client.clone(),
966                )
967            })
968    }
969
970    /// Get a group by its coordinator speaker name (sync)
971    #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
972    pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
973        self.group(name)
974    }
975
976    /// Create a new group with the specified coordinator and members
977    ///
978    /// Adds each member speaker to the coordinator's current group.
979    /// Attempts every speaker even if some fail, returning per-speaker results.
980    /// After calling this, re-fetch groups via `groups()` to see the updated topology.
981    ///
982    /// # Example
983    ///
984    /// ```rust,ignore
985    /// let living_room = system.speaker("Living Room").unwrap();
986    /// let kitchen = system.speaker("Kitchen").unwrap();
987    /// let bedroom = system.speaker("Bedroom").unwrap();
988    ///
989    /// let result = system.create_group(&living_room, &[&kitchen, &bedroom])?;
990    /// if !result.is_success() {
991    ///     for (id, err) in &result.failed {
992    ///         eprintln!("Failed to add {}: {}", id, err);
993    ///     }
994    /// }
995    /// ```
996    pub fn create_group(
997        &self,
998        coordinator: &Speaker,
999        members: &[&Speaker],
1000    ) -> Result<crate::group::GroupChangeResult, SdkError> {
1001        let coord_group = self
1002            .group_for_speaker(&coordinator.id)
1003            .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
1004
1005        let mut succeeded = Vec::new();
1006        let mut failed = Vec::new();
1007
1008        for member in members {
1009            match coord_group.add_speaker(member) {
1010                Ok(()) => succeeded.push(member.id.clone()),
1011                Err(e) => failed.push((member.id.clone(), e)),
1012            }
1013        }
1014
1015        Ok(crate::group::GroupChangeResult { succeeded, failed })
1016    }
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use sonos_state::GroupInfo;
1023
1024    /// Create a test SonosSystem with the given devices.
1025    ///
1026    /// Uses the offline constructor: no topology SOAP poll, no SSDP
1027    /// rediscovery. Tests below supply topology explicitly via
1028    /// `state_manager.initialize()`, which is what the online path would have
1029    /// fetched anyway.
1030    fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
1031        SonosSystem::from_devices_offline(devices)
1032    }
1033
1034    #[test]
1035    fn test_groups_returns_all_groups() {
1036        let devices = vec![
1037            Device {
1038                id: "RINCON_111".to_string(),
1039                name: "Living Room".to_string(),
1040                room_name: "Living Room".to_string(),
1041                ip_address: "192.168.1.100".to_string(),
1042                port: 1400,
1043                model_name: "Sonos One".to_string(),
1044            },
1045            Device {
1046                id: "RINCON_222".to_string(),
1047                name: "Kitchen".to_string(),
1048                room_name: "Kitchen".to_string(),
1049                ip_address: "192.168.1.101".to_string(),
1050                port: 1400,
1051                model_name: "Sonos One".to_string(),
1052            },
1053        ];
1054
1055        let system = create_test_system(devices).unwrap();
1056
1057        // Initialize with topology containing groups
1058        let speaker1 = SpeakerId::new("RINCON_111");
1059        let speaker2 = SpeakerId::new("RINCON_222");
1060        let group1 = GroupInfo::new(
1061            GroupId::new("RINCON_111:1"),
1062            speaker1.clone(),
1063            vec![speaker1.clone()],
1064        );
1065        let group2 = GroupInfo::new(
1066            GroupId::new("RINCON_222:1"),
1067            speaker2.clone(),
1068            vec![speaker2.clone()],
1069        );
1070
1071        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
1072        system.state_manager.initialize(topology);
1073
1074        // Verify groups() returns all groups
1075        let groups = system.groups();
1076        assert_eq!(groups.len(), 2);
1077
1078        let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
1079        assert!(group_ids.contains(&"RINCON_111:1".to_string()));
1080        assert!(group_ids.contains(&"RINCON_222:1".to_string()));
1081    }
1082
1083    #[test]
1084    fn test_groups_returns_empty_when_no_groups() {
1085        let devices = vec![Device {
1086            id: "RINCON_111".to_string(),
1087            name: "Living Room".to_string(),
1088            room_name: "Living Room".to_string(),
1089            ip_address: "192.168.1.100".to_string(),
1090            port: 1400,
1091            model_name: "Sonos One".to_string(),
1092        }];
1093
1094        let system = create_test_system(devices).unwrap();
1095
1096        // No topology initialized, so no groups
1097        let groups = system.groups();
1098        assert!(groups.is_empty());
1099    }
1100
1101    #[test]
1102    fn test_group_by_id_returns_correct_group() {
1103        let devices = vec![Device {
1104            id: "RINCON_111".to_string(),
1105            name: "Living Room".to_string(),
1106            room_name: "Living Room".to_string(),
1107            ip_address: "192.168.1.100".to_string(),
1108            port: 1400,
1109            model_name: "Sonos One".to_string(),
1110        }];
1111
1112        let system = create_test_system(devices).unwrap();
1113
1114        // Initialize with topology
1115        let speaker = SpeakerId::new("RINCON_111");
1116        let group_id = GroupId::new("RINCON_111:1");
1117        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1118
1119        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1120        system.state_manager.initialize(topology);
1121
1122        // Verify group_by_id returns the correct group
1123        let found = system.group_by_id(&group_id);
1124        assert!(found.is_some());
1125        let found = found.unwrap();
1126        assert_eq!(found.id.as_str(), "RINCON_111:1");
1127        assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
1128        assert_eq!(found.member_ids.len(), 1);
1129    }
1130
1131    #[test]
1132    fn test_group_by_id_returns_none_for_unknown() {
1133        let devices = vec![Device {
1134            id: "RINCON_111".to_string(),
1135            name: "Living Room".to_string(),
1136            room_name: "Living Room".to_string(),
1137            ip_address: "192.168.1.100".to_string(),
1138            port: 1400,
1139            model_name: "Sonos One".to_string(),
1140        }];
1141
1142        let system = create_test_system(devices).unwrap();
1143
1144        // No groups initialized
1145        let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
1146        let found = system.group_by_id(&unknown_id);
1147        assert!(found.is_none());
1148    }
1149
1150    #[test]
1151    fn test_group_for_speaker_returns_correct_group() {
1152        let devices = vec![
1153            Device {
1154                id: "RINCON_111".to_string(),
1155                name: "Living Room".to_string(),
1156                room_name: "Living Room".to_string(),
1157                ip_address: "192.168.1.100".to_string(),
1158                port: 1400,
1159                model_name: "Sonos One".to_string(),
1160            },
1161            Device {
1162                id: "RINCON_222".to_string(),
1163                name: "Kitchen".to_string(),
1164                room_name: "Kitchen".to_string(),
1165                ip_address: "192.168.1.101".to_string(),
1166                port: 1400,
1167                model_name: "Sonos One".to_string(),
1168            },
1169        ];
1170
1171        let system = create_test_system(devices).unwrap();
1172
1173        // Initialize with a group containing both speakers
1174        let speaker1 = SpeakerId::new("RINCON_111");
1175        let speaker2 = SpeakerId::new("RINCON_222");
1176        let group = GroupInfo::new(
1177            GroupId::new("RINCON_111:1"),
1178            speaker1.clone(),
1179            vec![speaker1.clone(), speaker2.clone()],
1180        );
1181
1182        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1183        system.state_manager.initialize(topology);
1184
1185        // Verify group_for_speaker returns the correct group for both speakers
1186        let found1 = system.group_for_speaker(&speaker1);
1187        assert!(found1.is_some());
1188        let found1 = found1.unwrap();
1189        assert_eq!(found1.id.as_str(), "RINCON_111:1");
1190        assert_eq!(found1.member_ids.len(), 2);
1191
1192        let found2 = system.group_for_speaker(&speaker2);
1193        assert!(found2.is_some());
1194        let found2 = found2.unwrap();
1195        assert_eq!(found2.id.as_str(), "RINCON_111:1");
1196        assert_eq!(found2.member_ids.len(), 2);
1197    }
1198
1199    #[test]
1200    fn test_group_for_speaker_returns_none_for_unknown() {
1201        let devices = vec![Device {
1202            id: "RINCON_111".to_string(),
1203            name: "Living Room".to_string(),
1204            room_name: "Living Room".to_string(),
1205            ip_address: "192.168.1.100".to_string(),
1206            port: 1400,
1207            model_name: "Sonos One".to_string(),
1208        }];
1209
1210        let system = create_test_system(devices).unwrap();
1211
1212        // No groups initialized
1213        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1214        let found = system.group_for_speaker(&unknown_speaker);
1215        assert!(found.is_none());
1216    }
1217
1218    #[test]
1219    fn test_group_methods_consistency() {
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: "192.168.1.100".to_string(),
1225            port: 1400,
1226            model_name: "Sonos One".to_string(),
1227        }];
1228
1229        let system = create_test_system(devices).unwrap();
1230
1231        // Initialize with topology
1232        let speaker = SpeakerId::new("RINCON_111");
1233        let group_id = GroupId::new("RINCON_111:1");
1234        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1235
1236        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1237        system.state_manager.initialize(topology);
1238
1239        // Verify all three methods return consistent data
1240        let groups = system.groups();
1241        assert_eq!(groups.len(), 1);
1242
1243        let by_id = system.group_by_id(&group_id);
1244        assert!(by_id.is_some());
1245
1246        let by_speaker = system.group_for_speaker(&speaker);
1247        assert!(by_speaker.is_some());
1248
1249        // All should return the same group
1250        assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
1251        assert_eq!(
1252            groups[0].id.as_str(),
1253            by_speaker.as_ref().unwrap().id.as_str()
1254        );
1255        assert_eq!(
1256            groups[0].coordinator_id.as_str(),
1257            by_id.as_ref().unwrap().coordinator_id.as_str()
1258        );
1259        assert_eq!(
1260            groups[0].coordinator_id.as_str(),
1261            by_speaker.as_ref().unwrap().coordinator_id.as_str()
1262        );
1263    }
1264
1265    #[test]
1266    fn test_group_by_name_returns_correct_group() {
1267        let devices = vec![
1268            Device {
1269                id: "RINCON_111".to_string(),
1270                name: "Living Room".to_string(),
1271                room_name: "Living Room".to_string(),
1272                ip_address: "192.168.1.100".to_string(),
1273                port: 1400,
1274                model_name: "Sonos One".to_string(),
1275            },
1276            Device {
1277                id: "RINCON_222".to_string(),
1278                name: "Kitchen".to_string(),
1279                room_name: "Kitchen".to_string(),
1280                ip_address: "192.168.1.101".to_string(),
1281                port: 1400,
1282                model_name: "Sonos One".to_string(),
1283            },
1284        ];
1285
1286        let system = create_test_system(devices).unwrap();
1287
1288        let speaker1 = SpeakerId::new("RINCON_111");
1289        let speaker2 = SpeakerId::new("RINCON_222");
1290        let group1 = GroupInfo::new(
1291            GroupId::new("RINCON_111:1"),
1292            speaker1.clone(),
1293            vec![speaker1.clone()],
1294        );
1295        let group2 = GroupInfo::new(
1296            GroupId::new("RINCON_222:1"),
1297            speaker2.clone(),
1298            vec![speaker2.clone()],
1299        );
1300
1301        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
1302        system.state_manager.initialize(topology);
1303
1304        // Find by coordinator name
1305        let found = system.group("Living Room");
1306        assert!(found.is_some());
1307        assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
1308
1309        let found = system.group("Kitchen");
1310        assert!(found.is_some());
1311        assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
1312
1313        // Unknown name returns None
1314        assert!(system.group("Nonexistent").is_none());
1315    }
1316
1317    /// Compile-time assertion that `create_group`'s signature is correct.
1318    ///
1319    /// Never called: `create_group` forwards to `Group::add_speaker`, which
1320    /// would open a real TCP connection and wait out soap-client's 5s connect
1321    /// timeout, yet the assertion is purely about types. Type-checking a
1322    /// never-called function still fails the build if the signature changes, at
1323    /// zero runtime cost.
1324    #[allow(dead_code)]
1325    fn _assert_create_group_signature(
1326        system: &SonosSystem,
1327        coordinator: &Speaker,
1328        member: &Speaker,
1329    ) {
1330        fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1331
1332        assert_change_result(system.create_group(coordinator, &[member]));
1333    }
1334
1335    /// Guards the whole point of `from_devices_offline`: no network I/O.
1336    ///
1337    /// The device IP is in RFC 5737 TEST-NET-3, which is guaranteed
1338    /// unroutable. If construction ever polls it again, soap-client's 5s
1339    /// connect timeout blows the bound; a lookup miss re-enabling SSDP costs
1340    /// 3s more. A wall-clock bound is the only way to assert absence of I/O
1341    /// without a mock transport.
1342    #[test]
1343    fn test_from_devices_offline_makes_no_network_calls() {
1344        let devices = vec![Device {
1345            id: "RINCON_111".to_string(),
1346            name: "Living Room".to_string(),
1347            room_name: "Living Room".to_string(),
1348            ip_address: "203.0.113.1".to_string(),
1349            port: 1400,
1350            model_name: "Sonos One".to_string(),
1351        }];
1352
1353        let start = std::time::Instant::now();
1354        let system = SonosSystem::from_devices_offline(devices).unwrap();
1355        assert!(system.speaker("Living Room").is_some());
1356        assert!(system.speaker("Nonexistent").is_none());
1357        assert!(system.groups().is_empty());
1358        let elapsed = start.elapsed();
1359
1360        assert!(
1361            elapsed < Duration::from_millis(500),
1362            "offline construction and lookups should not touch the network, took {elapsed:?}"
1363        );
1364    }
1365
1366    /// Dropping a `SonosSystem` must actually free its `StateManager`.
1367    ///
1368    /// The init closure is stored *on* the manager, so capturing a strong
1369    /// `Arc<StateManager>` in it made the manager own a closure that owned the
1370    /// manager. The cycle was invisible from the outside — construction and
1371    /// teardown both "worked" — but every `SonosSystem::new()` permanently
1372    /// leaked the manager, its store, the event-worker thread, the event
1373    /// manager's tokio runtime, and the callback socket. Only a `Weak` that
1374    /// outlives the system can observe the difference.
1375    #[test]
1376    fn test_dropping_system_releases_state_manager() {
1377        let devices = vec![Device {
1378            id: "RINCON_111".to_string(),
1379            name: "Living Room".to_string(),
1380            room_name: "Living Room".to_string(),
1381            ip_address: "203.0.113.1".to_string(),
1382            port: 1400,
1383            model_name: "Sonos One".to_string(),
1384        }];
1385
1386        let system = SonosSystem::from_devices_offline(devices).unwrap();
1387        let weak = system.state_manager_weak();
1388
1389        // Alive: reachable. The live count is deliberately not asserted — each
1390        // Speaker handle legitimately holds its own Arc, so the number tracks
1391        // the device count rather than anything about the cycle.
1392        assert!(weak.upgrade().is_some());
1393
1394        drop(system);
1395
1396        // Dropped: the system owned the speakers too, so nothing legitimate is
1397        // left holding the manager. A surviving strong reference can only be the
1398        // init closure the manager itself stores.
1399        assert_eq!(
1400            weak.strong_count(),
1401            0,
1402            "StateManager outlived its SonosSystem — the event-init closure is \
1403             holding a strong Arc to the manager that stores it"
1404        );
1405        assert!(weak.upgrade().is_none());
1406    }
1407
1408    /// The same, but after `watch()` has run the lazy event-manager init, which
1409    /// is the path that actually exercises the closure's capture.
1410    ///
1411    /// Speakers hold `Arc`s to the manager, so the strong count is >1 here; the
1412    /// assertion is the one that matters — once every handle is gone, nothing
1413    /// keeps the manager alive.
1414    #[test]
1415    fn test_dropping_system_after_watch_releases_state_manager() {
1416        let devices = vec![Device {
1417            id: "RINCON_111".to_string(),
1418            name: "Living Room".to_string(),
1419            room_name: "Living Room".to_string(),
1420            ip_address: "203.0.113.1".to_string(),
1421            port: 1400,
1422            model_name: "Sonos One".to_string(),
1423        }];
1424
1425        let system = SonosSystem::from_devices_offline(devices).unwrap();
1426        let weak = system.state_manager_weak();
1427
1428        {
1429            let speaker = system.speaker("Living Room").unwrap();
1430            // Runs the init closure. No event manager can bind here (offline
1431            // test host may or may not permit it), so the mode is whatever the
1432            // environment allows — the point is that the closure executed.
1433            let _watch = speaker.volume.watch().unwrap();
1434        }
1435
1436        drop(system);
1437
1438        assert!(
1439            weak.upgrade().is_none(),
1440            "StateManager outlived its SonosSystem after watch() ran the lazy \
1441             event-init closure"
1442        );
1443    }
1444
1445    #[test]
1446    fn test_display_name_prefers_room_name() {
1447        let device = Device {
1448            id: "RINCON_111".to_string(),
1449            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1450            room_name: "Kitchen".to_string(),
1451            ip_address: "192.168.1.100".to_string(),
1452            port: 1400,
1453            model_name: "Sonos One".to_string(),
1454        };
1455        assert_eq!(display_name(&device), "Kitchen");
1456    }
1457
1458    #[test]
1459    fn test_display_name_falls_back_to_friendly_name() {
1460        let device = Device {
1461            id: "RINCON_111".to_string(),
1462            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1463            room_name: "Unknown".to_string(),
1464            ip_address: "192.168.1.100".to_string(),
1465            port: 1400,
1466            model_name: "Sonos One".to_string(),
1467        };
1468        assert_eq!(
1469            display_name(&device),
1470            "192.168.1.100 - Sonos One - RINCON_111"
1471        );
1472
1473        let device_empty = Device {
1474            id: "RINCON_222".to_string(),
1475            name: "192.168.1.101 - Sonos One".to_string(),
1476            room_name: "".to_string(),
1477            ip_address: "192.168.1.101".to_string(),
1478            port: 1400,
1479            model_name: "Sonos One".to_string(),
1480        };
1481        assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1482    }
1483
1484    #[test]
1485    fn test_speaker_lookup_case_insensitive() {
1486        let devices = vec![Device {
1487            id: "RINCON_111".to_string(),
1488            name: "Kitchen".to_string(),
1489            room_name: "Kitchen".to_string(),
1490            ip_address: "192.168.1.100".to_string(),
1491            port: 1400,
1492            model_name: "Sonos One".to_string(),
1493        }];
1494        let system = create_test_system(devices).unwrap();
1495        assert!(system.speaker("Kitchen").is_some());
1496        assert!(system.speaker("kitchen").is_some());
1497        assert!(system.speaker("KITCHEN").is_some());
1498        assert!(system.speaker("Nonexistent").is_none());
1499    }
1500
1501    #[test]
1502    fn test_speaker_uses_room_name() {
1503        let devices = vec![Device {
1504            id: "RINCON_111".to_string(),
1505            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1506            room_name: "Kitchen".to_string(),
1507            ip_address: "192.168.1.100".to_string(),
1508            port: 1400,
1509            model_name: "Sonos One".to_string(),
1510        }];
1511
1512        let system = create_test_system(devices).unwrap();
1513        let spk = system.speaker("Kitchen");
1514        assert!(spk.is_some());
1515        assert_eq!(spk.unwrap().name, "Kitchen");
1516
1517        // Verbose friendlyName should NOT match
1518        assert!(system
1519            .speaker("192.168.1.100 - Sonos One - RINCON_111")
1520            .is_none());
1521    }
1522
1523    // ========================================================================
1524    // Bonded home theater: satellite exclusion vs. name-key collision
1525    // ========================================================================
1526
1527    /// The real hardware that exposed the bug: a Playbar with two Sonos One
1528    /// surrounds bonded as one home theater, all three reporting
1529    /// `room_name = "Basement"`.
1530    ///
1531    /// IPs are RFC 5737 TEST-NET-3 so nothing here can reach a real device.
1532    fn basement_home_theater() -> Vec<Device> {
1533        vec![
1534            Device {
1535                id: "RINCON_PLAYBAR".to_string(),
1536                name: "Basement".to_string(),
1537                room_name: "Basement".to_string(),
1538                ip_address: "203.0.113.10".to_string(),
1539                port: 1400,
1540                model_name: "Sonos Playbar".to_string(),
1541            },
1542            Device {
1543                id: "RINCON_SURROUND_L".to_string(),
1544                name: "Basement".to_string(),
1545                room_name: "Basement".to_string(),
1546                ip_address: "203.0.113.11".to_string(),
1547                port: 1400,
1548                model_name: "Sonos One".to_string(),
1549            },
1550            Device {
1551                id: "RINCON_SURROUND_R".to_string(),
1552                name: "Basement".to_string(),
1553                room_name: "Basement".to_string(),
1554                ip_address: "203.0.113.12".to_string(),
1555                port: 1400,
1556                model_name: "Sonos One".to_string(),
1557            },
1558        ]
1559    }
1560
1561    /// Every permutation of a device list, to stand in for arbitrary SSDP
1562    /// response order. Device counts here are 2-3, so the factorial is fine.
1563    fn all_orderings(devices: &[Device]) -> Vec<Vec<Device>> {
1564        if devices.len() <= 1 {
1565            return vec![devices.to_vec()];
1566        }
1567        let mut out = Vec::new();
1568        for i in 0..devices.len() {
1569            let mut rest = devices.to_vec();
1570            let head = rest.remove(i);
1571            for mut tail in all_orderings(&rest) {
1572                let mut one = vec![head.clone()];
1573                one.append(&mut tail);
1574                out.push(one);
1575            }
1576        }
1577        out
1578    }
1579
1580    /// Build a system through the real construction sequence, with the topology
1581    /// that `ensure_topology` would have polled injected instead.
1582    ///
1583    /// Deliberately does *not* re-key the map itself: ordering is the bug, so
1584    /// the test must let production decide when filtering happens.
1585    fn system_with_satellites(devices: &[Device], satellites: &[&str]) -> SonosSystem {
1586        SonosSystem::from_devices_offline_with_topology(devices.to_vec(), |system| {
1587            system
1588                .state_manager
1589                .set_satellite_ids(satellites.iter().map(|id| SpeakerId::new(*id)).collect());
1590        })
1591        .unwrap()
1592    }
1593
1594    /// `from_devices_offline_with_groups` must produce real multi-member groups.
1595    ///
1596    /// This is the capability a downstream crate was missing: `with_groups` only
1597    /// builds single-member groups, and the `seed` closure of
1598    /// `from_devices_offline_with_topology` cannot be used outside this crate
1599    /// because it hands back a `&SonosSystem` whose `state_manager` is private.
1600    /// sonos-cli hit exactly this when trying to test that its default speaker
1601    /// selection prefers the largest group.
1602    #[test]
1603    fn test_offline_with_groups_builds_multi_member_groups() {
1604        let devices: Vec<Device> = ["Bedroom", "Living Room", "Kitchen"]
1605            .iter()
1606            .enumerate()
1607            .map(|(i, name)| Device {
1608                id: format!("RINCON_{i:03}"),
1609                name: name.to_string(),
1610                room_name: name.to_string(),
1611                ip_address: format!("192.0.2.{}", 10 + i),
1612                port: 1400,
1613                model_name: "Sonos One".to_string(),
1614            })
1615            .collect();
1616
1617        let system = SonosSystem::from_devices_offline_with_groups(
1618            devices,
1619            vec![
1620                // Bedroom + Living Room grouped, Bedroom coordinating.
1621                (
1622                    GroupId::new("RINCON_000:1"),
1623                    SpeakerId::new("RINCON_000"),
1624                    vec![SpeakerId::new("RINCON_000"), SpeakerId::new("RINCON_001")],
1625                ),
1626                // Kitchen standalone.
1627                (
1628                    GroupId::new("RINCON_002:1"),
1629                    SpeakerId::new("RINCON_002"),
1630                    vec![SpeakerId::new("RINCON_002")],
1631                ),
1632            ],
1633        )
1634        .unwrap();
1635
1636        let groups = system.groups();
1637        assert_eq!(groups.len(), 2, "expected two groups, got {}", groups.len());
1638
1639        let multi = groups
1640            .iter()
1641            .find(|g| g.member_ids.len() == 2)
1642            .expect("a two-member group must exist — this is what with_groups cannot express");
1643        assert_eq!(multi.coordinator_id, SpeakerId::new("RINCON_000"));
1644        assert_eq!(
1645            multi.coordinator().map(|c| c.name),
1646            Some("Bedroom".to_string())
1647        );
1648
1649        assert!(
1650            groups.iter().any(|g| g.member_ids.len() == 1),
1651            "the standalone group must survive alongside the multi-member one"
1652        );
1653    }
1654
1655    /// A bonded home theater must appear as exactly one controllable speaker.
1656    ///
1657    /// Hardware symptom this pins down: `sonos speakers` logged two "duplicate
1658    /// speaker name" warnings and then listed no Basement at all, while `sonos
1659    /// groups` showed Basement with a live volume. The name-keyed map collapsed
1660    /// all three devices onto one key, and satellite filtering — which ran
1661    /// afterwards — deleted whichever one had won, taking the room with it.
1662    #[test]
1663    fn test_bonded_home_theater_keeps_visible_coordinator() {
1664        // SSDP response order is arbitrary — on the real system a surround
1665        // answered before the Playbar — and which device wins the name key
1666        // depends entirely on it. Assert over every permutation: a fix that only
1667        // holds when the coordinator happens to arrive first is not a fix.
1668        for order in all_orderings(&basement_home_theater()) {
1669            let ids: Vec<&str> = order.iter().map(|d| d.id.as_str()).collect();
1670            let system =
1671                system_with_satellites(&order, &["RINCON_SURROUND_L", "RINCON_SURROUND_R"]);
1672
1673            // The room survives, exactly once.
1674            assert_eq!(
1675                system.speaker_names(),
1676                vec!["Basement".to_string()],
1677                "bonded home theater should be exactly one speaker named after its room \
1678                 (discovery order {ids:?})"
1679            );
1680
1681            // And it is the Playbar — the visible coordinator that accepts
1682            // commands — not a surround. This is what `sonos -s Basement volume
1683            // 30` reaches.
1684            let basement = system
1685                .speaker("Basement")
1686                .unwrap_or_else(|| panic!("Basement must be reachable by name (order {ids:?})"));
1687            assert_eq!(
1688                basement.id,
1689                SpeakerId::new("RINCON_PLAYBAR"),
1690                "survivor must be the visible coordinator, not a satellite (order {ids:?})"
1691            );
1692            assert_eq!(basement.model_name, "Sonos Playbar");
1693            assert_eq!(basement.ip.to_string(), "203.0.113.10");
1694
1695            // Satellites are not addressable as speakers in their own right.
1696            assert!(system
1697                .speaker_by_id(&SpeakerId::new("RINCON_SURROUND_L"))
1698                .is_none());
1699            assert!(system
1700                .speaker_by_id(&SpeakerId::new("RINCON_SURROUND_R"))
1701                .is_none());
1702        }
1703    }
1704
1705    /// No regression for the ordinary case: a room with one visible speaker and
1706    /// no satellites anywhere is untouched.
1707    #[test]
1708    fn test_single_visible_speaker_room_unaffected() {
1709        let devices = vec![
1710            Device {
1711                id: "RINCON_KITCHEN".to_string(),
1712                name: "Kitchen".to_string(),
1713                room_name: "Kitchen".to_string(),
1714                ip_address: "203.0.113.20".to_string(),
1715                port: 1400,
1716                model_name: "Sonos One".to_string(),
1717            },
1718            Device {
1719                id: "RINCON_OFFICE".to_string(),
1720                name: "Office".to_string(),
1721                room_name: "Office".to_string(),
1722                ip_address: "203.0.113.21".to_string(),
1723                port: 1400,
1724                model_name: "Sonos Roam".to_string(),
1725            },
1726        ];
1727
1728        // No satellites: the rebuild is a no-op and both rooms stand.
1729        let system = system_with_satellites(&devices, &[]);
1730
1731        assert_eq!(system.speakers().len(), 2);
1732        assert_eq!(
1733            system.speaker("Kitchen").unwrap().id,
1734            SpeakerId::new("RINCON_KITCHEN")
1735        );
1736        assert_eq!(
1737            system.speaker("Office").unwrap().id,
1738            SpeakerId::new("RINCON_OFFICE")
1739        );
1740    }
1741
1742    /// A visible speaker sharing a room with satellites keeps its plain name.
1743    ///
1744    /// Guards against over-correcting: satellite exclusion must not push the
1745    /// coordinator onto a disambiguated key, which would break `sonos -s
1746    /// Basement`.
1747    #[test]
1748    fn test_coordinator_keeps_plain_room_name_not_disambiguated() {
1749        let devices = basement_home_theater();
1750        let system = system_with_satellites(&devices, &["RINCON_SURROUND_L", "RINCON_SURROUND_R"]);
1751
1752        assert!(
1753            system.speaker("Basement").is_some(),
1754            "coordinator must hold the plain room name"
1755        );
1756        for name in system.speaker_names() {
1757            assert!(
1758                !name.contains('('),
1759                "coordinator should not be disambiguated when the collision was satellites: {name}"
1760            );
1761        }
1762    }
1763
1764    /// Two *genuinely visible* speakers sharing a room name: both are kept, the
1765    /// first under the plain name and the second suffixed with its ID.
1766    ///
1767    /// The old behavior silently dropped one — real data loss with only a log
1768    /// line to show for it. Sonos prevents duplicate room names in the app, so
1769    /// this state means something unusual (a rename mid-discovery, a stale cache
1770    /// entry for a replaced unit); dropping a controllable speaker is the worse
1771    /// answer in every such case.
1772    #[test]
1773    fn test_two_visible_speakers_same_room_are_both_kept() {
1774        let devices = vec![
1775            Device {
1776                id: "RINCON_DUPE_1".to_string(),
1777                name: "Basement".to_string(),
1778                room_name: "Basement".to_string(),
1779                ip_address: "203.0.113.30".to_string(),
1780                port: 1400,
1781                model_name: "Sonos One".to_string(),
1782            },
1783            Device {
1784                id: "RINCON_DUPE_2".to_string(),
1785                name: "Basement".to_string(),
1786                room_name: "Basement".to_string(),
1787                ip_address: "203.0.113.31".to_string(),
1788                port: 1400,
1789                model_name: "Sonos Five".to_string(),
1790            },
1791        ];
1792
1793        // Neither is a satellite.
1794        let system = system_with_satellites(&devices, &[]);
1795
1796        // Nothing is lost.
1797        assert_eq!(
1798            system.speakers().len(),
1799            2,
1800            "both visible speakers must be retained, not silently overwritten"
1801        );
1802
1803        // The first-seen keeps the plain name, so existing scripts keep working.
1804        assert_eq!(
1805            system.speaker("Basement").unwrap().id,
1806            SpeakerId::new("RINCON_DUPE_1")
1807        );
1808
1809        // The second is reachable under an ID-suffixed name.
1810        let second = system
1811            .speaker("Basement (RINCON_DUPE_2)")
1812            .expect("second visible speaker must be reachable by disambiguated name");
1813        assert_eq!(second.id, SpeakerId::new("RINCON_DUPE_2"));
1814        assert_eq!(second.model_name, "Sonos Five");
1815
1816        // Both remain addressable by ID.
1817        assert!(system
1818            .speaker_by_id(&SpeakerId::new("RINCON_DUPE_1"))
1819            .is_some());
1820        assert!(system
1821            .speaker_by_id(&SpeakerId::new("RINCON_DUPE_2"))
1822            .is_some());
1823    }
1824
1825    #[test]
1826    fn test_group_lookup_case_insensitive() {
1827        let devices = vec![Device {
1828            id: "RINCON_111".to_string(),
1829            name: "Living Room".to_string(),
1830            room_name: "Living Room".to_string(),
1831            ip_address: "192.168.1.100".to_string(),
1832            port: 1400,
1833            model_name: "Sonos One".to_string(),
1834        }];
1835
1836        let system = create_test_system(devices).unwrap();
1837
1838        let speaker = SpeakerId::new("RINCON_111");
1839        let group = GroupInfo::new(
1840            GroupId::new("RINCON_111:1"),
1841            speaker.clone(),
1842            vec![speaker.clone()],
1843        );
1844
1845        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1846        system.state_manager.initialize(topology);
1847
1848        assert!(system.group("Living Room").is_some());
1849        assert!(system.group("living room").is_some());
1850        assert!(system.group("LIVING ROOM").is_some());
1851        assert!(system.group("Nonexistent").is_none());
1852    }
1853}