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