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