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::{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::decoder::PropertyChange;
43use crate::event_worker::spawn_state_event_worker;
44use crate::iter::{ChangeIterator, EventFanout};
45use crate::model::{GroupId, SpeakerId, SpeakerInfo};
46use crate::property::{GroupInfo, Property, Scope, SonosProperty, Topology};
47use crate::{Result, StateError};
48
49/// Closure type for lazy event manager initialization.
50///
51/// Stored on `StateManager` as the single source of truth. Called by
52/// `PropertyHandle::watch()` to trigger event manager creation on first use.
53/// Uses `Box<dyn Error>` to avoid circular dependency on `sonos-sdk` error types.
54pub type EventInitFn = Arc<
55    dyn Fn() -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> + Send + Sync,
56>;
57
58// ============================================================================
59// Write provenance - ChangeSource / WriteStamp / WriteOutcome
60// ============================================================================
61
62/// Where a property value came from.
63///
64/// Recorded on every write and carried on every [`ChangeEvent`], for two
65/// reasons: it breaks ties between writes that share an `Instant` (the variant
66/// order below is the tie-break order, most authoritative first), and it lets a
67/// consumer tell a device-pushed value apart from one this process just wrote
68/// optimistically.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum ChangeSource {
71    /// Decoded from a UPnP NOTIFY, or from a poll standing in for one.
72    ///
73    /// The most authoritative source: the device volunteered this value, so it
74    /// was true at the moment it was sent.
75    Event,
76    /// Written locally immediately after a control action succeeded.
77    ///
78    /// The device acknowledged the action, so the value is true as of the
79    /// acknowledgement — but it is our inference, not the device's report.
80    LocalAction,
81    /// Returned by an explicit `fetch()` SOAP read.
82    ///
83    /// The least authoritative, because a `fetch()` observes the device at
84    /// *request* time but lands at *response* time — see [`WriteStamp`].
85    Fetch,
86}
87
88impl ChangeSource {
89    /// Tie-break rank for writes bearing the same `observed_at`.
90    ///
91    /// Only consulted on an exact `Instant` collision, which in practice means
92    /// two writes derived from one observation. Higher wins.
93    fn rank(self) -> u8 {
94        match self {
95            ChangeSource::Event => 2,
96            ChangeSource::LocalAction => 1,
97            ChangeSource::Fetch => 0,
98        }
99    }
100}
101
102/// When a value was *observed*, and by what.
103///
104/// The critical word is **observed**, not *written*. A `fetch()` reads the
105/// device at request time but only calls `set_property` when the SOAP response
106/// comes back, which can be hundreds of milliseconds later. If the stamp were
107/// taken at write time, a slow `fetch()` would always look newer than an event
108/// that arrived while it was in flight, and would overwrite a fresher value
109/// with a stale one — the speaker would visibly snap back to its old volume.
110///
111/// So the caller stamps the moment the observation was made
112/// ([`WriteStamp::observed_at`]) and the store rejects any write that is older
113/// than the one already recorded. Event and local-action writes have no such
114/// gap and use [`WriteStamp::now`].
115#[derive(Debug, Clone, Copy)]
116pub struct WriteStamp {
117    /// When the underlying observation was made — *not* when it was written.
118    pub observed_at: Instant,
119    /// What produced the observation.
120    pub source: ChangeSource,
121}
122
123impl WriteStamp {
124    /// Stamp an observation made right now.
125    ///
126    /// Correct for [`ChangeSource::Event`] and [`ChangeSource::LocalAction`],
127    /// where observation and write happen in the same breath.
128    pub fn now(source: ChangeSource) -> Self {
129        Self {
130            observed_at: Instant::now(),
131            source,
132        }
133    }
134
135    /// Stamp an observation made at a known earlier instant.
136    ///
137    /// Use this for `fetch()`: pass the `Instant` captured *before* the SOAP
138    /// request, so a response that lands after a newer event loses to it.
139    pub fn observed_at(source: ChangeSource, observed_at: Instant) -> Self {
140        Self {
141            observed_at,
142            source,
143        }
144    }
145
146    /// Whether this observation is at least as recent as `prev`.
147    ///
148    /// Strictly newer wins outright. On an exact `Instant` collision the more
149    /// authoritative [`ChangeSource`] wins, so an event cannot be displaced by
150    /// a `fetch()` that happens to share its timestamp.
151    fn supersedes(&self, prev: &WriteStamp) -> bool {
152        self.observed_at > prev.observed_at
153            || (self.observed_at == prev.observed_at && self.source.rank() >= prev.source.rank())
154    }
155}
156
157/// Result of attempting to write a property.
158///
159/// Three outcomes rather than the previous `bool`, because "the value is
160/// different" and "this write was allowed to happen at all" are separate
161/// questions once writes are ordered. Only `Changed` emits a notification.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum WriteOutcome {
164    /// Accepted, and the stored value is different than before.
165    Changed,
166    /// Accepted, but the value was already what was written.
167    Unchanged,
168    /// Rejected: a strictly newer observation is already stored.
169    Stale,
170}
171
172impl WriteOutcome {
173    /// Whether this write changed the stored value (and so should notify).
174    pub fn changed(self) -> bool {
175        matches!(self, WriteOutcome::Changed)
176    }
177}
178
179// ============================================================================
180// ChangeEvent - for iter()
181// ============================================================================
182
183/// A change event emitted when a watched property changes.
184///
185/// Carries the new value as a typed [`PropertyChange`], so a consumer draining
186/// a backlog observes *every* value the property passed through rather than
187/// whatever the store happens to hold by the time it looks. That distinction
188/// matters: a `Playing -> Transitioning -> Playing` sequence is three queued
189/// events but only one final store value, and re-reading the store would make
190/// the middle state — and the fact that anything moved at all — invisible.
191///
192/// `property_key()` and `service()` are derived from the payload rather than
193/// stored beside it, so the two cannot drift apart.
194#[derive(Debug, Clone)]
195pub struct ChangeEvent {
196    /// Speaker or entity that changed
197    pub speaker_id: SpeakerId,
198    /// The new value, typed
199    pub change: PropertyChange,
200    /// What produced this value
201    pub source: ChangeSource,
202    /// When the change was observed
203    pub timestamp: Instant,
204}
205
206impl ChangeEvent {
207    pub fn new(speaker_id: SpeakerId, change: PropertyChange, stamp: WriteStamp) -> Self {
208        Self {
209            speaker_id,
210            change,
211            source: stamp.source,
212            timestamp: stamp.observed_at,
213        }
214    }
215
216    /// The key of the property that changed.
217    pub fn property_key(&self) -> &'static str {
218        self.change.key()
219    }
220
221    /// The UPnP service the changed property belongs to.
222    pub fn service(&self) -> Service {
223        self.change.service()
224    }
225}
226
227// ============================================================================
228// Watch bookkeeping
229// ============================================================================
230
231/// The holds on one watched `(speaker_id, property_key)` pair.
232///
233/// A watch is a *hold*, not a flag: several independent watchers can claim the
234/// same pair, and it stays watched until the last of them lets go. The two
235/// fields are separate — rather than one counter — because the two kinds of hold
236/// are released by completely different events, on different schedules:
237///
238/// - **`direct`** holds come from [`StateManager::register_watch`]: the SDK's
239///   polling-fallback and cache-only paths, group-member notification
240///   forwarding, `watch_property_with_subscription`, and tests. Each is released
241///   individually by [`StateManager::unregister_watch`], normally from a
242///   `CacheOnlyGuard::drop`. These are what need counting: *n* watchers of one
243///   property must survive *n-1* drops.
244/// - **`subscription`** is a single flag covering every `WatchGuard` acquired
245///   through [`WatchRegistry::register_watch`]. It cannot be a counter, because
246///   nothing decrements it one at a time: `WatchGuard::drop` only decrements
247///   `sonos-event-manager`'s per-`(ip, service)` subscription ref count, and
248///   `unregister_watches_for_service` fires once, later, when *that* count hits
249///   zero — at which point every contributing guard is provably gone. A counter
250///   incremented per guard but cleared only in bulk would either leak (a watch
251///   nobody holds emitting forever) or, if decremented by one, drop while other
252///   guards are still alive.
253///
254/// The pair stops being watched, and the entry leaves the map, only when the
255/// flag is clear *and* the count is zero. Keeping them apart is the actual fix:
256/// a subscription teardown must not take the individually-held `direct` watches
257/// of its sibling properties with it.
258#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
259pub(crate) struct WatchHolds {
260    /// Whether any `WatchGuard` is registered for this pair.
261    subscription: bool,
262    /// Number of outstanding `register_watch` holds.
263    direct: usize,
264}
265
266impl WatchHolds {
267    fn is_held(&self) -> bool {
268        self.subscription || self.direct > 0
269    }
270}
271
272/// Watch holds per `(speaker_id, property_key)` pair.
273pub(crate) type WatchCounts = HashMap<(SpeakerId, &'static str), WatchHolds>;
274
275/// Mark `(speaker_id, key)` as held by a `WatchGuard`.
276fn retain_subscription_watch(
277    watched: &RwLock<WatchCounts>,
278    speaker_id: &SpeakerId,
279    key: &'static str,
280) {
281    watched
282        .write()
283        .entry((speaker_id.clone(), key))
284        .or_default()
285        .subscription = true;
286}
287
288/// Add one `direct` hold on `(speaker_id, key)`.
289pub(crate) fn retain_direct_watch(
290    watched: &RwLock<WatchCounts>,
291    speaker_id: &SpeakerId,
292    key: &'static str,
293) {
294    watched
295        .write()
296        .entry((speaker_id.clone(), key))
297        .or_default()
298        .direct += 1;
299}
300
301/// Release one `direct` hold, dropping the entry once no holds remain.
302///
303/// Releasing a pair that is not held is a no-op: an over-release must not wrap
304/// around and resurrect the watch.
305fn release_direct_watch(watched: &RwLock<WatchCounts>, speaker_id: &SpeakerId, key: &'static str) {
306    let mut guard = watched.write();
307    let entry_key = (speaker_id.clone(), key);
308    if let Some(holds) = guard.get_mut(&entry_key) {
309        holds.direct = holds.direct.saturating_sub(1);
310        if !holds.is_held() {
311            guard.remove(&entry_key);
312        }
313    }
314}
315
316/// Clear the subscription hold on `(speaker_id, key)`, keeping `direct` holds.
317///
318/// Called when a UPnP subscription is finally torn down. `direct` holders are
319/// deliberately untouched: they are tracked per watcher and released by their
320/// own guards, and their property may not even be the one that was subscribed.
321fn release_subscription_watch(
322    watched: &RwLock<WatchCounts>,
323    speaker_id: &SpeakerId,
324    key: &'static str,
325) {
326    let mut guard = watched.write();
327    let entry_key = (speaker_id.clone(), key);
328    if let Some(holds) = guard.get_mut(&entry_key) {
329        holds.subscription = false;
330        if !holds.is_held() {
331            guard.remove(&entry_key);
332        }
333    }
334}
335
336/// Whether `(speaker_id, key)` currently has any hold on it.
337pub(crate) fn is_pair_watched(
338    watched: &WatchCounts,
339    speaker_id: &SpeakerId,
340    key: &'static str,
341) -> bool {
342    watched.contains_key(&(speaker_id.clone(), key))
343}
344
345// ============================================================================
346// Internal StateStore
347// ============================================================================
348
349/// Internal state storage
350pub struct StateStore {
351    /// Speaker metadata
352    pub(crate) speakers: HashMap<SpeakerId, SpeakerInfo>,
353    /// IP to speaker ID mapping
354    pub(crate) ip_to_speaker: HashMap<IpAddr, SpeakerId>,
355    /// Property values: (speaker_id, property_key) -> type-erased value
356    pub(crate) speaker_props: HashMap<SpeakerId, PropertyBag>,
357    /// Group metadata
358    pub(crate) groups: HashMap<GroupId, GroupInfo>,
359    /// Group properties
360    pub(crate) group_props: HashMap<GroupId, PropertyBag>,
361    /// System properties
362    pub(crate) system_props: PropertyBag,
363    /// Speaker to group mapping for quick lookups
364    pub(crate) speaker_to_group: HashMap<SpeakerId, GroupId>,
365    /// Satellite speaker IDs (Invisible="1") from topology
366    pub(crate) satellite_ids: HashSet<SpeakerId>,
367}
368
369impl StateStore {
370    pub(crate) fn new() -> Self {
371        Self {
372            speakers: HashMap::new(),
373            ip_to_speaker: HashMap::new(),
374            speaker_props: HashMap::new(),
375            groups: HashMap::new(),
376            group_props: HashMap::new(),
377            system_props: PropertyBag::new(),
378            speaker_to_group: HashMap::new(),
379            satellite_ids: HashSet::new(),
380        }
381    }
382
383    pub(crate) fn add_speaker(&mut self, speaker: SpeakerInfo) {
384        let id = speaker.id.clone();
385        let ip = speaker.ip_address;
386        self.ip_to_speaker.insert(ip, id.clone());
387        self.speakers.insert(id.clone(), speaker);
388        self.speaker_props
389            .entry(id)
390            .or_insert_with(PropertyBag::new);
391    }
392
393    fn speaker(&self, id: &SpeakerId) -> Option<&SpeakerInfo> {
394        self.speakers.get(id)
395    }
396
397    fn speakers(&self) -> Vec<SpeakerInfo> {
398        self.speakers.values().cloned().collect()
399    }
400
401    pub(crate) fn add_group(&mut self, group: GroupInfo) {
402        let id = group.id.clone();
403        // Update speaker_to_group mapping for all members
404        for member_id in &group.member_ids {
405            self.speaker_to_group.insert(member_id.clone(), id.clone());
406        }
407        self.groups.insert(id.clone(), group);
408        self.group_props.entry(id).or_insert_with(PropertyBag::new);
409    }
410
411    /// Get the group a speaker belongs to
412    #[allow(dead_code)]
413    pub(crate) fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<&GroupInfo> {
414        let group_id = self.speaker_to_group.get(speaker_id)?;
415        self.groups.get(group_id)
416    }
417
418    /// Clear all groups and speaker_to_group mappings
419    ///
420    /// Used when processing topology updates to replace all group data
421    pub(crate) fn clear_groups(&mut self) {
422        self.groups.clear();
423        self.group_props.clear();
424        self.speaker_to_group.clear();
425    }
426
427    /// Resolve the coordinator speaker for the given speaker.
428    ///
429    /// Looks up `speaker_to_group → groups → coordinator_id`.
430    /// Returns the speaker's own ID if no group info exists (safe default).
431    pub(crate) fn resolve_coordinator(&self, speaker_id: &SpeakerId) -> SpeakerId {
432        self.speaker_to_group
433            .get(speaker_id)
434            .and_then(|gid| self.groups.get(gid))
435            .map(|group| group.coordinator_id.clone())
436            .unwrap_or_else(|| speaker_id.clone())
437    }
438
439    /// Get a property value with coordinator resolution for PerCoordinator services.
440    ///
441    /// If the property's service is PerCoordinator AND the property scope is Speaker,
442    /// reads from the coordinator's speaker_props. Otherwise reads from the
443    /// speaker's own props.
444    ///
445    /// Group-scoped properties (e.g. GroupVolume) come from a PerCoordinator service
446    /// but are stored in `group_props`, not `speaker_props`, so they are not resolved
447    /// through the coordinator's speaker_props.
448    pub(crate) fn get_resolved<P: SonosProperty>(&self, speaker_id: &SpeakerId) -> Option<P> {
449        if P::SERVICE.scope() == ServiceScope::PerCoordinator && P::SCOPE == Scope::Speaker {
450            let coordinator_id = self.resolve_coordinator(speaker_id);
451            self.speaker_props.get(&coordinator_id)?.get::<P>()
452        } else {
453            self.speaker_props.get(speaker_id)?.get::<P>()
454        }
455    }
456
457    /// Resolve which speaker's bag a `set` of `P` for `speaker_id` should target.
458    ///
459    /// The exact mirror of [`Self::get_resolved`]'s branch, so a write always
460    /// lands where the matching read looks. Factored out rather than inlined at
461    /// the two sites because the two must not be able to drift apart.
462    pub(crate) fn resolve_write_target<P: SonosProperty>(
463        &self,
464        speaker_id: &SpeakerId,
465    ) -> SpeakerId {
466        if P::SERVICE.scope() == ServiceScope::PerCoordinator && P::SCOPE == Scope::Speaker {
467            self.resolve_coordinator(speaker_id)
468        } else {
469            speaker_id.clone()
470        }
471    }
472
473    #[cfg_attr(not(test), allow(dead_code))]
474    pub(crate) fn get<P: Property>(&self, speaker_id: &SpeakerId) -> Option<P> {
475        self.speaker_props.get(speaker_id)?.get::<P>()
476    }
477
478    pub(crate) fn set<P: Property>(
479        &mut self,
480        speaker_id: &SpeakerId,
481        value: P,
482        stamp: WriteStamp,
483    ) -> WriteOutcome {
484        let bag = self
485            .speaker_props
486            .entry(speaker_id.clone())
487            .or_insert_with(PropertyBag::new);
488        bag.set(value, stamp)
489    }
490
491    pub(crate) fn get_group<P: Property>(&self, group_id: &GroupId) -> Option<P> {
492        self.group_props.get(group_id)?.get::<P>()
493    }
494
495    pub(crate) fn set_group<P: Property>(
496        &mut self,
497        group_id: &GroupId,
498        value: P,
499        stamp: WriteStamp,
500    ) -> WriteOutcome {
501        let bag = self
502            .group_props
503            .entry(group_id.clone())
504            .or_insert_with(PropertyBag::new);
505        bag.set(value, stamp)
506    }
507
508    fn set_system<P: Property>(&mut self, value: P, stamp: WriteStamp) -> WriteOutcome {
509        self.system_props.set(value, stamp)
510    }
511
512    /// Update a speaker's IP address in the store. Returns the old IP if changed.
513    pub(crate) fn update_speaker_ip_address(
514        &mut self,
515        speaker_id: &SpeakerId,
516        new_ip: IpAddr,
517    ) -> Option<IpAddr> {
518        if let Some(info) = self.speakers.get_mut(speaker_id) {
519            let old_ip = info.ip_address;
520            if old_ip != new_ip {
521                info.ip_address = new_ip;
522                return Some(old_ip);
523            }
524        }
525        None
526    }
527
528    fn is_empty(&self) -> bool {
529        self.speakers.is_empty()
530    }
531
532    fn speaker_count(&self) -> usize {
533        self.speakers.len()
534    }
535
536    fn group_count(&self) -> usize {
537        self.groups.len()
538    }
539}
540
541// ============================================================================
542// PropertyBag - type-erased property storage
543// ============================================================================
544
545pub(crate) struct PropertyBag {
546    /// Map<TypeId, Box<dyn Any>> where Any is the property value
547    values: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
548    /// Provenance of the value currently stored under each `TypeId`.
549    ///
550    /// Kept in a sibling map rather than boxed alongside the value so the
551    /// existing type-erased `values` layout, and its `downcast_ref` reads, stay
552    /// exactly as they were.
553    stamps: HashMap<TypeId, WriteStamp>,
554}
555
556impl PropertyBag {
557    pub(crate) fn new() -> Self {
558        Self {
559            values: HashMap::new(),
560            stamps: HashMap::new(),
561        }
562    }
563
564    fn get<P: Property>(&self) -> Option<P> {
565        let type_id = TypeId::of::<P>();
566        self.values
567            .get(&type_id)
568            .and_then(|boxed| boxed.downcast_ref::<P>())
569            .cloned()
570    }
571
572    /// Write `value` if `stamp` is not older than what is already stored.
573    ///
574    /// The staleness check comes *first*: a stale write is rejected outright,
575    /// before the equality comparison, so it can neither change the value nor
576    /// advance the stamp. Without that ordering a late `fetch()` response would
577    /// overwrite a newer event-derived value.
578    fn set<P: Property>(&mut self, value: P, stamp: WriteStamp) -> WriteOutcome {
579        let type_id = TypeId::of::<P>();
580
581        if let Some(prev) = self.stamps.get(&type_id) {
582            if !stamp.supersedes(prev) {
583                tracing::debug!(
584                    "Rejecting stale {:?} write for {}: observed {:?} before the stored {:?} write",
585                    stamp.source,
586                    P::KEY,
587                    stamp.observed_at,
588                    prev.source,
589                );
590                return WriteOutcome::Stale;
591            }
592        }
593
594        let current = self
595            .values
596            .get(&type_id)
597            .and_then(|boxed| boxed.downcast_ref::<P>());
598        let changed = current != Some(&value);
599
600        // Record the stamp even when the value is unchanged: the observation
601        // *did* happen and is now the most recent one, so a later write must be
602        // ordered against it rather than against some older observation.
603        self.stamps.insert(type_id, stamp);
604
605        if changed {
606            self.values.insert(type_id, Box::new(value));
607            WriteOutcome::Changed
608        } else {
609            WriteOutcome::Unchanged
610        }
611    }
612}
613
614// ============================================================================
615// StateManager - main entry point
616// ============================================================================
617
618/// Core state manager with sync-first API
619///
620/// All public methods are synchronous. Background event processing
621/// happens in a dedicated thread.
622pub struct StateManager {
623    /// Property values storage
624    store: Arc<RwLock<StateStore>>,
625
626    /// Watched properties for iter() filtering, reference-counted.
627    ///
628    /// Counted rather than a plain set because several independent watchers can
629    /// hold the same `(speaker_id, property_key)` at once — two widgets watching
630    /// one property, an SDK `WatchHandle` alongside a direct `register_watch`, or
631    /// a handle reacquired before the previous one has dropped.
632    /// With a `HashSet` the *first* release removed the entry and silenced every
633    /// remaining watcher; the count means an entry disappears only when the last
634    /// watcher lets go.
635    watched: Arc<RwLock<WatchCounts>>,
636
637    /// IP to speaker ID mapping (for event worker)
638    ip_to_speaker: Arc<RwLock<HashMap<IpAddr, SpeakerId>>>,
639
640    /// Event manager (set-once via OnceLock — enables live events)
641    event_manager: OnceLock<Arc<SonosEventManager>>,
642
643    /// Fan-out for change events: every `iter()` is an independent subscriber
644    /// receiving every event.
645    ///
646    /// Replaces the previous single `mpsc::Sender`/shared-`Receiver` pair, under
647    /// which two `iter()` loops silently split the stream between them. See
648    /// [`EventFanout`].
649    fanout: Arc<EventFanout>,
650
651    /// Background event processor handle (lazily spawned)
652    _worker: Mutex<Option<JoinHandle<()>>>,
653
654    /// Cleanup timeout for subscriptions
655    cleanup_timeout: Duration,
656
657    /// Maps property key → Service for WatchRegistry's unregister_watches_for_service.
658    /// Shared with StateWatchRegistry via Arc.
659    key_to_service: Arc<RwLock<HashMap<&'static str, Service>>>,
660
661    /// Lazy event manager initialization closure (set-once).
662    /// Called by watch() to trigger event manager creation on first use.
663    event_init: OnceLock<EventInitFn>,
664}
665
666// ============================================================================
667// StateWatchRegistry - WatchRegistry impl for SonosEventManager
668// ============================================================================
669
670/// Lightweight WatchRegistry implementation wired into the event manager.
671///
672/// Separated from StateManager because `mpsc::Sender` is `!Sync`,
673/// preventing StateManager itself from satisfying `WatchRegistry: Sync`.
674/// This struct holds only the Arc-wrapped fields needed for watch management.
675struct StateWatchRegistry {
676    watched: Arc<RwLock<WatchCounts>>,
677    ip_to_speaker: Arc<RwLock<HashMap<IpAddr, SpeakerId>>>,
678    key_to_service: Arc<RwLock<HashMap<&'static str, Service>>>,
679}
680
681impl WatchRegistry for StateWatchRegistry {
682    fn register_watch(&self, speaker_id: &SpeakerId, key: &'static str, service: Service) {
683        retain_subscription_watch(&self.watched, speaker_id, key);
684        self.key_to_service.write().insert(key, service);
685    }
686
687    fn unregister_watches_for_service(&self, ip: IpAddr, service: Service) {
688        // 1. Resolve IP → SpeakerId. Bound to a local first so the read guard
689        //    is released before this function takes any other lock.
690        let resolved = self.ip_to_speaker.read().get(&ip).cloned();
691        let speaker_id = match resolved {
692            Some(id) => id,
693            None => {
694                tracing::warn!(
695                    "unregister_watches_for_service: no speaker found for IP {}",
696                    ip
697                );
698                return;
699            }
700        };
701
702        // 2. Find property keys belonging to this service
703        let service_keys: Vec<&'static str> = self
704            .key_to_service
705            .read()
706            .iter()
707            .filter(|(_, &svc)| svc == service)
708            .map(|(&key, _)| key)
709            .collect();
710
711        // 3. Drop the subscription hold on each of this service's keys.
712        //
713        // Only the subscription hold: a `direct` hold is owned by an individual
714        // watcher (polling fallback, cache-only, member forwarding) which
715        // releases it through its own guard. Removing entries wholesale here is
716        // what previously made dropping one `WatchHandle` silence its siblings.
717        for key in service_keys {
718            release_subscription_watch(&self.watched, &speaker_id, key);
719        }
720    }
721}
722
723impl StateManager {
724    /// Create a new StateManager with default settings (sync)
725    ///
726    /// # Example
727    ///
728    /// ```rust,ignore
729    /// let manager = StateManager::new()?;
730    /// ```
731    pub fn new() -> Result<Self> {
732        Self::builder().build()
733    }
734
735    /// Create a StateManager builder for custom configuration
736    pub fn builder() -> StateManagerBuilder {
737        StateManagerBuilder::default()
738    }
739
740    /// Add discovered devices (sync)
741    ///
742    /// # Example
743    ///
744    /// ```rust,ignore
745    /// let devices = sonos_discovery::get();
746    /// manager.add_devices(devices)?;
747    /// ```
748    pub fn add_devices(&self, devices: Vec<Device>) -> Result<()> {
749        let mut store = self.store.write();
750        let mut ip_map = self.ip_to_speaker.write();
751
752        for device in devices {
753            let speaker_id = SpeakerId::new(&device.id);
754            let ip: IpAddr = device
755                .ip_address
756                .parse()
757                .map_err(|_| StateError::InvalidIpAddress(device.ip_address.clone()))?;
758
759            let friendly_name = if device.room_name.is_empty() || device.room_name == "Unknown" {
760                device.name.clone()
761            } else {
762                device.room_name.clone()
763            };
764
765            let info = SpeakerInfo {
766                id: speaker_id.clone(),
767                name: friendly_name,
768                room_name: device.room_name.clone(),
769                ip_address: ip,
770                port: device.port,
771                model_name: device.model_name.clone(),
772                software_version: "unknown".to_string(),
773                boot_seq: 0,
774                satellites: vec![],
775            };
776
777            // Update ip_to_speaker mapping
778            ip_map.insert(ip, speaker_id.clone());
779            tracing::debug!(
780                "Added speaker {} at IP {} to ip_to_speaker map",
781                speaker_id.as_str(),
782                ip
783            );
784
785            store.add_speaker(info);
786        }
787
788        // Also add devices to event manager if present
789        drop(store);
790        drop(ip_map);
791
792        if let Some(em) = self.event_manager.get() {
793            let devices_for_em: Vec<_> = self
794                .speaker_infos()
795                .iter()
796                .map(|info| sonos_discovery::Device {
797                    id: info.id.as_str().to_string(),
798                    name: info.name.clone(),
799                    room_name: info.room_name.clone(),
800                    ip_address: info.ip_address.to_string(),
801                    port: info.port,
802                    model_name: info.model_name.clone(),
803                })
804                .collect();
805
806            if let Err(e) = em.add_devices(devices_for_em) {
807                tracing::warn!("Failed to add devices to event manager: {}", e);
808            }
809        }
810
811        Ok(())
812    }
813
814    /// Get all speaker info
815    pub fn speaker_infos(&self) -> Vec<SpeakerInfo> {
816        self.store.read().speakers()
817    }
818
819    /// Get a specific speaker info by ID
820    pub fn speaker_info(&self, speaker_id: &SpeakerId) -> Option<SpeakerInfo> {
821        self.store.read().speaker(speaker_id).cloned()
822    }
823
824    /// Get speaker IP by ID
825    pub fn get_speaker_ip(&self, speaker_id: &SpeakerId) -> Option<IpAddr> {
826        self.store.read().speaker(speaker_id).map(|s| s.ip_address)
827    }
828
829    /// Get boot_seq for a speaker (used by GroupManagement AddMember)
830    pub fn get_boot_seq(&self, speaker_id: &SpeakerId) -> Option<u32> {
831        self.store.read().speaker(speaker_id).map(|s| s.boot_seq)
832    }
833
834    /// Update a speaker's IP address in both the store and the reverse map.
835    pub fn update_speaker_ip(&self, speaker_id: &SpeakerId, new_ip: IpAddr) {
836        let old_ip = {
837            let mut store = self.store.write();
838            store.update_speaker_ip_address(speaker_id, new_ip)
839        };
840        if let Some(old_ip) = old_ip {
841            let mut map = self.ip_to_speaker.write();
842            map.remove(&old_ip);
843            map.insert(new_ip, speaker_id.clone());
844        }
845    }
846
847    /// Get all satellite speaker IDs from topology data.
848    pub fn get_satellite_ids(&self) -> Vec<SpeakerId> {
849        self.store.read().satellite_ids.iter().cloned().collect()
850    }
851
852    /// Store satellite speaker IDs from topology data.
853    pub fn set_satellite_ids(&self, ids: Vec<SpeakerId>) {
854        self.store.write().satellite_ids = ids.into_iter().collect();
855    }
856
857    /// Create a blocking iterator over change events
858    ///
859    /// Only emits events for properties that have been watched.
860    ///
861    /// Each call returns an **independent** iterator: every iterator receives
862    /// every event, so two event loops both see the whole stream instead of
863    /// splitting it between them. An iterator only receives events emitted after
864    /// it was created, so take it before the writes you want to observe.
865    ///
866    /// Each iterator owns an unbounded queue, so a slow consumer never loses an
867    /// event and never blocks a fast one — and never drains means never bounded.
868    ///
869    /// # Example
870    ///
871    /// ```rust,ignore
872    /// // First, watch some properties
873    /// speaker.volume.watch()?;
874    ///
875    /// // Then iterate over changes — the new value rides along on the event
876    /// for event in manager.iter() {
877    ///     match &event.change {
878    ///         PropertyChange::Volume(v) => println!("volume -> {}%", v.value()),
879    ///         other => println!("{} changed", other.key()),
880    ///     }
881    /// }
882    /// ```
883    pub fn iter(&self) -> ChangeIterator {
884        ChangeIterator::new(&self.fanout)
885    }
886
887    /// Get current property value (sync, no subscription)
888    ///
889    /// For PerCoordinator speaker-scoped properties, this transparently reads
890    /// from the coordinator's store, so group members see the coordinator's value.
891    pub fn get_property<P: SonosProperty>(&self, speaker_id: &SpeakerId) -> Option<P> {
892        self.store.read().get_resolved::<P>(speaker_id)
893    }
894
895    /// Get current group property value (sync, no subscription)
896    pub fn get_group_property<P: Property>(&self, group_id: &GroupId) -> Option<P> {
897        self.store.read().get_group::<P>(group_id)
898    }
899
900    /// Set a property value
901    ///
902    /// Updates the property value in the store and emits a change event
903    /// if the property is being watched.
904    ///
905    /// The write is routed the same way [`Self::get_property`] reads: for a
906    /// `PerCoordinator` speaker-scoped property, the value lands in the
907    /// *coordinator's* bag, because `get_resolved` reads it from there. Writing
908    /// the raw `speaker_id` instead put the value in a bag nothing ever reads —
909    /// so `speaker.play()` on a grouped member updated a cache entry that
910    /// `playback_state.get()` could not see, and the UI kept showing the old
911    /// state until an event arrived.
912    ///
913    /// The notification is still keyed on the *requesting* speaker, so a member
914    /// watching the property is woken by its own write. The coordinator's own
915    /// watchers are reached by the worker's group fan-out on the next event.
916    ///
917    /// Stamped [`ChangeSource::LocalAction`] as of now. Use
918    /// [`Self::set_property_stamped`] for a `fetch()` result, whose observation
919    /// predates the write by a full network round trip.
920    pub fn set_property<P: SonosProperty>(&self, speaker_id: &SpeakerId, value: P) {
921        self.set_property_stamped(
922            speaker_id,
923            value,
924            WriteStamp::now(ChangeSource::LocalAction),
925        );
926    }
927
928    /// Set a property value with explicit write provenance.
929    ///
930    /// Rejected without effect if `stamp` is older than the observation already
931    /// stored — see [`WriteStamp`]. Returns the outcome so a caller can tell a
932    /// rejected write from an accepted one.
933    pub fn set_property_stamped<P: SonosProperty>(
934        &self,
935        speaker_id: &SpeakerId,
936        value: P,
937        stamp: WriteStamp,
938    ) -> WriteOutcome {
939        // Resolve and write under one lock: taking the coordinator from a
940        // separate read would leave a window in which a topology event regroups
941        // the speaker and the write lands in the wrong bag.
942        let (target_id, outcome) = {
943            let mut store = self.store.write();
944            let target_id = store.resolve_write_target::<P>(speaker_id);
945            let outcome = store.set::<P>(&target_id, value.clone(), stamp);
946            (target_id, outcome)
947        };
948
949        if outcome.changed() {
950            // Key the notification on the speaker the caller asked about, so a
951            // member watching the property is woken by its own write...
952            self.maybe_emit_change(speaker_id, &value, stamp);
953            // ...and on the coordinator too when they differ, since it is the
954            // coordinator's bag that actually changed.
955            if target_id != *speaker_id {
956                self.maybe_emit_change(&target_id, &value, stamp);
957            }
958        }
959
960        outcome
961    }
962
963    /// Set a group property value
964    ///
965    /// Updates the group property value in the store and emits a change event
966    /// if the property is being watched (keyed on the coordinator's speaker ID).
967    /// Used by the SDK layer to store group-scoped values fetched via API calls.
968    ///
969    /// Stamped [`ChangeSource::LocalAction`]; see
970    /// [`Self::set_group_property_stamped`] for `fetch()` results.
971    pub fn set_group_property<P: SonosProperty>(&self, group_id: &GroupId, value: P) {
972        self.set_group_property_stamped(
973            group_id,
974            value,
975            WriteStamp::now(ChangeSource::LocalAction),
976        );
977    }
978
979    /// Set a group property value with explicit write provenance.
980    ///
981    /// Rejected without effect if `stamp` is older than the observation already
982    /// stored — see [`WriteStamp`].
983    pub fn set_group_property_stamped<P: SonosProperty>(
984        &self,
985        group_id: &GroupId,
986        value: P,
987        stamp: WriteStamp,
988    ) -> WriteOutcome {
989        let (outcome, coordinator_id) = {
990            let mut store = self.store.write();
991            let outcome = store.set_group::<P>(group_id, value.clone(), stamp);
992            if !outcome.changed() {
993                return outcome;
994            }
995            let coordinator_id = store.groups.get(group_id).map(|g| g.coordinator_id.clone());
996            (outcome, coordinator_id)
997        };
998
999        if let Some(coordinator_id) = coordinator_id {
1000            self.maybe_emit_change(&coordinator_id, &value, stamp);
1001        }
1002
1003        outcome
1004    }
1005
1006    /// Register a property as watched (called by PropertyHandle::watch)
1007    ///
1008    /// Adds one reference. Balanced by [`Self::unregister_watch`]; the property
1009    /// keeps emitting until every registration has been unregistered.
1010    pub fn register_watch(&self, speaker_id: &SpeakerId, property_key: &'static str) {
1011        retain_direct_watch(&self.watched, speaker_id, property_key);
1012    }
1013
1014    /// Unregister a property watch
1015    ///
1016    /// Releases one reference taken by [`Self::register_watch`]. The property
1017    /// stops being watched only when the last reference is released, so one
1018    /// watcher going away cannot silence its siblings. Unregistering something
1019    /// that was never registered is a no-op.
1020    pub fn unregister_watch(&self, speaker_id: &SpeakerId, property_key: &'static str) {
1021        release_direct_watch(&self.watched, speaker_id, property_key);
1022    }
1023
1024    /// Watch a property with automatic UPnP subscription (recommended API)
1025    ///
1026    /// This is the preferred method for watching properties as it:
1027    /// 1. Registers the property for change notifications
1028    /// 2. Subscribes to the UPnP service via the event manager
1029    ///
1030    /// Returns the current cached value if available.
1031    pub fn watch_property_with_subscription<P: SonosProperty>(
1032        &self,
1033        speaker_id: &SpeakerId,
1034    ) -> Result<Option<P>> {
1035        // Register for change notifications
1036        self.register_watch(speaker_id, P::KEY);
1037
1038        // Subscribe via event manager if available
1039        if let Some(em) = self.event_manager.get() {
1040            // Get speaker IP from store
1041            if let Some(ip) = self.get_speaker_ip(speaker_id) {
1042                if let Err(e) = em.ensure_service_subscribed(ip, P::SERVICE) {
1043                    tracing::warn!(
1044                        "Failed to subscribe to {:?} for {}: {}",
1045                        P::SERVICE,
1046                        speaker_id.as_str(),
1047                        e
1048                    );
1049                }
1050            }
1051        }
1052
1053        Ok(self.get_property::<P>(speaker_id))
1054    }
1055
1056    /// Unwatch a property and release UPnP subscription
1057    pub fn unwatch_property_with_subscription<P: SonosProperty>(&self, speaker_id: &SpeakerId) {
1058        // Unregister from change notifications
1059        self.unregister_watch(speaker_id, P::KEY);
1060
1061        // Release subscription via event manager if available
1062        if let Some(em) = self.event_manager.get() {
1063            if let Some(ip) = self.get_speaker_ip(speaker_id) {
1064                if let Err(e) = em.release_service_subscription(ip, P::SERVICE) {
1065                    tracing::warn!(
1066                        "Failed to unsubscribe from {:?} for {}: {}",
1067                        P::SERVICE,
1068                        speaker_id.as_str(),
1069                        e
1070                    );
1071                }
1072            }
1073        }
1074    }
1075
1076    /// Check if a property is being watched
1077    pub fn is_watched(&self, speaker_id: &SpeakerId, property_key: &'static str) -> bool {
1078        is_pair_watched(&self.watched.read(), speaker_id, property_key)
1079    }
1080
1081    /// Emit a change event if the property is being watched
1082    fn maybe_emit_change<P: SonosProperty>(
1083        &self,
1084        speaker_id: &SpeakerId,
1085        value: &P,
1086        stamp: WriteStamp,
1087    ) {
1088        let is_watched = is_pair_watched(&self.watched.read(), speaker_id, P::KEY);
1089        if !is_watched {
1090            return;
1091        }
1092
1093        // A property with no `PropertyChange` variant cannot be carried in an
1094        // event. That is only `Topology` today, which is not watchable through
1095        // the SDK, so this is unreachable in practice — but it is a silent
1096        // dropped notification if a new watchable property forgets to implement
1097        // `to_change`, so it warns rather than returning quietly.
1098        let Some(change) = value.to_change() else {
1099            tracing::warn!(
1100                "Not emitting a change event for {}: no PropertyChange variant \
1101                 (SonosProperty::to_change returned None)",
1102                P::KEY
1103            );
1104            return;
1105        };
1106
1107        self.fanout
1108            .send(ChangeEvent::new(speaker_id.clone(), change, stamp));
1109    }
1110
1111    /// Initialize from topology data
1112    pub fn initialize(&self, topology: Topology) {
1113        let mut store = self.store.write();
1114        for speaker in &topology.speakers {
1115            store.add_speaker(speaker.clone());
1116        }
1117        for group in &topology.groups {
1118            store.add_group(group.clone());
1119        }
1120        // `LocalAction`: topology supplied by the caller (a poll result or a
1121        // test fixture), not decoded from a NOTIFY. Nothing orders against the
1122        // system bag today, but it is stamped for consistency.
1123        store.set_system(topology, WriteStamp::now(ChangeSource::LocalAction));
1124    }
1125
1126    /// Check if initialized with any speakers
1127    pub fn is_initialized(&self) -> bool {
1128        !self.store.read().is_empty()
1129    }
1130
1131    /// Get number of speakers
1132    pub fn speaker_count(&self) -> usize {
1133        self.store.read().speaker_count()
1134    }
1135
1136    /// Get number of groups
1137    pub fn group_count(&self) -> usize {
1138        self.store.read().group_count()
1139    }
1140
1141    /// Get all current groups
1142    ///
1143    /// Returns all groups in the system. Every speaker is always in a group,
1144    /// so a single speaker forms a group of one.
1145    pub fn groups(&self) -> Vec<GroupInfo> {
1146        self.store.read().groups.values().cloned().collect()
1147    }
1148
1149    /// Get a specific group by ID
1150    pub fn get_group(&self, group_id: &GroupId) -> Option<GroupInfo> {
1151        self.store.read().groups.get(group_id).cloned()
1152    }
1153
1154    /// Get the group a speaker belongs to
1155    ///
1156    /// Uses the speaker_to_group mapping for quick lookup.
1157    pub fn get_group_for_speaker(&self, speaker_id: &SpeakerId) -> Option<GroupInfo> {
1158        let store = self.store.read();
1159        let group_id = store.speaker_to_group.get(speaker_id)?;
1160        store.groups.get(group_id).cloned()
1161    }
1162
1163    /// Resolve the subscription target for a PerCoordinator service.
1164    ///
1165    /// For PerCoordinator services, returns the coordinator's `(SpeakerId, IpAddr)`
1166    /// so the SDK can route UPnP subscriptions to the coordinator speaker.
1167    /// Falls back to the speaker itself if no group data exists.
1168    ///
1169    /// For non-PerCoordinator services, returns the speaker's own identity.
1170    pub fn resolve_subscription_target(
1171        &self,
1172        speaker_id: &SpeakerId,
1173        speaker_ip: IpAddr,
1174        service: Service,
1175    ) -> (SpeakerId, IpAddr) {
1176        if service.scope() == ServiceScope::PerCoordinator {
1177            let store = self.store.read();
1178            let coordinator_id = store.resolve_coordinator(speaker_id);
1179            if coordinator_id == *speaker_id {
1180                (speaker_id.clone(), speaker_ip)
1181            } else {
1182                let coord_ip = store
1183                    .speaker(&coordinator_id)
1184                    .map(|s| s.ip_address)
1185                    .unwrap_or(speaker_ip);
1186                (coordinator_id, coord_ip)
1187            }
1188        } else {
1189            (speaker_id.clone(), speaker_ip)
1190        }
1191    }
1192
1193    /// Get access to the event manager (if configured)
1194    ///
1195    /// This allows PropertyHandle::watch() to trigger UPnP subscriptions
1196    /// via the event manager's ensure_service_subscribed() method.
1197    pub fn event_manager(&self) -> Option<&Arc<SonosEventManager>> {
1198        self.event_manager.get()
1199    }
1200
1201    /// Wire an event manager into this StateManager after construction.
1202    ///
1203    /// Spawns the event worker thread and registers all known devices.
1204    /// Can only be called once — subsequent calls are no-ops.
1205    pub fn set_event_manager(&self, em: Arc<SonosEventManager>) -> Result<()> {
1206        tracing::debug!("StateManager::set_event_manager called");
1207        if self.event_manager.set(Arc::clone(&em)).is_err() {
1208            tracing::debug!("Event manager already set — no-op");
1209            return Ok(()); // Already set — no-op
1210        }
1211
1212        // Wire this StateManager as the WatchRegistry
1213        em.set_watch_registry(Arc::new(StateWatchRegistry {
1214            watched: Arc::clone(&self.watched),
1215            ip_to_speaker: Arc::clone(&self.ip_to_speaker),
1216            key_to_service: Arc::clone(&self.key_to_service),
1217        }));
1218
1219        // Register all known devices with the event manager
1220        let devices_for_em: Vec<_> = self
1221            .speaker_infos()
1222            .iter()
1223            .map(|info| sonos_discovery::Device {
1224                id: info.id.as_str().to_string(),
1225                name: info.name.clone(),
1226                room_name: info.room_name.clone(),
1227                ip_address: info.ip_address.to_string(),
1228                port: info.port,
1229                model_name: info.model_name.clone(),
1230            })
1231            .collect();
1232
1233        if let Err(e) = em.add_devices(devices_for_em) {
1234            tracing::warn!(
1235                "Failed to add devices to event manager during lazy init: {}",
1236                e
1237            );
1238        }
1239
1240        // Spawn event worker thread
1241        let worker = spawn_state_event_worker(
1242            em,
1243            Arc::clone(&self.store),
1244            Arc::clone(&self.watched),
1245            Arc::clone(&self.fanout),
1246            Arc::clone(&self.ip_to_speaker),
1247        );
1248        info!("StateManager event worker started (lazy init)");
1249
1250        if let Ok(mut w) = self._worker.lock() {
1251            *w = Some(worker);
1252        }
1253
1254        Ok(())
1255    }
1256
1257    /// Set the lazy event manager initialization closure.
1258    ///
1259    /// Called once by `SonosSystem::from_devices_inner()` after construction.
1260    /// Subsequent calls are no-ops (OnceLock semantics).
1261    pub fn set_event_init(&self, f: EventInitFn) {
1262        let _ = self.event_init.set(f);
1263    }
1264
1265    /// Get the event init closure (if set).
1266    ///
1267    /// Used by `PropertyHandle::watch()` and `GroupPropertyHandle::watch()`
1268    /// to trigger lazy event manager creation on first use.
1269    pub fn event_init(&self) -> Option<&EventInitFn> {
1270        self.event_init.get()
1271    }
1272}
1273
1274impl Clone for StateManager {
1275    fn clone(&self) -> Self {
1276        let event_manager = OnceLock::new();
1277        if let Some(em) = self.event_manager.get() {
1278            let _ = event_manager.set(Arc::clone(em));
1279        }
1280        let event_init = OnceLock::new();
1281        if let Some(f) = self.event_init.get() {
1282            let _ = event_init.set(Arc::clone(f));
1283        }
1284        Self {
1285            store: Arc::clone(&self.store),
1286            watched: Arc::clone(&self.watched),
1287            ip_to_speaker: Arc::clone(&self.ip_to_speaker),
1288            event_manager,
1289            fanout: Arc::clone(&self.fanout),
1290            _worker: Mutex::new(None),
1291            cleanup_timeout: self.cleanup_timeout,
1292            key_to_service: Arc::clone(&self.key_to_service),
1293            event_init,
1294        }
1295    }
1296}
1297
1298// ============================================================================
1299// StateManagerBuilder
1300// ============================================================================
1301
1302/// Builder for StateManager configuration
1303pub struct StateManagerBuilder {
1304    cleanup_timeout: Duration,
1305    event_manager: Option<Arc<SonosEventManager>>,
1306}
1307
1308impl Default for StateManagerBuilder {
1309    fn default() -> Self {
1310        Self {
1311            cleanup_timeout: Duration::from_secs(5),
1312            event_manager: None,
1313        }
1314    }
1315}
1316
1317impl StateManagerBuilder {
1318    /// Set the cleanup timeout for subscriptions
1319    pub fn cleanup_timeout(mut self, timeout: Duration) -> Self {
1320        self.cleanup_timeout = timeout;
1321        self
1322    }
1323
1324    /// Set the event manager for live event processing
1325    ///
1326    /// When an event manager is provided, the StateManager will:
1327    /// - Spawn a background worker to process events
1328    /// - Automatically subscribe/unsubscribe via `watch()`/`unwatch()` on properties
1329    /// - Update state from incoming events
1330    pub fn with_event_manager(mut self, em: Arc<SonosEventManager>) -> Self {
1331        self.event_manager = Some(em);
1332        self
1333    }
1334
1335    /// Build the StateManager
1336    pub fn build(self) -> Result<StateManager> {
1337        let fanout = Arc::new(EventFanout::new());
1338
1339        let store = Arc::new(RwLock::new(StateStore::new()));
1340        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1341        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1342        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1343
1344        let event_manager_lock = OnceLock::new();
1345        let mut worker = None;
1346
1347        // If event_manager provided at build time, wire it up eagerly
1348        if let Some(em) = self.event_manager {
1349            let _ = event_manager_lock.set(Arc::clone(&em));
1350
1351            // Wire WatchRegistry
1352            em.set_watch_registry(Arc::new(StateWatchRegistry {
1353                watched: Arc::clone(&watched),
1354                ip_to_speaker: Arc::clone(&ip_to_speaker),
1355                key_to_service: Arc::clone(&key_to_service),
1356            }));
1357
1358            let worker_handle = spawn_state_event_worker(
1359                em,
1360                Arc::clone(&store),
1361                Arc::clone(&watched),
1362                Arc::clone(&fanout),
1363                Arc::clone(&ip_to_speaker),
1364            );
1365            info!("StateManager event worker started");
1366            worker = Some(worker_handle);
1367        }
1368
1369        let manager = StateManager {
1370            store,
1371            watched,
1372            ip_to_speaker,
1373            event_manager: event_manager_lock,
1374            fanout,
1375            _worker: Mutex::new(worker),
1376            cleanup_timeout: self.cleanup_timeout,
1377            key_to_service,
1378            event_init: OnceLock::new(),
1379        };
1380
1381        info!("StateManager created (sync-first mode)");
1382        Ok(manager)
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389    use crate::iter::ChangeIterator;
1390    use crate::property::{GroupVolume, PlaybackState, Volume};
1391    use sonos_api::Service;
1392
1393    /// An event-sourced stamp for "now", for tests that only need a valid one.
1394    fn test_stamp() -> WriteStamp {
1395        WriteStamp::now(ChangeSource::Event)
1396    }
1397
1398    #[test]
1399    fn test_state_manager_creation() {
1400        let manager = StateManager::new().unwrap();
1401        assert!(!manager.is_initialized());
1402        assert_eq!(manager.speaker_count(), 0);
1403    }
1404
1405    #[test]
1406    fn test_add_devices() {
1407        let manager = StateManager::new().unwrap();
1408
1409        let devices = vec![Device {
1410            id: "RINCON_123".to_string(),
1411            name: "Living Room".to_string(),
1412            room_name: "Living Room".to_string(),
1413            ip_address: "192.168.1.100".to_string(),
1414            port: 1400,
1415            model_name: "Sonos One".to_string(),
1416        }];
1417
1418        manager.add_devices(devices).unwrap();
1419        assert_eq!(manager.speaker_count(), 1);
1420    }
1421
1422    #[test]
1423    fn test_property_storage() {
1424        let manager = StateManager::new().unwrap();
1425
1426        let devices = vec![Device {
1427            id: "RINCON_123".to_string(),
1428            name: "Living Room".to_string(),
1429            room_name: "Living Room".to_string(),
1430            ip_address: "192.168.1.100".to_string(),
1431            port: 1400,
1432            model_name: "Sonos One".to_string(),
1433        }];
1434        manager.add_devices(devices).unwrap();
1435
1436        let speaker_id = SpeakerId::new("RINCON_123");
1437
1438        // Initially None
1439        assert!(manager.get_property::<Volume>(&speaker_id).is_none());
1440
1441        // Set value
1442        manager.set_property(&speaker_id, Volume::new(50));
1443        assert_eq!(
1444            manager.get_property::<Volume>(&speaker_id),
1445            Some(Volume::new(50))
1446        );
1447    }
1448
1449    #[test]
1450    fn test_watch_registration() {
1451        let manager = StateManager::new().unwrap();
1452
1453        let devices = vec![Device {
1454            id: "RINCON_123".to_string(),
1455            name: "Living Room".to_string(),
1456            room_name: "Living Room".to_string(),
1457            ip_address: "192.168.1.100".to_string(),
1458            port: 1400,
1459            model_name: "Sonos One".to_string(),
1460        }];
1461        manager.add_devices(devices).unwrap();
1462
1463        let speaker_id = SpeakerId::new("RINCON_123");
1464
1465        // Not watched initially
1466        assert!(!manager.is_watched(&speaker_id, "volume"));
1467
1468        // Register watch
1469        manager.register_watch(&speaker_id, "volume");
1470        assert!(manager.is_watched(&speaker_id, "volume"));
1471
1472        // Unregister watch
1473        manager.unregister_watch(&speaker_id, "volume");
1474        assert!(!manager.is_watched(&speaker_id, "volume"));
1475    }
1476
1477    #[test]
1478    fn test_change_event_emission() {
1479        let manager = StateManager::new().unwrap();
1480
1481        let devices = vec![Device {
1482            id: "RINCON_123".to_string(),
1483            name: "Living Room".to_string(),
1484            room_name: "Living Room".to_string(),
1485            ip_address: "192.168.1.100".to_string(),
1486            port: 1400,
1487            model_name: "Sonos One".to_string(),
1488        }];
1489        manager.add_devices(devices).unwrap();
1490
1491        let speaker_id = SpeakerId::new("RINCON_123");
1492
1493        // Register watch
1494        manager.register_watch(&speaker_id, "volume");
1495
1496        // Subscribe before writing: an iterator receives events emitted after
1497        // it exists, not a replay of everything since the manager was built.
1498        let iter = manager.iter();
1499
1500        // Set property (should emit event)
1501        manager.set_property(&speaker_id, Volume::new(75));
1502        let event = iter.recv_timeout(std::time::Duration::from_millis(100));
1503        assert!(event.is_some());
1504
1505        let event = event.unwrap();
1506        assert_eq!(event.speaker_id.as_str(), "RINCON_123");
1507        assert_eq!(event.property_key(), "volume");
1508    }
1509
1510    #[test]
1511    fn test_set_group_property_emits_change_event() {
1512        let manager = StateManager::new().unwrap();
1513
1514        let devices = vec![Device {
1515            id: "RINCON_123".to_string(),
1516            name: "Living Room".to_string(),
1517            room_name: "Living Room".to_string(),
1518            ip_address: "192.168.1.100".to_string(),
1519            port: 1400,
1520            model_name: "Sonos One".to_string(),
1521        }];
1522        manager.add_devices(devices).unwrap();
1523
1524        let speaker_id = SpeakerId::new("RINCON_123");
1525        let group_id = GroupId::new("RINCON_123:1");
1526
1527        // Add group so coordinator lookup works
1528        {
1529            let mut store = manager.store.write();
1530            store.add_group(GroupInfo::new(
1531                group_id.clone(),
1532                speaker_id.clone(),
1533                vec![speaker_id.clone()],
1534            ));
1535        }
1536
1537        // Register watch on coordinator for group_volume
1538        manager.register_watch(&speaker_id, "group_volume");
1539
1540        let iter = manager.iter();
1541
1542        // Set group property (should emit event via coordinator)
1543        manager.set_group_property(&group_id, GroupVolume::new(80));
1544
1545        // Verify event was emitted
1546        let event = iter.recv_timeout(std::time::Duration::from_millis(100));
1547        assert!(event.is_some());
1548
1549        let event = event.unwrap();
1550        assert_eq!(event.speaker_id.as_str(), "RINCON_123");
1551        assert_eq!(event.property_key(), "group_volume");
1552        assert_eq!(event.service(), Service::GroupRenderingControl);
1553    }
1554
1555    #[test]
1556    fn test_set_group_property_no_event_when_unwatched() {
1557        let manager = StateManager::new().unwrap();
1558
1559        let devices = vec![Device {
1560            id: "RINCON_123".to_string(),
1561            name: "Living Room".to_string(),
1562            room_name: "Living Room".to_string(),
1563            ip_address: "192.168.1.100".to_string(),
1564            port: 1400,
1565            model_name: "Sonos One".to_string(),
1566        }];
1567        manager.add_devices(devices).unwrap();
1568
1569        let speaker_id = SpeakerId::new("RINCON_123");
1570        let group_id = GroupId::new("RINCON_123:1");
1571
1572        {
1573            let mut store = manager.store.write();
1574            store.add_group(GroupInfo::new(
1575                group_id.clone(),
1576                speaker_id.clone(),
1577                vec![speaker_id.clone()],
1578            ));
1579        }
1580
1581        let iter = manager.iter();
1582
1583        // Don't register any watch
1584        manager.set_group_property(&group_id, GroupVolume::new(50));
1585        let event = iter.recv_timeout(std::time::Duration::from_millis(100));
1586        assert!(event.is_none());
1587    }
1588
1589    // ========================================================================
1590    // StateStore Group Operations Tests
1591    // ========================================================================
1592
1593    #[test]
1594    fn test_add_group_updates_speaker_to_group() {
1595        let mut store = StateStore::new();
1596
1597        let speaker1 = SpeakerId::new("RINCON_111");
1598        let speaker2 = SpeakerId::new("RINCON_222");
1599        let group_id = GroupId::new("RINCON_111:1");
1600
1601        let group = GroupInfo::new(
1602            group_id.clone(),
1603            speaker1.clone(),
1604            vec![speaker1.clone(), speaker2.clone()],
1605        );
1606
1607        store.add_group(group);
1608
1609        // Verify speaker_to_group mapping is updated for all members
1610        assert_eq!(store.speaker_to_group.get(&speaker1), Some(&group_id));
1611        assert_eq!(store.speaker_to_group.get(&speaker2), Some(&group_id));
1612    }
1613
1614    #[test]
1615    fn test_add_group_single_speaker() {
1616        let mut store = StateStore::new();
1617
1618        let speaker = SpeakerId::new("RINCON_333");
1619        let group_id = GroupId::new("RINCON_333:1");
1620
1621        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1622
1623        store.add_group(group.clone());
1624
1625        // Verify speaker_to_group mapping
1626        assert_eq!(store.speaker_to_group.get(&speaker), Some(&group_id));
1627
1628        // Verify group is stored
1629        assert_eq!(store.groups.get(&group_id), Some(&group));
1630    }
1631
1632    #[test]
1633    fn test_get_group_for_speaker_returns_correct_group() {
1634        let mut store = StateStore::new();
1635
1636        let speaker1 = SpeakerId::new("RINCON_111");
1637        let speaker2 = SpeakerId::new("RINCON_222");
1638        let speaker3 = SpeakerId::new("RINCON_333");
1639        let group1_id = GroupId::new("RINCON_111:1");
1640        let group2_id = GroupId::new("RINCON_333:1");
1641
1642        // Group 1: speaker1 (coordinator) + speaker2
1643        let group1 = GroupInfo::new(
1644            group1_id.clone(),
1645            speaker1.clone(),
1646            vec![speaker1.clone(), speaker2.clone()],
1647        );
1648
1649        // Group 2: speaker3 alone
1650        let group2 = GroupInfo::new(group2_id.clone(), speaker3.clone(), vec![speaker3.clone()]);
1651
1652        store.add_group(group1.clone());
1653        store.add_group(group2.clone());
1654
1655        // Verify get_group_for_speaker returns correct groups
1656        assert_eq!(store.get_group_for_speaker(&speaker1), Some(&group1));
1657        assert_eq!(store.get_group_for_speaker(&speaker2), Some(&group1));
1658        assert_eq!(store.get_group_for_speaker(&speaker3), Some(&group2));
1659    }
1660
1661    #[test]
1662    fn test_get_group_for_speaker_returns_none_for_unknown() {
1663        let store = StateStore::new();
1664
1665        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1666
1667        assert!(store.get_group_for_speaker(&unknown_speaker).is_none());
1668    }
1669
1670    #[test]
1671    fn test_clear_groups_removes_all_group_data() {
1672        let mut store = StateStore::new();
1673
1674        let speaker1 = SpeakerId::new("RINCON_111");
1675        let speaker2 = SpeakerId::new("RINCON_222");
1676        let group_id = GroupId::new("RINCON_111:1");
1677
1678        let group = GroupInfo::new(
1679            group_id.clone(),
1680            speaker1.clone(),
1681            vec![speaker1.clone(), speaker2.clone()],
1682        );
1683
1684        store.add_group(group);
1685
1686        // Verify data exists
1687        assert!(!store.groups.is_empty());
1688        assert!(!store.speaker_to_group.is_empty());
1689
1690        // Clear groups
1691        store.clear_groups();
1692
1693        // Verify all group data is cleared
1694        assert!(store.groups.is_empty());
1695        assert!(store.group_props.is_empty());
1696        assert!(store.speaker_to_group.is_empty());
1697    }
1698
1699    #[test]
1700    fn test_clear_groups_then_add_new_groups() {
1701        let mut store = StateStore::new();
1702
1703        // Add initial group
1704        let speaker1 = SpeakerId::new("RINCON_111");
1705        let group1_id = GroupId::new("RINCON_111:1");
1706        let group1 = GroupInfo::new(group1_id.clone(), speaker1.clone(), vec![speaker1.clone()]);
1707        store.add_group(group1);
1708
1709        // Clear and add new group
1710        store.clear_groups();
1711
1712        let speaker2 = SpeakerId::new("RINCON_222");
1713        let group2_id = GroupId::new("RINCON_222:1");
1714        let group2 = GroupInfo::new(group2_id.clone(), speaker2.clone(), vec![speaker2.clone()]);
1715        store.add_group(group2.clone());
1716
1717        // Verify old group is gone, new group exists
1718        assert!(!store.groups.contains_key(&group1_id));
1719        assert_eq!(store.groups.get(&group2_id), Some(&group2));
1720
1721        // Verify speaker_to_group is updated correctly
1722        assert!(!store.speaker_to_group.contains_key(&speaker1));
1723        assert_eq!(store.speaker_to_group.get(&speaker2), Some(&group2_id));
1724    }
1725
1726    // ========================================================================
1727    // StateManager Group Methods Tests
1728    // ========================================================================
1729
1730    #[test]
1731    fn test_state_manager_groups_returns_all_groups() {
1732        let manager = StateManager::new().unwrap();
1733
1734        // Add devices
1735        let devices = vec![
1736            Device {
1737                id: "RINCON_111".to_string(),
1738                name: "Living Room".to_string(),
1739                room_name: "Living Room".to_string(),
1740                ip_address: "192.168.1.100".to_string(),
1741                port: 1400,
1742                model_name: "Sonos One".to_string(),
1743            },
1744            Device {
1745                id: "RINCON_222".to_string(),
1746                name: "Kitchen".to_string(),
1747                room_name: "Kitchen".to_string(),
1748                ip_address: "192.168.1.101".to_string(),
1749                port: 1400,
1750                model_name: "Sonos One".to_string(),
1751            },
1752        ];
1753        manager.add_devices(devices).unwrap();
1754
1755        // Create groups via initialize
1756        let speaker1 = SpeakerId::new("RINCON_111");
1757        let speaker2 = SpeakerId::new("RINCON_222");
1758        let group1 = GroupInfo::new(
1759            GroupId::new("RINCON_111:1"),
1760            speaker1.clone(),
1761            vec![speaker1.clone()],
1762        );
1763        let group2 = GroupInfo::new(
1764            GroupId::new("RINCON_222:1"),
1765            speaker2.clone(),
1766            vec![speaker2.clone()],
1767        );
1768
1769        let topology = Topology::new(
1770            manager.speaker_infos(),
1771            vec![group1.clone(), group2.clone()],
1772        );
1773        manager.initialize(topology);
1774
1775        // Verify groups() returns all groups
1776        let groups = manager.groups();
1777        assert_eq!(groups.len(), 2);
1778
1779        // Verify both groups are present (order may vary)
1780        let group_ids: Vec<_> = groups.iter().map(|g| g.id.clone()).collect();
1781        assert!(group_ids.contains(&GroupId::new("RINCON_111:1")));
1782        assert!(group_ids.contains(&GroupId::new("RINCON_222:1")));
1783    }
1784
1785    #[test]
1786    fn test_state_manager_groups_returns_empty_when_no_groups() {
1787        let manager = StateManager::new().unwrap();
1788
1789        // No groups added
1790        let groups = manager.groups();
1791        assert!(groups.is_empty());
1792    }
1793
1794    #[test]
1795    fn test_state_manager_get_group_returns_correct_group() {
1796        let manager = StateManager::new().unwrap();
1797
1798        // Add device
1799        let devices = vec![Device {
1800            id: "RINCON_111".to_string(),
1801            name: "Living Room".to_string(),
1802            room_name: "Living Room".to_string(),
1803            ip_address: "192.168.1.100".to_string(),
1804            port: 1400,
1805            model_name: "Sonos One".to_string(),
1806        }];
1807        manager.add_devices(devices).unwrap();
1808
1809        // Create group via initialize
1810        let speaker = SpeakerId::new("RINCON_111");
1811        let group_id = GroupId::new("RINCON_111:1");
1812        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1813
1814        let topology = Topology::new(manager.speaker_infos(), vec![group.clone()]);
1815        manager.initialize(topology);
1816
1817        // Verify get_group returns the correct group
1818        let found = manager.get_group(&group_id);
1819        assert!(found.is_some());
1820        assert_eq!(found.unwrap(), group);
1821    }
1822
1823    #[test]
1824    fn test_state_manager_get_group_returns_none_for_unknown() {
1825        let manager = StateManager::new().unwrap();
1826
1827        // No groups added
1828        let unknown_id = GroupId::new("RINCON_UNKNOWN:1");
1829        let found = manager.get_group(&unknown_id);
1830        assert!(found.is_none());
1831    }
1832
1833    #[test]
1834    fn test_state_manager_get_group_for_speaker_returns_correct_group() {
1835        let manager = StateManager::new().unwrap();
1836
1837        // Add devices
1838        let devices = vec![
1839            Device {
1840                id: "RINCON_111".to_string(),
1841                name: "Living Room".to_string(),
1842                room_name: "Living Room".to_string(),
1843                ip_address: "192.168.1.100".to_string(),
1844                port: 1400,
1845                model_name: "Sonos One".to_string(),
1846            },
1847            Device {
1848                id: "RINCON_222".to_string(),
1849                name: "Kitchen".to_string(),
1850                room_name: "Kitchen".to_string(),
1851                ip_address: "192.168.1.101".to_string(),
1852                port: 1400,
1853                model_name: "Sonos One".to_string(),
1854            },
1855        ];
1856        manager.add_devices(devices).unwrap();
1857
1858        // Create a group with both speakers
1859        let speaker1 = SpeakerId::new("RINCON_111");
1860        let speaker2 = SpeakerId::new("RINCON_222");
1861        let group_id = GroupId::new("RINCON_111:1");
1862        let group = GroupInfo::new(
1863            group_id.clone(),
1864            speaker1.clone(),
1865            vec![speaker1.clone(), speaker2.clone()],
1866        );
1867
1868        let topology = Topology::new(manager.speaker_infos(), vec![group.clone()]);
1869        manager.initialize(topology);
1870
1871        // Verify get_group_for_speaker returns the correct group for both speakers
1872        let found1 = manager.get_group_for_speaker(&speaker1);
1873        assert!(found1.is_some());
1874        assert_eq!(found1.unwrap(), group);
1875
1876        let found2 = manager.get_group_for_speaker(&speaker2);
1877        assert!(found2.is_some());
1878        assert_eq!(found2.unwrap(), group);
1879    }
1880
1881    #[test]
1882    fn test_state_manager_get_group_for_speaker_returns_none_for_unknown() {
1883        let manager = StateManager::new().unwrap();
1884
1885        // No groups added
1886        let unknown_speaker = SpeakerId::new("RINCON_UNKNOWN");
1887        let found = manager.get_group_for_speaker(&unknown_speaker);
1888        assert!(found.is_none());
1889    }
1890
1891    #[test]
1892    fn test_state_manager_group_methods_consistency() {
1893        let manager = StateManager::new().unwrap();
1894
1895        // Add device
1896        let devices = vec![Device {
1897            id: "RINCON_111".to_string(),
1898            name: "Living Room".to_string(),
1899            room_name: "Living Room".to_string(),
1900            ip_address: "192.168.1.100".to_string(),
1901            port: 1400,
1902            model_name: "Sonos One".to_string(),
1903        }];
1904        manager.add_devices(devices).unwrap();
1905
1906        // Create group via initialize
1907        let speaker = SpeakerId::new("RINCON_111");
1908        let group_id = GroupId::new("RINCON_111:1");
1909        let group = GroupInfo::new(group_id.clone(), speaker.clone(), vec![speaker.clone()]);
1910
1911        let topology = Topology::new(manager.speaker_infos(), vec![group.clone()]);
1912        manager.initialize(topology);
1913
1914        // Verify all three methods return consistent data
1915        let groups = manager.groups();
1916        assert_eq!(groups.len(), 1);
1917        assert_eq!(groups[0], group);
1918
1919        let by_id = manager.get_group(&group_id);
1920        assert_eq!(by_id, Some(group.clone()));
1921
1922        let by_speaker = manager.get_group_for_speaker(&speaker);
1923        assert_eq!(by_speaker, Some(group.clone()));
1924
1925        // All should return the same group
1926        assert_eq!(groups[0], by_id.unwrap());
1927        assert_eq!(groups[0], by_speaker.unwrap());
1928    }
1929
1930    // ========================================================================
1931    // boot_seq Tests
1932    // ========================================================================
1933
1934    #[test]
1935    fn test_get_boot_seq_returns_none_for_unknown_speaker() {
1936        let manager = StateManager::new().unwrap();
1937        let unknown = SpeakerId::new("RINCON_UNKNOWN");
1938        assert!(manager.get_boot_seq(&unknown).is_none());
1939    }
1940
1941    #[test]
1942    fn test_boot_seq_defaults_to_zero_for_new_speaker() {
1943        let manager = StateManager::new().unwrap();
1944
1945        let devices = vec![Device {
1946            id: "RINCON_123".to_string(),
1947            name: "Living Room".to_string(),
1948            room_name: "Living Room".to_string(),
1949            ip_address: "192.168.1.100".to_string(),
1950            port: 1400,
1951            model_name: "Sonos One".to_string(),
1952        }];
1953        manager.add_devices(devices).unwrap();
1954
1955        let speaker_id = SpeakerId::new("RINCON_123");
1956
1957        // Before any topology event, boot_seq should be 0
1958        assert_eq!(manager.get_boot_seq(&speaker_id), Some(0));
1959    }
1960
1961    // ========================================================================
1962    // StateWatchRegistry Tests
1963    // ========================================================================
1964
1965    #[test]
1966    fn test_state_watch_registry_register_and_unregister() {
1967        let watched = Arc::new(RwLock::new(WatchCounts::new()));
1968        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
1969        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
1970
1971        let ip: IpAddr = "192.168.1.100".parse().unwrap();
1972        let speaker_id = SpeakerId::new("RINCON_123");
1973        ip_to_speaker.write().insert(ip, speaker_id.clone());
1974
1975        let registry = StateWatchRegistry {
1976            watched: Arc::clone(&watched),
1977            ip_to_speaker: Arc::clone(&ip_to_speaker),
1978            key_to_service: Arc::clone(&key_to_service),
1979        };
1980
1981        // Register watches on two services
1982        registry.register_watch(&speaker_id, "volume", Service::RenderingControl);
1983        registry.register_watch(&speaker_id, "mute", Service::RenderingControl);
1984        registry.register_watch(&speaker_id, "playback_state", Service::AVTransport);
1985
1986        assert_eq!(watched.read().len(), 3);
1987
1988        // Unregister RenderingControl — should remove volume + mute, keep playback_state
1989        registry.unregister_watches_for_service(ip, Service::RenderingControl);
1990
1991        let w = watched.read();
1992        assert_eq!(w.len(), 1);
1993        assert!(is_pair_watched(&w, &speaker_id, "playback_state"));
1994        assert!(!is_pair_watched(&w, &speaker_id, "volume"));
1995        assert!(!is_pair_watched(&w, &speaker_id, "mute"));
1996    }
1997
1998    #[test]
1999    fn test_state_watch_registry_unknown_ip_is_noop() {
2000        let watched = Arc::new(RwLock::new(WatchCounts::new()));
2001        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
2002        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
2003
2004        let speaker_id = SpeakerId::new("RINCON_123");
2005
2006        let registry = StateWatchRegistry {
2007            watched: Arc::clone(&watched),
2008            ip_to_speaker,
2009            key_to_service: Arc::clone(&key_to_service),
2010        };
2011
2012        // Register a watch (simulating direct add to shared set)
2013        retain_direct_watch(&watched, &speaker_id, "volume");
2014        key_to_service
2015            .write()
2016            .insert("volume", Service::RenderingControl);
2017
2018        // Unregister for an unknown IP — should be a no-op
2019        let unknown_ip: IpAddr = "10.0.0.1".parse().unwrap();
2020        registry.unregister_watches_for_service(unknown_ip, Service::RenderingControl);
2021
2022        // Watch should still be there
2023        assert_eq!(watched.read().len(), 1);
2024    }
2025
2026    #[test]
2027    fn test_state_watch_registry_only_removes_matching_speaker() {
2028        let watched = Arc::new(RwLock::new(WatchCounts::new()));
2029        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
2030        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
2031
2032        let ip1: IpAddr = "192.168.1.100".parse().unwrap();
2033        let ip2: IpAddr = "192.168.1.101".parse().unwrap();
2034        let speaker1 = SpeakerId::new("RINCON_111");
2035        let speaker2 = SpeakerId::new("RINCON_222");
2036
2037        ip_to_speaker.write().insert(ip1, speaker1.clone());
2038        ip_to_speaker.write().insert(ip2, speaker2.clone());
2039
2040        let registry = StateWatchRegistry {
2041            watched: Arc::clone(&watched),
2042            ip_to_speaker,
2043            key_to_service: Arc::clone(&key_to_service),
2044        };
2045
2046        // Both speakers watch volume
2047        registry.register_watch(&speaker1, "volume", Service::RenderingControl);
2048        registry.register_watch(&speaker2, "volume", Service::RenderingControl);
2049        assert_eq!(watched.read().len(), 2);
2050
2051        // Unregister only speaker1's IP
2052        registry.unregister_watches_for_service(ip1, Service::RenderingControl);
2053
2054        let w = watched.read();
2055        assert_eq!(w.len(), 1);
2056        assert!(is_pair_watched(&w, &speaker2, "volume"));
2057        assert!(!is_pair_watched(&w, &speaker1, "volume"));
2058    }
2059
2060    // ========================================================================
2061    // Watch reference counting
2062    // ========================================================================
2063
2064    /// Two watchers on the *same* property: the first release must not silence
2065    /// the second, and the second must actually clear it.
2066    ///
2067    /// This is the arithmetic behind the sibling-survival guarantee. With a
2068    /// plain `HashSet` the first `unregister_watch` removed the only entry, so
2069    /// watcher two went quiet while still holding its handle.
2070    #[test]
2071    fn test_watch_refcount_survives_partial_release() {
2072        let manager = StateManager::new().unwrap();
2073        let speaker_id = SpeakerId::new("RINCON_123");
2074
2075        manager.register_watch(&speaker_id, "volume");
2076        manager.register_watch(&speaker_id, "volume");
2077        assert!(manager.is_watched(&speaker_id, "volume"));
2078
2079        // One watcher goes away; the other still holds a reference.
2080        manager.unregister_watch(&speaker_id, "volume");
2081        assert!(
2082            manager.is_watched(&speaker_id, "volume"),
2083            "one of two watchers released — the property must stay watched"
2084        );
2085
2086        // Last watcher goes away.
2087        manager.unregister_watch(&speaker_id, "volume");
2088        assert!(!manager.is_watched(&speaker_id, "volume"));
2089
2090        // Over-release must not wrap around and resurrect the watch.
2091        manager.unregister_watch(&speaker_id, "volume");
2092        assert!(!manager.is_watched(&speaker_id, "volume"));
2093    }
2094
2095    /// A subscription teardown for one service must not take individually-held
2096    /// watches with it.
2097    ///
2098    /// `unregister_watches_for_service` clears every key of a service at once.
2099    /// Previously it removed the map entries outright, so a `direct` hold taken
2100    /// by the polling-fallback / cache-only path — or by a second watcher of the
2101    /// same property — was destroyed by an unrelated subscription expiring.
2102    #[test]
2103    fn test_service_unregister_keeps_directly_held_watches() {
2104        let watched = Arc::new(RwLock::new(WatchCounts::new()));
2105        let ip_to_speaker = Arc::new(RwLock::new(HashMap::new()));
2106        let key_to_service = Arc::new(RwLock::new(HashMap::new()));
2107
2108        let ip: IpAddr = "192.168.1.100".parse().unwrap();
2109        let speaker_id = SpeakerId::new("RINCON_123");
2110        ip_to_speaker.write().insert(ip, speaker_id.clone());
2111
2112        let registry = StateWatchRegistry {
2113            watched: Arc::clone(&watched),
2114            ip_to_speaker,
2115            key_to_service,
2116        };
2117
2118        // A guard-based watch and a direct watch on the same property...
2119        registry.register_watch(&speaker_id, "volume", Service::RenderingControl);
2120        retain_direct_watch(&watched, &speaker_id, "volume");
2121        // ...plus a direct hold on a sibling property of the same service.
2122        registry.register_watch(&speaker_id, "mute", Service::RenderingControl);
2123        retain_direct_watch(&watched, &speaker_id, "mute");
2124
2125        registry.unregister_watches_for_service(ip, Service::RenderingControl);
2126
2127        let w = watched.read();
2128        assert!(
2129            is_pair_watched(&w, &speaker_id, "volume"),
2130            "the direct hold on volume must survive the subscription teardown"
2131        );
2132        assert!(
2133            is_pair_watched(&w, &speaker_id, "mute"),
2134            "the direct hold on mute must survive the subscription teardown"
2135        );
2136    }
2137
2138    // ========================================================================
2139    // set_property / get_property symmetry
2140    // ========================================================================
2141
2142    /// `set_property` must write where `get_property` reads.
2143    ///
2144    /// For a `PerCoordinator` speaker-scoped property, `get_resolved` reads the
2145    /// *coordinator's* bag. Writing the raw `speaker_id` therefore stored the
2146    /// value where nothing would ever look: `speaker.play()` on a grouped member
2147    /// updated a bag no reader consults, so the optimistic cache update was
2148    /// invisible from both the member and the coordinator.
2149    #[test]
2150    fn test_set_property_on_group_member_is_readable_from_both() {
2151        let manager = StateManager::new().unwrap();
2152
2153        let devices = vec![
2154            Device {
2155                id: "RINCON_COORD".to_string(),
2156                name: "Living Room".to_string(),
2157                room_name: "Living Room".to_string(),
2158                ip_address: "192.168.1.100".to_string(),
2159                port: 1400,
2160                model_name: "Sonos One".to_string(),
2161            },
2162            Device {
2163                id: "RINCON_MEMBER".to_string(),
2164                name: "Kitchen".to_string(),
2165                room_name: "Kitchen".to_string(),
2166                ip_address: "192.168.1.101".to_string(),
2167                port: 1400,
2168                model_name: "Sonos One".to_string(),
2169            },
2170        ];
2171        manager.add_devices(devices).unwrap();
2172
2173        let coordinator = SpeakerId::new("RINCON_COORD");
2174        let member = SpeakerId::new("RINCON_MEMBER");
2175        let group_id = GroupId::new("RINCON_COORD:1");
2176        let topology = Topology::new(
2177            manager.speaker_infos(),
2178            vec![GroupInfo::new(
2179                group_id,
2180                coordinator.clone(),
2181                vec![coordinator.clone(), member.clone()],
2182            )],
2183        );
2184        manager.initialize(topology);
2185
2186        // Write through the *member* — what speaker.play() does on a grouped
2187        // speaker. PlaybackState is AVTransport (PerCoordinator) + Speaker scope.
2188        manager.set_property(&member, PlaybackState::Playing);
2189
2190        assert_eq!(
2191            manager.get_property::<PlaybackState>(&member),
2192            Some(PlaybackState::Playing),
2193            "the member must be able to read back what it just wrote"
2194        );
2195        assert_eq!(
2196            manager.get_property::<PlaybackState>(&coordinator),
2197            Some(PlaybackState::Playing),
2198            "the write belongs in the coordinator's bag, which is where reads resolve"
2199        );
2200
2201        // A PerSpeaker property written on the member stays on the member.
2202        manager.set_property(&member, Volume::new(33));
2203        assert_eq!(
2204            manager.get_property::<Volume>(&member),
2205            Some(Volume::new(33))
2206        );
2207        assert_eq!(
2208            manager.get_property::<Volume>(&coordinator),
2209            None,
2210            "PerSpeaker writes must not be redirected to the coordinator"
2211        );
2212    }
2213
2214    // ========================================================================
2215    // resolve_coordinator Tests
2216    // ========================================================================
2217
2218    #[test]
2219    fn test_resolve_coordinator_for_standalone_speaker() {
2220        let mut store = StateStore::new();
2221
2222        let speaker = SpeakerId::new("RINCON_111");
2223        let group_id = GroupId::new("RINCON_111:1");
2224
2225        store.add_speaker(SpeakerInfo {
2226            id: speaker.clone(),
2227            name: "Living Room".to_string(),
2228            room_name: "Living Room".to_string(),
2229            ip_address: "192.168.1.100".parse().unwrap(),
2230            port: 1400,
2231            model_name: "Test".to_string(),
2232            software_version: "1.0".to_string(),
2233            boot_seq: 0,
2234            satellites: vec![],
2235        });
2236        store.add_group(GroupInfo::new(
2237            group_id,
2238            speaker.clone(),
2239            vec![speaker.clone()],
2240        ));
2241
2242        // Standalone speaker is its own coordinator
2243        assert_eq!(store.resolve_coordinator(&speaker), speaker);
2244    }
2245
2246    #[test]
2247    fn test_resolve_coordinator_for_group_member() {
2248        let mut store = StateStore::new();
2249
2250        let coordinator = SpeakerId::new("RINCON_COORD");
2251        let member = SpeakerId::new("RINCON_MEMBER");
2252        let group_id = GroupId::new("RINCON_COORD:1");
2253
2254        store.add_group(GroupInfo::new(
2255            group_id,
2256            coordinator.clone(),
2257            vec![coordinator.clone(), member.clone()],
2258        ));
2259
2260        // Member resolves to the coordinator
2261        assert_eq!(store.resolve_coordinator(&member), coordinator);
2262        // Coordinator resolves to itself
2263        assert_eq!(store.resolve_coordinator(&coordinator), coordinator);
2264    }
2265
2266    #[test]
2267    fn test_resolve_coordinator_no_group_data() {
2268        let store = StateStore::new();
2269
2270        let speaker = SpeakerId::new("RINCON_UNKNOWN");
2271
2272        // No group data — falls back to speaker's own ID
2273        assert_eq!(store.resolve_coordinator(&speaker), speaker);
2274    }
2275
2276    // ========================================================================
2277    // get_resolved Tests
2278    // ========================================================================
2279
2280    #[test]
2281    fn test_get_resolved_per_coordinator_reads_from_coordinator() {
2282        let mut store = StateStore::new();
2283
2284        let coordinator = SpeakerId::new("RINCON_COORD");
2285        let member = SpeakerId::new("RINCON_MEMBER");
2286        let group_id = GroupId::new("RINCON_COORD:1");
2287
2288        store.add_speaker(SpeakerInfo {
2289            id: coordinator.clone(),
2290            name: "Coord".to_string(),
2291            room_name: "Coord".to_string(),
2292            ip_address: "192.168.1.100".parse().unwrap(),
2293            port: 1400,
2294            model_name: "Test".to_string(),
2295            software_version: "1.0".to_string(),
2296            boot_seq: 0,
2297            satellites: vec![],
2298        });
2299        store.add_speaker(SpeakerInfo {
2300            id: member.clone(),
2301            name: "Member".to_string(),
2302            room_name: "Member".to_string(),
2303            ip_address: "192.168.1.101".parse().unwrap(),
2304            port: 1400,
2305            model_name: "Test".to_string(),
2306            software_version: "1.0".to_string(),
2307            boot_seq: 0,
2308            satellites: vec![],
2309        });
2310        store.add_group(GroupInfo::new(
2311            group_id,
2312            coordinator.clone(),
2313            vec![coordinator.clone(), member.clone()],
2314        ));
2315
2316        // Set PlaybackState only on coordinator
2317        store.set(&coordinator, PlaybackState::Playing, test_stamp());
2318
2319        // get_resolved on member should return coordinator's value (PerCoordinator + Speaker scope)
2320        let resolved: Option<PlaybackState> = store.get_resolved(&member);
2321        assert_eq!(resolved, Some(PlaybackState::Playing));
2322
2323        // Direct get on member should return None (no data copied)
2324        let direct: Option<PlaybackState> = store.get(&member);
2325        assert_eq!(direct, None);
2326    }
2327
2328    #[test]
2329    fn test_get_resolved_per_speaker_reads_own_props() {
2330        let mut store = StateStore::new();
2331
2332        let coordinator = SpeakerId::new("RINCON_COORD");
2333        let member = SpeakerId::new("RINCON_MEMBER");
2334        let group_id = GroupId::new("RINCON_COORD:1");
2335
2336        store.add_speaker(SpeakerInfo {
2337            id: coordinator.clone(),
2338            name: "Coord".to_string(),
2339            room_name: "Coord".to_string(),
2340            ip_address: "192.168.1.100".parse().unwrap(),
2341            port: 1400,
2342            model_name: "Test".to_string(),
2343            software_version: "1.0".to_string(),
2344            boot_seq: 0,
2345            satellites: vec![],
2346        });
2347        store.add_speaker(SpeakerInfo {
2348            id: member.clone(),
2349            name: "Member".to_string(),
2350            room_name: "Member".to_string(),
2351            ip_address: "192.168.1.101".parse().unwrap(),
2352            port: 1400,
2353            model_name: "Test".to_string(),
2354            software_version: "1.0".to_string(),
2355            boot_seq: 0,
2356            satellites: vec![],
2357        });
2358        store.add_group(GroupInfo::new(
2359            group_id,
2360            coordinator.clone(),
2361            vec![coordinator.clone(), member.clone()],
2362        ));
2363
2364        // Set Volume on coordinator only (PerSpeaker service)
2365        store.set(&coordinator, Volume::new(80), test_stamp());
2366
2367        // get_resolved on member should NOT resolve to coordinator for PerSpeaker
2368        let resolved: Option<Volume> = store.get_resolved(&member);
2369        assert_eq!(resolved, None);
2370
2371        // get_resolved on coordinator returns its own value
2372        let coord_resolved: Option<Volume> = store.get_resolved(&coordinator);
2373        assert_eq!(coord_resolved, Some(Volume::new(80)));
2374    }
2375
2376    #[test]
2377    fn test_update_speaker_ip() {
2378        let manager = StateManager::new().unwrap();
2379
2380        let devices = vec![Device {
2381            id: "RINCON_111".to_string(),
2382            name: "Office".to_string(),
2383            room_name: "Office".to_string(),
2384            ip_address: "192.168.4.198".to_string(),
2385            port: 1400,
2386            model_name: "Roam 2".to_string(),
2387        }];
2388        manager.add_devices(devices).unwrap();
2389
2390        let speaker_id = SpeakerId::new("RINCON_111");
2391        let old_ip: IpAddr = "192.168.4.198".parse().unwrap();
2392        let new_ip: IpAddr = "192.168.4.200".parse().unwrap();
2393
2394        // Verify initial state
2395        assert_eq!(manager.get_speaker_ip(&speaker_id), Some(old_ip));
2396
2397        // Update IP
2398        manager.update_speaker_ip(&speaker_id, new_ip);
2399
2400        // Verify forward map updated
2401        assert_eq!(manager.get_speaker_ip(&speaker_id), Some(new_ip));
2402
2403        // Verify reverse map updated (old IP removed, new IP present)
2404        let ip_map = manager.ip_to_speaker.read();
2405        assert!(!ip_map.contains_key(&old_ip));
2406        assert_eq!(ip_map.get(&new_ip), Some(&speaker_id));
2407    }
2408
2409    #[test]
2410    fn test_update_speaker_ip_no_change() {
2411        let manager = StateManager::new().unwrap();
2412
2413        let devices = vec![Device {
2414            id: "RINCON_111".to_string(),
2415            name: "Office".to_string(),
2416            room_name: "Office".to_string(),
2417            ip_address: "192.168.4.198".to_string(),
2418            port: 1400,
2419            model_name: "Roam 2".to_string(),
2420        }];
2421        manager.add_devices(devices).unwrap();
2422
2423        let speaker_id = SpeakerId::new("RINCON_111");
2424        let same_ip: IpAddr = "192.168.4.198".parse().unwrap();
2425
2426        // Update with same IP — should be a no-op
2427        manager.update_speaker_ip(&speaker_id, same_ip);
2428        assert_eq!(manager.get_speaker_ip(&speaker_id), Some(same_ip));
2429    }
2430
2431    #[test]
2432    fn test_satellite_ids() {
2433        let manager = StateManager::new().unwrap();
2434
2435        assert!(manager.get_satellite_ids().is_empty());
2436
2437        let ids = vec![SpeakerId::new("RINCON_SAT1"), SpeakerId::new("RINCON_SAT2")];
2438        manager.set_satellite_ids(ids.clone());
2439
2440        let stored = manager.get_satellite_ids();
2441        assert_eq!(stored.len(), 2);
2442        assert!(stored.contains(&SpeakerId::new("RINCON_SAT1")));
2443        assert!(stored.contains(&SpeakerId::new("RINCON_SAT2")));
2444    }
2445
2446    // ========================================================================
2447    // Change events carry values / monotonic write ordering
2448    // ========================================================================
2449
2450    /// The headline win: a queued burst is fully observable.
2451    ///
2452    /// `Playing -> Transitioning -> Playing` is three events but only one final
2453    /// store value. A consumer that drained the queue and re-read the store saw
2454    /// `Playing` three times — the `Transitioning` state, and the fact that
2455    /// anything moved at all, were unrecoverable. Carrying the value on the
2456    /// event makes the whole sequence visible.
2457    ///
2458    /// Deliberately does *not* assert on the store: the store holding the final
2459    /// `Playing` is correct and unchanged. The point is that the channel now
2460    /// preserves what the store cannot.
2461    #[test]
2462    fn test_queued_events_preserve_every_intermediate_value() {
2463        let manager = StateManager::new().unwrap();
2464        manager
2465            .add_devices(vec![Device {
2466                id: "RINCON_QUEUE".to_string(),
2467                name: "Queue Test".to_string(),
2468                room_name: "Test".to_string(),
2469                ip_address: "192.0.2.10".to_string(),
2470                port: 1400,
2471                model_name: "Sonos One".to_string(),
2472            }])
2473            .unwrap();
2474
2475        let speaker_id = SpeakerId::new("RINCON_QUEUE");
2476        manager.register_watch(&speaker_id, PlaybackState::KEY);
2477
2478        let iter = manager.iter();
2479
2480        // Queue all three transitions *before* reading any of them, which is
2481        // exactly the render-loop-behind-by-a-frame case.
2482        manager.set_property(&speaker_id, PlaybackState::Playing);
2483        manager.set_property(&speaker_id, PlaybackState::Transitioning);
2484        manager.set_property(&speaker_id, PlaybackState::Playing);
2485        let observed: Vec<PlaybackState> = iter
2486            .try_iter()
2487            .filter_map(|e| match e.change {
2488                PropertyChange::PlaybackState(s) => Some(s),
2489                _ => None,
2490            })
2491            .collect();
2492
2493        assert_eq!(
2494            observed,
2495            vec![
2496                PlaybackState::Playing,
2497                PlaybackState::Transitioning,
2498                PlaybackState::Playing,
2499            ],
2500            "every queued value must be observable from the event stream"
2501        );
2502
2503        // And the store still holds only the final value — which is why the
2504        // event payload is the only way to see the middle one.
2505        assert_eq!(
2506            manager.get_property::<PlaybackState>(&speaker_id),
2507            Some(PlaybackState::Playing)
2508        );
2509    }
2510
2511    /// A manager with one speaker watched for `Volume`, ready to emit.
2512    fn manager_watching_volume() -> (StateManager, SpeakerId) {
2513        let manager = StateManager::new().unwrap();
2514        manager
2515            .add_devices(vec![Device {
2516                id: "RINCON_FANOUT".to_string(),
2517                name: "Living Room".to_string(),
2518                room_name: "Living Room".to_string(),
2519                // RFC 5737 TEST-NET-1: documentation-only, never routed.
2520                ip_address: "192.0.2.10".to_string(),
2521                port: 1400,
2522                model_name: "Sonos One".to_string(),
2523            }])
2524            .unwrap();
2525        let speaker_id = SpeakerId::new("RINCON_FANOUT");
2526        manager.register_watch(&speaker_id, Volume::KEY);
2527        (manager, speaker_id)
2528    }
2529
2530    fn volumes_from(iter: &ChangeIterator) -> Vec<u8> {
2531        iter.try_iter()
2532            .filter_map(|e| match e.change {
2533                PropertyChange::Volume(v) => Some(v.value()),
2534                _ => None,
2535            })
2536            .collect()
2537    }
2538
2539    /// **The defect this fan-out exists to fix.** Two independent `iter()` loops
2540    /// must each see the *whole* stream.
2541    ///
2542    /// Previously both iterators locked one shared receiver, so every event went
2543    /// to whichever consumer won the lock and each saw only a random subset —
2544    /// silently, with no error and no log. A dashboard that added a second event
2545    /// loop simply started missing half its updates.
2546    #[test]
2547    fn test_two_iterators_each_receive_every_event() {
2548        let (manager, speaker_id) = manager_watching_volume();
2549
2550        let dashboard = manager.iter();
2551        let logger = manager.iter();
2552
2553        for v in [10u8, 20, 30, 40] {
2554            manager.set_property(&speaker_id, Volume::new(v));
2555        }
2556
2557        // Neither consumer is missing anything, and neither stole from the other.
2558        assert_eq!(
2559            volumes_from(&dashboard),
2560            vec![10, 20, 30, 40],
2561            "the first iterator must see every event"
2562        );
2563        assert_eq!(
2564            volumes_from(&logger),
2565            vec![10, 20, 30, 40],
2566            "the second iterator must see every event too, not a subset"
2567        );
2568    }
2569
2570    /// No-regression baseline: one consumer still receives every event, in the
2571    /// order it was emitted. Fanning out must not reorder or drop anything, which
2572    /// is what keeps the observation-time ordering of 4.1a meaningful downstream.
2573    #[test]
2574    fn test_single_iterator_receives_every_event_in_order() {
2575        let (manager, speaker_id) = manager_watching_volume();
2576
2577        let iter = manager.iter();
2578        let sent: Vec<u8> = (1..=25).collect();
2579        for &v in &sent {
2580            manager.set_property(&speaker_id, Volume::new(v));
2581        }
2582
2583        assert_eq!(volumes_from(&iter), sent);
2584    }
2585
2586    /// Dropping one consumer must neither stall the survivor nor leak its slot.
2587    ///
2588    /// The departed iterator's queue is released, and the remaining one keeps
2589    /// receiving — the sender side does not wedge on a dead subscriber or keep
2590    /// feeding it forever.
2591    #[test]
2592    fn test_dropped_consumer_does_not_stall_survivor() {
2593        let (manager, speaker_id) = manager_watching_volume();
2594
2595        let survivor = manager.iter();
2596        let departing = manager.iter();
2597
2598        manager.set_property(&speaker_id, Volume::new(5));
2599        drop(departing);
2600
2601        // The survivor still gets everything, before and after the departure.
2602        manager.set_property(&speaker_id, Volume::new(6));
2603        manager.set_property(&speaker_id, Volume::new(7));
2604
2605        assert_eq!(
2606            volumes_from(&survivor),
2607            vec![5, 6, 7],
2608            "the remaining consumer must keep receiving after a sibling drops"
2609        );
2610
2611        // And a fresh subscriber still works, so the registry is not corrupted.
2612        let latecomer = manager.iter();
2613        manager.set_property(&speaker_id, Volume::new(8));
2614        assert_eq!(volumes_from(&latecomer), vec![8]);
2615    }
2616
2617    /// A slow `fetch()` must not overwrite a newer event-derived value.
2618    ///
2619    /// Simulates the real race by timestamp rather than by threads: the fetch
2620    /// observation is stamped *before* the event's, as it would be if the SOAP
2621    /// request were issued first and its response arrived second.
2622    #[test]
2623    fn test_stale_fetch_does_not_clobber_newer_event_value() {
2624        let manager = StateManager::new().unwrap();
2625        manager
2626            .add_devices(vec![Device {
2627                id: "RINCON_RACE".to_string(),
2628                name: "Race Test".to_string(),
2629                room_name: "Test".to_string(),
2630                ip_address: "192.0.2.11".to_string(),
2631                port: 1400,
2632                model_name: "Sonos One".to_string(),
2633            }])
2634            .unwrap();
2635
2636        let speaker_id = SpeakerId::new("RINCON_RACE");
2637
2638        // t0: a fetch is issued (observation made), but its response is still
2639        // in flight.
2640        let fetch_observed_at = Instant::now();
2641
2642        // t1: an event arrives and lands first with the newer, correct value.
2643        let event_outcome = manager.set_property_stamped(
2644            &speaker_id,
2645            Volume::new(40),
2646            WriteStamp::now(ChangeSource::Event),
2647        );
2648        assert_eq!(event_outcome, WriteOutcome::Changed);
2649
2650        // t2: the fetch response finally lands, carrying the *older* reading.
2651        let fetch_outcome = manager.set_property_stamped(
2652            &speaker_id,
2653            Volume::new(10),
2654            WriteStamp::observed_at(ChangeSource::Fetch, fetch_observed_at),
2655        );
2656
2657        assert_eq!(
2658            fetch_outcome,
2659            WriteOutcome::Stale,
2660            "a fetch observed before the stored event must be rejected"
2661        );
2662        assert_eq!(
2663            manager.get_property::<Volume>(&speaker_id),
2664            Some(Volume::new(40)),
2665            "the newer event value must survive the late fetch response"
2666        );
2667    }
2668
2669    /// The guard must not reject legitimately newer writes — otherwise the
2670    /// store would freeze after its first write and the test above would pass
2671    /// for the wrong reason.
2672    #[test]
2673    fn test_newer_write_is_accepted_after_an_earlier_one() {
2674        let manager = StateManager::new().unwrap();
2675        manager
2676            .add_devices(vec![Device {
2677                id: "RINCON_FWD".to_string(),
2678                name: "Forward Test".to_string(),
2679                room_name: "Test".to_string(),
2680                ip_address: "192.0.2.12".to_string(),
2681                port: 1400,
2682                model_name: "Sonos One".to_string(),
2683            }])
2684            .unwrap();
2685
2686        let speaker_id = SpeakerId::new("RINCON_FWD");
2687        let early = Instant::now();
2688
2689        assert_eq!(
2690            manager.set_property_stamped(
2691                &speaker_id,
2692                Volume::new(10),
2693                WriteStamp::observed_at(ChangeSource::Fetch, early),
2694            ),
2695            WriteOutcome::Changed
2696        );
2697
2698        // A later fetch, correctly ordered, wins.
2699        assert_eq!(
2700            manager.set_property_stamped(
2701                &speaker_id,
2702                Volume::new(20),
2703                WriteStamp::now(ChangeSource::Fetch),
2704            ),
2705            WriteOutcome::Changed
2706        );
2707        assert_eq!(
2708            manager.get_property::<Volume>(&speaker_id),
2709            Some(Volume::new(20))
2710        );
2711    }
2712}