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            crate::operation::xml_escape(&request.enqueued_uri),
473            crate::operation::xml_escape(&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: &str) -> Result<Self::Response, crate::error::ApiError> {
480        use crate::operation::response_field;
481        Ok(AddURIToQueueResponse {
482            first_track_number_enqueued: response_field(xml, "FirstTrackNumberEnqueued"),
483            num_tracks_added: response_field(xml, "NumTracksAdded"),
484            new_queue_length: response_field(xml, "NewQueueLength"),
485        })
486    }
487}
488
489pub fn add_uri_to_queue_operation(
490    enqueued_uri: String,
491    enqueued_uri_meta_data: String,
492    desired_first_track_number_enqueued: u32,
493    enqueue_as_next: bool,
494) -> crate::operation::OperationBuilder<AddURIToQueueOperation> {
495    let request = AddURIToQueueOperationRequest {
496        instance_id: 0,
497        enqueued_uri,
498        enqueued_uri_meta_data,
499        desired_first_track_number_enqueued,
500        enqueue_as_next,
501    };
502    crate::operation::OperationBuilder::new(request)
503}
504
505define_upnp_operation! {
506    operation: RemoveTrackFromQueueOperation,
507    action: "RemoveTrackFromQueue",
508    service: AVTransport,
509    request: {
510        object_id: String,
511        update_id: u32,
512    },
513    response: (),
514    payload: |req| {
515        format!(
516            "<InstanceID>{}</InstanceID><ObjectID>{}</ObjectID><UpdateID>{}</UpdateID>",
517            req.instance_id,
518            crate::operation::xml_escape(&req.object_id),
519            req.update_id
520        )
521    },
522    parse: |_xml| Ok(()),
523}
524
525impl Validate for RemoveTrackFromQueueOperationRequest {}
526
527define_operation_with_response! {
528    operation: RemoveTrackRangeFromQueueOperation,
529    action: "RemoveTrackRangeFromQueue",
530    service: AVTransport,
531    request: {
532        update_id: u32,
533        starting_index: u32,
534        number_of_tracks: u32,
535    },
536    response: RemoveTrackRangeFromQueueResponse {
537        new_update_id: u32,
538    },
539    request_xml_mapping: {
540        update_id: "UpdateID",
541        starting_index: "StartingIndex",
542        number_of_tracks: "NumberOfTracks",
543    },
544    xml_mapping: {
545        new_update_id: "NewUpdateID",
546    },
547}
548
549impl Validate for RemoveTrackRangeFromQueueOperationRequest {}
550
551define_upnp_operation! {
552    operation: RemoveAllTracksFromQueueOperation,
553    action: "RemoveAllTracksFromQueue",
554    service: AVTransport,
555    request: {},
556    response: (),
557    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
558    parse: |_xml| Ok(()),
559}
560
561impl Validate for RemoveAllTracksFromQueueOperationRequest {}
562
563define_operation_with_response! {
564    operation: SaveQueueOperation,
565    action: "SaveQueue",
566    service: AVTransport,
567    request: {
568        title: String,
569        object_id: String,
570    },
571    response: SaveQueueResponse {
572        assigned_object_id: String,
573    },
574    request_xml_mapping: {
575        title: "Title",
576        object_id: "ObjectID",
577    },
578    xml_mapping: {
579        assigned_object_id: "AssignedObjectID",
580    },
581}
582
583impl Validate for SaveQueueOperationRequest {}
584
585define_operation_with_response! {
586    operation: CreateSavedQueueOperation,
587    action: "CreateSavedQueue",
588    service: AVTransport,
589    request: {
590        title: String,
591        enqueued_uri: String,
592        enqueued_uri_meta_data: String,
593    },
594    response: CreateSavedQueueResponse {
595        num_tracks_added: u32,
596        new_queue_length: u32,
597        assigned_object_id: String,
598        new_update_id: u32,
599    },
600    request_xml_mapping: {
601        title: "Title",
602        enqueued_uri: "EnqueuedURI",
603        enqueued_uri_meta_data: "EnqueuedURIMetaData",
604    },
605    xml_mapping: {
606        num_tracks_added: "NumTracksAdded",
607        new_queue_length: "NewQueueLength",
608        assigned_object_id: "AssignedObjectID",
609        new_update_id: "NewUpdateID",
610    },
611}
612
613impl Validate for CreateSavedQueueOperationRequest {}
614
615define_upnp_operation! {
616    operation: BackupQueueOperation,
617    action: "BackupQueue",
618    service: AVTransport,
619    request: {},
620    response: (),
621    payload: |req| format!("<InstanceID>{}</InstanceID>", req.instance_id),
622    parse: |_xml| Ok(()),
623}
624
625impl Validate for BackupQueueOperationRequest {}
626
627// =============================================================================
628// GROUP COORDINATION
629// =============================================================================
630
631define_operation_with_response! {
632    operation: BecomeCoordinatorOfStandaloneGroupOperation,
633    action: "BecomeCoordinatorOfStandaloneGroup",
634    service: AVTransport,
635    request: {},
636    response: BecomeCoordinatorOfStandaloneGroupResponse {
637        delegated_group_coordinator_id: String,
638        new_group_id: String,
639    },
640    xml_mapping: {
641        delegated_group_coordinator_id: "DelegatedGroupCoordinatorID",
642        new_group_id: "NewGroupID",
643    },
644}
645
646impl Validate for BecomeCoordinatorOfStandaloneGroupOperationRequest {}
647
648define_upnp_operation! {
649    operation: DelegateGroupCoordinationToOperation,
650    action: "DelegateGroupCoordinationTo",
651    service: AVTransport,
652    request: {
653        new_coordinator: String,
654        rejoin_group: bool,
655    },
656    response: (),
657    payload: |req| {
658        format!(
659            "<InstanceID>{}</InstanceID><NewCoordinator>{}</NewCoordinator><RejoinGroup>{}</RejoinGroup>",
660            req.instance_id,
661            crate::operation::xml_escape(&req.new_coordinator),
662            if req.rejoin_group { "true" } else { "false" }
663        )
664    },
665    parse: |_xml| Ok(()),
666}
667
668impl Validate for DelegateGroupCoordinationToOperationRequest {}
669
670// =============================================================================
671// ALARMS
672// =============================================================================
673
674define_upnp_operation! {
675    operation: SnoozeAlarmOperation,
676    action: "SnoozeAlarm",
677    service: AVTransport,
678    request: {
679        duration: String,
680    },
681    response: (),
682    payload: |req| {
683        format!(
684            "<InstanceID>{}</InstanceID><Duration>{}</Duration>",
685            req.instance_id,
686            crate::operation::xml_escape(&req.duration)
687        )
688    },
689    parse: |_xml| Ok(()),
690}
691
692impl Validate for SnoozeAlarmOperationRequest {}
693
694define_operation_with_response! {
695    operation: GetRunningAlarmPropertiesOperation,
696    action: "GetRunningAlarmProperties",
697    service: AVTransport,
698    request: {},
699    response: GetRunningAlarmPropertiesResponse {
700        alarm_id: u32,
701        group_id: String,
702        logged_start_time: String,
703    },
704    xml_mapping: {
705        alarm_id: "AlarmID",
706        group_id: "GroupID",
707        logged_start_time: "LoggedStartTime",
708    },
709}
710
711impl Validate for GetRunningAlarmPropertiesOperationRequest {}
712
713// =============================================================================
714// LEGACY ALIASES
715// =============================================================================
716
717// Basic playback
718pub use next_operation as next;
719pub use pause_operation as pause;
720pub use play_operation as play;
721pub use previous_operation as previous;
722pub use stop_operation as stop;
723
724// Seek and position
725pub use get_position_info_operation as get_position_info;
726pub use seek_operation as seek;
727
728// Transport info and settings
729pub use get_current_transport_actions_operation as get_current_transport_actions;
730pub use get_device_capabilities_operation as get_device_capabilities;
731pub use get_transport_info_operation as get_transport_info;
732pub use get_transport_settings_operation as get_transport_settings;
733
734// Media info and URI
735pub use get_media_info_operation as get_media_info;
736pub use set_a_v_transport_u_r_i_operation as set_av_transport_uri;
737pub use set_next_a_v_transport_u_r_i_operation as set_next_av_transport_uri;
738
739// Crossfade and play mode
740pub use get_crossfade_mode_operation as get_crossfade_mode;
741pub use set_crossfade_mode_operation as set_crossfade_mode;
742pub use set_play_mode_operation as set_play_mode;
743
744// Sleep timer
745pub use configure_sleep_timer_operation as configure_sleep_timer;
746pub use get_remaining_sleep_timer_duration_operation as get_remaining_sleep_timer_duration;
747
748// Queue operations
749pub use add_uri_to_queue_operation as add_uri_to_queue;
750pub use backup_queue_operation as backup_queue;
751pub use create_saved_queue_operation as create_saved_queue;
752pub use remove_all_tracks_from_queue_operation as remove_all_tracks_from_queue;
753pub use remove_track_from_queue_operation as remove_track_from_queue;
754pub use remove_track_range_from_queue_operation as remove_track_range_from_queue;
755pub use save_queue_operation as save_queue;
756
757// Group coordination
758pub use become_coordinator_of_standalone_group_operation as become_coordinator_of_standalone_group;
759pub use delegate_group_coordination_to_operation as delegate_group_coordination_to;
760
761// Alarms
762pub use get_running_alarm_properties_operation as get_running_alarm_properties;
763pub use snooze_alarm_operation as snooze_alarm;
764
765// =============================================================================
766// SERVICE CONSTANT AND SUBSCRIPTION HELPERS
767// =============================================================================
768
769/// Service identifier for AVTransport
770pub const SERVICE: crate::Service = crate::Service::AVTransport;
771
772/// Subscribe to AVTransport events
773pub fn subscribe(
774    client: &crate::SonosClient,
775    ip: &str,
776    callback_url: &str,
777) -> crate::Result<crate::ManagedSubscription> {
778    client.subscribe(ip, SERVICE, callback_url)
779}
780
781/// Subscribe to AVTransport events with custom timeout
782pub fn subscribe_with_timeout(
783    client: &crate::SonosClient,
784    ip: &str,
785    callback_url: &str,
786    timeout_seconds: u32,
787) -> crate::Result<crate::ManagedSubscription> {
788    client.subscribe_with_timeout(ip, SERVICE, callback_url, timeout_seconds)
789}
790
791// =============================================================================
792// TESTS
793// =============================================================================
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::operation::UPnPOperation;
799
800    // --- Basic Playback Tests ---
801
802    #[test]
803    fn test_play_operation_builder() {
804        let op = play_operation("1".to_string()).build().unwrap();
805        assert_eq!(op.request().speed, "1");
806        assert_eq!(op.metadata().action, "Play");
807    }
808
809    #[test]
810    fn test_play_validation() {
811        let request = PlayOperationRequest {
812            instance_id: 0,
813            speed: "".to_string(),
814        };
815        assert!(request.validate_basic().is_err());
816
817        let request = PlayOperationRequest {
818            instance_id: 0,
819            speed: "1".to_string(),
820        };
821        assert!(request.validate_basic().is_ok());
822    }
823
824    #[test]
825    fn test_play_payload() {
826        let request = PlayOperationRequest {
827            instance_id: 0,
828            speed: "1".to_string(),
829        };
830        let payload = PlayOperation::build_payload(&request).unwrap();
831        assert!(payload.contains("<InstanceID>0</InstanceID>"));
832        assert!(payload.contains("<Speed>1</Speed>"));
833    }
834
835    #[test]
836    fn test_pause_operation_builder() {
837        let op = pause_operation().build().unwrap();
838        assert_eq!(op.metadata().action, "Pause");
839    }
840
841    #[test]
842    fn test_stop_operation_builder() {
843        let op = stop_operation().build().unwrap();
844        assert_eq!(op.metadata().action, "Stop");
845    }
846
847    #[test]
848    fn test_next_operation_builder() {
849        let op = next_operation().build().unwrap();
850        assert_eq!(op.metadata().action, "Next");
851    }
852
853    #[test]
854    fn test_previous_operation_builder() {
855        let op = previous_operation().build().unwrap();
856        assert_eq!(op.metadata().action, "Previous");
857    }
858
859    // --- Seek Tests ---
860
861    #[test]
862    fn test_seek_operation_builder() {
863        let op = seek_operation("TRACK_NR".to_string(), "5".to_string())
864            .build()
865            .unwrap();
866        assert_eq!(op.request().unit, "TRACK_NR");
867        assert_eq!(op.request().target, "5");
868        assert_eq!(op.metadata().action, "Seek");
869    }
870
871    #[test]
872    fn test_seek_validation() {
873        let request = SeekOperationRequest {
874            instance_id: 0,
875            unit: "INVALID".to_string(),
876            target: "5".to_string(),
877        };
878        assert!(request.validate_basic().is_err());
879
880        let request = SeekOperationRequest {
881            instance_id: 0,
882            unit: "REL_TIME".to_string(),
883            target: "0:01:30".to_string(),
884        };
885        assert!(request.validate_basic().is_ok());
886    }
887
888    #[test]
889    fn test_seek_payload() {
890        let request = SeekOperationRequest {
891            instance_id: 0,
892            unit: "TRACK_NR".to_string(),
893            target: "3".to_string(),
894        };
895        let payload = SeekOperation::build_payload(&request).unwrap();
896        assert!(payload.contains("<Unit>TRACK_NR</Unit>"));
897        assert!(payload.contains("<Target>3</Target>"));
898    }
899
900    // --- Transport Info Tests ---
901
902    #[test]
903    fn test_get_transport_info_builder() {
904        let op = get_transport_info_operation().build().unwrap();
905        assert_eq!(op.metadata().action, "GetTransportInfo");
906    }
907
908    #[test]
909    fn test_get_position_info_builder() {
910        let op = get_position_info_operation().build().unwrap();
911        assert_eq!(op.metadata().action, "GetPositionInfo");
912    }
913
914    #[test]
915    fn test_get_media_info_builder() {
916        let op = get_media_info_operation().build().unwrap();
917        assert_eq!(op.metadata().action, "GetMediaInfo");
918    }
919
920    #[test]
921    fn test_get_transport_settings_builder() {
922        let op = get_transport_settings_operation().build().unwrap();
923        assert_eq!(op.metadata().action, "GetTransportSettings");
924    }
925
926    // --- Crossfade and Play Mode Tests ---
927
928    #[test]
929    fn test_get_crossfade_mode_builder() {
930        let op = get_crossfade_mode_operation().build().unwrap();
931        assert_eq!(op.metadata().action, "GetCrossfadeMode");
932    }
933
934    #[test]
935    fn test_set_crossfade_mode_builder() {
936        let op = set_crossfade_mode_operation(true).build().unwrap();
937        assert!(op.request().crossfade_mode);
938        assert_eq!(op.metadata().action, "SetCrossfadeMode");
939    }
940
941    #[test]
942    fn test_set_crossfade_mode_payload() {
943        let request = SetCrossfadeModeOperationRequest {
944            instance_id: 0,
945            crossfade_mode: true,
946        };
947        let payload = SetCrossfadeModeOperation::build_payload(&request).unwrap();
948        assert!(payload.contains("<CrossfadeMode>1</CrossfadeMode>"));
949
950        let request = SetCrossfadeModeOperationRequest {
951            instance_id: 0,
952            crossfade_mode: false,
953        };
954        let payload = SetCrossfadeModeOperation::build_payload(&request).unwrap();
955        assert!(payload.contains("<CrossfadeMode>0</CrossfadeMode>"));
956    }
957
958    #[test]
959    fn test_set_play_mode_builder() {
960        let op = set_play_mode_operation("SHUFFLE".to_string())
961            .build()
962            .unwrap();
963        assert_eq!(op.request().new_play_mode, "SHUFFLE");
964        assert_eq!(op.metadata().action, "SetPlayMode");
965    }
966
967    #[test]
968    fn test_set_play_mode_validation() {
969        let request = SetPlayModeOperationRequest {
970            instance_id: 0,
971            new_play_mode: "INVALID".to_string(),
972        };
973        assert!(request.validate_basic().is_err());
974
975        let request = SetPlayModeOperationRequest {
976            instance_id: 0,
977            new_play_mode: "REPEAT_ALL".to_string(),
978        };
979        assert!(request.validate_basic().is_ok());
980    }
981
982    // --- Sleep Timer Tests ---
983
984    #[test]
985    fn test_configure_sleep_timer_builder() {
986        let op = configure_sleep_timer_operation("0:30:00".to_string())
987            .build()
988            .unwrap();
989        assert_eq!(op.request().new_sleep_timer_duration, "0:30:00");
990        assert_eq!(op.metadata().action, "ConfigureSleepTimer");
991    }
992
993    #[test]
994    fn test_get_remaining_sleep_timer_duration_builder() {
995        let op = get_remaining_sleep_timer_duration_operation()
996            .build()
997            .unwrap();
998        assert_eq!(op.metadata().action, "GetRemainingSleepTimerDuration");
999    }
1000
1001    // --- Queue Tests ---
1002
1003    #[test]
1004    fn test_remove_all_tracks_from_queue_builder() {
1005        let op = remove_all_tracks_from_queue_operation().build().unwrap();
1006        assert_eq!(op.metadata().action, "RemoveAllTracksFromQueue");
1007    }
1008
1009    #[test]
1010    fn test_backup_queue_builder() {
1011        let op = backup_queue_operation().build().unwrap();
1012        assert_eq!(op.metadata().action, "BackupQueue");
1013    }
1014
1015    // Element names below are the UPnP SCPD argument names. Snake_case field names
1016    // cannot produce this casing mechanically, so these assert the wire format that
1017    // devices actually accept (previously emitted <Object_id>, <Update_id>, etc.).
1018
1019    #[test]
1020    fn test_save_queue_payload_element_names() {
1021        let request = SaveQueueOperationRequest {
1022            instance_id: 0,
1023            title: "My Mix".to_string(),
1024            object_id: "SQ:12".to_string(),
1025        };
1026        let payload = SaveQueueOperation::build_payload(&request).unwrap();
1027        assert!(payload.contains("<Title>My Mix</Title>"), "{payload}");
1028        assert!(payload.contains("<ObjectID>SQ:12</ObjectID>"), "{payload}");
1029    }
1030
1031    #[test]
1032    fn test_create_saved_queue_payload_element_names() {
1033        let request = CreateSavedQueueOperationRequest {
1034            instance_id: 0,
1035            title: "Playlist".to_string(),
1036            enqueued_uri: "x-file-cifs://nas/song.mp3".to_string(),
1037            enqueued_uri_meta_data: "meta".to_string(),
1038        };
1039        let payload = CreateSavedQueueOperation::build_payload(&request).unwrap();
1040        assert!(
1041            payload.contains("<EnqueuedURI>x-file-cifs://nas/song.mp3</EnqueuedURI>"),
1042            "{payload}"
1043        );
1044        assert!(
1045            payload.contains("<EnqueuedURIMetaData>meta</EnqueuedURIMetaData>"),
1046            "{payload}"
1047        );
1048    }
1049
1050    #[test]
1051    fn test_remove_track_range_payload_element_names() {
1052        let request = RemoveTrackRangeFromQueueOperationRequest {
1053            instance_id: 0,
1054            update_id: 7,
1055            starting_index: 2,
1056            number_of_tracks: 3,
1057        };
1058        let payload = RemoveTrackRangeFromQueueOperation::build_payload(&request).unwrap();
1059        assert!(payload.contains("<UpdateID>7</UpdateID>"), "{payload}");
1060        assert!(
1061            payload.contains("<StartingIndex>2</StartingIndex>"),
1062            "{payload}"
1063        );
1064        assert!(
1065            payload.contains("<NumberOfTracks>3</NumberOfTracks>"),
1066            "{payload}"
1067        );
1068    }
1069
1070    #[test]
1071    fn test_add_uri_to_queue_escapes_ampersand() {
1072        // Real streaming URIs contain `&`; interpolating raw produces malformed SOAP.
1073        let request = AddURIToQueueOperationRequest {
1074            instance_id: 0,
1075            enqueued_uri: "x-sonosapi-stream:s1?sid=254&flags=32".to_string(),
1076            enqueued_uri_meta_data: "<DIDL-Lite>x</DIDL-Lite>".to_string(),
1077            desired_first_track_number_enqueued: 0,
1078            enqueue_as_next: false,
1079        };
1080        let payload = AddURIToQueueOperation::build_payload(&request).unwrap();
1081        assert!(payload.contains("sid=254&amp;flags=32"), "{payload}");
1082        assert!(!payload.contains("sid=254&flags=32"), "{payload}");
1083        // DIDL-Lite metadata must not close the element early.
1084        assert!(
1085            payload.contains("&lt;DIDL-Lite&gt;x&lt;/DIDL-Lite&gt;"),
1086            "{payload}"
1087        );
1088    }
1089
1090    // --- Group Coordination Tests ---
1091
1092    #[test]
1093    fn test_become_coordinator_of_standalone_group_builder() {
1094        let op = become_coordinator_of_standalone_group_operation()
1095            .build()
1096            .unwrap();
1097        assert_eq!(op.metadata().action, "BecomeCoordinatorOfStandaloneGroup");
1098    }
1099
1100    // --- Alarm Tests ---
1101
1102    #[test]
1103    fn test_snooze_alarm_builder() {
1104        let op = snooze_alarm_operation("0:10:00".to_string())
1105            .build()
1106            .unwrap();
1107        assert_eq!(op.request().duration, "0:10:00");
1108        assert_eq!(op.metadata().action, "SnoozeAlarm");
1109    }
1110
1111    #[test]
1112    fn test_get_running_alarm_properties_builder() {
1113        let op = get_running_alarm_properties_operation().build().unwrap();
1114        assert_eq!(op.metadata().action, "GetRunningAlarmProperties");
1115    }
1116
1117    // --- Service Tests ---
1118
1119    #[test]
1120    fn test_service_constant() {
1121        assert_eq!(SERVICE, crate::Service::AVTransport);
1122    }
1123
1124    #[test]
1125    fn test_subscription_helpers() {
1126        let client = crate::SonosClient::new();
1127        let _subscribe_fn = || subscribe(&client, "192.168.1.100", "http://callback.url");
1128        let _subscribe_timeout_fn =
1129            || subscribe_with_timeout(&client, "192.168.1.100", "http://callback.url", 3600);
1130    }
1131}