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}