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        Ok(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
216    /// Create a test SonosSystem with named speakers and no network access.
217    ///
218    /// Builds an in-memory system with synthetic speaker data. No SSDP discovery,
219    /// no event manager socket binding, no cache reads. Speakers get sequential
220    /// IPs starting at `192.168.1.100`.
221    ///
222    /// Only available when the `test-support` feature is enabled.
223    ///
224    /// # Example
225    ///
226    /// ```rust,ignore
227    /// let system = SonosSystem::with_speakers(&["Kitchen", "Bedroom"]);
228    /// assert_eq!(system.speakers().len(), 2);
229    /// assert!(system.speaker("Kitchen").is_some());
230    /// ```
231    #[cfg(feature = "test-support")]
232    pub fn with_speakers(names: &[&str]) -> Self {
233        let devices: Vec<Device> = names
234            .iter()
235            .enumerate()
236            .map(|(i, name)| Device {
237                id: format!("RINCON_{i:03}"),
238                name: name.to_string(),
239                room_name: name.to_string(),
240                ip_address: format!("192.168.1.{}", 100 + i),
241                port: 1400,
242                model_name: "Sonos One".to_string(),
243            })
244            .collect();
245
246        let state_manager =
247            Arc::new(StateManager::new().expect("StateManager::new() should not fail"));
248
249        state_manager
250            .add_devices(devices.clone())
251            .expect("add_devices should not fail with valid test data");
252
253        let api_client = SonosClient::new();
254        let speakers = Self::build_speakers(&devices, &state_manager, &api_client)
255            .expect("build_speakers should not fail with valid test data");
256
257        Self {
258            state_manager,
259            event_manager: Mutex::new(None),
260            api_client,
261            speakers: RwLock::new(speakers),
262            last_rediscovery: AtomicU64::new(0),
263        }
264    }
265
266    /// Create a test SonosSystem with speakers AND group topology.
267    ///
268    /// Each speaker gets a standalone group (coordinator = self, members = [self]).
269    /// This makes `system.groups()` and `system.group("name")` work in tests.
270    ///
271    /// # Example
272    ///
273    /// ```rust,ignore
274    /// let system = SonosSystem::with_groups(&["Kitchen", "Bedroom"]);
275    /// assert_eq!(system.groups().len(), 2);
276    /// assert!(system.group("Kitchen").is_some());
277    /// ```
278    #[cfg(feature = "test-support")]
279    pub fn with_groups(names: &[&str]) -> Self {
280        let system = Self::with_speakers(names);
281
282        let groups: Vec<GroupInfo> = names
283            .iter()
284            .enumerate()
285            .map(|(i, _name)| {
286                let speaker_id = SpeakerId::new(format!("RINCON_{i:03}"));
287                let group_id = GroupId::new(format!("RINCON_{i:03}:1"));
288                GroupInfo::new(group_id, speaker_id.clone(), vec![speaker_id])
289            })
290            .collect();
291
292        let topology = Topology::new(system.state_manager.speaker_infos(), groups);
293        system.state_manager.initialize(topology);
294
295        system
296    }
297
298    /// Build Speaker handles from a list of devices.
299    fn build_speakers(
300        devices: &[Device],
301        state_manager: &Arc<StateManager>,
302        api_client: &SonosClient,
303    ) -> Result<HashMap<String, Speaker>, SdkError> {
304        let mut speakers = HashMap::new();
305        for device in devices {
306            let speaker_id = SpeakerId::new(&device.id);
307            let ip = device
308                .ip_address
309                .parse()
310                .map_err(|_| SdkError::InvalidIpAddress)?;
311
312            let name = display_name(device);
313            let speaker = Speaker::new(
314                speaker_id,
315                name.clone(),
316                ip,
317                device.model_name.clone(),
318                Arc::clone(state_manager),
319                api_client.clone(),
320            );
321
322            if speakers.contains_key(&name) {
323                tracing::warn!(
324                    "duplicate speaker name \"{}\", keeping last discovered",
325                    name
326                );
327            }
328            speakers.insert(name, speaker);
329        }
330        Ok(speakers)
331    }
332
333    /// Get speaker by name (sync)
334    ///
335    /// If the speaker isn't in the current map, triggers an SSDP
336    /// rediscovery (rate-limited to once per 30s) before returning `None`.
337    ///
338    /// # Example
339    ///
340    /// ```rust,ignore
341    /// let kitchen = sonos.speaker("Kitchen").unwrap();
342    /// kitchen.play()?;
343    /// ```
344    pub fn speaker(&self, name: &str) -> Option<Speaker> {
345        {
346            let speakers = self.speakers.read().ok()?;
347            if let Some(speaker) = find_speaker_by_name(&speakers, name) {
348                return Some(speaker);
349            }
350        }
351        // Not found — try rediscovery (cooldown-limited)
352        self.try_rediscover(name);
353        let speakers = self.speakers.read().ok()?;
354        find_speaker_by_name(&speakers, name)
355    }
356
357    /// Get speaker by name (sync)
358    #[deprecated(since = "0.2.0", note = "renamed to `speaker()`")]
359    pub fn get_speaker_by_name(&self, name: &str) -> Option<Speaker> {
360        self.speaker(name)
361    }
362
363    /// Run SSDP rediscovery with cooldown. Updates internal speaker map and cache.
364    fn try_rediscover(&self, name: &str) {
365        let now = std::time::SystemTime::now()
366            .duration_since(std::time::UNIX_EPOCH)
367            .unwrap_or_default()
368            .as_secs();
369        let last = self.last_rediscovery.load(Ordering::Relaxed);
370        if last > 0 && now - last < REDISCOVERY_COOLDOWN_SECS {
371            return; // Cooldown period not elapsed
372        }
373        self.last_rediscovery.store(now, Ordering::Relaxed);
374
375        // 1. SSDP runs WITHOUT holding any lock (3s)
376        tracing::info!("speaker '{}' not found, running auto-rediscovery...", name);
377        let devices = sonos_discovery::get_with_timeout(Duration::from_secs(3));
378        if devices.is_empty() {
379            return;
380        }
381
382        // 2. Register devices with state manager (required for property tracking)
383        if let Err(e) = self.state_manager.add_devices(devices.clone()) {
384            tracing::warn!("Failed to register rediscovered devices: {}", e);
385            return;
386        }
387
388        // 3. Build new Speaker handles (no lock needed)
389        let new_speakers =
390            match Self::build_speakers(&devices, &self.state_manager, &self.api_client) {
391                Ok(s) => s,
392                Err(e) => {
393                    tracing::warn!("Failed to build speakers from rediscovery: {}", e);
394                    return;
395                }
396            };
397
398        // 4. Acquire write lock BRIEFLY for map swap only
399        if let Ok(mut map) = self.speakers.write() {
400            *map = new_speakers;
401        }
402
403        // 5. Save cache (non-fatal on failure)
404        if let Err(e) = cache::save(&devices) {
405            tracing::warn!("Failed to save discovery cache: {}", e);
406        }
407    }
408
409    /// Get all speakers (sync)
410    pub fn speakers(&self) -> Vec<Speaker> {
411        self.speakers
412            .read()
413            .map(|s| s.values().cloned().collect())
414            .unwrap_or_default()
415    }
416
417    /// Get speaker by ID (sync)
418    pub fn speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
419        let speakers = self.speakers.read().ok()?;
420        speakers.values().find(|s| s.id == *speaker_id).cloned()
421    }
422
423    /// Get speaker by ID (sync)
424    #[deprecated(since = "0.2.0", note = "renamed to `speaker_by_id()`")]
425    pub fn get_speaker_by_id(&self, speaker_id: &SpeakerId) -> Option<Speaker> {
426        self.speaker_by_id(speaker_id)
427    }
428
429    /// Get all speaker names (sync)
430    pub fn speaker_names(&self) -> Vec<String> {
431        self.speakers
432            .read()
433            .map(|s| s.keys().cloned().collect())
434            .unwrap_or_default()
435    }
436
437    /// Get the state manager for advanced usage
438    pub fn state_manager(&self) -> &Arc<StateManager> {
439        &self.state_manager
440    }
441
442    /// Get a blocking iterator over property change events
443    ///
444    /// Only emits events for properties that have been `watch()`ed.
445    ///
446    /// # Example
447    ///
448    /// ```rust,ignore
449    /// // First, watch some properties
450    /// speaker.volume.watch()?;
451    /// speaker.playback_state.watch()?;
452    ///
453    /// // Then iterate over changes (blocking)
454    /// for event in system.iter() {
455    ///     println!("Changed: {} on {}", event.property_key, event.speaker_id);
456    /// }
457    /// ```
458    pub fn iter(&self) -> sonos_state::ChangeIterator {
459        self.state_manager.iter()
460    }
461
462    // ========================================================================
463    // Topology Fetch
464    // ========================================================================
465
466    /// Ensure group topology has been fetched.
467    ///
468    /// If the state manager has no groups (e.g., no ZoneGroupTopology subscription
469    /// events have been received yet), this method makes a direct GetZoneGroupState
470    /// call to the first available speaker and initializes the state manager with
471    /// the result. This is a one-shot operation: once groups are populated,
472    /// subsequent calls are a no-op.
473    fn ensure_topology(&self) {
474        // Fast path: groups already present
475        if self.state_manager.group_count() > 0 {
476            return;
477        }
478
479        // Pick the first speaker IP to query
480        let speaker_ip = {
481            let speakers = match self.speakers.read() {
482                Ok(s) => s,
483                Err(_) => return,
484            };
485            match speakers.values().next() {
486                Some(speaker) => speaker.ip.to_string(),
487                None => return,
488            }
489        };
490
491        // Call GetZoneGroupState on that speaker
492        let topology_state = match sonos_api::services::zone_group_topology::state::poll(
493            &self.api_client,
494            &speaker_ip,
495        ) {
496            Ok(state) => state,
497            Err(e) => {
498                tracing::warn!(
499                    "Failed to fetch zone group topology from {}: {}",
500                    speaker_ip,
501                    e
502                );
503                return;
504            }
505        };
506
507        // Decode the API-level topology into state-level GroupInfo values
508        let topology_changes = sonos_state::decode_topology_event(&topology_state);
509
510        // Build a Topology with existing speaker data and the freshly fetched groups
511        let topology = Topology::new(self.state_manager.speaker_infos(), topology_changes.groups);
512        self.state_manager.initialize(topology);
513
514        tracing::debug!(
515            "Fetched zone group topology on-demand ({} groups)",
516            self.state_manager.group_count()
517        );
518    }
519
520    // ========================================================================
521    // Group Methods
522    // ========================================================================
523
524    /// Get all current groups (sync)
525    ///
526    /// Returns all groups in the system. Every speaker is always in a group,
527    /// so a single speaker forms a group of one.
528    ///
529    /// # Example
530    ///
531    /// ```rust,ignore
532    /// for group in system.groups() {
533    ///     println!("Group: {} ({} members)", group.id, group.member_count());
534    ///     if let Some(coordinator) = group.coordinator() {
535    ///         println!("  Coordinator: {}", coordinator.name);
536    ///     }
537    /// }
538    /// ```
539    pub fn groups(&self) -> Vec<Group> {
540        self.ensure_topology();
541        self.state_manager
542            .groups()
543            .into_iter()
544            .filter_map(|info| {
545                Group::from_info(
546                    info,
547                    Arc::clone(&self.state_manager),
548                    self.api_client.clone(),
549                )
550            })
551            .collect()
552    }
553
554    /// Get a specific group by ID (sync)
555    ///
556    /// Returns `None` if no group with that ID exists.
557    ///
558    /// # Example
559    ///
560    /// ```rust,ignore
561    /// if let Some(group) = system.group_by_id(&group_id) {
562    ///     println!("Found group with {} members", group.member_count());
563    /// }
564    /// ```
565    pub fn group_by_id(&self, group_id: &GroupId) -> Option<Group> {
566        self.ensure_topology();
567        let info = self.state_manager.get_group(group_id)?;
568        Group::from_info(
569            info,
570            Arc::clone(&self.state_manager),
571            self.api_client.clone(),
572        )
573    }
574
575    /// Get a specific group by ID (sync)
576    #[deprecated(since = "0.2.0", note = "renamed to `group_by_id()`")]
577    pub fn get_group_by_id(&self, group_id: &GroupId) -> Option<Group> {
578        self.group_by_id(group_id)
579    }
580
581    /// Get the group a speaker belongs to (sync)
582    ///
583    /// Returns `None` if the speaker is not found or has no group.
584    /// Since all speakers are always in a group, this typically only returns
585    /// `None` if the speaker ID is invalid.
586    ///
587    /// # Example
588    ///
589    /// ```rust,ignore
590    /// if let Some(speaker) = system.speaker("Living Room") {
591    ///     if let Some(group) = system.group_for_speaker(&speaker.id) {
592    ///         println!("{} is in a group with {} speakers",
593    ///             speaker.name, group.member_count());
594    ///     }
595    /// }
596    /// ```
597    pub fn group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
598        self.ensure_topology();
599        let info = self.state_manager.get_group_for_speaker(speaker_id)?;
600        Group::from_info(
601            info,
602            Arc::clone(&self.state_manager),
603            self.api_client.clone(),
604        )
605    }
606
607    /// Get the group a speaker belongs to (sync)
608    #[deprecated(
609        since = "0.2.0",
610        note = "use `speaker.group()` or `group_for_speaker()` instead"
611    )]
612    pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<Group> {
613        self.group_for_speaker(speaker_id)
614    }
615
616    /// Get a group by its coordinator speaker name (sync)
617    ///
618    /// Sonos groups don't have independent names — they are identified by the
619    /// coordinator speaker's friendly name. This method matches groups by looking
620    /// up the coordinator's name in the state manager.
621    ///
622    /// Returns `None` if no group's coordinator matches the given name.
623    ///
624    /// # Example
625    ///
626    /// ```rust,ignore
627    /// if let Some(group) = system.group("Living Room") {
628    ///     println!("Found group with {} members", group.member_count());
629    /// }
630    /// ```
631    pub fn group(&self, name: &str) -> Option<Group> {
632        self.ensure_topology();
633        self.state_manager
634            .groups()
635            .into_iter()
636            .find(|info| {
637                self.state_manager
638                    .speaker_info(&info.coordinator_id)
639                    .is_some_and(|si| si.name.eq_ignore_ascii_case(name))
640            })
641            .and_then(|info| {
642                Group::from_info(
643                    info,
644                    Arc::clone(&self.state_manager),
645                    self.api_client.clone(),
646                )
647            })
648    }
649
650    /// Get a group by its coordinator speaker name (sync)
651    #[deprecated(since = "0.2.0", note = "renamed to `group()`")]
652    pub fn get_group_by_name(&self, name: &str) -> Option<Group> {
653        self.group(name)
654    }
655
656    /// Create a new group with the specified coordinator and members
657    ///
658    /// Adds each member speaker to the coordinator's current group.
659    /// Attempts every speaker even if some fail, returning per-speaker results.
660    /// After calling this, re-fetch groups via `groups()` to see the updated topology.
661    ///
662    /// # Example
663    ///
664    /// ```rust,ignore
665    /// let living_room = system.speaker("Living Room").unwrap();
666    /// let kitchen = system.speaker("Kitchen").unwrap();
667    /// let bedroom = system.speaker("Bedroom").unwrap();
668    ///
669    /// let result = system.create_group(&living_room, &[&kitchen, &bedroom])?;
670    /// if !result.is_success() {
671    ///     for (id, err) in &result.failed {
672    ///         eprintln!("Failed to add {}: {}", id, err);
673    ///     }
674    /// }
675    /// ```
676    pub fn create_group(
677        &self,
678        coordinator: &Speaker,
679        members: &[&Speaker],
680    ) -> Result<crate::group::GroupChangeResult, SdkError> {
681        let coord_group = self
682            .group_for_speaker(&coordinator.id)
683            .ok_or_else(|| SdkError::SpeakerNotFound(coordinator.id.as_str().to_string()))?;
684
685        let mut succeeded = Vec::new();
686        let mut failed = Vec::new();
687
688        for member in members {
689            match coord_group.add_speaker(member) {
690                Ok(()) => succeeded.push(member.id.clone()),
691                Err(e) => failed.push((member.id.clone(), e)),
692            }
693        }
694
695        Ok(crate::group::GroupChangeResult { succeeded, failed })
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use sonos_state::GroupInfo;
703
704    /// Create a test SonosSystem with the given devices
705    ///
706    /// Note: This requires network access for the event manager.
707    /// Tests using this helper should be run with actual network connectivity
708    /// or mocked appropriately.
709    fn create_test_system(devices: Vec<Device>) -> Result<SonosSystem, SdkError> {
710        SonosSystem::from_discovered_devices(devices)
711    }
712
713    #[test]
714    fn test_groups_returns_all_groups() {
715        let devices = vec![
716            Device {
717                id: "RINCON_111".to_string(),
718                name: "Living Room".to_string(),
719                room_name: "Living Room".to_string(),
720                ip_address: "192.168.1.100".to_string(),
721                port: 1400,
722                model_name: "Sonos One".to_string(),
723            },
724            Device {
725                id: "RINCON_222".to_string(),
726                name: "Kitchen".to_string(),
727                room_name: "Kitchen".to_string(),
728                ip_address: "192.168.1.101".to_string(),
729                port: 1400,
730                model_name: "Sonos One".to_string(),
731            },
732        ];
733
734        let system = create_test_system(devices).unwrap();
735
736        // Initialize with topology containing groups
737        let speaker1 = SpeakerId::new("RINCON_111");
738        let speaker2 = SpeakerId::new("RINCON_222");
739        let group1 = GroupInfo::new(
740            GroupId::new("RINCON_111:1"),
741            speaker1.clone(),
742            vec![speaker1.clone()],
743        );
744        let group2 = GroupInfo::new(
745            GroupId::new("RINCON_222:1"),
746            speaker2.clone(),
747            vec![speaker2.clone()],
748        );
749
750        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
751        system.state_manager.initialize(topology);
752
753        // Verify groups() returns all groups
754        let groups = system.groups();
755        assert_eq!(groups.len(), 2);
756
757        let group_ids: Vec<_> = groups.iter().map(|g| g.id.as_str().to_string()).collect();
758        assert!(group_ids.contains(&"RINCON_111:1".to_string()));
759        assert!(group_ids.contains(&"RINCON_222:1".to_string()));
760    }
761
762    #[test]
763    fn test_groups_returns_empty_when_no_groups() {
764        let devices = vec![Device {
765            id: "RINCON_111".to_string(),
766            name: "Living Room".to_string(),
767            room_name: "Living Room".to_string(),
768            ip_address: "192.168.1.100".to_string(),
769            port: 1400,
770            model_name: "Sonos One".to_string(),
771        }];
772
773        let system = create_test_system(devices).unwrap();
774
775        // No topology initialized, so no groups
776        let groups = system.groups();
777        assert!(groups.is_empty());
778    }
779
780    #[test]
781    fn test_group_by_id_returns_correct_group() {
782        let devices = vec![Device {
783            id: "RINCON_111".to_string(),
784            name: "Living Room".to_string(),
785            room_name: "Living Room".to_string(),
786            ip_address: "192.168.1.100".to_string(),
787            port: 1400,
788            model_name: "Sonos One".to_string(),
789        }];
790
791        let system = create_test_system(devices).unwrap();
792
793        // Initialize with topology
794        let speaker = SpeakerId::new("RINCON_111");
795        let group_id = GroupId::new("RINCON_111:1");
796        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
797
798        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
799        system.state_manager.initialize(topology);
800
801        // Verify group_by_id returns the correct group
802        let found = system.group_by_id(&group_id);
803        assert!(found.is_some());
804        let found = found.unwrap();
805        assert_eq!(found.id.as_str(), "RINCON_111:1");
806        assert_eq!(found.coordinator_id.as_str(), "RINCON_111");
807        assert_eq!(found.member_ids.len(), 1);
808    }
809
810    #[test]
811    fn test_group_by_id_returns_none_for_unknown() {
812        let devices = vec![Device {
813            id: "RINCON_111".to_string(),
814            name: "Living Room".to_string(),
815            room_name: "Living Room".to_string(),
816            ip_address: "192.168.1.100".to_string(),
817            port: 1400,
818            model_name: "Sonos One".to_string(),
819        }];
820
821        let system = create_test_system(devices).unwrap();
822
823        // No groups initialized
824        let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
825        let found = system.group_by_id(&unknown_id);
826        assert!(found.is_none());
827    }
828
829    #[test]
830    fn test_group_for_speaker_returns_correct_group() {
831        let devices = vec![
832            Device {
833                id: "RINCON_111".to_string(),
834                name: "Living Room".to_string(),
835                room_name: "Living Room".to_string(),
836                ip_address: "192.168.1.100".to_string(),
837                port: 1400,
838                model_name: "Sonos One".to_string(),
839            },
840            Device {
841                id: "RINCON_222".to_string(),
842                name: "Kitchen".to_string(),
843                room_name: "Kitchen".to_string(),
844                ip_address: "192.168.1.101".to_string(),
845                port: 1400,
846                model_name: "Sonos One".to_string(),
847            },
848        ];
849
850        let system = create_test_system(devices).unwrap();
851
852        // Initialize with a group containing both speakers
853        let speaker1 = SpeakerId::new("RINCON_111");
854        let speaker2 = SpeakerId::new("RINCON_222");
855        let group = GroupInfo::new(
856            GroupId::new("RINCON_111:1"),
857            speaker1.clone(),
858            vec![speaker1.clone(), speaker2.clone()],
859        );
860
861        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
862        system.state_manager.initialize(topology);
863
864        // Verify group_for_speaker returns the correct group for both speakers
865        let found1 = system.group_for_speaker(&speaker1);
866        assert!(found1.is_some());
867        let found1 = found1.unwrap();
868        assert_eq!(found1.id.as_str(), "RINCON_111:1");
869        assert_eq!(found1.member_ids.len(), 2);
870
871        let found2 = system.group_for_speaker(&speaker2);
872        assert!(found2.is_some());
873        let found2 = found2.unwrap();
874        assert_eq!(found2.id.as_str(), "RINCON_111:1");
875        assert_eq!(found2.member_ids.len(), 2);
876    }
877
878    #[test]
879    fn test_group_for_speaker_returns_none_for_unknown() {
880        let devices = vec![Device {
881            id: "RINCON_111".to_string(),
882            name: "Living Room".to_string(),
883            room_name: "Living Room".to_string(),
884            ip_address: "192.168.1.100".to_string(),
885            port: 1400,
886            model_name: "Sonos One".to_string(),
887        }];
888
889        let system = create_test_system(devices).unwrap();
890
891        // No groups initialized
892        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
893        let found = system.group_for_speaker(&unknown_speaker);
894        assert!(found.is_none());
895    }
896
897    #[test]
898    fn test_group_methods_consistency() {
899        let devices = vec![Device {
900            id: "RINCON_111".to_string(),
901            name: "Living Room".to_string(),
902            room_name: "Living Room".to_string(),
903            ip_address: "192.168.1.100".to_string(),
904            port: 1400,
905            model_name: "Sonos One".to_string(),
906        }];
907
908        let system = create_test_system(devices).unwrap();
909
910        // Initialize with topology
911        let speaker = SpeakerId::new("RINCON_111");
912        let group_id = GroupId::new("RINCON_111:1");
913        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
914
915        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
916        system.state_manager.initialize(topology);
917
918        // Verify all three methods return consistent data
919        let groups = system.groups();
920        assert_eq!(groups.len(), 1);
921
922        let by_id = system.group_by_id(&group_id);
923        assert!(by_id.is_some());
924
925        let by_speaker = system.group_for_speaker(&speaker);
926        assert!(by_speaker.is_some());
927
928        // All should return the same group
929        assert_eq!(groups[0].id.as_str(), by_id.as_ref().unwrap().id.as_str());
930        assert_eq!(
931            groups[0].id.as_str(),
932            by_speaker.as_ref().unwrap().id.as_str()
933        );
934        assert_eq!(
935            groups[0].coordinator_id.as_str(),
936            by_id.as_ref().unwrap().coordinator_id.as_str()
937        );
938        assert_eq!(
939            groups[0].coordinator_id.as_str(),
940            by_speaker.as_ref().unwrap().coordinator_id.as_str()
941        );
942    }
943
944    #[test]
945    fn test_group_by_name_returns_correct_group() {
946        let devices = vec![
947            Device {
948                id: "RINCON_111".to_string(),
949                name: "Living Room".to_string(),
950                room_name: "Living Room".to_string(),
951                ip_address: "192.168.1.100".to_string(),
952                port: 1400,
953                model_name: "Sonos One".to_string(),
954            },
955            Device {
956                id: "RINCON_222".to_string(),
957                name: "Kitchen".to_string(),
958                room_name: "Kitchen".to_string(),
959                ip_address: "192.168.1.101".to_string(),
960                port: 1400,
961                model_name: "Sonos One".to_string(),
962            },
963        ];
964
965        let system = create_test_system(devices).unwrap();
966
967        let speaker1 = SpeakerId::new("RINCON_111");
968        let speaker2 = SpeakerId::new("RINCON_222");
969        let group1 = GroupInfo::new(
970            GroupId::new("RINCON_111:1"),
971            speaker1.clone(),
972            vec![speaker1.clone()],
973        );
974        let group2 = GroupInfo::new(
975            GroupId::new("RINCON_222:1"),
976            speaker2.clone(),
977            vec![speaker2.clone()],
978        );
979
980        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group1, group2]);
981        system.state_manager.initialize(topology);
982
983        // Find by coordinator name
984        let found = system.group("Living Room");
985        assert!(found.is_some());
986        assert_eq!(found.unwrap().id.as_str(), "RINCON_111:1");
987
988        let found = system.group("Kitchen");
989        assert!(found.is_some());
990        assert_eq!(found.unwrap().id.as_str(), "RINCON_222:1");
991
992        // Unknown name returns None
993        assert!(system.group("Nonexistent").is_none());
994    }
995
996    #[test]
997    fn test_create_group_method_exists() {
998        // Compile-time assertion that method signature is correct
999        fn assert_change_result(_r: Result<crate::group::GroupChangeResult, SdkError>) {}
1000
1001        let devices = vec![
1002            Device {
1003                id: "RINCON_111".to_string(),
1004                name: "Living Room".to_string(),
1005                room_name: "Living Room".to_string(),
1006                ip_address: "192.168.1.100".to_string(),
1007                port: 1400,
1008                model_name: "Sonos One".to_string(),
1009            },
1010            Device {
1011                id: "RINCON_222".to_string(),
1012                name: "Kitchen".to_string(),
1013                room_name: "Kitchen".to_string(),
1014                ip_address: "192.168.1.101".to_string(),
1015                port: 1400,
1016                model_name: "Sonos One".to_string(),
1017            },
1018        ];
1019
1020        let system = create_test_system(devices).unwrap();
1021
1022        // Initialize topology so group_for_speaker works
1023        let speaker1 = SpeakerId::new("RINCON_111");
1024        let speaker2 = SpeakerId::new("RINCON_222");
1025        let group = GroupInfo::new(
1026            GroupId::new("RINCON_111:1"),
1027            speaker1.clone(),
1028            vec![speaker1.clone()],
1029        );
1030        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1031        system.state_manager.initialize(topology);
1032
1033        let coordinator = system.speaker_by_id(&speaker1).unwrap();
1034        let member = system.speaker_by_id(&speaker2).unwrap();
1035
1036        // Will fail at network level but proves signature compiles
1037        assert_change_result(system.create_group(&coordinator, &[&member]));
1038    }
1039
1040    #[test]
1041    fn test_display_name_prefers_room_name() {
1042        let device = Device {
1043            id: "RINCON_111".to_string(),
1044            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1045            room_name: "Kitchen".to_string(),
1046            ip_address: "192.168.1.100".to_string(),
1047            port: 1400,
1048            model_name: "Sonos One".to_string(),
1049        };
1050        assert_eq!(display_name(&device), "Kitchen");
1051    }
1052
1053    #[test]
1054    fn test_display_name_falls_back_to_friendly_name() {
1055        let device = Device {
1056            id: "RINCON_111".to_string(),
1057            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1058            room_name: "Unknown".to_string(),
1059            ip_address: "192.168.1.100".to_string(),
1060            port: 1400,
1061            model_name: "Sonos One".to_string(),
1062        };
1063        assert_eq!(
1064            display_name(&device),
1065            "192.168.1.100 - Sonos One - RINCON_111"
1066        );
1067
1068        let device_empty = Device {
1069            id: "RINCON_222".to_string(),
1070            name: "192.168.1.101 - Sonos One".to_string(),
1071            room_name: "".to_string(),
1072            ip_address: "192.168.1.101".to_string(),
1073            port: 1400,
1074            model_name: "Sonos One".to_string(),
1075        };
1076        assert_eq!(display_name(&device_empty), "192.168.1.101 - Sonos One");
1077    }
1078
1079    #[test]
1080    fn test_speaker_lookup_case_insensitive() {
1081        let devices = vec![Device {
1082            id: "RINCON_111".to_string(),
1083            name: "Kitchen".to_string(),
1084            room_name: "Kitchen".to_string(),
1085            ip_address: "192.168.1.100".to_string(),
1086            port: 1400,
1087            model_name: "Sonos One".to_string(),
1088        }];
1089        let system = create_test_system(devices).unwrap();
1090        assert!(system.speaker("Kitchen").is_some());
1091        assert!(system.speaker("kitchen").is_some());
1092        assert!(system.speaker("KITCHEN").is_some());
1093        assert!(system.speaker("Nonexistent").is_none());
1094    }
1095
1096    #[test]
1097    fn test_speaker_uses_room_name() {
1098        let devices = vec![Device {
1099            id: "RINCON_111".to_string(),
1100            name: "192.168.1.100 - Sonos One - RINCON_111".to_string(),
1101            room_name: "Kitchen".to_string(),
1102            ip_address: "192.168.1.100".to_string(),
1103            port: 1400,
1104            model_name: "Sonos One".to_string(),
1105        }];
1106
1107        let system = create_test_system(devices).unwrap();
1108        let spk = system.speaker("Kitchen");
1109        assert!(spk.is_some());
1110        assert_eq!(spk.unwrap().name, "Kitchen");
1111
1112        // Verbose friendlyName should NOT match
1113        assert!(system
1114            .speaker("192.168.1.100 - Sonos One - RINCON_111")
1115            .is_none());
1116    }
1117
1118    #[test]
1119    fn test_group_lookup_case_insensitive() {
1120        let devices = vec![Device {
1121            id: "RINCON_111".to_string(),
1122            name: "Living Room".to_string(),
1123            room_name: "Living Room".to_string(),
1124            ip_address: "192.168.1.100".to_string(),
1125            port: 1400,
1126            model_name: "Sonos One".to_string(),
1127        }];
1128
1129        let system = create_test_system(devices).unwrap();
1130
1131        let speaker = SpeakerId::new("RINCON_111");
1132        let group = GroupInfo::new(
1133            GroupId::new("RINCON_111:1"),
1134            speaker.clone(),
1135            vec![speaker.clone()],
1136        );
1137
1138        let topology = Topology::new(system.state_manager.speaker_infos(), vec![group]);
1139        system.state_manager.initialize(topology);
1140
1141        assert!(system.group("Living Room").is_some());
1142        assert!(system.group("living room").is_some());
1143        assert!(system.group("LIVING ROOM").is_some());
1144        assert!(system.group("Nonexistent").is_none());
1145    }
1146}