Skip to main content

sonos_api/services/av_transport/
operations.rs

1//! AVTransport service operations
2//!
3//! This module contains all UPnP operations for the AVTransport service,
4//! which controls playback, queue management, and transport settings.
5
6use crate::{define_operation_with_response, define_upnp_operation, Validate};
7use paste::paste;
8
9// =============================================================================
10// BASIC PLAYBACK CONTROL
11// =============================================================================
12
13define_upnp_operation! {
14    operation: PlayOperation,
15    action: "Play",
16    service: AVTransport,
17    request: {
18        speed: String,
19    },
20    response: (),
21    payload: |req| {
22        format!("<InstanceID>{}</InstanceID><Speed>{}</Speed>", req.instance_id, req.speed)
23    },
24    parse: |_xml| Ok(()),
25}
26
27impl Validate for PlayOperationRequest {
28    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
29        if self.speed.is_empty() {
30            return Err(crate::operation::ValidationError::invalid_value(
31                "speed",
32                &self.speed,
33            ));
34        }
35        match self.speed.as_str() {
36            "1" | "0" => Ok(()),
37            other => {
38                if other.parse::<f32>().is_ok() {
39                    Ok(())
40                } else {
41                    Err(crate::operation::ValidationError::Custom {
42                        parameter: "speed".to_string(),
43                        message: "Speed must be '1', '0', or a numeric value".to_string(),
44                    })
45                }
46            }
47        }
48    }
49}
50
51define_upnp_operation! {
52    operation: PauseOperation,
53    action: "Pause",
54    service: AVTransport,
55    request: {},
56    response: (),
57    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
58    parse: |_xml| Ok(()),
59}
60
61impl Validate for PauseOperationRequest {}
62
63define_upnp_operation! {
64    operation: StopOperation,
65    action: "Stop",
66    service: AVTransport,
67    request: {},
68    response: (),
69    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
70    parse: |_xml| Ok(()),
71}
72
73impl Validate for StopOperationRequest {}
74
75define_upnp_operation! {
76    operation: NextOperation,
77    action: "Next",
78    service: AVTransport,
79    request: {},
80    response: (),
81    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
82    parse: |_xml| Ok(()),
83}
84
85impl Validate for NextOperationRequest {}
86
87define_upnp_operation! {
88    operation: PreviousOperation,
89    action: "Previous",
90    service: AVTransport,
91    request: {},
92    response: (),
93    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
94    parse: |_xml| Ok(()),
95}
96
97impl Validate for PreviousOperationRequest {}
98
99// =============================================================================
100// SEEK AND POSITION
101// =============================================================================
102
103define_upnp_operation! {
104    operation: SeekOperation,
105    action: "Seek",
106    service: AVTransport,
107    request: {
108        unit: String,
109        target: String,
110    },
111    response: (),
112    payload: |req| {
113        format!(
114            "<InstanceID>{}</InstanceID><Unit>{}</Unit><Target>{}</Target>",
115            req.instance_id,
116            crate::operation::xml_escape(&req.unit),
117            crate::operation::xml_escape(&req.target)
118        )
119    },
120    parse: |_xml| Ok(()),
121}
122
123impl Validate for SeekOperationRequest {
124    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
125        match self.unit.as_str() {
126            "TRACK_NR" | "REL_TIME" | "TIME_DELTA" => Ok(()),
127            other => Err(crate::operation::ValidationError::Custom {
128                parameter: "unit".to_string(),
129                message: format!(
130                    "Invalid unit '{other}'. Must be 'TRACK_NR', 'REL_TIME', or 'TIME_DELTA'"
131                ),
132            }),
133        }
134    }
135}
136
137define_operation_with_response! {
138    operation: GetPositionInfoOperation,
139    action: "GetPositionInfo",
140    service: AVTransport,
141    request: {},
142    response: GetPositionInfoResponse {
143        track: u32,
144        track_duration: String,
145        track_meta_data: String,
146        track_uri: String,
147        rel_time: String,
148        abs_time: String,
149        rel_count: i32,
150        abs_count: i32,
151    },
152    xml_mapping: {
153        track: "Track",
154        track_duration: "TrackDuration",
155        track_meta_data: "TrackMetaData",
156        track_uri: "TrackURI",
157        rel_time: "RelTime",
158        abs_time: "AbsTime",
159        rel_count: "RelCount",
160        abs_count: "AbsCount",
161    },
162}
163
164impl Validate for GetPositionInfoOperationRequest {}
165
166// =============================================================================
167// TRANSPORT INFO AND SETTINGS
168// =============================================================================
169
170define_operation_with_response! {
171    operation: GetTransportInfoOperation,
172    action: "GetTransportInfo",
173    service: AVTransport,
174    request: {},
175    response: GetTransportInfoResponse {
176        current_transport_state: String,
177        current_transport_status: String,
178        current_speed: String,
179    },
180    xml_mapping: {
181        current_transport_state: "CurrentTransportState",
182        current_transport_status: "CurrentTransportStatus",
183        current_speed: "CurrentSpeed",
184    },
185}
186
187impl Validate for GetTransportInfoOperationRequest {}
188
189define_operation_with_response! {
190    operation: GetTransportSettingsOperation,
191    action: "GetTransportSettings",
192    service: AVTransport,
193    request: {},
194    response: GetTransportSettingsResponse {
195        play_mode: String,
196        rec_quality_mode: String,
197    },
198    xml_mapping: {
199        play_mode: "PlayMode",
200        rec_quality_mode: "RecQualityMode",
201    },
202}
203
204impl Validate for GetTransportSettingsOperationRequest {}
205
206define_operation_with_response! {
207    operation: GetCurrentTransportActionsOperation,
208    action: "GetCurrentTransportActions",
209    service: AVTransport,
210    request: {},
211    response: GetCurrentTransportActionsResponse {
212        actions: String,
213    },
214    xml_mapping: {
215        actions: "Actions",
216    },
217}
218
219impl Validate for GetCurrentTransportActionsOperationRequest {}
220
221define_operation_with_response! {
222    operation: GetDeviceCapabilitiesOperation,
223    action: "GetDeviceCapabilities",
224    service: AVTransport,
225    request: {},
226    response: GetDeviceCapabilitiesResponse {
227        play_media: String,
228        rec_media: String,
229        rec_quality_modes: String,
230    },
231    xml_mapping: {
232        play_media: "PlayMedia",
233        rec_media: "RecMedia",
234        rec_quality_modes: "RecQualityModes",
235    },
236}
237
238impl Validate for GetDeviceCapabilitiesOperationRequest {}
239
240// =============================================================================
241// MEDIA INFO AND URI SETTING
242// =============================================================================
243
244define_operation_with_response! {
245    operation: GetMediaInfoOperation,
246    action: "GetMediaInfo",
247    service: AVTransport,
248    request: {},
249    response: GetMediaInfoResponse {
250        nr_tracks: u32,
251        media_duration: String,
252        current_uri: String,
253        current_uri_meta_data: String,
254        next_uri: String,
255        next_uri_meta_data: String,
256        play_medium: String,
257        record_medium: String,
258        write_status: String,
259    },
260    xml_mapping: {
261        nr_tracks: "NrTracks",
262        media_duration: "MediaDuration",
263        current_uri: "CurrentURI",
264        current_uri_meta_data: "CurrentURIMetaData",
265        next_uri: "NextURI",
266        next_uri_meta_data: "NextURIMetaData",
267        play_medium: "PlayMedium",
268        record_medium: "RecordMedium",
269        write_status: "WriteStatus",
270    },
271}
272
273impl Validate for GetMediaInfoOperationRequest {}
274
275define_upnp_operation! {
276    operation: SetAVTransportURIOperation,
277    action: "SetAVTransportURI",
278    service: AVTransport,
279    request: {
280        current_uri: String,
281        current_uri_meta_data: String,
282    },
283    response: (),
284    payload: |req| {
285        format!(
286            "<InstanceID>{}</InstanceID><CurrentURI>{}</CurrentURI><CurrentURIMetaData>{}</CurrentURIMetaData>",
287            req.instance_id,
288            crate::operation::xml_escape(&req.current_uri),
289            crate::operation::xml_escape(&req.current_uri_meta_data)
290        )
291    },
292    parse: |_xml| Ok(()),
293}
294
295impl Validate for SetAVTransportURIOperationRequest {}
296
297define_upnp_operation! {
298    operation: SetNextAVTransportURIOperation,
299    action: "SetNextAVTransportURI",
300    service: AVTransport,
301    request: {
302        next_uri: String,
303        next_uri_meta_data: String,
304    },
305    response: (),
306    payload: |req| {
307        format!(
308            "<InstanceID>{}</InstanceID><NextURI>{}</NextURI><NextURIMetaData>{}</NextURIMetaData>",
309            req.instance_id,
310            crate::operation::xml_escape(&req.next_uri),
311            crate::operation::xml_escape(&req.next_uri_meta_data)
312        )
313    },
314    parse: |_xml| Ok(()),
315}
316
317impl Validate for SetNextAVTransportURIOperationRequest {}
318
319// =============================================================================
320// CROSSFADE AND PLAY MODE
321// =============================================================================
322
323define_operation_with_response! {
324    operation: GetCrossfadeModeOperation,
325    action: "GetCrossfadeMode",
326    service: AVTransport,
327    request: {},
328    response: GetCrossfadeModeResponse {
329        crossfade_mode: String,
330    },
331    xml_mapping: {
332        crossfade_mode: "CrossfadeMode",
333    },
334}
335
336impl Validate for GetCrossfadeModeOperationRequest {}
337
338define_upnp_operation! {
339    operation: SetCrossfadeModeOperation,
340    action: "SetCrossfadeMode",
341    service: AVTransport,
342    request: {
343        crossfade_mode: bool,
344    },
345    response: (),
346    payload: |req| {
347        format!(
348            "<InstanceID>{}</InstanceID><CrossfadeMode>{}</CrossfadeMode>",
349            req.instance_id,
350            if req.crossfade_mode { "1" } else { "0" }
351        )
352    },
353    parse: |_xml| Ok(()),
354}
355
356impl Validate for SetCrossfadeModeOperationRequest {}
357
358define_upnp_operation! {
359    operation: SetPlayModeOperation,
360    action: "SetPlayMode",
361    service: AVTransport,
362    request: {
363        new_play_mode: String,
364    },
365    response: (),
366    payload: |req| {
367        format!(
368            "<InstanceID>{}</InstanceID><NewPlayMode>{}</NewPlayMode>",
369            req.instance_id,
370            crate::operation::xml_escape(&req.new_play_mode)
371        )
372    },
373    parse: |_xml| Ok(()),
374}
375
376impl Validate for SetPlayModeOperationRequest {
377    fn validate_basic(&self) -> Result<(), crate::operation::ValidationError> {
378        match self.new_play_mode.as_str() {
379            "NORMAL" | "REPEAT_ALL" | "REPEAT_ONE" | "SHUFFLE_NOREPEAT" | "SHUFFLE"
380            | "SHUFFLE_REPEAT_ONE" => Ok(()),
381            other => Err(crate::operation::ValidationError::Custom {
382                parameter: "new_play_mode".to_string(),
383                message: format!(
384                    "Invalid play mode '{other}'. Must be NORMAL, REPEAT_ALL, REPEAT_ONE, SHUFFLE_NOREPEAT, SHUFFLE, or SHUFFLE_REPEAT_ONE"
385                ),
386            }),
387        }
388    }
389}
390
391// =============================================================================
392// SLEEP TIMER
393// =============================================================================
394
395define_upnp_operation! {
396    operation: ConfigureSleepTimerOperation,
397    action: "ConfigureSleepTimer",
398    service: AVTransport,
399    request: {
400        new_sleep_timer_duration: String,
401    },
402    response: (),
403    payload: |req| {
404        format!(
405            "<InstanceID>{}</InstanceID><NewSleepTimerDuration>{}</NewSleepTimerDuration>",
406            req.instance_id,
407            crate::operation::xml_escape(&req.new_sleep_timer_duration)
408        )
409    },
410    parse: |_xml| Ok(()),
411}
412
413impl Validate for ConfigureSleepTimerOperationRequest {}
414
415define_operation_with_response! {
416    operation: GetRemainingSleepTimerDurationOperation,
417    action: "GetRemainingSleepTimerDuration",
418    service: AVTransport,
419    request: {},
420    response: GetRemainingSleepTimerDurationResponse {
421        remaining_sleep_timer_duration: String,
422        current_sleep_timer_generation: u32,
423    },
424    xml_mapping: {
425        remaining_sleep_timer_duration: "RemainingSleepTimerDuration",
426        current_sleep_timer_generation: "CurrentSleepTimerGeneration",
427    },
428}
429
430impl Validate for GetRemainingSleepTimerDurationOperationRequest {}
431
432// =============================================================================
433// QUEUE OPERATIONS
434// =============================================================================
435
436// AddURIToQueue - manually defined because it has a boolean parameter
437// and returns a response, which the macros don't handle together
438use serde::{Deserialize, Serialize};
439
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct AddURIToQueueOperationRequest {
442    pub instance_id: u32,
443    pub enqueued_uri: String,
444    pub enqueued_uri_meta_data: String,
445    pub desired_first_track_number_enqueued: u32,
446    pub enqueue_as_next: bool,
447}
448
449impl Validate for AddURIToQueueOperationRequest {}
450
451#[derive(Debug, Clone, Serialize, Deserialize, Default)]
452pub struct AddURIToQueueResponse {
453    pub first_track_number_enqueued: u32,
454    pub num_tracks_added: u32,
455    pub new_queue_length: u32,
456}
457
458pub struct AddURIToQueueOperation;
459
460impl crate::operation::UPnPOperation for AddURIToQueueOperation {
461    type Request = AddURIToQueueOperationRequest;
462    type Response = AddURIToQueueResponse;
463
464    const SERVICE: crate::service::Service = crate::service::Service::AVTransport;
465    const ACTION: &'static str = "AddURIToQueue";
466
467    fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
468        <Self::Request as Validate>::validate(request, crate::operation::ValidationLevel::Basic)?;
469        Ok(format!(
470            "<InstanceID>{}</InstanceID><EnqueuedURI>{}</EnqueuedURI><EnqueuedURIMetaData>{}</EnqueuedURIMetaData><DesiredFirstTrackNumberEnqueued>{}</DesiredFirstTrackNumberEnqueued><EnqueueAsNext>{}</EnqueueAsNext>",
471            request.instance_id,
472            request.enqueued_uri,
473            request.enqueued_uri_meta_data,
474            request.desired_first_track_number_enqueued,
475            if request.enqueue_as_next { "1" } else { "0" }
476        ))
477    }
478
479    fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, crate::error::ApiError> {
480        Ok(AddURIToQueueResponse {
481            first_track_number_enqueued: xml
482                .get_child("FirstTrackNumberEnqueued")
483                .and_then(|e| e.get_text())
484                .and_then(|s| s.parse().ok())
485                .unwrap_or_default(),
486            num_tracks_added: xml
487                .get_child("NumTracksAdded")
488                .and_then(|e| e.get_text())
489                .and_then(|s| s.parse().ok())
490                .unwrap_or_default(),
491            new_queue_length: xml
492                .get_child("NewQueueLength")
493                .and_then(|e| e.get_text())
494                .and_then(|s| s.parse().ok())
495                .unwrap_or_default(),
496        })
497    }
498}
499
500pub fn add_uri_to_queue_operation(
501    enqueued_uri: String,
502    enqueued_uri_meta_data: String,
503    desired_first_track_number_enqueued: u32,
504    enqueue_as_next: bool,
505) -> crate::operation::OperationBuilder<AddURIToQueueOperation> {
506    let request = AddURIToQueueOperationRequest {
507        instance_id: 0,
508        enqueued_uri,
509        enqueued_uri_meta_data,
510        desired_first_track_number_enqueued,
511        enqueue_as_next,
512    };
513    crate::operation::OperationBuilder::new(request)
514}
515
516define_upnp_operation! {
517    operation: RemoveTrackFromQueueOperation,
518    action: "RemoveTrackFromQueue",
519    service: AVTransport,
520    request: {
521        object_id: String,
522        update_id: u32,
523    },
524    response: (),
525    payload: |req| {
526        format!(
527            "<InstanceID>{}</InstanceID><ObjectID>{}</ObjectID><UpdateID>{}</UpdateID>",
528            req.instance_id,
529            crate::operation::xml_escape(&req.object_id),
530            req.update_id
531        )
532    },
533    parse: |_xml| Ok(()),
534}
535
536impl Validate for RemoveTrackFromQueueOperationRequest {}
537
538define_operation_with_response! {
539    operation: RemoveTrackRangeFromQueueOperation,
540    action: "RemoveTrackRangeFromQueue",
541    service: AVTransport,
542    request: {
543        update_id: u32,
544        starting_index: u32,
545        number_of_tracks: u32,
546    },
547    response: RemoveTrackRangeFromQueueResponse {
548        new_update_id: u32,
549    },
550    xml_mapping: {
551        new_update_id: "NewUpdateID",
552    },
553}
554
555impl Validate for RemoveTrackRangeFromQueueOperationRequest {}
556
557define_upnp_operation! {
558    operation: RemoveAllTracksFromQueueOperation,
559    action: "RemoveAllTracksFromQueue",
560    service: AVTransport,
561    request: {},
562    response: (),
563    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
564    parse: |_xml| Ok(()),
565}
566
567impl Validate for RemoveAllTracksFromQueueOperationRequest {}
568
569define_operation_with_response! {
570    operation: SaveQueueOperation,
571    action: "SaveQueue",
572    service: AVTransport,
573    request: {
574        title: String,
575        object_id: String,
576    },
577    response: SaveQueueResponse {
578        assigned_object_id: String,
579    },
580    xml_mapping: {
581        assigned_object_id: "AssignedObjectID",
582    },
583}
584
585impl Validate for SaveQueueOperationRequest {}
586
587define_operation_with_response! {
588    operation: CreateSavedQueueOperation,
589    action: "CreateSavedQueue",
590    service: AVTransport,
591    request: {
592        title: String,
593        enqueued_uri: String,
594        enqueued_uri_meta_data: String,
595    },
596    response: CreateSavedQueueResponse {
597        num_tracks_added: u32,
598        new_queue_length: u32,
599        assigned_object_id: String,
600        new_update_id: u32,
601    },
602    xml_mapping: {
603        num_tracks_added: "NumTracksAdded",
604        new_queue_length: "NewQueueLength",
605        assigned_object_id: "AssignedObjectID",
606        new_update_id: "NewUpdateID",
607    },
608}
609
610impl Validate for CreateSavedQueueOperationRequest {}
611
612define_upnp_operation! {
613    operation: BackupQueueOperation,
614    action: "BackupQueue",
615    service: AVTransport,
616    request: {},
617    response: (),
618    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
619    parse: |_xml| Ok(()),
620}
621
622impl Validate for BackupQueueOperationRequest {}
623
624// =============================================================================
625// GROUP COORDINATION
626// =============================================================================
627
628define_operation_with_response! {
629    operation: BecomeCoordinatorOfStandaloneGroupOperation,
630    action: "BecomeCoordinatorOfStandaloneGroup",
631    service: AVTransport,
632    request: {},
633    response: BecomeCoordinatorOfStandaloneGroupResponse {
634        delegated_group_coordinator_id: String,
635        new_group_id: String,
636    },
637    xml_mapping: {
638        delegated_group_coordinator_id: "DelegatedGroupCoordinatorID",
639        new_group_id: "NewGroupID",
640    },
641}
642
643impl Validate for BecomeCoordinatorOfStandaloneGroupOperationRequest {}
644
645define_upnp_operation! {
646    operation: DelegateGroupCoordinationToOperation,
647    action: "DelegateGroupCoordinationTo",
648    service: AVTransport,
649    request: {
650        new_coordinator: String,
651        rejoin_group: bool,
652    },
653    response: (),
654    payload: |req| {
655        format!(
656            "<InstanceID>{}</InstanceID><NewCoordinator>{}</NewCoordinator><RejoinGroup>{}</RejoinGroup>",
657            req.instance_id,
658            crate::operation::xml_escape(&req.new_coordinator),
659            if req.rejoin_group { "true" } else { "false" }
660        )
661    },
662    parse: |_xml| Ok(()),
663}
664
665impl Validate for DelegateGroupCoordinationToOperationRequest {}
666
667// =============================================================================
668// ALARMS
669// =============================================================================
670
671define_upnp_operation! {
672    operation: SnoozeAlarmOperation,
673    action: "SnoozeAlarm",
674    service: AVTransport,
675    request: {
676        duration: String,
677    },
678    response: (),
679    payload: |req| {
680        format!(
681            "<InstanceID>{}</InstanceID><Duration>{}</Duration>",
682            req.instance_id,
683            crate::operation::xml_escape(&req.duration)
684        )
685    },
686    parse: |_xml| Ok(()),
687}
688
689impl Validate for SnoozeAlarmOperationRequest {}
690
691define_operation_with_response! {
692    operation: GetRunningAlarmPropertiesOperation,
693    action: "GetRunningAlarmProperties",
694    service: AVTransport,
695    request: {},
696    response: GetRunningAlarmPropertiesResponse {
697        alarm_id: u32,
698        group_id: String,
699        logged_start_time: String,
700    },
701    xml_mapping: {
702        alarm_id: "AlarmID",
703        group_id: "GroupID",
704        logged_start_time: "LoggedStartTime",
705    },
706}
707
708impl Validate for GetRunningAlarmPropertiesOperationRequest {}
709
710// =============================================================================
711// LEGACY ALIASES
712// =============================================================================
713
714// Basic playback
715pub use next_operation as next;
716pub use pause_operation as pause;
717pub use play_operation as play;
718pub use previous_operation as previous;
719pub use stop_operation as stop;
720
721// Seek and position
722pub use get_position_info_operation as get_position_info;
723pub use seek_operation as seek;
724
725// Transport info and settings
726pub use get_current_transport_actions_operation as get_current_transport_actions;
727pub use get_device_capabilities_operation as get_device_capabilities;
728pub use get_transport_info_operation as get_transport_info;
729pub use get_transport_settings_operation as get_transport_settings;
730
731// Media info and URI
732pub use get_media_info_operation as get_media_info;
733pub use set_a_v_transport_u_r_i_operation as set_av_transport_uri;
734pub use set_next_a_v_transport_u_r_i_operation as set_next_av_transport_uri;
735
736// Crossfade and play mode
737pub use get_crossfade_mode_operation as get_crossfade_mode;
738pub use set_crossfade_mode_operation as set_crossfade_mode;
739pub use set_play_mode_operation as set_play_mode;
740
741// Sleep timer
742pub use configure_sleep_timer_operation as configure_sleep_timer;
743pub use get_remaining_sleep_timer_duration_operation as get_remaining_sleep_timer_duration;
744
745// Queue operations
746pub use add_uri_to_queue_operation as add_uri_to_queue;
747pub use backup_queue_operation as backup_queue;
748pub use create_saved_queue_operation as create_saved_queue;
749pub use remove_all_tracks_from_queue_operation as remove_all_tracks_from_queue;
750pub use remove_track_from_queue_operation as remove_track_from_queue;
751pub use remove_track_range_from_queue_operation as remove_track_range_from_queue;
752pub use save_queue_operation as save_queue;
753
754// Group coordination
755pub use become_coordinator_of_standalone_group_operation as become_coordinator_of_standalone_group;
756pub use delegate_group_coordination_to_operation as delegate_group_coordination_to;
757
758// Alarms
759pub use get_running_alarm_properties_operation as get_running_alarm_properties;
760pub use snooze_alarm_operation as snooze_alarm;
761
762// =============================================================================
763// SERVICE CONSTANT AND SUBSCRIPTION HELPERS
764// =============================================================================
765
766/// Service identifier for AVTransport
767pub const SERVICE: crate::Service = crate::Service::AVTransport;
768
769/// Subscribe to AVTransport events
770pub fn subscribe(
771    client: &crate::SonosClient,
772    ip: &str,
773    callback_url: &str,
774) -> crate::Result<crate::ManagedSubscription> {
775    client.subscribe(ip, SERVICE, callback_url)
776}
777
778/// Subscribe to AVTransport events with custom timeout
779pub fn subscribe_with_timeout(
780    client: &crate::SonosClient,
781    ip: &str,
782    callback_url: &str,
783    timeout_seconds: u32,
784) -> crate::Result<crate::ManagedSubscription> {
785    client.subscribe_with_timeout(ip, SERVICE, callback_url, timeout_seconds)
786}
787
788// =============================================================================
789// TESTS
790// =============================================================================
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use crate::operation::UPnPOperation;
796
797    // --- Basic Playback Tests ---
798
799    #[test]
800    fn test_play_operation_builder() {
801        let op = play_operation("1".to_string()).build().unwrap();
802        assert_eq!(op.request().speed, "1");
803        assert_eq!(op.metadata().action, "Play");
804    }
805
806    #[test]
807    fn test_play_validation() {
808        let request = PlayOperationRequest {
809            instance_id: 0,
810            speed: "".to_string(),
811        };
812        assert!(request.validate_basic().is_err());
813
814        let request = PlayOperationRequest {
815            instance_id: 0,
816            speed: "1".to_string(),
817        };
818        assert!(request.validate_basic().is_ok());
819    }
820
821    #[test]
822    fn test_play_payload() {
823        let request = PlayOperationRequest {
824            instance_id: 0,
825            speed: "1".to_string(),
826        };
827        let payload = PlayOperation::build_payload(&request).unwrap();
828        assert!(payload.contains("<InstanceID>0</InstanceID>"));
829        assert!(payload.contains("<Speed>1</Speed>"));
830    }
831
832    #[test]
833    fn test_pause_operation_builder() {
834        let op = pause_operation().build().unwrap();
835        assert_eq!(op.metadata().action, "Pause");
836    }
837
838    #[test]
839    fn test_stop_operation_builder() {
840        let op = stop_operation().build().unwrap();
841        assert_eq!(op.metadata().action, "Stop");
842    }
843
844    #[test]
845    fn test_next_operation_builder() {
846        let op = next_operation().build().unwrap();
847        assert_eq!(op.metadata().action, "Next");
848    }
849
850    #[test]
851    fn test_previous_operation_builder() {
852        let op = previous_operation().build().unwrap();
853        assert_eq!(op.metadata().action, "Previous");
854    }
855
856    // --- Seek Tests ---
857
858    #[test]
859    fn test_seek_operation_builder() {
860        let op = seek_operation("TRACK_NR".to_string(), "5".to_string())
861            .build()
862            .unwrap();
863        assert_eq!(op.request().unit, "TRACK_NR");
864        assert_eq!(op.request().target, "5");
865        assert_eq!(op.metadata().action, "Seek");
866    }
867
868    #[test]
869    fn test_seek_validation() {
870        let request = SeekOperationRequest {
871            instance_id: 0,
872            unit: "INVALID".to_string(),
873            target: "5".to_string(),
874        };
875        assert!(request.validate_basic().is_err());
876
877        let request = SeekOperationRequest {
878            instance_id: 0,
879            unit: "REL_TIME".to_string(),
880            target: "0:01:30".to_string(),
881        };
882        assert!(request.validate_basic().is_ok());
883    }
884
885    #[test]
886    fn test_seek_payload() {
887        let request = SeekOperationRequest {
888            instance_id: 0,
889            unit: "TRACK_NR".to_string(),
890            target: "3".to_string(),
891        };
892        let payload = SeekOperation::build_payload(&request).unwrap();
893        assert!(payload.contains("<Unit>TRACK_NR</Unit>"));
894        assert!(payload.contains("<Target>3</Target>"));
895    }
896
897    // --- Transport Info Tests ---
898
899    #[test]
900    fn test_get_transport_info_builder() {
901        let op = get_transport_info_operation().build().unwrap();
902        assert_eq!(op.metadata().action, "GetTransportInfo");
903    }
904
905    #[test]
906    fn test_get_position_info_builder() {
907        let op = get_position_info_operation().build().unwrap();
908        assert_eq!(op.metadata().action, "GetPositionInfo");
909    }
910
911    #[test]
912    fn test_get_media_info_builder() {
913        let op = get_media_info_operation().build().unwrap();
914        assert_eq!(op.metadata().action, "GetMediaInfo");
915    }
916
917    #[test]
918    fn test_get_transport_settings_builder() {
919        let op = get_transport_settings_operation().build().unwrap();
920        assert_eq!(op.metadata().action, "GetTransportSettings");
921    }
922
923    // --- Crossfade and Play Mode Tests ---
924
925    #[test]
926    fn test_get_crossfade_mode_builder() {
927        let op = get_crossfade_mode_operation().build().unwrap();
928        assert_eq!(op.metadata().action, "GetCrossfadeMode");
929    }
930
931    #[test]
932    fn test_set_crossfade_mode_builder() {
933        let op = set_crossfade_mode_operation(true).build().unwrap();
934        assert!(op.request().crossfade_mode);
935        assert_eq!(op.metadata().action, "SetCrossfadeMode");
936    }
937
938    #[test]
939    fn test_set_crossfade_mode_payload() {
940        let request = SetCrossfadeModeOperationRequest {
941            instance_id: 0,
942            crossfade_mode: true,
943        };
944        let payload = SetCrossfadeModeOperation::build_payload(&request).unwrap();
945        assert!(payload.contains("<CrossfadeMode>1</CrossfadeMode>"));
946
947        let request = SetCrossfadeModeOperationRequest {
948            instance_id: 0,
949            crossfade_mode: false,
950        };
951        let payload = SetCrossfadeModeOperation::build_payload(&request).unwrap();
952        assert!(payload.contains("<CrossfadeMode>0</CrossfadeMode>"));
953    }
954
955    #[test]
956    fn test_set_play_mode_builder() {
957        let op = set_play_mode_operation("SHUFFLE".to_string())
958            .build()
959            .unwrap();
960        assert_eq!(op.request().new_play_mode, "SHUFFLE");
961        assert_eq!(op.metadata().action, "SetPlayMode");
962    }
963
964    #[test]
965    fn test_set_play_mode_validation() {
966        let request = SetPlayModeOperationRequest {
967            instance_id: 0,
968            new_play_mode: "INVALID".to_string(),
969        };
970        assert!(request.validate_basic().is_err());
971
972        let request = SetPlayModeOperationRequest {
973            instance_id: 0,
974            new_play_mode: "REPEAT_ALL".to_string(),
975        };
976        assert!(request.validate_basic().is_ok());
977    }
978
979    // --- Sleep Timer Tests ---
980
981    #[test]
982    fn test_configure_sleep_timer_builder() {
983        let op = configure_sleep_timer_operation("0:30:00".to_string())
984            .build()
985            .unwrap();
986        assert_eq!(op.request().new_sleep_timer_duration, "0:30:00");
987        assert_eq!(op.metadata().action, "ConfigureSleepTimer");
988    }
989
990    #[test]
991    fn test_get_remaining_sleep_timer_duration_builder() {
992        let op = get_remaining_sleep_timer_duration_operation()
993            .build()
994            .unwrap();
995        assert_eq!(op.metadata().action, "GetRemainingSleepTimerDuration");
996    }
997
998    // --- Queue Tests ---
999
1000    #[test]
1001    fn test_remove_all_tracks_from_queue_builder() {
1002        let op = remove_all_tracks_from_queue_operation().build().unwrap();
1003        assert_eq!(op.metadata().action, "RemoveAllTracksFromQueue");
1004    }
1005
1006    #[test]
1007    fn test_backup_queue_builder() {
1008        let op = backup_queue_operation().build().unwrap();
1009        assert_eq!(op.metadata().action, "BackupQueue");
1010    }
1011
1012    // --- Group Coordination Tests ---
1013
1014    #[test]
1015    fn test_become_coordinator_of_standalone_group_builder() {
1016        let op = become_coordinator_of_standalone_group_operation()
1017            .build()
1018            .unwrap();
1019        assert_eq!(op.metadata().action, "BecomeCoordinatorOfStandaloneGroup");
1020    }
1021
1022    // --- Alarm Tests ---
1023
1024    #[test]
1025    fn test_snooze_alarm_builder() {
1026        let op = snooze_alarm_operation("0:10:00".to_string())
1027            .build()
1028            .unwrap();
1029        assert_eq!(op.request().duration, "0:10:00");
1030        assert_eq!(op.metadata().action, "SnoozeAlarm");
1031    }
1032
1033    #[test]
1034    fn test_get_running_alarm_properties_builder() {
1035        let op = get_running_alarm_properties_operation().build().unwrap();
1036        assert_eq!(op.metadata().action, "GetRunningAlarmProperties");
1037    }
1038
1039    // --- Service Tests ---
1040
1041    #[test]
1042    fn test_service_constant() {
1043        assert_eq!(SERVICE, crate::Service::AVTransport);
1044    }
1045
1046    #[test]
1047    fn test_subscription_helpers() {
1048        let client = crate::SonosClient::new();
1049        let _subscribe_fn = || subscribe(&client, "192.168.1.100", "http://callback.url");
1050        let _subscribe_timeout_fn =
1051            || subscribe_with_timeout(&client, "192.168.1.100", "http://callback.url", 3600);
1052    }
1053}