Skip to main content

sonos_sdk/
speaker.rs

1//! Speaker handle with property accessors
2//!
3//! Provides a DOM-like interface for accessing speaker properties.
4//!
5//! ## Write Operations and State Cache
6//!
7//! Write methods (e.g., `play()`, `set_volume()`) update the state cache
8//! optimistically after the SOAP call succeeds. This means `speaker.volume.get()`
9//! reflects the written value immediately. However, if the speaker rejects the
10//! command silently, the cache may be stale until the next UPnP event corrects it.
11//! Use `speaker.volume.watch()` for authoritative real-time state.
12
13use std::net::IpAddr;
14use std::sync::Arc;
15
16use sonos_api::SonosClient;
17use sonos_discovery::Device;
18use sonos_state::{Bass, Loudness, Mute, PlaybackState, SpeakerId, StateManager, Treble, Volume};
19
20use crate::Group;
21
22use sonos_api::operation::{ComposableOperation, UPnPOperation, ValidationError};
23use sonos_api::services::{
24    av_transport::{
25        self, AddURIToQueueResponse, BecomeCoordinatorOfStandaloneGroupResponse,
26        CreateSavedQueueResponse, GetCrossfadeModeResponse, GetCurrentTransportActionsResponse,
27        GetDeviceCapabilitiesResponse, GetMediaInfoResponse,
28        GetRemainingSleepTimerDurationResponse, GetRunningAlarmPropertiesResponse,
29        GetTransportSettingsResponse, RemoveTrackRangeFromQueueResponse, SaveQueueResponse,
30    },
31    rendering_control::{self, SetRelativeVolumeResponse},
32};
33
34use crate::SdkError;
35
36/// Seek target for the `seek()` method
37///
38/// Combines the seek unit and target value into a single type-safe enum,
39/// preventing mismatched unit/target combinations.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum SeekTarget {
42    /// Seek to a track number (1-based)
43    Track(u32),
44    /// Seek to an absolute time position (e.g., `"0:02:30"`)
45    Time(String),
46    /// Seek by a time delta (e.g., `"+0:00:30"` or `"-0:00:10"`)
47    Delta(String),
48}
49
50impl SeekTarget {
51    /// Returns the UPnP seek unit string
52    fn unit(&self) -> &str {
53        match self {
54            SeekTarget::Track(_) => "TRACK_NR",
55            SeekTarget::Time(_) => "REL_TIME",
56            SeekTarget::Delta(_) => "TIME_DELTA",
57        }
58    }
59
60    /// Returns the UPnP seek target string
61    fn target(&self) -> String {
62        match self {
63            SeekTarget::Track(n) => n.to_string(),
64            SeekTarget::Time(t) => t.clone(),
65            SeekTarget::Delta(d) => d.clone(),
66        }
67    }
68}
69
70/// Play mode for the `set_play_mode()` method
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum PlayMode {
73    /// Normal sequential playback
74    Normal,
75    /// Repeat all tracks
76    RepeatAll,
77    /// Repeat current track
78    RepeatOne,
79    /// Shuffle without repeat
80    ShuffleNoRepeat,
81    /// Shuffle with repeat
82    Shuffle,
83    /// Shuffle and repeat current track
84    ShuffleRepeatOne,
85}
86
87impl std::fmt::Display for PlayMode {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            PlayMode::Normal => write!(f, "NORMAL"),
91            PlayMode::RepeatAll => write!(f, "REPEAT_ALL"),
92            PlayMode::RepeatOne => write!(f, "REPEAT_ONE"),
93            PlayMode::ShuffleNoRepeat => write!(f, "SHUFFLE_NOREPEAT"),
94            PlayMode::Shuffle => write!(f, "SHUFFLE"),
95            PlayMode::ShuffleRepeatOne => write!(f, "SHUFFLE_REPEAT_ONE"),
96        }
97    }
98}
99
100use crate::property::{
101    BassHandle, CurrentTrackHandle, EventInitFn, GroupMembershipHandle, LoudnessHandle, MuteHandle,
102    PlaybackStateHandle, PositionHandle, PropertyHandle, SpeakerContext, TrebleHandle,
103    VolumeHandle,
104};
105
106/// Speaker handle with property access
107///
108/// Provides direct access to speaker properties through property handles.
109/// Each property handle provides `get()`, `fetch()`, `watch()`, and `unwatch()` methods.
110///
111/// # Example
112///
113/// ```rust,ignore
114/// // Get cached volume
115/// let volume = speaker.volume.get();
116///
117/// // Fetch fresh volume from device
118/// let fresh_volume = speaker.volume.fetch()?;
119///
120/// // Watch for volume changes — hold the handle to keep the subscription alive
121/// let _vol = speaker.volume.watch()?;
122/// ```
123#[derive(Clone)]
124pub struct Speaker {
125    /// Unique speaker identifier
126    pub id: SpeakerId,
127    /// Friendly name of the speaker
128    pub name: String,
129    /// IP address of the speaker
130    pub ip: IpAddr,
131    /// Model name of the speaker (e.g., "Sonos One", "Sonos Beam")
132    pub model_name: String,
133
134    // ========================================================================
135    // RenderingControl properties
136    // ========================================================================
137    /// Volume property (0-100)
138    pub volume: VolumeHandle,
139    /// Mute state (true = muted)
140    pub mute: MuteHandle,
141    /// Bass EQ setting (-10 to +10)
142    pub bass: BassHandle,
143    /// Treble EQ setting (-10 to +10)
144    pub treble: TrebleHandle,
145    /// Loudness compensation setting
146    pub loudness: LoudnessHandle,
147
148    // ========================================================================
149    // AVTransport properties
150    // ========================================================================
151    /// Playback state (Playing/Paused/Stopped/Transitioning)
152    pub playback_state: PlaybackStateHandle,
153    /// Current playback position and duration
154    pub position: PositionHandle,
155    /// Current track information (title, artist, album, etc.)
156    pub current_track: CurrentTrackHandle,
157
158    // ========================================================================
159    // ZoneGroupTopology properties
160    // ========================================================================
161    /// Group membership information (group_id, is_coordinator)
162    pub group_membership: GroupMembershipHandle,
163
164    // Internal context shared with property handles
165    context: Arc<SpeakerContext>,
166}
167
168impl Speaker {
169    /// Create a Speaker from a discovered Device
170    ///
171    /// This is the preferred way to create a Speaker when you have a Device
172    /// from discovery. It handles IP address parsing and extracts all relevant
173    /// fields from the Device struct.
174    ///
175    /// # Example
176    ///
177    /// ```rust,ignore
178    /// let devices = sonos_discovery::get();
179    /// for device in devices {
180    ///     let speaker = Speaker::from_device(&device, state_manager.clone(), api_client.clone())?;
181    ///     println!("Created speaker: {}", speaker.name);
182    /// }
183    /// ```
184    pub fn from_device(
185        device: &Device,
186        state_manager: Arc<StateManager>,
187        api_client: SonosClient,
188    ) -> Result<Self, SdkError> {
189        let ip: IpAddr = device
190            .ip_address
191            .parse()
192            .map_err(|_| SdkError::InvalidIpAddress)?;
193
194        let name = if device.room_name.is_empty() || device.room_name == "Unknown" {
195            device.name.clone()
196        } else {
197            device.room_name.clone()
198        };
199
200        Ok(Self::new(
201            SpeakerId::new(&device.id),
202            name,
203            ip,
204            device.model_name.clone(),
205            state_manager,
206            api_client,
207        ))
208    }
209
210    /// Create a new Speaker handle
211    ///
212    /// For most use cases, prefer [`Speaker::from_device()`] which handles
213    /// IP parsing and extracts fields from a Device struct.
214    pub fn new(
215        id: SpeakerId,
216        name: String,
217        ip: IpAddr,
218        model_name: String,
219        state_manager: Arc<StateManager>,
220        api_client: SonosClient,
221    ) -> Self {
222        Self::new_with_event_init(id, name, ip, model_name, state_manager, api_client, None)
223    }
224
225    /// Create a new Speaker handle with an optional event init closure
226    ///
227    /// When `event_init` is provided, calling `watch()` on any property will
228    /// trigger lazy event manager initialization on first use.
229    pub(crate) fn new_with_event_init(
230        id: SpeakerId,
231        name: String,
232        ip: IpAddr,
233        model_name: String,
234        state_manager: Arc<StateManager>,
235        api_client: SonosClient,
236        event_init: Option<EventInitFn>,
237    ) -> Self {
238        let context = match event_init {
239            Some(init) => {
240                SpeakerContext::with_event_init(id.clone(), ip, state_manager, api_client, init)
241            }
242            None => SpeakerContext::new(id.clone(), ip, state_manager, api_client),
243        };
244
245        Self {
246            id,
247            name,
248            ip,
249            model_name,
250            // RenderingControl properties
251            volume: PropertyHandle::new(Arc::clone(&context)),
252            mute: PropertyHandle::new(Arc::clone(&context)),
253            bass: PropertyHandle::new(Arc::clone(&context)),
254            treble: PropertyHandle::new(Arc::clone(&context)),
255            loudness: PropertyHandle::new(Arc::clone(&context)),
256            // AVTransport properties
257            playback_state: PropertyHandle::new(Arc::clone(&context)),
258            position: PropertyHandle::new(Arc::clone(&context)),
259            current_track: PropertyHandle::new(Arc::clone(&context)),
260            // ZoneGroupTopology properties
261            group_membership: PropertyHandle::new(Arc::clone(&context)),
262            // Internal
263            context,
264        }
265    }
266
267    // ========================================================================
268    // Navigation
269    // ========================================================================
270
271    /// Get the group this speaker belongs to (sync, no network call)
272    ///
273    /// Reads from the state store's topology data. Returns `None` if
274    /// topology has not been loaded yet.
275    ///
276    /// # Example
277    ///
278    /// ```rust,ignore
279    /// let kitchen = sonos.speaker("Kitchen").unwrap();
280    /// if let Some(group) = kitchen.group() {
281    ///     println!("Kitchen is in group {}", group.id);
282    /// }
283    /// ```
284    pub fn group(&self) -> Option<Group> {
285        let info = self
286            .context
287            .state_manager
288            .get_group_for_speaker(&self.context.speaker_id)?;
289        Group::from_info(
290            info,
291            Arc::clone(&self.context.state_manager),
292            self.context.api_client.clone(),
293        )
294    }
295
296    // ========================================================================
297    // Private helpers
298    // ========================================================================
299
300    /// Execute a UPnP operation against this speaker
301    fn exec<Op: UPnPOperation>(
302        &self,
303        operation: Result<ComposableOperation<Op>, ValidationError>,
304    ) -> Result<Op::Response, SdkError> {
305        let op = operation?;
306        self.context
307            .api_client
308            .execute_enhanced(&self.context.speaker_ip.to_string(), op)
309            .map_err(SdkError::ApiError)
310    }
311
312    // ========================================================================
313    // AVTransport — Basic playback
314    // ========================================================================
315
316    /// Start or resume playback
317    ///
318    /// Updates the state cache to `PlaybackState::Playing` on success.
319    pub fn play(&self) -> Result<(), SdkError> {
320        self.exec(av_transport::play("1".to_string()).build())?;
321        self.context
322            .state_manager
323            .set_property(&self.context.speaker_id, PlaybackState::Playing);
324        Ok(())
325    }
326
327    /// Pause playback
328    ///
329    /// Updates the state cache to `PlaybackState::Paused` on success.
330    pub fn pause(&self) -> Result<(), SdkError> {
331        self.exec(av_transport::pause().build())?;
332        self.context
333            .state_manager
334            .set_property(&self.context.speaker_id, PlaybackState::Paused);
335        Ok(())
336    }
337
338    /// Stop playback
339    ///
340    /// Updates the state cache to `PlaybackState::Stopped` on success.
341    pub fn stop(&self) -> Result<(), SdkError> {
342        self.exec(av_transport::stop().build())?;
343        self.context
344            .state_manager
345            .set_property(&self.context.speaker_id, PlaybackState::Stopped);
346        Ok(())
347    }
348
349    /// Skip to next track
350    pub fn next(&self) -> Result<(), SdkError> {
351        self.exec(av_transport::next().build())?;
352        Ok(())
353    }
354
355    /// Skip to previous track
356    pub fn previous(&self) -> Result<(), SdkError> {
357        self.exec(av_transport::previous().build())?;
358        Ok(())
359    }
360
361    // ========================================================================
362    // AVTransport — Seek
363    // ========================================================================
364
365    /// Seek to a position
366    ///
367    /// # Example
368    ///
369    /// ```rust,ignore
370    /// speaker.seek(SeekTarget::Time("0:02:30".into()))?;  // Seek to 2:30
371    /// speaker.seek(SeekTarget::Track(3))?;                 // Seek to track 3
372    /// speaker.seek(SeekTarget::Delta("+0:00:30".into()))?; // Skip forward 30s
373    /// ```
374    pub fn seek(&self, target: SeekTarget) -> Result<(), SdkError> {
375        self.exec(av_transport::seek(target.unit().to_string(), target.target()).build())?;
376        Ok(())
377    }
378
379    // ========================================================================
380    // AVTransport — URI setting
381    // ========================================================================
382
383    /// Set the current transport URI
384    pub fn set_av_transport_uri(&self, uri: &str, metadata: &str) -> Result<(), SdkError> {
385        self.exec(
386            av_transport::set_av_transport_uri(uri.to_string(), metadata.to_string()).build(),
387        )?;
388        Ok(())
389    }
390
391    /// Set the next transport URI (for gapless playback)
392    pub fn set_next_av_transport_uri(&self, uri: &str, metadata: &str) -> Result<(), SdkError> {
393        self.exec(
394            av_transport::set_next_av_transport_uri(uri.to_string(), metadata.to_string()).build(),
395        )?;
396        Ok(())
397    }
398
399    // ========================================================================
400    // AVTransport — Info queries
401    // ========================================================================
402
403    /// Get media info (number of tracks, duration, URI, etc.)
404    pub fn get_media_info(&self) -> Result<GetMediaInfoResponse, SdkError> {
405        self.exec(av_transport::get_media_info().build())
406    }
407
408    /// Get transport settings (play mode, recording quality)
409    pub fn get_transport_settings(&self) -> Result<GetTransportSettingsResponse, SdkError> {
410        self.exec(av_transport::get_transport_settings().build())
411    }
412
413    /// Get currently available transport actions
414    pub fn get_current_transport_actions(
415        &self,
416    ) -> Result<GetCurrentTransportActionsResponse, SdkError> {
417        self.exec(av_transport::get_current_transport_actions().build())
418    }
419
420    // ========================================================================
421    // AVTransport — Play mode / crossfade
422    // ========================================================================
423
424    /// Set play mode
425    ///
426    /// # Example
427    ///
428    /// ```rust,ignore
429    /// speaker.set_play_mode(PlayMode::Shuffle)?;
430    /// speaker.set_play_mode(PlayMode::RepeatAll)?;
431    /// ```
432    pub fn set_play_mode(&self, mode: PlayMode) -> Result<(), SdkError> {
433        self.exec(av_transport::set_play_mode(mode.to_string()).build())?;
434        Ok(())
435    }
436
437    /// Get crossfade mode
438    pub fn get_crossfade_mode(&self) -> Result<GetCrossfadeModeResponse, SdkError> {
439        self.exec(av_transport::get_crossfade_mode().build())
440    }
441
442    /// Set crossfade mode
443    pub fn set_crossfade_mode(&self, enabled: bool) -> Result<(), SdkError> {
444        self.exec(av_transport::set_crossfade_mode(enabled).build())?;
445        Ok(())
446    }
447
448    // ========================================================================
449    // AVTransport — Sleep timer
450    // ========================================================================
451
452    /// Configure sleep timer (e.g., `"01:00:00"` for 1 hour, `""` to cancel)
453    pub fn configure_sleep_timer(&self, duration: &str) -> Result<(), SdkError> {
454        self.exec(av_transport::configure_sleep_timer(duration.to_string()).build())?;
455        Ok(())
456    }
457
458    /// Cancel an active sleep timer
459    pub fn cancel_sleep_timer(&self) -> Result<(), SdkError> {
460        self.configure_sleep_timer("")
461    }
462
463    /// Get remaining sleep timer duration
464    pub fn get_remaining_sleep_timer(
465        &self,
466    ) -> Result<GetRemainingSleepTimerDurationResponse, SdkError> {
467        self.exec(av_transport::get_remaining_sleep_timer_duration().build())
468    }
469
470    // ========================================================================
471    // AVTransport — Queue operations
472    // ========================================================================
473
474    /// Add a URI to the queue
475    pub fn add_uri_to_queue(
476        &self,
477        uri: &str,
478        metadata: &str,
479        position: u32,
480        enqueue_as_next: bool,
481    ) -> Result<AddURIToQueueResponse, SdkError> {
482        self.exec(
483            av_transport::add_uri_to_queue(
484                uri.to_string(),
485                metadata.to_string(),
486                position,
487                enqueue_as_next,
488            )
489            .build(),
490        )
491    }
492
493    /// Remove a track from the queue
494    pub fn remove_track_from_queue(&self, object_id: &str, update_id: u32) -> Result<(), SdkError> {
495        self.exec(av_transport::remove_track_from_queue(object_id.to_string(), update_id).build())?;
496        Ok(())
497    }
498
499    /// Remove all tracks from the queue
500    pub fn remove_all_tracks_from_queue(&self) -> Result<(), SdkError> {
501        self.exec(av_transport::remove_all_tracks_from_queue().build())?;
502        Ok(())
503    }
504
505    /// Save the current queue as a Sonos playlist
506    pub fn save_queue(&self, title: &str, object_id: &str) -> Result<SaveQueueResponse, SdkError> {
507        self.exec(av_transport::save_queue(title.to_string(), object_id.to_string()).build())
508    }
509
510    /// Create a new saved queue (playlist) with a URI
511    pub fn create_saved_queue(
512        &self,
513        title: &str,
514        uri: &str,
515        metadata: &str,
516    ) -> Result<CreateSavedQueueResponse, SdkError> {
517        self.exec(
518            av_transport::create_saved_queue(
519                title.to_string(),
520                uri.to_string(),
521                metadata.to_string(),
522            )
523            .build(),
524        )
525    }
526
527    /// Remove a range of tracks from the queue
528    pub fn remove_track_range_from_queue(
529        &self,
530        update_id: u32,
531        starting_index: u32,
532        number_of_tracks: u32,
533    ) -> Result<RemoveTrackRangeFromQueueResponse, SdkError> {
534        self.exec(
535            av_transport::remove_track_range_from_queue(
536                update_id,
537                starting_index,
538                number_of_tracks,
539            )
540            .build(),
541        )
542    }
543
544    /// Backup the current queue
545    pub fn backup_queue(&self) -> Result<(), SdkError> {
546        self.exec(av_transport::backup_queue().build())?;
547        Ok(())
548    }
549
550    // ========================================================================
551    // AVTransport — Device capabilities
552    // ========================================================================
553
554    /// Get device capabilities (supported media formats)
555    pub fn get_device_capabilities(&self) -> Result<GetDeviceCapabilitiesResponse, SdkError> {
556        self.exec(av_transport::get_device_capabilities().build())
557    }
558
559    // ========================================================================
560    // AVTransport — Alarm operations
561    // ========================================================================
562
563    /// Snooze the currently running alarm
564    pub fn snooze_alarm(&self, duration: &str) -> Result<(), SdkError> {
565        self.exec(av_transport::snooze_alarm(duration.to_string()).build())?;
566        Ok(())
567    }
568
569    /// Get properties of the currently running alarm
570    pub fn get_running_alarm_properties(
571        &self,
572    ) -> Result<GetRunningAlarmPropertiesResponse, SdkError> {
573        self.exec(av_transport::get_running_alarm_properties().build())
574    }
575
576    // ========================================================================
577    // AVTransport — Group coordination
578    // ========================================================================
579
580    /// Leave current group and become a standalone player
581    pub fn become_standalone(
582        &self,
583    ) -> Result<BecomeCoordinatorOfStandaloneGroupResponse, SdkError> {
584        self.exec(av_transport::become_coordinator_of_standalone_group().build())
585    }
586
587    /// Delegate group coordination to another speaker
588    pub fn delegate_coordination_to(
589        &self,
590        new_coordinator: &SpeakerId,
591        rejoin_group: bool,
592    ) -> Result<(), SdkError> {
593        self.exec(
594            av_transport::delegate_group_coordination_to(
595                new_coordinator.as_str().to_string(),
596                rejoin_group,
597            )
598            .build(),
599        )?;
600        Ok(())
601    }
602
603    /// Join a group (convenience wrapper for `group.add_speaker(self)`)
604    ///
605    /// Adds this speaker to the specified group. After calling this,
606    /// re-fetch groups via `system.groups()` to see updated membership.
607    pub fn join_group(&self, group: &Group) -> Result<(), SdkError> {
608        group.add_speaker(self)
609    }
610
611    /// Leave current group and become a standalone player
612    ///
613    /// Semantic alias for [`become_standalone()`](Self::become_standalone).
614    /// After calling this, the speaker forms its own group of one.
615    pub fn leave_group(&self) -> Result<BecomeCoordinatorOfStandaloneGroupResponse, SdkError> {
616        self.become_standalone()
617    }
618
619    // ========================================================================
620    // RenderingControl — Volume and EQ
621    // ========================================================================
622
623    /// Set speaker volume (0-100)
624    ///
625    /// Updates the state cache to the new `Volume` on success.
626    pub fn set_volume(&self, volume: u8) -> Result<(), SdkError> {
627        self.exec(rendering_control::set_volume("Master".to_string(), volume).build())?;
628        self.context
629            .state_manager
630            .set_property(&self.context.speaker_id, Volume(volume));
631        Ok(())
632    }
633
634    /// Adjust volume relative to current level
635    ///
636    /// Returns the new absolute volume.
637    pub fn set_relative_volume(
638        &self,
639        adjustment: i8,
640    ) -> Result<SetRelativeVolumeResponse, SdkError> {
641        let response = self.exec(
642            rendering_control::set_relative_volume("Master".to_string(), adjustment).build(),
643        )?;
644        self.context
645            .state_manager
646            .set_property(&self.context.speaker_id, Volume(response.new_volume));
647        Ok(response)
648    }
649
650    /// Set mute state
651    ///
652    /// Updates the state cache to the new `Mute` value on success.
653    pub fn set_mute(&self, muted: bool) -> Result<(), SdkError> {
654        self.exec(rendering_control::set_mute("Master".to_string(), muted).build())?;
655        self.context
656            .state_manager
657            .set_property(&self.context.speaker_id, Mute(muted));
658        Ok(())
659    }
660
661    /// Set bass EQ level (-10 to +10)
662    pub fn set_bass(&self, level: i8) -> Result<(), SdkError> {
663        self.exec(rendering_control::set_bass(level).build())?;
664        self.context
665            .state_manager
666            .set_property(&self.context.speaker_id, Bass(level));
667        Ok(())
668    }
669
670    /// Set treble EQ level (-10 to +10)
671    pub fn set_treble(&self, level: i8) -> Result<(), SdkError> {
672        self.exec(rendering_control::set_treble(level).build())?;
673        self.context
674            .state_manager
675            .set_property(&self.context.speaker_id, Treble(level));
676        Ok(())
677    }
678
679    /// Set loudness compensation
680    pub fn set_loudness(&self, enabled: bool) -> Result<(), SdkError> {
681        self.exec(rendering_control::set_loudness("Master".to_string(), enabled).build())?;
682        self.context
683            .state_manager
684            .set_property(&self.context.speaker_id, Loudness(enabled));
685        Ok(())
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use sonos_discovery::Device;
693
694    fn create_test_speaker() -> Speaker {
695        let manager = StateManager::new().unwrap();
696        let devices = vec![Device {
697            id: "RINCON_TEST123".to_string(),
698            name: "Test Speaker".to_string(),
699            room_name: "Test Room".to_string(),
700            ip_address: "192.168.1.100".to_string(),
701            port: 1400,
702            model_name: "Sonos One".to_string(),
703        }];
704        manager.add_devices(devices).unwrap();
705        let state_manager = Arc::new(manager);
706        let api_client = SonosClient::new();
707
708        Speaker::new(
709            SpeakerId::new("RINCON_TEST123"),
710            "Test Speaker".to_string(),
711            "192.168.1.100".parse().unwrap(),
712            "Sonos One".to_string(),
713            state_manager,
714            api_client,
715        )
716    }
717
718    #[test]
719    fn test_set_volume_rejects_invalid() {
720        let speaker = create_test_speaker();
721        let result = speaker.set_volume(150);
722        assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
723    }
724
725    #[test]
726    fn test_set_bass_rejects_invalid() {
727        let speaker = create_test_speaker();
728        let result = speaker.set_bass(15);
729        assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
730    }
731
732    #[test]
733    fn test_set_treble_rejects_invalid() {
734        let speaker = create_test_speaker();
735        let result = speaker.set_treble(-15);
736        assert!(matches!(result, Err(SdkError::ValidationFailed(_))));
737    }
738
739    #[test]
740    fn test_speaker_action_methods_exist() {
741        // Compile-time assertion that all method signatures are correct
742        fn assert_void(_r: Result<(), SdkError>) {}
743        fn assert_response<T>(_r: Result<T, SdkError>) {}
744
745        let speaker = create_test_speaker();
746
747        // AVTransport — these will fail at network level but prove signatures compile
748        assert_void(speaker.play());
749        assert_void(speaker.pause());
750        assert_void(speaker.stop());
751        assert_void(speaker.next());
752        assert_void(speaker.previous());
753        assert_void(speaker.seek(SeekTarget::Time("0:00:00".into())));
754        assert_void(speaker.set_av_transport_uri("", ""));
755        assert_void(speaker.set_next_av_transport_uri("", ""));
756        assert_response::<GetMediaInfoResponse>(speaker.get_media_info());
757        assert_response::<GetTransportSettingsResponse>(speaker.get_transport_settings());
758        assert_response::<GetCurrentTransportActionsResponse>(
759            speaker.get_current_transport_actions(),
760        );
761        assert_void(speaker.set_play_mode(PlayMode::Normal));
762        assert_response::<GetCrossfadeModeResponse>(speaker.get_crossfade_mode());
763        assert_void(speaker.set_crossfade_mode(true));
764        assert_void(speaker.configure_sleep_timer(""));
765        assert_void(speaker.cancel_sleep_timer());
766        assert_response::<GetRemainingSleepTimerDurationResponse>(
767            speaker.get_remaining_sleep_timer(),
768        );
769        assert_response::<AddURIToQueueResponse>(speaker.add_uri_to_queue("", "", 0, false));
770        assert_void(speaker.remove_track_from_queue("", 0));
771        assert_void(speaker.remove_all_tracks_from_queue());
772        assert_response::<SaveQueueResponse>(speaker.save_queue("", ""));
773        assert_response::<CreateSavedQueueResponse>(speaker.create_saved_queue("", "", ""));
774        assert_response::<RemoveTrackRangeFromQueueResponse>(
775            speaker.remove_track_range_from_queue(0, 0, 1),
776        );
777        assert_void(speaker.backup_queue());
778        assert_response::<GetDeviceCapabilitiesResponse>(speaker.get_device_capabilities());
779        assert_void(speaker.snooze_alarm("00:10:00"));
780        assert_response::<GetRunningAlarmPropertiesResponse>(
781            speaker.get_running_alarm_properties(),
782        );
783        assert_response::<BecomeCoordinatorOfStandaloneGroupResponse>(speaker.become_standalone());
784        assert_void(speaker.delegate_coordination_to(&SpeakerId::new("RINCON_OTHER"), false));
785
786        // RenderingControl
787        assert_void(speaker.set_volume(50));
788        assert_response::<SetRelativeVolumeResponse>(speaker.set_relative_volume(5));
789        assert_void(speaker.set_mute(true));
790        assert_void(speaker.set_bass(0));
791        assert_void(speaker.set_treble(0));
792        assert_void(speaker.set_loudness(true));
793
794        // Group convenience methods
795        let group = create_test_group_for_speaker(&speaker);
796        assert_void(speaker.join_group(&group));
797        assert_response::<BecomeCoordinatorOfStandaloneGroupResponse>(speaker.leave_group());
798    }
799
800    fn create_test_group_for_speaker(speaker: &Speaker) -> crate::Group {
801        use sonos_state::{GroupId, GroupInfo};
802        let state_manager = Arc::new(StateManager::new().unwrap());
803        let devices = vec![Device {
804            id: speaker.id.as_str().to_string(),
805            name: speaker.name.clone(),
806            room_name: speaker.name.clone(),
807            ip_address: speaker.ip.to_string(),
808            port: 1400,
809            model_name: speaker.model_name.clone(),
810        }];
811        state_manager.add_devices(devices).unwrap();
812
813        let group_info = GroupInfo::new(
814            GroupId::new(format!("{}:1", speaker.id.as_str())),
815            speaker.id.clone(),
816            vec![speaker.id.clone()],
817        );
818
819        crate::Group::from_info(group_info, state_manager, SonosClient::new()).unwrap()
820    }
821}