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