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