Skip to main content

sonos_sdk/
system.rs

1//! SonosSystem - Main entry point for the SDK
2//!
3//! Provides a sync-first, DOM-like API for controlling Sonos devices.
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex, RwLock};
8use std::time::Duration;
9
10use sonos_api::SonosClient;
11use sonos_discovery::{self, Device};
12use sonos_event_manager::SonosEventManager;
13#[cfg(feature = "test-support")]
14use sonos_state::GroupInfo;
15use sonos_state::{EventInitFn, GroupId, SpeakerId, StateManager, Topology};
16
17use crate::{cache, Group, SdkError, Speaker};
18
19/// Compute the display name for a device.
20///
21/// Prefers `room_name` (user-assigned in the Sonos app, e.g., "Kitchen").
22/// Falls back to `name` (UPnP `friendlyName`) when `room_name` is absent or unknown.
23fn display_name(device: &Device) -> String {
24    if device.room_name.is_empty() || device.room_name == "Unknown" {
25        device.name.clone()
26    } else {
27        device.room_name.clone()
28    }
29}
30
31/// Find a speaker by name with case-insensitive fallback.
32///
33/// Tries an exact O(1) HashMap lookup first, then falls back to
34/// case-insensitive iteration (O(n), typically n < 50).
35fn find_speaker_by_name(speakers: &HashMap<String, Speaker>, name: &str) -> Option<Speaker> {
36    if let Some(speaker) = speakers.get(name) {
37        return Some(speaker.clone());
38    }
39    speakers
40        .values()
41        .find(|s| s.name.eq_ignore_ascii_case(name))
42        .cloned()
43}
44
45/// Main system entry point - provides DOM-like API
46///
47/// SonosSystem is fully synchronous - no async/await required.
48///
49/// # Example
50///
51/// ```rust,ignore
52/// use sonos_sdk::SonosSystem;
53///
54/// fn main() -> Result<(), sonos_sdk::SdkError> {
55///     let system = SonosSystem::new()?;
56///
57///     // Get speaker by name
58///     let speaker = system.speaker("Living Room")
59///         .ok_or_else(|| sonos_sdk::SdkError::SpeakerNotFound("Living Room".to_string()))?;
60///
61///     // Three methods on each property:
62///     let volume = speaker.volume.get();              // Get cached value
63///     let fresh_volume = speaker.volume.fetch()?;     // API call + update cache
64///     let current = speaker.volume.watch()?;          // Start watching for changes
65///
66///     // Iterate over changes
67///     for event in system.iter() {
68///         println!("Property changed: {:?}", event);
69///     }
70///
71///     Ok(())
72/// }
73/// ```
74pub struct SonosSystem {
75    /// State manager for property values
76    state_manager: Arc<StateManager>,
77
78    /// Event manager for UPnP subscriptions (lazily initialized on first watch()).
79    /// Kept alive here to prevent the Arc from being dropped; the StateManager
80    /// holds its own reference via OnceLock for use by watch()/unwatch().
81    #[allow(dead_code)]
82    event_manager: Mutex<Option<Arc<SonosEventManager>>>,
83
84    /// API client for direct operations
85    api_client: SonosClient,
86
87    /// Speaker handles by name
88    speakers: RwLock<HashMap<String, Speaker>>,
89
90    /// Timestamp of last rediscovery attempt (seconds since UNIX_EPOCH, 0 = never)
91    last_rediscovery: AtomicU64,
92}
93
94const REDISCOVERY_COOLDOWN_SECS: u64 = 30;
95
96impl SonosSystem {
97    /// Create a new SonosSystem with cache-first device discovery (sync)
98    ///
99    /// Discovery strategy:
100    /// 1. Try loading cached devices from disk (~/.cache/sonos/cache.json)
101    /// 2. If cache is fresh (< 24h), use cached devices
102    /// 3. If cache is stale, run SSDP; fall back to stale cache if SSDP finds nothing
103    /// 4. If no cache exists, run SSDP discovery
104    /// 5. If no devices found anywhere, return `Err(SdkError::DiscoveryFailed)`
105    pub fn new() -> Result<Self, SdkError> {
106        let devices = match cache::load() {
107            Some(cached) if !cache::is_stale(&cached) => {
108                // Fresh cache — use directly
109                cached.devices
110            }
111            Some(cached) => {
112                // Stale cache — try SSDP, fall back to stale data
113                let fresh = sonos_discovery::get_with_timeout(Duration::from_secs(3));
114                if fresh.is_empty() {
115                    tracing::warn!("Cache is stale and SSDP found no devices; using stale cache");
116                    cached.devices
117                } else {
118                    if let Err(e) = cache::save(&fresh) {
119                        tracing::warn!("Failed to save discovery cache: {}", e);
120                    }
121                    fresh
122                }
123            }
124            None => {
125                // No cache — full SSDP discovery
126                let fresh = sonos_discovery::get_with_timeout(Duration::from_secs(3));
127                if fresh.is_empty() {
128                    return Err(SdkError::DiscoveryFailed(
129                        "no Sonos devices found on the network".to_string(),
130                    ));
131                }
132                if let Err(e) = cache::save(&fresh) {
133                    tracing::warn!("Failed to save discovery cache: {}", e);
134                }
135                fresh
136            }
137        };
138
139        Self::from_discovered_devices(devices)
140    }
141
142    /// Create a new SonosSystem from pre-discovered devices (sync)
143    ///
144    /// Internal constructor used by `new()` and SDK unit tests.
145    /// Also available publicly when the `test-support` feature is enabled
146    /// (for integration tests and downstream test code).
147    #[cfg(not(feature = "test-support"))]
148    pub(crate) fn from_discovered_devices(devices: Vec<Device>) -> Result<Self, SdkError> {
149        Self::from_devices_inner(devices)
150    }
151
152    /// Create a new SonosSystem from pre-discovered devices (sync)
153    ///
154    /// Available publicly for integration tests when `test-support` is enabled.
155    /// Normal consumers should use [`SonosSystem::new()`] instead.
156    #[cfg(feature = "test-support")]
157    pub fn from_discovered_devices(devices: Vec<Device>) -> Result<Self, SdkError> {
158        Self::from_devices_inner(devices)
159    }
160
161    fn from_devices_inner(devices: Vec<Device>) -> Result<Self, SdkError> {
162        // 1. Create shared state FIRST — no event manager yet (lazy init)
163        let state_manager = Arc::new(StateManager::new().map_err(SdkError::StateError)?);
164        state_manager
165            .add_devices(devices.clone())
166            .map_err(SdkError::StateError)?;
167
168        let api_client = SonosClient::new();
169        let event_manager: Arc<Mutex<Option<Arc<SonosEventManager>>>> = Arc::new(Mutex::new(None));
170
171        // 2. Build init closure and store on StateManager (single source of truth)
172        let init_fn: EventInitFn = {
173            let em_mutex = Arc::clone(&event_manager);
174            let sm = Arc::clone(&state_manager);
175            Arc::new(
176                move || -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
177                    let mut guard = em_mutex.lock().map_err(|_| SdkError::LockPoisoned)?;
178                    if guard.is_some() {
179                        tracing::trace!(
180                            "Event manager init closure called but already initialized"
181                        );
182                        return Ok(());
183                    }
184                    tracing::info!("Lazy-initializing event manager (first watch() call)");
185                    let em = Arc::new(SonosEventManager::new().map_err(|e| {
186                        tracing::error!("Failed to create SonosEventManager: {}", e);
187                        SdkError::EventManager(e.to_string())
188                    })?);
189                    tracing::debug!("SonosEventManager created, wiring into StateManager");
190                    sm.set_event_manager(Arc::clone(&em))
191                        .map_err(SdkError::StateError)?;
192                    *guard = Some(em);
193                    tracing::info!("Event manager initialization complete");
194                    Ok(())
195                },
196            )
197        };
198        state_manager.set_event_init(init_fn);
199
200        // 3. Build speakers (init fn is on StateManager — no per-speaker threading needed)
201        let speakers = Self::build_speakers(&devices, &state_manager, &api_client)?;
202
203        // 4. Assemble struct from the SAME Arcs
204        let system = Self {
205            state_manager,
206            event_manager: Arc::try_unwrap(event_manager).unwrap_or_else(|arc| {
207                let inner = arc.lock().unwrap().clone();
208                Mutex::new(inner)
209            }),
210            api_client,
211            speakers: RwLock::new(speakers),
212            last_rediscovery: AtomicU64::new(0),
213        };
214
215        // 5. Prefetch topology before any subscriptions can start.
216        //    This ensures group structure is known when the first AVTransport
217        //    events arrive, so PerCoordinator suppression/propagation works
218        //    from the very first event.
219        system.ensure_topology();
220
221        // 6. Filter satellite speakers (surrounds/subs marked Invisible="1")
222        let satellite_ids = system.state_manager.get_satellite_ids();
223        if !satellite_ids.is_empty() {
224            if let Ok(mut speakers) = system.speakers.write() {
225                speakers.retain(|_name, speaker| !satellite_ids.contains(&speaker.id));
226            }
227            tracing::debug!("Filtered {} satellite speakers", satellite_ids.len());
228        }
229
230        // 7. Refresh Speaker handle IPs from state store (topology may have updated them)
231        if let Ok(mut speakers) = system.speakers.write() {
232            for speaker in speakers.values_mut() {
233                if let Some(info) = system.state_manager.speaker_info(&speaker.id) {
234                    speaker.ip = info.ip_address;
235                }
236            }
237        }
238
239        Ok(system)
240    }
241
242    /// Create a test SonosSystem with named speakers and no network access.
243    ///
244    /// Builds an in-memory system with synthetic speaker data. No SSDP discovery,
245    /// no event manager socket binding, no cache reads. Speakers get sequential
246    /// IPs starting at `192.168.1.100`.
247    ///
248    /// Only available when the `test-support` feature is enabled.
249    ///
250    /// # Example
251    ///
252    /// ```rust,ignore
253    /// let system = SonosSystem::with_speakers(&["Kitchen", "Bedroom"]);
254    /// assert_eq!(system.speakers().len(), 2);
255    /// assert!(system.speaker("Kitchen").is_some());
256    /// ```
257    #[cfg(feature = "test-support")]
258    pub fn with_speakers(names: &[&str]) -> Self {
259        let devices: Vec<Device> = names
260            .iter()
261            .enumerate()
262            .map(|(i, name)| Device {
263                id: format!("RINCON_{i:03}"),
264                name: name.to_string(),
265                room_name: name.to_string(),
266                ip_address: format!("192.168.1.{}", 100 + i),
267                port: 1400,
268                model_name: "Sonos One".to_string(),
269            })
270            .collect();
271
272        let state_manager =
273            Arc::new(StateManager::new().expect("StateManager::new() should not fail"));
274
275        state_manager
276            .add_devices(devices.clone())
277            .expect("add_devices should not fail with valid test data");
278
279        let api_client = SonosClient::new();
280        let speakers = Self::build_speakers(&devices, &state_manager, &api_client)
281            .expect("build_speakers should not fail with valid test data");
282
283        Self {
284            state_manager,
285            event_manager: Mutex::new(None),
286            api_client,
287            speakers: RwLock::new(speakers),
288            last_rediscovery: AtomicU64::new(0),
289        }
290    }
291
292    /// Create a test SonosSystem with speakers AND group topology.
293    ///
294    /// Each speaker gets a standalone group (coordinator = self, members = [self]).
295    /// This makes `system.groups()` and `system.group("name")` work in tests.
296    ///
297    /// # Example
298    ///
299    /// ```rust,ignore
300    /// let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
301    /// assert_eq!(system.groups().len(), 2);
302    /// assert!(system.group("Kitchen").is_some());
303    /// ```
304    #[cfg(feature = "test-support")]
305    pub fn with_groups(names: &[&str]) -> Self {
306        let system = Self::with_speakers(names);
307
308        let groups: Vec<GroupInfo> = names
309            .iter()
310            .enumerate()
311            .map(|(i, _name)| {
312                let speaker_id = SpeakerId::new(format!("RINCON_{i:03}"));
313                let group_id = GroupId::new(format!("RINCON_{i:03}:1"));
314                GroupInfo::new(group_id, speaker_id.clone(), vec![speaker_id])
315            })
316            .collect();
317
318        let topology = Topology::new(system.state_manager.speaker_infos(), groups);
319        system.state_manager.initialize(topology);
320
321        system
322    }
323
324    /// Build Speaker handles from a list of devices.
325    fn build_speakers(
326        devices: &[Device],
327        state_manager: &Arc<StateManager>,
328        api_client: &SonosClient,
329    ) -> Result<HashMap<String, Speaker>, SdkError> {
330        let mut speakers = HashMap::new();
331        for device in devices {
332            let speaker_id = SpeakerId::new(&device.id);
333            let ip = device
334                .ip_address
335                .parse()
336                .map_err(|_| SdkError::InvalidIpAddress)?;
337
338            let name = display_name(device);
339            let speaker = Speaker::new(
340                speaker_id,
341                name.clone(),
342                ip,
343                device.model_name.clone(),
344                Arc::clone(state_manager),
345                api_client.clone(),
346            );
347
348            if speakers.contains_key(&name) {
349                tracing::warn!(
350                    "duplicate speaker name \"{}\", keeping last discovered",
351                    name
352                );
353            }
354            speakers.insert(name, speaker);
355        }
356        Ok(speakers)
357    }
358
359    /// Get speaker by name (sync)
360    ///
361    /// If the speaker isn't in the current map, triggers an SSDP
362    /// rediscovery (rate-limited to once per 30s) before returning `None`.
363    ///
364    /// # Example
365    ///
366    /// ```rust,ignore
367    /// let kitchen = sonos.speaker("Kitchen").unwrap();
368    /// kitchen.play()?;
369    /// ```
370    pub fn speaker(&self, name: &str) -> Option<Speaker> {
371        {
372            let speakers = self.speakers.read().ok()?;
373            if let Some(speaker) = find_speaker_by_name(&speakers, name) {
374                return Some(speaker);
375            }
376        }
377        // Not found — try rediscovery (cooldown-limited)
378        self.try_rediscover(name);
379        let speakers = self.speakers.read().ok()?;
380        find_speaker_by_name(&speakers, name)
381    }
382
383    /// Get speaker by name (sync)
384    #[deprecated(since = "0.2.0", note = "renamed to `speaker()`")]
385    pub fn get_speaker_by_name(&self, name: &str) -> Option<Speaker> {
386        self.speaker(name)
387    }
388
389    /// Run SSDP rediscovery with cooldown. Updates internal speaker map and cache.
390    fn try_rediscover(&self, name: &str) {
391        let now = std::time::SystemTime::now()
392            .duration_since(std::time::UNIX_EPOCH)
393            .unwrap_or_default()
394            .as_secs();
395        let last = self.last_rediscovery.load(Ordering::Relaxed);
396        if last > 0 && now - last < REDISCOVERY_COOLDOWN_SECS {
397            return; // Cooldown period not elapsed
398        }
399        self.last_rediscovery.store(now, Ordering::Relaxed);
400
401        // 1. SSDP runs WITHOUT holding any lock (3s)
402        tracing::info!("speaker '{}' not found, running auto-rediscovery...", name);
403        let devices = sonos_discovery::get_with_timeout(Duration::from_secs(3));
404        if devices.is_empty() {
405            return;
406        }
407
408        // 2. Register devices with state manager (required for property tracking)
409        if let Err(e) = self.state_manager.add_devices(devices.clone()) {
410            tracing::warn!("Failed to register rediscovered devices: {}", e);
411            return;
412        }
413
414        // 3. Build new Speaker handles (no lock needed)
415        let new_speakers =
416            match Self::build_speakers(&devices, &self.state_manager, &self.api_client) {
417                Ok(s) => s,
418                Err(e) => {
419                    tracing::warn!("Failed to build speakers from rediscovery: {}", e);
420                    return;
421                }
422            };
423
424        // 4. Acquire write lock BRIEFLY for map swap only
425        if let Ok(mut map) = self.speakers.write() {
426            *map = new_speakers;
427        }
428
429        // 5. Save cache (non-fatal on failure)
430        if let Err(e) = cache::save(&devices) {
431            tracing::warn!("Failed to save discovery cache: {}", e);
432        }
433    }
434
435    /// Get all speakers (sync)
436    pub fn speakers(&self) -> Vec<Speaker> {
437        self.speakers
438            .read()
439            .map(|s| s.values().cloned().collect())
440            .unwrap_or_default()
441    }
442
443    /// Get speaker by ID (sync)
444    pub fn speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
445        let speakers = self.speakers.read().ok()?;
446        speakers.values().find(|s| s.id == *speaker_id).cloned()
447    }
448
449    /// Get speaker by ID (sync)
450    #[deprecated(since = "0.2.0", note = "renamed to `speaker_by_id()`")]
451    pub fn get_speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
452        self.speaker_by_id(speaker_id)
453    }
454
455    /// Get all speaker names (sync)
456    pub fn speaker_names(&self) -> Vec<String> {
457        self.speakers
458            .read()
459            .map(|s| s.keys().cloned().collect())
460            .unwrap_or_default()
461    }
462
463    /// Get the state manager for advanced usage
464    pub fn state_manager(&self) -> &Arc<StateManager> {
465        &self.state_manager
466    }
467
468    /// Get a blocking iterator over property change events
469    ///
470    /// Only emits events for properties that have been `watch()`ed.
471    ///
472    /// # Example
473    ///
474    /// ```rust,ignore
475    /// // First, watch some properties
476    /// speaker.volume.watch()?;
477    /// speaker.playback_state.watch()?;
478    ///
479    /// // Then iterate over changes (blocking)
480    /// for event in system.iter() {
481    ///     println!("Changed: {} on {}", event.property_key, event.speaker_id);
482    /// }
483    /// ```
484    pub fn iter(&self) -> sonos_state::ChangeIterator {
485        self.state_manager.iter()
486    }
487
488    // ========================================================================
489    // Topology Fetch
490    // ========================================================================
491
492    /// Ensure group topology has been fetched.
493    ///
494    /// Tries all known speaker IPs sequentially until one responds with topology.
495    /// Topology data is identical from any speaker, so first success wins.
496    /// Also refreshes speaker IPs and records satellite IDs from the topology.
497    fn ensure_topology(&self) {
498        if self.state_manager.group_count() > 0 {
499            return;
500        }
501
502        let speaker_ips: Vec<String> = {
503            let speakers = match self.speakers.read() {
504                Ok(s) => s,
505                Err(_) => return,
506            };
507            speakers.values().map(|s| s.ip.to_string()).collect()
508        };
509
510        for speaker_ip in &speaker_ips {
511            let topology_state = match sonos_api::services::zone_group_topology::state::poll(
512                &self.api_client,
513                speaker_ip,
514            ) {
515                Ok(state) => state,
516                Err(e) => {
517                    tracing::debug!("Topology fetch failed for {}: {}", speaker_ip, e);
518                    continue;
519                }
520            };
521
522            let topology_changes = sonos_state::decode_topology_event(&topology_state);
523
524            // Apply IP updates from topology before initializing groups
525            for (speaker_id, new_ip) in &topology_changes.speaker_ips {
526                self.state_manager.update_speaker_ip(speaker_id, *new_ip);
527            }
528
529            // Build topology with existing speaker data and freshly fetched groups
530            let topology =
531                Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
532            self.state_manager.initialize(topology);
533
534            // Store satellite IDs for later filtering
535            self.state_manager
536                .set_satellite_ids(topology_changes.satellite_ids);
537
538            tracing::debug!(
539                "Fetched zone group topology on-demand ({} groups)",
540                self.state_manager.group_count()
541            );
542            return;
543        }
544
545        tracing::warn!("ensure_topology: no speakers responded");
546    }
547
548    // ========================================================================
549    // Group Methods
550    // ========================================================================
551
552    /// Get all current groups (sync)
553    ///
554    /// Returns all groups in the system. Every speaker is always in a group,
555    /// so a single speaker forms a group of one.
556    ///
557    /// # Example
558    ///
559    /// ```rust,ignore
560    /// for group in system.groups() {
561    ///     println!("Group: {} ({} members)", group.id, group.member_count());
562    ///     if let Some(coordinator) = group.coordinator() {
563    ///         println!("  Coordinator: {}", coordinator.name);
564    ///     }
565    /// }
566    /// ```
567    pub fn groups(&self) -> Vec<Group> {
568        self.ensure_topology();
569        self.state_manager
570            .groups()
571            .into_iter()
572            .filter_map(|info| {
573                Group::from_info(
574                    info,
575                    Arc::clone(&self.state_manager),
576                    self.api_client.clone(),
577                )
578            })
579            .collect()
580    }
581
582    /// Get a specific group by ID (sync)
583    ///
584    /// Returns `None` if no group with that ID exists.
585    ///
586    /// # Example
587    ///
588    /// ```rust,ignore
589    /// if let Some(group) = system.group_by_id(&group_id) {
590    ///     println!("Found group with {} members", group.member_count());
591    /// }
592    /// ```
593    pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
594        self.ensure_topology();
595        let info = self.state_manager.get_group(group_id)?;
596        Group::from_info(
597            info,
598            Arc::clone(&self.state_manager),
599            self.api_client.clone(),
600        )
601    }
602
603    /// Get a specific group by ID (sync)
604    #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
605    pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
606        self.group_by_id(group_id)
607    }
608
609    /// Get the group a speaker belongs to (sync)
610    ///
611    /// Returns `None` if the speaker is not found or has no group.
612    /// Since all speakers are always in a group, this typically only returns
613    /// `None` if the speaker ID is invalid.
614    ///
615    /// # Example
616    ///
617    /// ```rust,ignore
618    /// if let Some(speaker) = system.speaker("Living Room") {
619    ///     if let Some(group) = system.group_for_speaker(&speaker.id) {
620    ///         println!("{} is in a group with {} speakers",
621    ///             speaker.name, group.member_count());
622    ///     }
623    /// }
624    /// ```
625    pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
626        self.ensure_topology();
627        let info = self.state_manager.get_group_for_speaker(speaker_id)?;
628        Group::from_info(
629            info,
630            Arc::clone(&self.state_manager),
631            self.api_client.clone(),
632        )
633    }
634
635    /// Get the group a speaker belongs to (sync)
636    #[deprecated(
637        since = "0.2.0",
638        note = "use `speaker.group()` or `group_for_speaker()` instead"
639    )]
640    pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
641        self.group_for_speaker(speaker_id)
642    }
643
644    /// Get a group by its coordinator speaker name (sync)
645    ///
646    /// Sonos groups don't have independent names — they are identified by the
647    /// coordinator speaker's friendly name. This method matches groups by looking
648    /// up the coordinator's name in the state manager.
649    ///
650    /// Returns `None` if no group's coordinator matches the given name.
651    ///
652    /// # Example
653    ///
654    /// ```rust,ignore
655    /// if let Some(group) = system.group("Living Room") {
656    ///     println!("Found group with {} members", group.member_count());
657    /// }
658    /// ```
659    pub fn group(&self, name: &str) -> Option<Group> {
660        self.ensure_topology();
661        self.state_manager
662            .groups()
663            .into_iter()
664            .find(|info| {
665                self.state_manager
666                    .speaker_info(&info.coordinator_id)
667                    .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
668            })
669            .and_then(|info| {
670                Group::from_info(
671                    info,
672                    Arc::clone(&self.state_manager),
673                    self.api_client.clone(),
674                )
675            })
676    }
677
678    /// Get a group by its coordinator speaker name (sync)
679    #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
680    pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
681        self.group(name)
682    }
683
684    /// Create a new group with the specified coordinator and members
685    ///
686    /// Adds each member speaker to the coordinator's current group.
687    /// Attempts every speaker even if some fail, returning per-speaker results.
688    /// After calling this, re-fetch groups via `groups()` to see the updated topology.
689    ///
690    /// # Example
691    ///
692    /// ```rust,ignore
693    /// let living_room = system.speaker("Living Room").unwrap();
694    /// let kitchen = system.speaker("Kitchen").unwrap();
695    /// let bedroom = system.speaker("Bedroom").unwrap();
696    ///
697    /// let result = system.create_group(&living_room, &[&kitchen, &bedroom])?;
698    /// if !result.is_success() {
699    ///     for (id, err) in &result.failed {
700    ///         eprintln!("Failed to add {}: {}", id, err);
701    ///     }
702    /// }
703    /// ```
704    pub fn create_group(
705        &self,
706        coordinator: &Speaker,
707        members: &[&Speaker],
708    ) -> Result<crate::group::GroupChangeResult, SdkError> {
709        let coord_group = self
710            .group_for_speaker(&coordinator.id)
711            .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
712
713        let mut succeeded = Vec::new();
714        let mut failed = Vec::new();
715
716        for member in members {
717            match coord_group.add_speaker(member) {
718                Ok(()) => succeeded.push(member.id.clone()),
719                Err(e) => failed.push((member.id.clone(), e)),
720            }
721        }
722
723        Ok(crate::group::GroupChangeResult { succeeded, failed })
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use sonos_state::GroupInfo;
731
732    /// Create a test SonosSystem with the given devices
733    ///
734    /// Note: This requires network access for the event manager.
735    /// Tests using this helper should be run with actual network connectivity
736    /// or mocked appropriately.
737    fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
738        SonosSystem::from_discovered_devices(devices)
739    }
740
741    #[test]
742    fn test_groups_returns_all_groups() {
743        let devices = vec![
744            Device {
745                id: "RINCON_111".to_string(),
746                name: "Living Room".to_string(),
747                room_name: "Living Room".to_string(),
748                ip_address: "192.168.1.100".to_string(),
749                port: 1400,
750                model_name: "Sonos One".to_string(),
751            },
752            Device {
753                id: "RINCON_222".to_string(),
754                name: "Kitchen".to_string(),
755                room_name: "Kitchen".to_string(),
756                ip_address: "192.168.1.101".to_string(),
757                port: 1400,
758                model_name: "Sonos One".to_string(),
759            },
760        ];
761
762        let system = create_test_system(devices).unwrap();
763
764        // Initialize with topology containing groups
765        let speaker1 = SpeakerId::new("RINCON_111");
766        let speaker2 = SpeakerId::new("RINCON_222");
767        let group1 = GroupInfo::new(
768            GroupId::new("RINCON_111:1"),
769            speaker1.clone(),
770            vec![speaker1.clone()],
771        );
772        let group2 = GroupInfo::new(
773            GroupId::new("RINCON_222:1"),
774            speaker2.clone(),
775            vec![speaker2.clone()],
776        );
777
778        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
779        system.state_manager.initialize(topology);
780
781        // Verify groups() returns all groups
782        let groups = system.groups();
783        assert_eq!(groups.len(), 2);
784
785        let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
786        assert!(group_ids.contains(&"RINCON_111:1".to_string()));
787        assert!(group_ids.contains(&"RINCON_222:1".to_string()));
788    }
789
790    #[test]
791    fn test_groups_returns_empty_when_no_groups() {
792        let devices = vec![Device {
793            id: "RINCON_111".to_string(),
794            name: "Living Room".to_string(),
795            room_name: "Living Room".to_string(),
796            ip_address: "192.168.1.100".to_string(),
797            port: 1400,
798            model_name: "Sonos One".to_string(),
799        }];
800
801        let system = create_test_system(devices).unwrap();
802
803        // No topology initialized, so no groups
804        let groups = system.groups();
805        assert!(groups.is_empty());
806    }
807
808    #[test]
809    fn test_group_by_id_returns_correct_group() {
810        let devices = vec![Device {
811            id: "RINCON_111".to_string(),
812            name: "Living Room".to_string(),
813            room_name: "Living Room".to_string(),
814            ip_address: "192.168.1.100".to_string(),
815            port: 1400,
816            model_name: "Sonos One".to_string(),
817        }];
818
819        let system = create_test_system(devices).unwrap();
820
821        // Initialize with topology
822        let speaker = SpeakerId::new("RINCON_111");
823        let group_id = GroupId::new("RINCON_111:1");
824        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
825
826        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
827        system.state_manager.initialize(topology);
828
829        // Verify group_by_id returns the correct group
830        let found = system.group_by_id(&group_id);
831        assert!(found.is_some());
832        let found = found.unwrap();
833        assert_eq!(found.id.as_str(), "RINCON_111:1");
834        assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
835        assert_eq!(found.member_ids.len(), 1);
836    }
837
838    #[test]
839    fn test_group_by_id_returns_none_for_unknown() {
840        let devices = vec![Device {
841            id: "RINCON_111".to_string(),
842            name: "Living Room".to_string(),
843            room_name: "Living Room".to_string(),
844            ip_address: "192.168.1.100".to_string(),
845            port: 1400,
846            model_name: "Sonos One".to_string(),
847        }];
848
849        let system = create_test_system(devices).unwrap();
850
851        // No groups initialized
852        let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
853        let found = system.group_by_id(&unknown_id);
854        assert!(found.is_none());
855    }
856
857    #[test]
858    fn test_group_for_speaker_returns_correct_group() {
859        let devices = vec![
860            Device {
861                id: "RINCON_111".to_string(),
862                name: "Living Room".to_string(),
863                room_name: "Living Room".to_string(),
864                ip_address: "192.168.1.100".to_string(),
865                port: 1400,
866                model_name: "Sonos One".to_string(),
867            },
868            Device {
869                id: "RINCON_222".to_string(),
870                name: "Kitchen".to_string(),
871                room_name: "Kitchen".to_string(),
872                ip_address: "192.168.1.101".to_string(),
873                port: 1400,
874                model_name: "Sonos One".to_string(),
875            },
876        ];
877
878        let system = create_test_system(devices).unwrap();
879
880        // Initialize with a group containing both speakers
881        let speaker1 = SpeakerId::new("RINCON_111");
882        let speaker2 = SpeakerId::new("RINCON_222");
883        let group = GroupInfo::new(
884            GroupId::new("RINCON_111:1"),
885            speaker1.clone(),
886            vec![speaker1.clone(), speaker2.clone()],
887        );
888
889        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
890        system.state_manager.initialize(topology);
891
892        // Verify group_for_speaker returns the correct group for both speakers
893        let found1 = system.group_for_speaker(&speaker1);
894        assert!(found1.is_some());
895        let found1 = found1.unwrap();
896        assert_eq!(found1.id.as_str(), "RINCON_111:1");
897        assert_eq!(found1.member_ids.len(), 2);
898
899        let found2 = system.group_for_speaker(&speaker2);
900        assert!(found2.is_some());
901        let found2 = found2.unwrap();
902        assert_eq!(found2.id.as_str(), "RINCON_111:1");
903        assert_eq!(found2.member_ids.len(), 2);
904    }
905
906    #[test]
907    fn test_group_for_speaker_returns_none_for_unknown() {
908        let devices = vec![Device {
909            id: "RINCON_111".to_string(),
910            name: "Living Room".to_string(),
911            room_name: "Living Room".to_string(),
912            ip_address: "192.168.1.100".to_string(),
913            port: 1400,
914            model_name: "Sonos One".to_string(),
915        }];
916
917        let system = create_test_system(devices).unwrap();
918
919        // No groups initialized
920        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
921        let found = system.group_for_speaker(&unknown_speaker);
922        assert!(found.is_none());
923    }
924
925    #[test]
926    fn test_group_methods_consistency() {
927        let devices = vec![Device {
928            id: "RINCON_111".to_string(),
929            name: "Living Room".to_string(),
930            room_name: "Living Room".to_string(),
931            ip_address: "192.168.1.100".to_string(),
932            port: 1400,
933            model_name: "Sonos One".to_string(),
934        }];
935
936        let system = create_test_system(devices).unwrap();
937
938        // Initialize with topology
939        let speaker = SpeakerId::new("RINCON_111");
940        let group_id = GroupId::new("RINCON_111:1");
941        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
942
943        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
944        system.state_manager.initialize(topology);
945
946        // Verify all three methods return consistent data
947        let groups = system.groups();
948        assert_eq!(groups.len(), 1);
949
950        let by_id = system.group_by_id(&group_id);
951        assert!(by_id.is_some());
952
953        let by_speaker = system.group_for_speaker(&speaker);
954        assert!(by_speaker.is_some());
955
956        // All should return the same group
957        assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
958        assert_eq!(
959            groups[0].id.as_str(),
960            by_speaker.as_ref().unwrap().id.as_str()
961        );
962        assert_eq!(
963            groups[0].coordinator_id.as_str(),
964            by_id.as_ref().unwrap().coordinator_id.as_str()
965        );
966        assert_eq!(
967            groups[0].coordinator_id.as_str(),
968            by_speaker.as_ref().unwrap().coordinator_id.as_str()
969        );
970    }
971
972    #[test]
973    fn test_group_by_name_returns_correct_group() {
974        let devices = vec![
975            Device {
976                id: "RINCON_111".to_string(),
977                name: "Living Room".to_string(),
978                room_name: "Living Room".to_string(),
979                ip_address: "192.168.1.100".to_string(),
980                port: 1400,
981                model_name: "Sonos One".to_string(),
982            },
983            Device {
984                id: "RINCON_222".to_string(),
985                name: "Kitchen".to_string(),
986                room_name: "Kitchen".to_string(),
987                ip_address: "192.168.1.101".to_string(),
988                port: 1400,
989                model_name: "Sonos One".to_string(),
990            },
991        ];
992
993        let system = create_test_system(devices).unwrap();
994
995        let speaker1 = SpeakerId::new("RINCON_111");
996        let speaker2 = SpeakerId::new("RINCON_222");
997        let group1 = GroupInfo::new(
998            GroupId::new("RINCON_111:1"),
999            speaker1.clone(),
1000            vec![speaker1.clone()],
1001        );
1002        let group2 = GroupInfo::new(
1003            GroupId::new("RINCON_222:1"),
1004            speaker2.clone(),
1005            vec![speaker2.clone()],
1006        );
1007
1008        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
1009        system.state_manager.initialize(topology);
1010
1011        // Find by coordinator name
1012        let found = system.group("Living Room");
1013        assert!(found.is_some());
1014        assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
1015
1016        let found = system.group("Kitchen");
1017        assert!(found.is_some());
1018        assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
1019
1020        // Unknown name returns None
1021        assert!(system.group("Nonexistent").is_none());
1022    }
1023
1024    #[test]
1025    fn test_create_group_method_exists() {
1026        // Compile-time assertion that method signature is correct
1027        fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1028
1029        let devices = vec![
1030            Device {
1031                id: "RINCON_111".to_string(),
1032                name: "Living Room".to_string(),
1033                room_name: "Living Room".to_string(),
1034                ip_address: "192.168.1.100".to_string(),
1035                port: 1400,
1036                model_name: "Sonos One".to_string(),
1037            },
1038            Device {
1039                id: "RINCON_222".to_string(),
1040                name: "Kitchen".to_string(),
1041                room_name: "Kitchen".to_string(),
1042                ip_address: "192.168.1.101".to_string(),
1043                port: 1400,
1044                model_name: "Sonos One".to_string(),
1045            },
1046        ];
1047
1048        let system = create_test_system(devices).unwrap();
1049
1050        // Initialize topology so group_for_speaker works
1051        let speaker1 = SpeakerId::new("RINCON_111");
1052        let speaker2 = SpeakerId::new("RINCON_222");
1053        let group = GroupInfo::new(
1054            GroupId::new("RINCON_111:1"),
1055            speaker1.clone(),
1056            vec![speaker1.clone()],
1057        );
1058        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1059        system.state_manager.initialize(topology);
1060
1061        let coordinator = system.speaker_by_id(&speaker1).unwrap();
1062        let member = system.speaker_by_id(&speaker2).unwrap();
1063
1064        // Will fail at network level but proves signature compiles
1065        assert_change_result(system.create_group(&coordinator, &[&member]));
1066    }
1067
1068    #[test]
1069    fn test_display_name_prefers_room_name() {
1070        let device = Device {
1071            id: "RINCON_111".to_string(),
1072            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1073            room_name: "Kitchen".to_string(),
1074            ip_address: "192.168.1.100".to_string(),
1075            port: 1400,
1076            model_name: "Sonos One".to_string(),
1077        };
1078        assert_eq!(display_name(&device), "Kitchen");
1079    }
1080
1081    #[test]
1082    fn test_display_name_falls_back_to_friendly_name() {
1083        let device = Device {
1084            id: "RINCON_111".to_string(),
1085            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1086            room_name: "Unknown".to_string(),
1087            ip_address: "192.168.1.100".to_string(),
1088            port: 1400,
1089            model_name: "Sonos One".to_string(),
1090        };
1091        assert_eq!(
1092            display_name(&device),
1093            "192.168.1.100 - Sonos One - RINCON_111"
1094        );
1095
1096        let device_empty = Device {
1097            id: "RINCON_222".to_string(),
1098            name: "192.168.1.101 - Sonos One".to_string(),
1099            room_name: "".to_string(),
1100            ip_address: "192.168.1.101".to_string(),
1101            port: 1400,
1102            model_name: "Sonos One".to_string(),
1103        };
1104        assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1105    }
1106
1107    #[test]
1108    fn test_speaker_lookup_case_insensitive() {
1109        let devices = vec![Device {
1110            id: "RINCON_111".to_string(),
1111            name: "Kitchen".to_string(),
1112            room_name: "Kitchen".to_string(),
1113            ip_address: "192.168.1.100".to_string(),
1114            port: 1400,
1115            model_name: "Sonos One".to_string(),
1116        }];
1117        let system = create_test_system(devices).unwrap();
1118        assert!(system.speaker("Kitchen").is_some());
1119        assert!(system.speaker("kitchen").is_some());
1120        assert!(system.speaker("KITCHEN").is_some());
1121        assert!(system.speaker("Nonexistent").is_none());
1122    }
1123
1124    #[test]
1125    fn test_speaker_uses_room_name() {
1126        let devices = vec![Device {
1127            id: "RINCON_111".to_string(),
1128            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1129            room_name: "Kitchen".to_string(),
1130            ip_address: "192.168.1.100".to_string(),
1131            port: 1400,
1132            model_name: "Sonos One".to_string(),
1133        }];
1134
1135        let system = create_test_system(devices).unwrap();
1136        let spk = system.speaker("Kitchen");
1137        assert!(spk.is_some());
1138        assert_eq!(spk.unwrap().name, "Kitchen");
1139
1140        // Verbose friendlyName should NOT match
1141        assert!(system
1142            .speaker("192.168.1.100 - Sonos One - RINCON_111")
1143            .is_none());
1144    }
1145
1146    #[test]
1147    fn test_group_lookup_case_insensitive() {
1148        let devices = vec![Device {
1149            id: "RINCON_111".to_string(),
1150            name: "Living Room".to_string(),
1151            room_name: "Living Room".to_string(),
1152            ip_address: "192.168.1.100".to_string(),
1153            port: 1400,
1154            model_name: "Sonos One".to_string(),
1155        }];
1156
1157        let system = create_test_system(devices).unwrap();
1158
1159        let speaker = SpeakerId::new("RINCON_111");
1160        let group = GroupInfo::new(
1161            GroupId::new("RINCON_111:1"),
1162            speaker.clone(),
1163            vec![speaker.clone()],
1164        );
1165
1166        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1167        system.state_manager.initialize(topology);
1168
1169        assert!(system.group("Living Room").is_some());
1170        assert!(system.group("living room").is_some());
1171        assert!(system.group("LIVING ROOM").is_some());
1172        assert!(system.group("Nonexistent").is_none());
1173    }
1174}