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