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