Skip to main content

sonos_state/
state.rs

1//! Sync-first State Management for Sonos devices
2//!
3//! Provides a synchronous API for managing Sonos device state with
4//! background event processing.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use sonos_state::{StateManager, Volume};
10//! use sonos_discovery;
11//!
12//! // Create state manager (sync)
13//! let manager = StateManager::new()?;
14//! let devices = sonos_discovery::get();
15//! manager.add_devices(devices)?;
16//!
17//! // Get speakers
18//! for info in manager.speaker_infos() {
19//!     println!("{}: {}", info.name, info.ip_address);
20//! }
21//!
22//! // Blocking iteration over changes
23//! for event in manager.iter() {
24//!     println!("Change: {:?}", event);
25//! }
26//! ```
27
28use std::any::{Any, TypeId};
29use std::collections::{HashMap, HashSet};
30use std::net::IpAddr;
31use std::sync::{mpsc, Arc, Mutex, OnceLock};
32use std::thread::JoinHandle;
33use std::time::{Duration, Instant};
34
35use parking_lot::RwLock;
36
37use sonos_api::{Service, ServiceScope};
38use sonos_discovery::Device;
39use sonos_event_manager::{SonosEventManager, WatchRegistry};
40use tracing::info;
41
42use crate::event_worker::spawn_state_event_worker;
43use crate::iter::ChangeIterator;
44use crate::model::{GroupId, SpeakerId, SpeakerInfo};
45use crate::property::{GroupInfo, Property, Scope, SonosProperty, Topology};
46use crate::{Result, StateError};
47
48/// Closure type for lazy event manager initialization.
49///
50/// Stored on `StateManager` as the single source of truth. Called by
51/// `PropertyHandle::watch()` to trigger event manager creation on first use.
52/// Uses `Box<dyn Error>` to avoid circular dependency on `sonos-sdk` error types.
53pub type EventInitFn = Arc<
54    dyn Fn() -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> + Send + Sync,
55>;
56
57// ============================================================================
58// ChangeEvent - for iter()
59// ============================================================================
60
61/// A change event emitted when a watched property changes
62#[derive(Debug, Clone)]
63pub struct ChangeEvent {
64    /// Speaker or entity that changed
65    pub speaker_id: SpeakerId,
66    /// Property key that changed
67    pub property_key: &'static str,
68    /// Service the property belongs to
69    pub service: Service,
70    /// When the change occurred
71    pub timestamp: Instant,
72}
73
74impl ChangeEvent {
75    pub fn new(speaker_id: SpeakerId, property_key: &'static str, service: Service) -> Self {
76        Self {
77            speaker_id,
78            property_key,
79            service,
80            timestamp: Instant::now(),
81        }
82    }
83}
84
85// ============================================================================
86// Watch bookkeeping
87// ============================================================================
88
89/// The holds on one watched `(speaker_id, property_key)` pair.
90///
91/// A watch is a *hold*, not a flag: several independent watchers can claim the
92/// same pair, and it stays watched until the last of them lets go. The two
93/// fields are separate — rather than one counter — because the two kinds of hold
94/// are released by completely different events, on different schedules:
95///
96/// - **`direct`** holds come from [`StateManager::register_watch`]: the SDK's
97///   polling-fallback and cache-only paths, group-member notification
98///   forwarding, `watch_property_with_subscription`, and tests. Each is released
99///   individually by [`StateManager::unregister_watch`], normally from a
100///   `CacheOnlyGuard::drop`. These are what need counting: *n* watchers of one
101///   property must survive *n-1* drops.
102/// - **`subscription`** is a single flag covering every `WatchGuard` acquired
103///   through [`WatchRegistry::register_watch`]. It cannot be a counter, because
104///   nothing decrements it one at a time: `WatchGuard::drop` only decrements
105///   `sonos-event-manager`'s per-`(ip, service)` subscription ref count, and
106///   `unregister_watches_for_service` fires once, later, when *that* count hits
107///   zero — at which point every contributing guard is provably gone. A counter
108///   incremented per guard but cleared only in bulk would either leak (a watch
109///   nobody holds emitting forever) or, if decremented by one, drop while other
110///   guards are still alive.
111///
112/// The pair stops being watched, and the entry leaves the map, only when the
113/// flag is clear *and* the count is zero. Keeping them apart is the actual fix:
114/// a subscription teardown must not take the individually-held `direct` watches
115/// of its sibling properties with it.
116#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
117pub(crate) struct WatchHolds {
118    /// Whether any `WatchGuard` is registered for this pair.
119    subscription: bool,
120    /// Number of outstanding `register_watch` holds.
121    direct: usize,
122}
123
124impl WatchHolds {
125    fn is_held(&self) -> bool {
126        self.subscription || self.direct > 0
127    }
128}
129
130/// Watch holds per `(speaker_id, property_key)` pair.
131pub(crate) type WatchCounts = HashMap<(SpeakerId, &'static str), WatchHolds>;
132
133/// Mark `(speaker_id, key)` as held by a `WatchGuard`.
134fn retain_subscription_watch(
135    watched: &RwLock<WatchCounts>,
136    speaker_id: &SpeakerId,
137    key: &'static str,
138) {
139    watched
140        .write()
141        .entry((speaker_id.clone(), key))
142        .or_default()
143        .subscription = true;
144}
145
146/// Add one `direct` hold on `(speaker_id, key)`.
147pub(crate) fn retain_direct_watch(
148    watched: &RwLock<WatchCounts>,
149    speaker_id: &SpeakerId,
150    key: &'static str,
151) {
152    watched
153        .write()
154        .entry((speaker_id.clone(), key))
155        .or_default()
156        .direct += 1;
157}
158
159/// Release one `direct` hold, dropping the entry once no holds remain.
160///
161/// Releasing a pair that is not held is a no-op: an over-release must not wrap
162/// around and resurrect the watch.
163fn release_direct_watch(watched: &RwLock<WatchCounts>, speaker_id: &SpeakerId, key: &'static str) {
164    let mut guard = watched.write();
165    let entry_key = (speaker_id.clone(), key);
166    if let Some(holds) = guard.get_mut(&entry_key) {
167        holds.direct = holds.direct.saturating_sub(1);
168        if !holds.is_held() {
169            guard.remove(&entry_key);
170        }
171    }
172}
173
174/// Clear the subscription hold on `(speaker_id, key)`, keeping `direct` holds.
175///
176/// Called when a UPnP subscription is finally torn down. `direct` holders are
177/// deliberately untouched: they are tracked per watcher and released by their
178/// own guards, and their property may not even be the one that was subscribed.
179fn release_subscription_watch(
180    watched: &RwLock<WatchCounts>,
181    speaker_id: &SpeakerId,
182    key: &'static str,
183) {
184    let mut guard = watched.write();
185    let entry_key = (speaker_id.clone(), key);
186    if let Some(holds) = guard.get_mut(&entry_key) {
187        holds.subscription = false;
188        if !holds.is_held() {
189            guard.remove(&entry_key);
190        }
191    }
192}
193
194/// Whether `(speaker_id, key)` currently has any hold on it.
195pub(crate) fn is_pair_watched(
196    watched: &WatchCounts,
197    speaker_id: &SpeakerId,
198    key: &'static str,
199) -> bool {
200    watched.contains_key(&(speaker_id.clone(), key))
201}
202
203// ============================================================================
204// Internal StateStore
205// ============================================================================
206
207/// Internal state storage
208pub struct StateStore {
209    /// Speaker metadata
210    pub(crate) speakers: HashMap<SpeakerId, SpeakerInfo>,
211    /// IP to speaker ID mapping
212    pub(crate) ip_to_speaker: HashMap<IpAddr, SpeakerId>,
213    /// Property values: (speaker_id, property_key) -> type-erased value
214    pub(crate) speaker_props: HashMap<SpeakerId, PropertyBag>,
215    /// Group metadata
216    pub(crate) groups: HashMap<GroupId, GroupInfo>,
217    /// Group properties
218    pub(crate) group_props: HashMap<GroupId, PropertyBag>,
219    /// System properties
220    pub(crate) system_props: PropertyBag,
221    /// Speaker to group mapping for quick lookups
222    pub(crate) speaker_to_group: HashMap<SpeakerId, GroupId>,
223    /// Satellite speaker IDs (Invisible="1") from topology
224    pub(crate) satellite_ids: HashSet<SpeakerId>,
225}
226
227impl StateStore {
228    pub(crate) fn new() -> Self {
229        Self {
230            speakers: HashMap::new(),
231            ip_to_speaker: HashMap::new(),
232            speaker_props: HashMap::new(),
233            groups: HashMap::new(),
234            group_props: HashMap::new(),
235            system_props: PropertyBag::new(),
236            speaker_to_group: HashMap::new(),
237            satellite_ids: HashSet::new(),
238        }
239    }
240
241    pub(crate) fn add_speaker(&mut self, speaker: SpeakerInfo) {
242        let id = speaker.id.clone();
243        let ip = speaker.ip_address;
244        self.ip_to_speaker.insert(ip, id.clone());
245        self.speakers.insert(id.clone(), speaker);
246        self.speaker_props
247            .entry(id)
248            .or_insert_with(PropertyBag::new);
249    }
250
251    fn speaker(&self, id: &SpeakerId) -> Option<&SpeakerInfo> {
252        self.speakers.get(id)
253    }
254
255    fn speakers(&self) -> Vec<SpeakerInfo> {
256        self.speakers.values().cloned().collect()
257    }
258
259    pub(crate) fn add_group(&mut self, group: GroupInfo) {
260        let id = group.id.clone();
261        // Update speaker_to_group mapping for all members
262        for member_id in &group.member_ids {
263            self.speaker_to_group.insert(member_id.clone(), id.clone());
264        }
265        self.groups.insert(id.clone(), group);
266        self.group_props.entry(id).or_insert_with(PropertyBag::new);
267    }
268
269    /// Get the group a speaker belongs to
270    #[allow(dead_code)]
271    pub(crate) fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<&GroupInfo> {
272        let group_id = self.speaker_to_group.get(speaker_id)?;
273        self.groups.get(group_id)
274    }
275
276    /// Clear all groups and speaker_to_group mappings
277    ///
278    /// Used when processing topology updates to replace all group data
279    pub(crate) fn clear_groups(&mut self) {
280        self.groups.clear();
281        self.group_props.clear();
282        self.speaker_to_group.clear();
283    }
284
285    /// Resolve the coordinator speaker for the given speaker.
286    ///
287    /// Looks up `speaker_to_group → groups → coordinator_id`.
288    /// Returns the speaker's own ID if no group info exists (safe default).
289    pub(crate) fn resolve_coordinator(&self, speaker_id: &SpeakerId) -> SpeakerId {
290        self.speaker_to_group
291            .get(speaker_id)
292            .and_then(|gid| self.groups.get(gid))
293            .map(|group| group.coordinator_id.clone())
294            .unwrap_or_else(|| speaker_id.clone())
295    }
296
297    /// Get a property value with coordinator resolution for PerCoordinator services.
298    ///
299    /// If the property's service is PerCoordinator AND the property scope is Speaker,
300    /// reads from the coordinator's speaker_props. Otherwise reads from the
301    /// speaker's own props.
302    ///
303    /// Group-scoped properties (e.g. GroupVolume) come from a PerCoordinator service
304    /// but are stored in `group_props`, not `speaker_props`, so they are not resolved
305    /// through the coordinator's speaker_props.
306    pub(crate) fn get_resolved<P: SonosProperty>(&self, speaker_id: &SpeakerId) -> Option<P> {
307        if P::SERVICE.scope() == ServiceScope::PerCoordinator && P::SCOPE == Scope::Speaker {
308            let coordinator_id = self.resolve_coordinator(speaker_id);
309            self.speaker_props.get(&coordinator_id)?.get::<P>()
310        } else {
311            self.speaker_props.get(speaker_id)?.get::<P>()
312        }
313    }
314
315    /// Resolve which speaker's bag a `set` of `P` for `speaker_id` should target.
316    ///
317    /// The exact mirror of [`Self::get_resolved`]'s branch, so a write always
318    /// lands where the matching read looks. Factored out rather than inlined at
319    /// the two sites because the two must not be able to drift apart.
320    pub(crate) fn resolve_write_target<P: SonosProperty>(
321        &self,
322        speaker_id: &SpeakerId,
323    ) -> SpeakerId {
324        if P::SERVICE.scope() == ServiceScope::PerCoordinator && P::SCOPE == Scope::Speaker {
325            self.resolve_coordinator(speaker_id)
326        } else {
327            speaker_id.clone()
328        }
329    }
330
331    #[cfg_attr(not(test), allow(dead_code))]
332    pub(crate) fn get<P: Property>(&self, speaker_id: &SpeakerId) -> Option<P> {
333        self.speaker_props.get(speaker_id)?.get::<P>()
334    }
335
336    pub(crate) fn set<P: Property>(&mut self, speaker_id: &SpeakerId, value: P) -> bool {
337        let bag = self
338            .speaker_props
339            .entry(speaker_id.clone())
340            .or_insert_with(PropertyBag::new);
341        bag.set(value)
342    }
343
344    pub(crate) fn get_group<P: Property>(&self, group_id: &GroupId) -> Option<P> {
345        self.group_props.get(group_id)?.get::<P>()
346    }
347
348    pub(crate) fn set_group<P: Property>(&mut self, group_id: &GroupId, value: P) -> bool {
349        let bag = self
350            .group_props
351            .entry(group_id.clone())
352            .or_insert_with(PropertyBag::new);
353        bag.set(value)
354    }
355
356    fn set_system<P: Property>(&mut self, value: P) -> bool {
357        self.system_props.set(value)
358    }
359
360    /// Update a speaker's IP address in the store. Returns the old IP if changed.
361    pub(crate) fn update_speaker_ip_address(
362        &mut self,
363        speaker_id: &SpeakerId,
364        new_ip: IpAddr,
365    ) -> Option<IpAddr> {
366        if let Some(info) = self.speakers.get_mut(speaker_id) {
367            let old_ip = info.ip_address;
368            if old_ip != new_ip {
369                info.ip_address = new_ip;
370                return Some(old_ip);
371            }
372        }
373        None
374    }
375
376    fn is_empty(&self) -> bool {
377        self.speakers.is_empty()
378    }
379
380    fn speaker_count(&self) -> usize {
381        self.speakers.len()
382    }
383
384    fn group_count(&self) -> usize {
385        self.groups.len()
386    }
387}
388
389// ============================================================================
390// PropertyBag - type-erased property storage
391// ============================================================================
392
393pub(crate) struct PropertyBag {
394    /// Map<TypeId, Box<dyn Any>> where Any is the property value
395    values: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
396}
397
398impl PropertyBag {
399    pub(crate) fn new() -> Self {
400        Self {
401            values: HashMap::new(),
402        }
403    }
404
405    fn get<P: Property>(&self) -> Option<P> {
406        let type_id = TypeId::of::<P>();
407        self.values
408            .get(&type_id)
409            .and_then(|boxed| boxed.downcast_ref::<P>())
410            .cloned()
411    }
412
413    fn set<P: Property>(&mut self, value: P) -> bool {
414        let type_id = TypeId::of::<P>();
415        let current = self
416            .values
417            .get(&type_id)
418            .and_then(|boxed| boxed.downcast_ref::<P>());
419
420        if current != Some(&value) {
421            self.values.insert(type_id, Box::new(value));
422            true
423        } else {
424            false
425        }
426    }
427}
428
429// ============================================================================
430// StateManager - main entry point
431// ============================================================================
432
433/// Core state manager with sync-first API
434///
435/// All public methods are synchronous. Background event processing
436/// happens in a dedicated thread.
437pub struct StateManager {
438    /// Property values storage
439    store: Arc<RwLock<StateStore>>,
440
441    /// Watched properties for iter() filtering, reference-counted.
442    ///
443    /// Counted rather than a plain set because several independent watchers can
444    /// hold the same `(speaker_id, property_key)` at once — two widgets watching
445    /// one property, a re-watch-per-frame loop overlapping with a long-lived
446    /// handle, or an SDK `WatchHandle` alongside a direct `register_watch`.
447    /// With a `HashSet` the *first* release removed the entry and silenced every
448    /// remaining watcher; the count means an entry disappears only when the last
449    /// watcher lets go.
450    watched: Arc<RwLock<WatchCounts>>,
451
452    /// IP to speaker ID mapping (for event worker)
453    ip_to_speaker: Arc<RwLock<HashMap<IpAddr, SpeakerId>>>,
454
455    /// Event manager (set-once via OnceLock — enables live events)
456    event_manager: OnceLock<Arc<SonosEventManager>>,
457
458    /// Channel for sending change events to iter()
459    event_tx: mpsc::Sender<ChangeEvent>,
460
461    /// Receiver for iter() - wrapped in `Arc<Mutex>` for cloning
462    event_rx: Arc<Mutex<mpsc::Receiver<ChangeEvent>>>,
463
464    /// Background event processor handle (lazily spawned)
465    _worker: Mutex<Option<JoinHandle<()>>>,
466
467    /// Cleanup timeout for subscriptions
468    cleanup_timeout: Duration,
469
470    /// Maps property key → Service for WatchRegistry's unregister_watches_for_service.
471    /// Shared with StateWatchRegistry via Arc.
472    key_to_service: Arc<RwLock<HashMap<&'static str, Service>>>,
473
474    /// Lazy event manager initialization closure (set-once).
475    /// Called by watch() to trigger event manager creation on first use.
476    event_init: OnceLock<EventInitFn>,
477}
478
479// ============================================================================
480// StateWatchRegistry - WatchRegistry impl for SonosEventManager
481// ============================================================================
482
483/// Lightweight WatchRegistry implementation wired into the event manager.
484///
485/// Separated from StateManager because `mpsc::Sender` is `!Sync`,
486/// preventing StateManager itself from satisfying `WatchRegistry: Sync`.
487/// This struct holds only the Arc-wrapped fields needed for watch management.
488struct StateWatchRegistry {
489    watched: Arc<RwLock<WatchCounts>>,
490    ip_to_speaker: Arc<RwLock<HashMap<IpAddr, SpeakerId>>>,
491    key_to_service: Arc<RwLock<HashMap<&'static str, Service>>>,
492}
493
494impl WatchRegistry for StateWatchRegistry {
495    fn register_watch(&self, speaker_id: &SpeakerId, key: &'static str, service: Service) {
496        retain_subscription_watch(&self.watched, speaker_id, key);
497        self.key_to_service.write().insert(key, service);
498    }
499
500    fn unregister_watches_for_service(&self, ip: IpAddr, service: Service) {
501        // 1. Resolve IP → SpeakerId
502        let speaker_id = match self.ip_to_speaker.read().get(&ip).cloned() {
503            Some(id) => id,
504            None => {
505                tracing::warn!(
506                    "unregister_watches_for_service: no speaker found for IP {}",
507                    ip
508                );
509                return;
510            }
511        };
512
513        // 2. Find property keys belonging to this service
514        let service_keys: Vec<&'static str> = self
515            .key_to_service
516            .read()
517            .iter()
518            .filter(|(_, &svc)| svc == service)
519            .map(|(&key, _)| key)
520            .collect();
521
522        // 3. Drop the subscription hold on each of this service's keys.
523        //
524        // Only the subscription hold: a `direct` hold is owned by an individual
525        // watcher (polling fallback, cache-only, member forwarding) which
526        // releases it through its own guard. Removing entries wholesale here is
527        // what previously made dropping one `WatchHandle` silence its siblings.
528        for key in service_keys {
529            release_subscription_watch(&self.watched, &speaker_id, key);
530        }
531    }
532}
533
534impl StateManager {
535    /// Create a new StateManager with default settings (sync)
536    ///
537    /// # Example
538    ///
539    /// ```rust,ignore
540    /// let manager = StateManager::new()?;
541    /// ```
542    pub fn new() -> Result<Self> {
543        Self::builder().build()
544    }
545
546    /// Create a StateManager builder for custom configuration
547    pub fn builder() -> StateManagerBuilder {
548        StateManagerBuilder::default()
549    }
550
551    /// Add discovered devices (sync)
552    ///
553    /// # Example
554    ///
555    /// ```rust,ignore
556    /// let devices = sonos_discovery::get();
557    /// manager.add_devices(devices)?;
558    /// ```
559    pub fn add_devices(&self, devices: Vec<Device>) -> Result<()> {
560        let mut store = self.store.write();
561        let mut ip_map = self.ip_to_speaker.write();
562
563        for device in devices {
564            let speaker_id = SpeakerId::new(&device.id);
565            let ip: IpAddr = device
566                .ip_address
567                .parse()
568                .map_err(|_| StateError::InvalidIpAddress(device.ip_address.clone()))?;
569
570            let friendly_name = if device.room_name.is_empty() || device.room_name == "Unknown" {
571                device.name.clone()
572            } else {
573                device.room_name.clone()
574            };
575
576            let info = SpeakerInfo {
577                id: speaker_id.clone(),
578                name: friendly_name,
579                room_name: device.room_name.clone(),
580                ip_address: ip,
581                port: device.port,
582                model_name: device.model_name.clone(),
583                software_version: "unknown".to_string(),
584                boot_seq: 0,
585                satellites: vec![],
586            };
587
588            // Update ip_to_speaker mapping
589            ip_map.insert(ip, speaker_id.clone());
590            tracing::debug!(
591                "Added speaker {} at IP {} to ip_to_speaker map",
592                speaker_id.as_str(),
593                ip
594            );
595
596            store.add_speaker(info);
597        }
598
599        // Also add devices to event manager if present
600        drop(store);
601        drop(ip_map);
602
603        if let Some(em) = self.event_manager.get() {
604            let devices_for_em: Vec<_> = self
605                .speaker_infos()
606                .iter()
607                .map(|info| sonos_discovery::Device {
608                    id: info.id.as_str().to_string(),
609                    name: info.name.clone(),
610                    room_name: info.room_name.clone(),
611                    ip_address: info.ip_address.to_string(),
612                    port: info.port,
613                    model_name: info.model_name.clone(),
614                })
615                .collect();
616
617            if let Err(e) = em.add_devices(devices_for_em) {
618                tracing::warn!("Failed to add devices to event manager: {}", e);
619            }
620        }
621
622        Ok(())
623    }
624
625    /// Get all speaker info
626    pub fn speaker_infos(&self) -> Vec<SpeakerInfo> {
627        self.store.read().speakers()
628    }
629
630    /// Get a specific speaker info by ID
631    pub fn speaker_info(&self, speaker_id: &SpeakerId) -> Option<SpeakerInfo> {
632        self.store.read().speaker(speaker_id).cloned()
633    }
634
635    /// Get speaker IP by ID
636    pub fn get_speaker_ip(&self, speaker_id: &SpeakerId) -> Option<IpAddr> {
637        self.store.read().speaker(speaker_id).map(|s| s.ip_address)
638    }
639
640    /// Get boot_seq for a speaker (used by GroupManagement AddMember)
641    pub fn get_boot_seq(&self, speaker_id: &SpeakerId) -> Option<u32> {
642        self.store.read().speaker(speaker_id).map(|s| s.boot_seq)
643    }
644
645    /// Update a speaker's IP address in both the store and the reverse map.
646    pub fn update_speaker_ip(&self, speaker_id: &SpeakerId, new_ip: IpAddr) {
647        let old_ip = {
648            let mut store = self.store.write();
649            store.update_speaker_ip_address(speaker_id, new_ip)
650        };
651        if let Some(old_ip) = old_ip {
652            let mut map = self.ip_to_speaker.write();
653            map.remove(&old_ip);
654            map.insert(new_ip, speaker_id.clone());
655        }
656    }
657
658    /// Get all satellite speaker IDs from topology data.
659    pub fn get_satellite_ids(&self) -> Vec<SpeakerId> {
660        self.store.read().satellite_ids.iter().cloned().collect()
661    }
662
663    /// Store satellite speaker IDs from topology data.
664    pub fn set_satellite_ids(&self, ids: Vec<SpeakerId>) {
665        self.store.write().satellite_ids = ids.into_iter().collect();
666    }
667
668    /// Create a blocking iterator over change events
669    ///
670    /// Only emits events for properties that have been watched.
671    ///
672    /// # Example
673    ///
674    /// ```rust,ignore
675    /// // First, watch some properties
676    /// speaker.volume.watch()?;
677    ///
678    /// // Then iterate over changes
679    /// for event in manager.iter() {
680    ///     println!("Changed: {} on {}", event.property_key, event.speaker_id);
681    /// }
682    /// ```
683    pub fn iter(&self) -> ChangeIterator {
684        ChangeIterator::new(Arc::clone(&self.event_rx))
685    }
686
687    /// Get current property value (sync, no subscription)
688    ///
689    /// For PerCoordinator speaker-scoped properties, this transparently reads
690    /// from the coordinator's store, so group members see the coordinator's value.
691    pub fn get_property<P: SonosProperty>(&self, speaker_id: &SpeakerId) -> Option<P> {
692        self.store.read().get_resolved::<P>(speaker_id)
693    }
694
695    /// Get current group property value (sync, no subscription)
696    pub fn get_group_property<P: Property>(&self, group_id: &GroupId) -> Option<P> {
697        self.store.read().get_group::<P>(group_id)
698    }
699
700    /// Set a property value
701    ///
702    /// Updates the property value in the store and emits a change event
703    /// if the property is being watched.
704    ///
705    /// The write is routed the same way [`Self::get_property`] reads: for a
706    /// `PerCoordinator` speaker-scoped property, the value lands in the
707    /// *coordinator's* bag, because `get_resolved` reads it from there. Writing
708    /// the raw `speaker_id` instead put the value in a bag nothing ever reads —
709    /// so `speaker.play()` on a grouped member updated a cache entry that
710    /// `playback_state.get()` could not see, and the UI kept showing the old
711    /// state until an event arrived.
712    ///
713    /// The notification is still keyed on the *requesting* speaker, so a member
714    /// watching the property is woken by its own write. The coordinator's own
715    /// watchers are reached by the worker's group fan-out on the next event.
716    pub fn set_property<P: SonosProperty>(&self, speaker_id: &SpeakerId, value: P) {
717        // Resolve and write under one lock: taking the coordinator from a
718        // separate read would leave a window in which a topology event regroups
719        // the speaker and the write lands in the wrong bag.
720        let (target_id, changed) = {
721            let mut store = self.store.write();
722            let target_id = store.resolve_write_target::<P>(speaker_id);
723            let changed = store.set::<P>(&target_id, value);
724            (target_id, changed)
725        };
726
727        if changed {
728            // Key the notification on the speaker the caller asked about, so a
729            // member watching the property is woken by its own write...
730            self.maybe_emit_change(speaker_id, P::KEY, P::SERVICE);
731            // ...and on the coordinator too when they differ, since it is the
732            // coordinator's bag that actually changed.
733            if target_id != *speaker_id {
734                self.maybe_emit_change(&target_id, P::KEY, P::SERVICE);
735            }
736        }
737    }
738
739    /// Set a group property value
740    ///
741    /// Updates the group property value in the store and emits a change event
742    /// if the property is being watched (keyed on the coordinator's speaker ID).
743    /// Used by the SDK layer to store group-scoped values fetched via API calls.
744    pub fn set_group_property<P: SonosProperty>(&self, group_id: &GroupId, value: P) {
745        let coordinator_id = {
746            let mut store = self.store.write();
747            let changed = store.set_group::<P>(group_id, value);
748            if !changed {
749                return;
750            }
751            store.groups.get(group_id).map(|g| g.coordinator_id.clone())
752        };
753
754        if let Some(coordinator_id) = coordinator_id {
755            self.maybe_emit_change(&coordinator_id, P::KEY, P::SERVICE);
756        }
757    }
758
759    /// Register a property as watched (called by PropertyHandle::watch)
760    ///
761    /// Adds one reference. Balanced by [`Self::unregister_watch`]; the property
762    /// keeps emitting until every registration has been unregistered.
763    pub fn register_watch(&self, speaker_id: &SpeakerId, property_key: &'static str) {
764        retain_direct_watch(&self.watched, speaker_id, property_key);
765    }
766
767    /// Unregister a property watch
768    ///
769    /// Releases one reference taken by [`Self::register_watch`]. The property
770    /// stops being watched only when the last reference is released, so one
771    /// watcher going away cannot silence its siblings. Unregistering something
772    /// that was never registered is a no-op.
773    pub fn unregister_watch(&self, speaker_id: &SpeakerId, property_key: &'static str) {
774        release_direct_watch(&self.watched, speaker_id, property_key);
775    }
776
777    /// Watch a property with automatic UPnP subscription (recommended API)
778    ///
779    /// This is the preferred method for watching properties as it:
780    /// 1. Registers the property for change notifications
781    /// 2. Subscribes to the UPnP service via the event manager
782    ///
783    /// Returns the current cached value if available.
784    pub fn watch_property_with_subscription<P: SonosProperty>(
785        &self,
786        speaker_id: &SpeakerId,
787    ) -> Result<Option<P>> {
788        // Register for change notifications
789        self.register_watch(speaker_id, P::KEY);
790
791        // Subscribe via event manager if available
792        if let Some(em) = self.event_manager.get() {
793            // Get speaker IP from store
794            if let Some(ip) = self.get_speaker_ip(speaker_id) {
795                if let Err(e) = em.ensure_service_subscribed(ip, P::SERVICE) {
796                    tracing::warn!(
797                        "Failed to subscribe to {:?} for {}: {}",
798                        P::SERVICE,
799                        speaker_id.as_str(),
800                        e
801                    );
802                }
803            }
804        }
805
806        Ok(self.get_property::<P>(speaker_id))
807    }
808
809    /// Unwatch a property and release UPnP subscription
810    pub fn unwatch_property_with_subscription<P: SonosProperty>(&self, speaker_id: &SpeakerId) {
811        // Unregister from change notifications
812        self.unregister_watch(speaker_id, P::KEY);
813
814        // Release subscription via event manager if available
815        if let Some(em) = self.event_manager.get() {
816            if let Some(ip) = self.get_speaker_ip(speaker_id) {
817                if let Err(e) = em.release_service_subscription(ip, P::SERVICE) {
818                    tracing::warn!(
819                        "Failed to unsubscribe from {:?} for {}: {}",
820                        P::SERVICE,
821                        speaker_id.as_str(),
822                        e
823                    );
824                }
825            }
826        }
827    }
828
829    /// Check if a property is being watched
830    pub fn is_watched(&self, speaker_id: &SpeakerId, property_key: &'static str) -> bool {
831        is_pair_watched(&self.watched.read(), speaker_id, property_key)
832    }
833
834    /// Emit a change event if the property is being watched
835    fn maybe_emit_change(
836        &self,
837        speaker_id: &SpeakerId,
838        property_key: &'static str,
839        service: Service,
840    ) {
841        let is_watched = is_pair_watched(&self.watched.read(), speaker_id, property_key);
842
843        if is_watched {
844            let event = ChangeEvent::new(speaker_id.clone(), property_key, service);
845            let _ = self.event_tx.send(event);
846        }
847    }
848
849    /// Initialize from topology data
850    pub fn initialize(&self, topology: Topology) {
851        let mut store = self.store.write();
852        for speaker in &topology.speakers {
853            store.add_speaker(speaker.clone());
854        }
855        for group in &topology.groups {
856            store.add_group(group.clone());
857        }
858        store.set_system(topology);
859    }
860
861    /// Check if initialized with any speakers
862    pub fn is_initialized(&self) -> bool {
863        !self.store.read().is_empty()
864    }
865
866    /// Get number of speakers
867    pub fn speaker_count(&self) -> usize {
868        self.store.read().speaker_count()
869    }
870
871    /// Get number of groups
872    pub fn group_count(&self) -> usize {
873        self.store.read().group_count()
874    }
875
876    /// Get all current groups
877    ///
878    /// Returns all groups in the system. Every speaker is always in a group,
879    /// so a single speaker forms a group of one.
880    pub fn groups(&self) -> Vec<GroupInfo> {
881        self.store.read().groups.values().cloned().collect()
882    }
883
884    /// Get a specific group by ID
885    pub fn get_group(&self, group_id: &GroupId) -> Option<GroupInfo> {
886        self.store.read().groups.get(group_id).cloned()
887    }
888
889    /// Get the group a speaker belongs to
890    ///
891    /// Uses the speaker_to_group mapping for quick lookup.
892    pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<GroupInfo> {
893        let store = self.store.read();
894        let group_id = store.speaker_to_group.get(speaker_id)?;
895        store.groups.get(group_id).cloned()
896    }
897
898    /// Resolve the subscription target for a PerCoordinator service.
899    ///
900    /// For PerCoordinator services, returns the coordinator's `(SpeakerId, IpAddr)`
901    /// so the SDK can route UPnP subscriptions to the coordinator speaker.
902    /// Falls back to the speaker itself if no group data exists.
903    ///
904    /// For non-PerCoordinator services, returns the speaker's own identity.
905    pub fn resolve_subscription_target(
906        &self,
907        speaker_id: &SpeakerId,
908        speaker_ip: IpAddr,
909        service: Service,
910    ) -> (SpeakerId, IpAddr) {
911        if service.scope() == ServiceScope::PerCoordinator {
912            let store = self.store.read();
913            let coordinator_id = store.resolve_coordinator(speaker_id);
914            if coordinator_id == *speaker_id {
915                (speaker_id.clone(), speaker_ip)
916            } else {
917                let coord_ip = store
918                    .speaker(&coordinator_id)
919                    .map(|s| s.ip_address)
920                    .unwrap_or(speaker_ip);
921                (coordinator_id, coord_ip)
922            }
923        } else {
924            (speaker_id.clone(), speaker_ip)
925        }
926    }
927
928    /// Get access to the event manager (if configured)
929    ///
930    /// This allows PropertyHandle::watch() to trigger UPnP subscriptions
931    /// via the event manager's ensure_service_subscribed() method.
932    pub fn event_manager(&self) -> Option<&Arc<SonosEventManager>> {
933        self.event_manager.get()
934    }
935
936    /// Wire an event manager into this StateManager after construction.
937    ///
938    /// Spawns the event worker thread and registers all known devices.
939    /// Can only be called once — subsequent calls are no-ops.
940    pub fn set_event_manager(&self, em: Arc<SonosEventManager>) -> Result<()> {
941        tracing::debug!("StateManager::set_event_manager called");
942        if self.event_manager.set(Arc::clone(&em)).is_err() {
943            tracing::debug!("Event manager already set — no-op");
944            return Ok(()); // Already set — no-op
945        }
946
947        // Wire this StateManager as the WatchRegistry
948        em.set_watch_registry(Arc::new(StateWatchRegistry {
949            watched: Arc::clone(&self.watched),
950            ip_to_speaker: Arc::clone(&self.ip_to_speaker),
951            key_to_service: Arc::clone(&self.key_to_service),
952        }));
953
954        // Register all known devices with the event manager
955        let devices_for_em: Vec<_> = self
956            .speaker_infos()
957            .iter()
958            .map(|info| sonos_discovery::Device {
959                id: info.id.as_str().to_string(),
960                name: info.name.clone(),
961                room_name: info.room_name.clone(),
962                ip_address: info.ip_address.to_string(),
963                port: info.port,
964                model_name: info.model_name.clone(),
965            })
966            .collect();
967
968        if let Err(e) = em.add_devices(devices_for_em) {
969            tracing::warn!(
970                "Failed to add devices to event manager during lazy init: {}",
971                e
972            );
973        }
974
975        // Spawn event worker thread
976        let worker = spawn_state_event_worker(
977            em,
978            Arc::clone(&self.store),
979            Arc::clone(&self.watched),
980            self.event_tx.clone(),
981            Arc::clone(&self.ip_to_speaker),
982        );
983        info!("StateManager event worker started (lazy init)");
984
985        if let Ok(mut w) = self._worker.lock() {
986            *w = Some(worker);
987        }
988
989        Ok(())
990    }
991
992    /// Set the lazy event manager initialization closure.
993    ///
994    /// Called once by `SonosSystem::from_devices_inner()` after construction.
995    /// Subsequent calls are no-ops (OnceLock semantics).
996    pub fn set_event_init(&self, f: EventInitFn) {
997        let _ = self.event_init.set(f);
998    }
999
1000    /// Get the event init closure (if set).
1001    ///
1002    /// Used by `PropertyHandle::watch()` and `GroupPropertyHandle::watch()`
1003    /// to trigger lazy event manager creation on first use.
1004    pub fn event_init(&self) -> Option<&EventInitFn> {
1005        self.event_init.get()
1006    }
1007}
1008
1009impl Clone for StateManager {
1010    fn clone(&self) -> Self {
1011        let event_manager = OnceLock::new();
1012        if let Some(em) = self.event_manager.get() {
1013            let _ = event_manager.set(Arc::clone(em));
1014        }
1015        let event_init = OnceLock::new();
1016        if let Some(f) = self.event_init.get() {
1017            let _ = event_init.set(Arc::clone(f));
1018        }
1019        Self {
1020            store: Arc::clone(&self.store),
1021            watched: Arc::clone(&self.watched),
1022            ip_to_speaker: Arc::clone(&self.ip_to_speaker),
1023            event_manager,
1024            event_tx: self.event_tx.clone(),
1025            event_rx: Arc::clone(&self.event_rx),
1026            _worker: Mutex::new(None),
1027            cleanup_timeout: self.cleanup_timeout,
1028            key_to_service: Arc::clone(&self.key_to_service),
1029            event_init,
1030        }
1031    }
1032}
1033
1034// ============================================================================
1035// StateManagerBuilder
1036// ============================================================================
1037
1038/// Builder for StateManager configuration
1039pub struct StateManagerBuilder {
1040    cleanup_timeout: Duration,
1041    event_manager: Option<Arc<SonosEventManager>>,
1042}
1043
1044impl Default for StateManagerBuilder {
1045    fn default() -> Self {
1046        Self {
1047            cleanup_timeout: Duration::from_secs(5),
1048            event_manager: None,
1049        }
1050    }
1051}
1052
1053impl StateManagerBuilder {
1054    /// Set the cleanup timeout for subscriptions
1055    pub fn cleanup_timeout(mut self, timeout: Duration) -> Self {
1056        self.cleanup_timeout = timeout;
1057        self
1058    }
1059
1060    /// Set the event manager for live event processing
1061    ///
1062    /// When an event manager is provided, the StateManager will:
1063    /// - Spawn a background worker to process events
1064    /// - Automatically subscribe/unsubscribe via `watch()`/`unwatch()` on properties
1065    /// - Update state from incoming events
1066    pub fn with_event_manager(mut self, em: Arc<SonosEventManager>) -> Self {
1067        self.event_manager = Some(em);
1068        self
1069    }
1070
1071    /// Build the StateManager
1072    pub fn build(self) -> Result<StateManager> {
1073        let (event_tx, event_rx) = mpsc::channel();
1074
1075        let store = Arc::new(RwLock::new(StateStore::new()));
1076        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1077        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1078        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1079
1080        let event_manager_lock = OnceLock::new();
1081        let mut worker = None;
1082
1083        // If event_manager provided at build time, wire it up eagerly
1084        if let Some(em) = self.event_manager {
1085            let _ = event_manager_lock.set(Arc::clone(&em));
1086
1087            // Wire WatchRegistry
1088            em.set_watch_registry(Arc::new(StateWatchRegistry {
1089                watched: Arc::clone(&watched),
1090                ip_to_speaker: Arc::clone(&ip_to_speaker),
1091                key_to_service: Arc::clone(&key_to_service),
1092            }));
1093
1094            let worker_handle = spawn_state_event_worker(
1095                em,
1096                Arc::clone(&store),
1097                Arc::clone(&watched),
1098                event_tx.clone(),
1099                Arc::clone(&ip_to_speaker),
1100            );
1101            info!("StateManager event worker started");
1102            worker = Some(worker_handle);
1103        }
1104
1105        let manager = StateManager {
1106            store,
1107            watched,
1108            ip_to_speaker,
1109            event_manager: event_manager_lock,
1110            event_tx,
1111            event_rx: Arc::new(Mutex::new(event_rx)),
1112            _worker: Mutex::new(worker),
1113            cleanup_timeout: self.cleanup_timeout,
1114            key_to_service,
1115            event_init: OnceLock::new(),
1116        };
1117
1118        info!("StateManager created (sync-first mode)");
1119        Ok(manager)
1120    }
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125    use super::*;
1126    use crate::property::{GroupVolume, PlaybackState, Volume};
1127    use sonos_api::Service;
1128
1129    #[test]
1130    fn test_state_manager_creation() {
1131        let manager = StateManager::new().unwrap();
1132        assert!(!manager.is_initialized());
1133        assert_eq!(manager.speaker_count(), 0);
1134    }
1135
1136    #[test]
1137    fn test_add_devices() {
1138        let manager = StateManager::new().unwrap();
1139
1140        let devices = vec![Device {
1141            id: "RINCON_123".to_string(),
1142            name: "Living Room".to_string(),
1143            room_name: "Living Room".to_string(),
1144            ip_address: "192.168.1.100".to_string(),
1145            port: 1400,
1146            model_name: "Sonos One".to_string(),
1147        }];
1148
1149        manager.add_devices(devices).unwrap();
1150        assert_eq!(manager.speaker_count(), 1);
1151    }
1152
1153    #[test]
1154    fn test_property_storage() {
1155        let manager = StateManager::new().unwrap();
1156
1157        let devices = vec![Device {
1158            id: "RINCON_123".to_string(),
1159            name: "Living Room".to_string(),
1160            room_name: "Living Room".to_string(),
1161            ip_address: "192.168.1.100".to_string(),
1162            port: 1400,
1163            model_name: "Sonos One".to_string(),
1164        }];
1165        manager.add_devices(devices).unwrap();
1166
1167        let speaker_id = SpeakerId::new("RINCON_123");
1168
1169        // Initially None
1170        assert!(manager.get_property::<Volume>(&speaker_id).is_none());
1171
1172        // Set value
1173        manager.set_property(&speaker_id, Volume::new(50));
1174        assert_eq!(
1175            manager.get_property::<Volume>(&speaker_id),
1176            Some(Volume::new(50))
1177        );
1178    }
1179
1180    #[test]
1181    fn test_watch_registration() {
1182        let manager = StateManager::new().unwrap();
1183
1184        let devices = vec![Device {
1185            id: "RINCON_123".to_string(),
1186            name: "Living Room".to_string(),
1187            room_name: "Living Room".to_string(),
1188            ip_address: "192.168.1.100".to_string(),
1189            port: 1400,
1190            model_name: "Sonos One".to_string(),
1191        }];
1192        manager.add_devices(devices).unwrap();
1193
1194        let speaker_id = SpeakerId::new("RINCON_123");
1195
1196        // Not watched initially
1197        assert!(!manager.is_watched(&speaker_id, "volume"));
1198
1199        // Register watch
1200        manager.register_watch(&speaker_id, "volume");
1201        assert!(manager.is_watched(&speaker_id, "volume"));
1202
1203        // Unregister watch
1204        manager.unregister_watch(&speaker_id, "volume");
1205        assert!(!manager.is_watched(&speaker_id, "volume"));
1206    }
1207
1208    #[test]
1209    fn test_change_event_emission() {
1210        let manager = StateManager::new().unwrap();
1211
1212        let devices = vec![Device {
1213            id: "RINCON_123".to_string(),
1214            name: "Living Room".to_string(),
1215            room_name: "Living Room".to_string(),
1216            ip_address: "192.168.1.100".to_string(),
1217            port: 1400,
1218            model_name: "Sonos One".to_string(),
1219        }];
1220        manager.add_devices(devices).unwrap();
1221
1222        let speaker_id = SpeakerId::new("RINCON_123");
1223
1224        // Register watch
1225        manager.register_watch(&speaker_id, "volume");
1226
1227        // Set property (should emit event)
1228        manager.set_property(&speaker_id, Volume::new(75));
1229
1230        // Get event via iter
1231        let iter = manager.iter();
1232        let event = iter.recv_timeout(std::time::Duration::from_millis(100));
1233        assert!(event.is_some());
1234
1235        let event = event.unwrap();
1236        assert_eq!(event.speaker_id.as_str(), "RINCON_123");
1237        assert_eq!(event.property_key, "volume");
1238    }
1239
1240    #[test]
1241    fn test_set_group_property_emits_change_event() {
1242        let manager = StateManager::new().unwrap();
1243
1244        let devices = vec![Device {
1245            id: "RINCON_123".to_string(),
1246            name: "Living Room".to_string(),
1247            room_name: "Living Room".to_string(),
1248            ip_address: "192.168.1.100".to_string(),
1249            port: 1400,
1250            model_name: "Sonos One".to_string(),
1251        }];
1252        manager.add_devices(devices).unwrap();
1253
1254        let speaker_id = SpeakerId::new("RINCON_123");
1255        let group_id = GroupId::new("RINCON_123:1");
1256
1257        // Add group so coordinator lookup works
1258        {
1259            let mut store = manager.store.write();
1260            store.add_group(GroupInfo::new(
1261                group_id.clone(),
1262                speaker_id.clone(),
1263                vec![speaker_id.clone()],
1264            ));
1265        }
1266
1267        // Register watch on coordinator for group_volume
1268        manager.register_watch(&speaker_id, "group_volume");
1269
1270        // Set group property (should emit event via coordinator)
1271        manager.set_group_property(&group_id, GroupVolume::new(80));
1272
1273        // Verify event was emitted
1274        let iter = manager.iter();
1275        let event = iter.recv_timeout(std::time::Duration::from_millis(100));
1276        assert!(event.is_some());
1277
1278        let event = event.unwrap();
1279        assert_eq!(event.speaker_id.as_str(), "RINCON_123");
1280        assert_eq!(event.property_key, "group_volume");
1281        assert_eq!(event.service, Service::GroupRenderingControl);
1282    }
1283
1284    #[test]
1285    fn test_set_group_property_no_event_when_unwatched() {
1286        let manager = StateManager::new().unwrap();
1287
1288        let devices = vec![Device {
1289            id: "RINCON_123".to_string(),
1290            name: "Living Room".to_string(),
1291            room_name: "Living Room".to_string(),
1292            ip_address: "192.168.1.100".to_string(),
1293            port: 1400,
1294            model_name: "Sonos One".to_string(),
1295        }];
1296        manager.add_devices(devices).unwrap();
1297
1298        let speaker_id = SpeakerId::new("RINCON_123");
1299        let group_id = GroupId::new("RINCON_123:1");
1300
1301        {
1302            let mut store = manager.store.write();
1303            store.add_group(GroupInfo::new(
1304                group_id.clone(),
1305                speaker_id.clone(),
1306                vec![speaker_id.clone()],
1307            ));
1308        }
1309
1310        // Don't register any watch
1311        manager.set_group_property(&group_id, GroupVolume::new(50));
1312
1313        let iter = manager.iter();
1314        let event = iter.recv_timeout(std::time::Duration::from_millis(100));
1315        assert!(event.is_none());
1316    }
1317
1318    // ========================================================================
1319    // StateStore Group Operations Tests
1320    // ========================================================================
1321
1322    #[test]
1323    fn test_add_group_updates_speaker_to_group() {
1324        let mut store = StateStore::new();
1325
1326        let speaker1 = SpeakerId::new("RINCON_111");
1327        let speaker2 = SpeakerId::new("RINCON_222");
1328        let group_id = GroupId::new("RINCON_111:1");
1329
1330        let group = GroupInfo::new(
1331            group_id.clone(),
1332            speaker1.clone(),
1333            vec![speaker1.clone(), speaker2.clone()],
1334        );
1335
1336        store.add_group(group);
1337
1338        // Verify speaker_to_group mapping is updated for all members
1339        assert_eq!(store.speaker_to_group.get(&speaker1), Some(&group_id));
1340        assert_eq!(store.speaker_to_group.get(&speaker2), Some(&group_id));
1341    }
1342
1343    #[test]
1344    fn test_add_group_single_speaker() {
1345        let mut store = StateStore::new();
1346
1347        let speaker = SpeakerId::new("RINCON_333");
1348        let group_id = GroupId::new("RINCON_333:1");
1349
1350        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1351
1352        store.add_group(group.clone());
1353
1354        // Verify speaker_to_group mapping
1355        assert_eq!(store.speaker_to_group.get(&speaker), Some(&group_id));
1356
1357        // Verify group is stored
1358        assert_eq!(store.groups.get(&group_id), Some(&group));
1359    }
1360
1361    #[test]
1362    fn test_get_group_for_speaker_returns_correct_group() {
1363        let mut store = StateStore::new();
1364
1365        let speaker1 = SpeakerId::new("RINCON_111");
1366        let speaker2 = SpeakerId::new("RINCON_222");
1367        let speaker3 = SpeakerId::new("RINCON_333");
1368        let group1_id = GroupId::new("RINCON_111:1");
1369        let group2_id = GroupId::new("RINCON_333:1");
1370
1371        // Group 1: speaker1 (coordinator) + speaker2
1372        let group1 = GroupInfo::new(
1373            group1_id.clone(),
1374            speaker1.clone(),
1375            vec![speaker1.clone(), speaker2.clone()],
1376        );
1377
1378        // Group 2: speaker3 alone
1379        let group2 = GroupInfo::new(group2_id.clone(), speaker3.clone(), vec![speaker3.clone()]);
1380
1381        store.add_group(group1.clone());
1382        store.add_group(group2.clone());
1383
1384        // Verify get_group_for_speaker returns correct groups
1385        assert_eq!(store.get_group_for_speaker(&speaker1), Some(&group1));
1386        assert_eq!(store.get_group_for_speaker(&speaker2), Some(&group1));
1387        assert_eq!(store.get_group_for_speaker(&speaker3), Some(&group2));
1388    }
1389
1390    #[test]
1391    fn test_get_group_for_speaker_returns_none_for_unknown() {
1392        let store = StateStore::new();
1393
1394        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1395
1396        assert!(store.get_group_for_speaker(&unknown_speaker).is_none());
1397    }
1398
1399    #[test]
1400    fn test_clear_groups_removes_all_group_data() {
1401        let mut store = StateStore::new();
1402
1403        let speaker1 = SpeakerId::new("RINCON_111");
1404        let speaker2 = SpeakerId::new("RINCON_222");
1405        let group_id = GroupId::new("RINCON_111:1");
1406
1407        let group = GroupInfo::new(
1408            group_id.clone(),
1409            speaker1.clone(),
1410            vec![speaker1.clone(), speaker2.clone()],
1411        );
1412
1413        store.add_group(group);
1414
1415        // Verify data exists
1416        assert!(!store.groups.is_empty());
1417        assert!(!store.speaker_to_group.is_empty());
1418
1419        // Clear groups
1420        store.clear_groups();
1421
1422        // Verify all group data is cleared
1423        assert!(store.groups.is_empty());
1424        assert!(store.group_props.is_empty());
1425        assert!(store.speaker_to_group.is_empty());
1426    }
1427
1428    #[test]
1429    fn test_clear_groups_then_add_new_groups() {
1430        let mut store = StateStore::new();
1431
1432        // Add initial group
1433        let speaker1 = SpeakerId::new("RINCON_111");
1434        let group1_id = GroupId::new("RINCON_111:1");
1435        let group1 = GroupInfo::new(group1_id.clone(), speaker1.clone(), vec![speaker1.clone()]);
1436        store.add_group(group1);
1437
1438        // Clear and add new group
1439        store.clear_groups();
1440
1441        let speaker2 = SpeakerId::new("RINCON_222");
1442        let group2_id = GroupId::new("RINCON_222:1");
1443        let group2 = GroupInfo::new(group2_id.clone(), speaker2.clone(), vec![speaker2.clone()]);
1444        store.add_group(group2.clone());
1445
1446        // Verify old group is gone, new group exists
1447        assert!(!store.groups.contains_key(&group1_id));
1448        assert_eq!(store.groups.get(&group2_id), Some(&group2));
1449
1450        // Verify speaker_to_group is updated correctly
1451        assert!(!store.speaker_to_group.contains_key(&speaker1));
1452        assert_eq!(store.speaker_to_group.get(&speaker2), Some(&group2_id));
1453    }
1454
1455    // ========================================================================
1456    // StateManager Group Methods Tests
1457    // ========================================================================
1458
1459    #[test]
1460    fn test_state_manager_groups_returns_all_groups() {
1461        let manager = StateManager::new().unwrap();
1462
1463        // Add devices
1464        let devices = vec![
1465            Device {
1466                id: "RINCON_111".to_string(),
1467                name: "Living Room".to_string(),
1468                room_name: "Living Room".to_string(),
1469                ip_address: "192.168.1.100".to_string(),
1470                port: 1400,
1471                model_name: "Sonos One".to_string(),
1472            },
1473            Device {
1474                id: "RINCON_222".to_string(),
1475                name: "Kitchen".to_string(),
1476                room_name: "Kitchen".to_string(),
1477                ip_address: "192.168.1.101".to_string(),
1478                port: 1400,
1479                model_name: "Sonos One".to_string(),
1480            },
1481        ];
1482        manager.add_devices(devices).unwrap();
1483
1484        // Create groups via initialize
1485        let speaker1 = SpeakerId::new("RINCON_111");
1486        let speaker2 = SpeakerId::new("RINCON_222");
1487        let group1 = GroupInfo::new(
1488            GroupId::new("RINCON_111:1"),
1489            speaker1.clone(),
1490            vec![speaker1.clone()],
1491        );
1492        let group2 = GroupInfo::new(
1493            GroupId::new("RINCON_222:1"),
1494            speaker2.clone(),
1495            vec![speaker2.clone()],
1496        );
1497
1498        let topology = Topology::new(
1499            manager.speaker_infos(),
1500            vec![group1.clone(), group2.clone()],
1501        );
1502        manager.initialize(topology);
1503
1504        // Verify groups() returns all groups
1505        let groups = manager.groups();
1506        assert_eq!(groups.len(), 2);
1507
1508        // Verify both groups are present (order may vary)
1509        let group_ids: Vec<_> = groups.iter().map(|g| g.id.clone()).collect();
1510        assert!(group_ids.contains(&GroupId::new("RINCON_111:1")));
1511        assert!(group_ids.contains(&GroupId::new("RINCON_222:1")));
1512    }
1513
1514    #[test]
1515    fn test_state_manager_groups_returns_empty_when_no_groups() {
1516        let manager = StateManager::new().unwrap();
1517
1518        // No groups added
1519        let groups = manager.groups();
1520        assert!(groups.is_empty());
1521    }
1522
1523    #[test]
1524    fn test_state_manager_get_group_returns_correct_group() {
1525        let manager = StateManager::new().unwrap();
1526
1527        // Add device
1528        let devices = vec![Device {
1529            id: "RINCON_111".to_string(),
1530            name: "Living Room".to_string(),
1531            room_name: "Living Room".to_string(),
1532            ip_address: "192.168.1.100".to_string(),
1533            port: 1400,
1534            model_name: "Sonos One".to_string(),
1535        }];
1536        manager.add_devices(devices).unwrap();
1537
1538        // Create group via initialize
1539        let speaker = SpeakerId::new("RINCON_111");
1540        let group_id = GroupId::new("RINCON_111:1");
1541        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1542
1543        let topology = Topology::new(manager.speaker_infos(), vec![group.clone()]);
1544        manager.initialize(topology);
1545
1546        // Verify get_group returns the correct group
1547        let found = manager.get_group(&group_id);
1548        assert!(found.is_some());
1549        assert_eq!(found.unwrap(), group);
1550    }
1551
1552    #[test]
1553    fn test_state_manager_get_group_returns_none_for_unknown() {
1554        let manager = StateManager::new().unwrap();
1555
1556        // No groups added
1557        let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
1558        let found = manager.get_group(&unknown_id);
1559        assert!(found.is_none());
1560    }
1561
1562    #[test]
1563    fn test_state_manager_get_group_for_speaker_returns_correct_group() {
1564        let manager = StateManager::new().unwrap();
1565
1566        // Add devices
1567        let devices = vec![
1568            Device {
1569                id: "RINCON_111".to_string(),
1570                name: "Living Room".to_string(),
1571                room_name: "Living Room".to_string(),
1572                ip_address: "192.168.1.100".to_string(),
1573                port: 1400,
1574                model_name: "Sonos One".to_string(),
1575            },
1576            Device {
1577                id: "RINCON_222".to_string(),
1578                name: "Kitchen".to_string(),
1579                room_name: "Kitchen".to_string(),
1580                ip_address: "192.168.1.101".to_string(),
1581                port: 1400,
1582                model_name: "Sonos One".to_string(),
1583            },
1584        ];
1585        manager.add_devices(devices).unwrap();
1586
1587        // Create a group with both speakers
1588        let speaker1 = SpeakerId::new("RINCON_111");
1589        let speaker2 = SpeakerId::new("RINCON_222");
1590        let group_id = GroupId::new("RINCON_111:1");
1591        let group = GroupInfo::new(
1592            group_id.clone(),
1593            speaker1.clone(),
1594            vec![speaker1.clone(), speaker2.clone()],
1595        );
1596
1597        let topology = Topology::new(manager.speaker_infos(), vec![group.clone()]);
1598        manager.initialize(topology);
1599
1600        // Verify get_group_for_speaker returns the correct group for both speakers
1601        let found1 = manager.get_group_for_speaker(&speaker1);
1602        assert!(found1.is_some());
1603        assert_eq!(found1.unwrap(), group);
1604
1605        let found2 = manager.get_group_for_speaker(&speaker2);
1606        assert!(found2.is_some());
1607        assert_eq!(found2.unwrap(), group);
1608    }
1609
1610    #[test]
1611    fn test_state_manager_get_group_for_speaker_returns_none_for_unknown() {
1612        let manager = StateManager::new().unwrap();
1613
1614        // No groups added
1615        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1616        let found = manager.get_group_for_speaker(&unknown_speaker);
1617        assert!(found.is_none());
1618    }
1619
1620    #[test]
1621    fn test_state_manager_group_methods_consistency() {
1622        let manager = StateManager::new().unwrap();
1623
1624        // Add device
1625        let devices = vec![Device {
1626            id: "RINCON_111".to_string(),
1627            name: "Living Room".to_string(),
1628            room_name: "Living Room".to_string(),
1629            ip_address: "192.168.1.100".to_string(),
1630            port: 1400,
1631            model_name: "Sonos One".to_string(),
1632        }];
1633        manager.add_devices(devices).unwrap();
1634
1635        // Create group via initialize
1636        let speaker = SpeakerId::new("RINCON_111");
1637        let group_id = GroupId::new("RINCON_111:1");
1638        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1639
1640        let topology = Topology::new(manager.speaker_infos(), vec![group.clone()]);
1641        manager.initialize(topology);
1642
1643        // Verify all three methods return consistent data
1644        let groups = manager.groups();
1645        assert_eq!(groups.len(), 1);
1646        assert_eq!(groups[0], group);
1647
1648        let by_id = manager.get_group(&group_id);
1649        assert_eq!(by_id, Some(group.clone()));
1650
1651        let by_speaker = manager.get_group_for_speaker(&speaker);
1652        assert_eq!(by_speaker, Some(group.clone()));
1653
1654        // All should return the same group
1655        assert_eq!(groups[0], by_id.unwrap());
1656        assert_eq!(groups[0], by_speaker.unwrap());
1657    }
1658
1659    // ========================================================================
1660    // boot_seq Tests
1661    // ========================================================================
1662
1663    #[test]
1664    fn test_get_boot_seq_returns_none_for_unknown_speaker() {
1665        let manager = StateManager::new().unwrap();
1666        let unknown = SpeakerId::new("RINCON_UNKNOWN");
1667        assert!(manager.get_boot_seq(&unknown).is_none());
1668    }
1669
1670    #[test]
1671    fn test_boot_seq_defaults_to_zero_for_new_speaker() {
1672        let manager = StateManager::new().unwrap();
1673
1674        let devices = vec![Device {
1675            id: "RINCON_123".to_string(),
1676            name: "Living Room".to_string(),
1677            room_name: "Living Room".to_string(),
1678            ip_address: "192.168.1.100".to_string(),
1679            port: 1400,
1680            model_name: "Sonos One".to_string(),
1681        }];
1682        manager.add_devices(devices).unwrap();
1683
1684        let speaker_id = SpeakerId::new("RINCON_123");
1685
1686        // Before any topology event, boot_seq should be 0
1687        assert_eq!(manager.get_boot_seq(&speaker_id), Some(0));
1688    }
1689
1690    // ========================================================================
1691    // StateWatchRegistry Tests
1692    // ========================================================================
1693
1694    #[test]
1695    fn test_state_watch_registry_register_and_unregister() {
1696        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1697        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1698        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1699
1700        let ip: IpAddr = "192.168.1.100".parse().unwrap();
1701        let speaker_id = SpeakerId::new("RINCON_123");
1702        ip_to_speaker.write().insert(ip, speaker_id.clone());
1703
1704        let registry = StateWatchRegistry {
1705            watched: Arc::clone(&watched),
1706            ip_to_speaker: Arc::clone(&ip_to_speaker),
1707            key_to_service: Arc::clone(&key_to_service),
1708        };
1709
1710        // Register watches on two services
1711        registry.register_watch(&speaker_id, "volume", Service::RenderingControl);
1712        registry.register_watch(&speaker_id, "mute", Service::RenderingControl);
1713        registry.register_watch(&speaker_id, "playback_state", Service::AVTransport);
1714
1715        assert_eq!(watched.read().len(), 3);
1716
1717        // Unregister RenderingControl — should remove volume + mute, keep playback_state
1718        registry.unregister_watches_for_service(ip, Service::RenderingControl);
1719
1720        let w = watched.read();
1721        assert_eq!(w.len(), 1);
1722        assert!(is_pair_watched(&w, &speaker_id, "playback_state"));
1723        assert!(!is_pair_watched(&w, &speaker_id, "volume"));
1724        assert!(!is_pair_watched(&w, &speaker_id, "mute"));
1725    }
1726
1727    #[test]
1728    fn test_state_watch_registry_unknown_ip_is_noop() {
1729        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1730        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1731        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1732
1733        let speaker_id = SpeakerId::new("RINCON_123");
1734
1735        let registry = StateWatchRegistry {
1736            watched: Arc::clone(&watched),
1737            ip_to_speaker,
1738            key_to_service: Arc::clone(&key_to_service),
1739        };
1740
1741        // Register a watch (simulating direct add to shared set)
1742        retain_direct_watch(&watched, &speaker_id, "volume");
1743        key_to_service
1744            .write()
1745            .insert("volume", Service::RenderingControl);
1746
1747        // Unregister for an unknown IP — should be a no-op
1748        let unknown_ip: IpAddr = "10.0.0.1".parse().unwrap();
1749        registry.unregister_watches_for_service(unknown_ip, Service::RenderingControl);
1750
1751        // Watch should still be there
1752        assert_eq!(watched.read().len(), 1);
1753    }
1754
1755    #[test]
1756    fn test_state_watch_registry_only_removes_matching_speaker() {
1757        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1758        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1759        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1760
1761        let ip1: IpAddr = "192.168.1.100".parse().unwrap();
1762        let ip2: IpAddr = "192.168.1.101".parse().unwrap();
1763        let speaker1 = SpeakerId::new("RINCON_111");
1764        let speaker2 = SpeakerId::new("RINCON_222");
1765
1766        ip_to_speaker.write().insert(ip1, speaker1.clone());
1767        ip_to_speaker.write().insert(ip2, speaker2.clone());
1768
1769        let registry = StateWatchRegistry {
1770            watched: Arc::clone(&watched),
1771            ip_to_speaker,
1772            key_to_service: Arc::clone(&key_to_service),
1773        };
1774
1775        // Both speakers watch volume
1776        registry.register_watch(&speaker1, "volume", Service::RenderingControl);
1777        registry.register_watch(&speaker2, "volume", Service::RenderingControl);
1778        assert_eq!(watched.read().len(), 2);
1779
1780        // Unregister only speaker1's IP
1781        registry.unregister_watches_for_service(ip1, Service::RenderingControl);
1782
1783        let w = watched.read();
1784        assert_eq!(w.len(), 1);
1785        assert!(is_pair_watched(&w, &speaker2, "volume"));
1786        assert!(!is_pair_watched(&w, &speaker1, "volume"));
1787    }
1788
1789    // ========================================================================
1790    // Watch reference counting
1791    // ========================================================================
1792
1793    /// Two watchers on the *same* property: the first release must not silence
1794    /// the second, and the second must actually clear it.
1795    ///
1796    /// This is the arithmetic behind the sibling-survival guarantee. With a
1797    /// plain `HashSet` the first `unregister_watch` removed the only entry, so
1798    /// watcher two went quiet while still holding its handle.
1799    #[test]
1800    fn test_watch_refcount_survives_partial_release() {
1801        let manager = StateManager::new().unwrap();
1802        let speaker_id = SpeakerId::new("RINCON_123");
1803
1804        manager.register_watch(&speaker_id, "volume");
1805        manager.register_watch(&speaker_id, "volume");
1806        assert!(manager.is_watched(&speaker_id, "volume"));
1807
1808        // One watcher goes away; the other still holds a reference.
1809        manager.unregister_watch(&speaker_id, "volume");
1810        assert!(
1811            manager.is_watched(&speaker_id, "volume"),
1812            "one of two watchers released — the property must stay watched"
1813        );
1814
1815        // Last watcher goes away.
1816        manager.unregister_watch(&speaker_id, "volume");
1817        assert!(!manager.is_watched(&speaker_id, "volume"));
1818
1819        // Over-release must not wrap around and resurrect the watch.
1820        manager.unregister_watch(&speaker_id, "volume");
1821        assert!(!manager.is_watched(&speaker_id, "volume"));
1822    }
1823
1824    /// A subscription teardown for one service must not take individually-held
1825    /// watches with it.
1826    ///
1827    /// `unregister_watches_for_service` clears every key of a service at once.
1828    /// Previously it removed the map entries outright, so a `direct` hold taken
1829    /// by the polling-fallback / cache-only path — or by a second watcher of the
1830    /// same property — was destroyed by an unrelated subscription expiring.
1831    #[test]
1832    fn test_service_unregister_keeps_directly_held_watches() {
1833        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1834        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1835        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1836
1837        let ip: IpAddr = "192.168.1.100".parse().unwrap();
1838        let speaker_id = SpeakerId::new("RINCON_123");
1839        ip_to_speaker.write().insert(ip, speaker_id.clone());
1840
1841        let registry = StateWatchRegistry {
1842            watched: Arc::clone(&watched),
1843            ip_to_speaker,
1844            key_to_service,
1845        };
1846
1847        // A guard-based watch and a direct watch on the same property...
1848        registry.register_watch(&speaker_id, "volume", Service::RenderingControl);
1849        retain_direct_watch(&watched, &speaker_id, "volume");
1850        // ...plus a direct hold on a sibling property of the same service.
1851        registry.register_watch(&speaker_id, "mute", Service::RenderingControl);
1852        retain_direct_watch(&watched, &speaker_id, "mute");
1853
1854        registry.unregister_watches_for_service(ip, Service::RenderingControl);
1855
1856        let w = watched.read();
1857        assert!(
1858            is_pair_watched(&w, &speaker_id, "volume"),
1859            "the direct hold on volume must survive the subscription teardown"
1860        );
1861        assert!(
1862            is_pair_watched(&w, &speaker_id, "mute"),
1863            "the direct hold on mute must survive the subscription teardown"
1864        );
1865    }
1866
1867    // ========================================================================
1868    // set_property / get_property symmetry
1869    // ========================================================================
1870
1871    /// `set_property` must write where `get_property` reads.
1872    ///
1873    /// For a `PerCoordinator` speaker-scoped property, `get_resolved` reads the
1874    /// *coordinator's* bag. Writing the raw `speaker_id` therefore stored the
1875    /// value where nothing would ever look: `speaker.play()` on a grouped member
1876    /// updated a bag no reader consults, so the optimistic cache update was
1877    /// invisible from both the member and the coordinator.
1878    #[test]
1879    fn test_set_property_on_group_member_is_readable_from_both() {
1880        let manager = StateManager::new().unwrap();
1881
1882        let devices = vec![
1883            Device {
1884                id: "RINCON_COORD".to_string(),
1885                name: "Living Room".to_string(),
1886                room_name: "Living Room".to_string(),
1887                ip_address: "192.168.1.100".to_string(),
1888                port: 1400,
1889                model_name: "Sonos One".to_string(),
1890            },
1891            Device {
1892                id: "RINCON_MEMBER".to_string(),
1893                name: "Kitchen".to_string(),
1894                room_name: "Kitchen".to_string(),
1895                ip_address: "192.168.1.101".to_string(),
1896                port: 1400,
1897                model_name: "Sonos One".to_string(),
1898            },
1899        ];
1900        manager.add_devices(devices).unwrap();
1901
1902        let coordinator = SpeakerId::new("RINCON_COORD");
1903        let member = SpeakerId::new("RINCON_MEMBER");
1904        let group_id = GroupId::new("RINCON_COORD:1");
1905        let topology = Topology::new(
1906            manager.speaker_infos(),
1907            vec![GroupInfo::new(
1908                group_id,
1909                coordinator.clone(),
1910                vec![coordinator.clone(), member.clone()],
1911            )],
1912        );
1913        manager.initialize(topology);
1914
1915        // Write through the *member* — what speaker.play() does on a grouped
1916        // speaker. PlaybackState is AVTransport (PerCoordinator) + Speaker scope.
1917        manager.set_property(&member, PlaybackState::Playing);
1918
1919        assert_eq!(
1920            manager.get_property::<PlaybackState>(&member),
1921            Some(PlaybackState::Playing),
1922            "the member must be able to read back what it just wrote"
1923        );
1924        assert_eq!(
1925            manager.get_property::<PlaybackState>(&coordinator),
1926            Some(PlaybackState::Playing),
1927            "the write belongs in the coordinator's bag, which is where reads resolve"
1928        );
1929
1930        // A PerSpeaker property written on the member stays on the member.
1931        manager.set_property(&member, Volume::new(33));
1932        assert_eq!(
1933            manager.get_property::<Volume>(&member),
1934            Some(Volume::new(33))
1935        );
1936        assert_eq!(
1937            manager.get_property::<Volume>(&coordinator),
1938            None,
1939            "PerSpeaker writes must not be redirected to the coordinator"
1940        );
1941    }
1942
1943    // ========================================================================
1944    // resolve_coordinator Tests
1945    // ========================================================================
1946
1947    #[test]
1948    fn test_resolve_coordinator_for_standalone_speaker() {
1949        let mut store = StateStore::new();
1950
1951        let speaker = SpeakerId::new("RINCON_111");
1952        let group_id = GroupId::new("RINCON_111:1");
1953
1954        store.add_speaker(SpeakerInfo {
1955            id: speaker.clone(),
1956            name: "Living Room".to_string(),
1957            room_name: "Living Room".to_string(),
1958            ip_address: "192.168.1.100".parse().unwrap(),
1959            port: 1400,
1960            model_name: "Test".to_string(),
1961            software_version: "1.0".to_string(),
1962            boot_seq: 0,
1963            satellites: vec![],
1964        });
1965        store.add_group(GroupInfo::new(
1966            group_id,
1967            speaker.clone(),
1968            vec![speaker.clone()],
1969        ));
1970
1971        // Standalone speaker is its own coordinator
1972        assert_eq!(store.resolve_coordinator(&speaker), speaker);
1973    }
1974
1975    #[test]
1976    fn test_resolve_coordinator_for_group_member() {
1977        let mut store = StateStore::new();
1978
1979        let coordinator = SpeakerId::new("RINCON_COORD");
1980        let member = SpeakerId::new("RINCON_MEMBER");
1981        let group_id = GroupId::new("RINCON_COORD:1");
1982
1983        store.add_group(GroupInfo::new(
1984            group_id,
1985            coordinator.clone(),
1986            vec![coordinator.clone(), member.clone()],
1987        ));
1988
1989        // Member resolves to the coordinator
1990        assert_eq!(store.resolve_coordinator(&member), coordinator);
1991        // Coordinator resolves to itself
1992        assert_eq!(store.resolve_coordinator(&coordinator), coordinator);
1993    }
1994
1995    #[test]
1996    fn test_resolve_coordinator_no_group_data() {
1997        let store = StateStore::new();
1998
1999        let speaker = SpeakerId::new("RINCON_UNKNOWN");
2000
2001        // No group data — falls back to speaker's own ID
2002        assert_eq!(store.resolve_coordinator(&speaker), speaker);
2003    }
2004
2005    // ========================================================================
2006    // get_resolved Tests
2007    // ========================================================================
2008
2009    #[test]
2010    fn test_get_resolved_per_coordinator_reads_from_coordinator() {
2011        let mut store = StateStore::new();
2012
2013        let coordinator = SpeakerId::new("RINCON_COORD");
2014        let member = SpeakerId::new("RINCON_MEMBER");
2015        let group_id = GroupId::new("RINCON_COORD:1");
2016
2017        store.add_speaker(SpeakerInfo {
2018            id: coordinator.clone(),
2019            name: "Coord".to_string(),
2020            room_name: "Coord".to_string(),
2021            ip_address: "192.168.1.100".parse().unwrap(),
2022            port: 1400,
2023            model_name: "Test".to_string(),
2024            software_version: "1.0".to_string(),
2025            boot_seq: 0,
2026            satellites: vec![],
2027        });
2028        store.add_speaker(SpeakerInfo {
2029            id: member.clone(),
2030            name: "Member".to_string(),
2031            room_name: "Member".to_string(),
2032            ip_address: "192.168.1.101".parse().unwrap(),
2033            port: 1400,
2034            model_name: "Test".to_string(),
2035            software_version: "1.0".to_string(),
2036            boot_seq: 0,
2037            satellites: vec![],
2038        });
2039        store.add_group(GroupInfo::new(
2040            group_id,
2041            coordinator.clone(),
2042            vec![coordinator.clone(), member.clone()],
2043        ));
2044
2045        // Set PlaybackState only on coordinator
2046        store.set(&coordinator, PlaybackState::Playing);
2047
2048        // get_resolved on member should return coordinator's value (PerCoordinator + Speaker scope)
2049        let resolved: Option<PlaybackState> = store.get_resolved(&member);
2050        assert_eq!(resolved, Some(PlaybackState::Playing));
2051
2052        // Direct get on member should return None (no data copied)
2053        let direct: Option<PlaybackState> = store.get(&member);
2054        assert_eq!(direct, None);
2055    }
2056
2057    #[test]
2058    fn test_get_resolved_per_speaker_reads_own_props() {
2059        let mut store = StateStore::new();
2060
2061        let coordinator = SpeakerId::new("RINCON_COORD");
2062        let member = SpeakerId::new("RINCON_MEMBER");
2063        let group_id = GroupId::new("RINCON_COORD:1");
2064
2065        store.add_speaker(SpeakerInfo {
2066            id: coordinator.clone(),
2067            name: "Coord".to_string(),
2068            room_name: "Coord".to_string(),
2069            ip_address: "192.168.1.100".parse().unwrap(),
2070            port: 1400,
2071            model_name: "Test".to_string(),
2072            software_version: "1.0".to_string(),
2073            boot_seq: 0,
2074            satellites: vec![],
2075        });
2076        store.add_speaker(SpeakerInfo {
2077            id: member.clone(),
2078            name: "Member".to_string(),
2079            room_name: "Member".to_string(),
2080            ip_address: "192.168.1.101".parse().unwrap(),
2081            port: 1400,
2082            model_name: "Test".to_string(),
2083            software_version: "1.0".to_string(),
2084            boot_seq: 0,
2085            satellites: vec![],
2086        });
2087        store.add_group(GroupInfo::new(
2088            group_id,
2089            coordinator.clone(),
2090            vec![coordinator.clone(), member.clone()],
2091        ));
2092
2093        // Set Volume on coordinator only (PerSpeaker service)
2094        store.set(&coordinator, Volume::new(80));
2095
2096        // get_resolved on member should NOT resolve to coordinator for PerSpeaker
2097        let resolved: Option<Volume> = store.get_resolved(&member);
2098        assert_eq!(resolved, None);
2099
2100        // get_resolved on coordinator returns its own value
2101        let coord_resolved: Option<Volume> = store.get_resolved(&coordinator);
2102        assert_eq!(coord_resolved, Some(Volume::new(80)));
2103    }
2104
2105    #[test]
2106    fn test_update_speaker_ip() {
2107        let manager = StateManager::new().unwrap();
2108
2109        let devices = vec![Device {
2110            id: "RINCON_111".to_string(),
2111            name: "Office".to_string(),
2112            room_name: "Office".to_string(),
2113            ip_address: "192.168.4.198".to_string(),
2114            port: 1400,
2115            model_name: "Roam 2".to_string(),
2116        }];
2117        manager.add_devices(devices).unwrap();
2118
2119        let speaker_id = SpeakerId::new("RINCON_111");
2120        let old_ip: IpAddr = "192.168.4.198".parse().unwrap();
2121        let new_ip: IpAddr = "192.168.4.200".parse().unwrap();
2122
2123        // Verify initial state
2124        assert_eq!(manager.get_speaker_ip(&speaker_id), Some(old_ip));
2125
2126        // Update IP
2127        manager.update_speaker_ip(&speaker_id, new_ip);
2128
2129        // Verify forward map updated
2130        assert_eq!(manager.get_speaker_ip(&speaker_id), Some(new_ip));
2131
2132        // Verify reverse map updated (old IP removed, new IP present)
2133        let ip_map = manager.ip_to_speaker.read();
2134        assert!(!ip_map.contains_key(&old_ip));
2135        assert_eq!(ip_map.get(&new_ip), Some(&speaker_id));
2136    }
2137
2138    #[test]
2139    fn test_update_speaker_ip_no_change() {
2140        let manager = StateManager::new().unwrap();
2141
2142        let devices = vec![Device {
2143            id: "RINCON_111".to_string(),
2144            name: "Office".to_string(),
2145            room_name: "Office".to_string(),
2146            ip_address: "192.168.4.198".to_string(),
2147            port: 1400,
2148            model_name: "Roam 2".to_string(),
2149        }];
2150        manager.add_devices(devices).unwrap();
2151
2152        let speaker_id = SpeakerId::new("RINCON_111");
2153        let same_ip: IpAddr = "192.168.4.198".parse().unwrap();
2154
2155        // Update with same IP — should be a no-op
2156        manager.update_speaker_ip(&speaker_id, same_ip);
2157        assert_eq!(manager.get_speaker_ip(&speaker_id), Some(same_ip));
2158    }
2159
2160    #[test]
2161    fn test_satellite_ids() {
2162        let manager = StateManager::new().unwrap();
2163
2164        assert!(manager.get_satellite_ids().is_empty());
2165
2166        let ids = vec![SpeakerId::new("RINCON_SAT1"), SpeakerId::new("RINCON_SAT2")];
2167        manager.set_satellite_ids(ids.clone());
2168
2169        let stored = manager.get_satellite_ids();
2170        assert_eq!(stored.len(), 2);
2171        assert!(stored.contains(&SpeakerId::new("RINCON_SAT1")));
2172        assert!(stored.contains(&SpeakerId::new("RINCON_SAT2")));
2173    }
2174}