Skip to main content

sonos_api/services/av_transport/
events.rs

1//! AVTransport service event types and parsing
2//!
3//! Provides direct serde-based XML parsing with no business logic,
4//! replicating exactly what Sonos produces for sonos-stream consumption.
5
6use serde::{Deserialize, Serialize};
7use std::net::IpAddr;
8
9use crate::events::{xml_utils, EnrichedEvent, EventParser, EventSource};
10use crate::{ApiError, Result, Service};
11
12/// Minimal AVTransport event - direct serde mapping from UPnP event XML
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(rename = "propertyset")]
15pub struct AVTransportEvent {
16    #[serde(rename = "property")]
17    property: AVTransportProperty,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct AVTransportProperty {
22    #[serde(
23        rename = "LastChange",
24        deserialize_with = "xml_utils::deserialize_nested"
25    )]
26    last_change: AVTransportEventData,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename = "Event")]
31pub struct AVTransportEventData {
32    #[serde(rename = "InstanceID")]
33    instance: AVTransportInstance,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct AVTransportInstance {
38    #[serde(rename = "TransportState", default)]
39    pub transport_state: Option<xml_utils::ValueAttribute>,
40
41    #[serde(rename = "TransportStatus", default)]
42    pub transport_status: Option<xml_utils::ValueAttribute>,
43
44    #[serde(rename = "TransportPlaySpeed", default)]
45    pub speed: Option<xml_utils::ValueAttribute>,
46
47    #[serde(rename = "CurrentTrackURI", default)]
48    pub current_track_uri: Option<xml_utils::ValueAttribute>,
49
50    #[serde(rename = "CurrentTrackDuration", default)]
51    pub track_duration: Option<xml_utils::ValueAttribute>,
52
53    #[serde(rename = "RelativeTimePosition", default)]
54    pub rel_time: Option<xml_utils::ValueAttribute>,
55
56    #[serde(rename = "AbsoluteTimePosition", default)]
57    pub abs_time: Option<xml_utils::ValueAttribute>,
58
59    #[serde(rename = "CurrentTrack", default)]
60    pub rel_count: Option<xml_utils::ValueAttribute>,
61
62    #[serde(rename = "CurrentPlayMode", default)]
63    pub play_mode: Option<xml_utils::ValueAttribute>,
64
65    #[serde(rename = "CurrentTrackMetaData", default)]
66    pub track_metadata: Option<xml_utils::ValueAttribute>,
67
68    #[serde(rename = "NextTrackURI", default)]
69    pub next_track_uri: Option<xml_utils::ValueAttribute>,
70
71    #[serde(rename = "NextTrackMetaData", default)]
72    pub next_track_metadata: Option<xml_utils::ValueAttribute>,
73
74    #[serde(rename = "NumberOfTracks", default)]
75    pub queue_length: Option<xml_utils::ValueAttribute>,
76}
77
78impl AVTransportEvent {
79    /// Get transport state
80    pub fn transport_state(&self) -> Option<String> {
81        self.property
82            .last_change
83            .instance
84            .transport_state
85            .as_ref()
86            .map(|v| v.val.clone())
87    }
88
89    /// Get transport status
90    pub fn transport_status(&self) -> Option<String> {
91        self.property
92            .last_change
93            .instance
94            .transport_status
95            .as_ref()
96            .map(|v| v.val.clone())
97    }
98
99    /// Get speed
100    pub fn speed(&self) -> Option<String> {
101        self.property
102            .last_change
103            .instance
104            .speed
105            .as_ref()
106            .map(|v| v.val.clone())
107    }
108
109    /// Get current track URI
110    pub fn current_track_uri(&self) -> Option<String> {
111        self.property
112            .last_change
113            .instance
114            .current_track_uri
115            .as_ref()
116            .map(|v| v.val.clone())
117    }
118
119    /// Get track duration
120    pub fn track_duration(&self) -> Option<String> {
121        self.property
122            .last_change
123            .instance
124            .track_duration
125            .as_ref()
126            .map(|v| v.val.clone())
127    }
128
129    /// Get relative time
130    pub fn rel_time(&self) -> Option<String> {
131        self.property
132            .last_change
133            .instance
134            .rel_time
135            .as_ref()
136            .map(|v| v.val.clone())
137    }
138
139    /// Get absolute time
140    pub fn abs_time(&self) -> Option<String> {
141        self.property
142            .last_change
143            .instance
144            .abs_time
145            .as_ref()
146            .map(|v| v.val.clone())
147    }
148
149    /// Get relative count
150    pub fn rel_count(&self) -> Option<u32> {
151        self.property
152            .last_change
153            .instance
154            .rel_count
155            .as_ref()
156            .and_then(|v| v.val.parse().ok())
157    }
158
159    /// Get absolute count (not available)
160    pub fn abs_count(&self) -> Option<u32> {
161        None
162    }
163
164    /// Get play mode
165    pub fn play_mode(&self) -> Option<String> {
166        self.property
167            .last_change
168            .instance
169            .play_mode
170            .as_ref()
171            .map(|v| v.val.clone())
172    }
173
174    /// Get track metadata
175    pub fn track_metadata(&self) -> Option<String> {
176        self.property
177            .last_change
178            .instance
179            .track_metadata
180            .as_ref()
181            .map(|v| v.val.clone())
182    }
183
184    /// Get next track URI
185    pub fn next_track_uri(&self) -> Option<String> {
186        self.property
187            .last_change
188            .instance
189            .next_track_uri
190            .as_ref()
191            .map(|v| v.val.clone())
192    }
193
194    /// Get next track metadata
195    pub fn next_track_metadata(&self) -> Option<String> {
196        self.property
197            .last_change
198            .instance
199            .next_track_metadata
200            .as_ref()
201            .map(|v| v.val.clone())
202    }
203
204    /// Get queue length
205    pub fn queue_length(&self) -> Option<u32> {
206        self.property
207            .last_change
208            .instance
209            .queue_length
210            .as_ref()
211            .and_then(|v| v.val.parse().ok())
212    }
213
214    /// Convert parsed UPnP event to canonical state representation.
215    pub fn into_state(&self) -> super::state::AVTransportState {
216        super::state::AVTransportState {
217            transport_state: self.transport_state(),
218            transport_status: self.transport_status(),
219            speed: self.speed(),
220            current_track_uri: self.current_track_uri(),
221            track_duration: self.track_duration(),
222            track_metadata: self.track_metadata(),
223            rel_time: self.rel_time(),
224            abs_time: self.abs_time(),
225            rel_count: self.rel_count(),
226            abs_count: self.abs_count(),
227            play_mode: self.play_mode(),
228            next_track_uri: self.next_track_uri(),
229            next_track_metadata: self.next_track_metadata(),
230            queue_length: self.queue_length(),
231        }
232    }
233
234    /// Parse from UPnP event XML using serde
235    pub fn from_xml(xml: &str) -> Result<Self> {
236        quick_xml::de::from_str(xml)
237            .map_err(|e| ApiError::ParseError(format!("Failed to parse AVTransport XML: {e}")))
238    }
239}
240
241/// Minimal parser implementation
242pub struct AVTransportEventParser;
243
244impl EventParser for AVTransportEventParser {
245    type EventData = AVTransportEvent;
246
247    fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
248        AVTransportEvent::from_xml(xml)
249    }
250
251    fn service_type(&self) -> Service {
252        Service::AVTransport
253    }
254}
255
256/// Create enriched event for sonos-stream integration
257pub fn create_enriched_event(
258    speaker_ip: IpAddr,
259    event_source: EventSource,
260    event_data: AVTransportEvent,
261) -> EnrichedEvent<AVTransportEvent> {
262    EnrichedEvent::new(speaker_ip, Service::AVTransport, event_source, event_data)
263}
264
265/// Create enriched event with registration ID
266pub fn create_enriched_event_with_registration_id(
267    registration_id: u64,
268    speaker_ip: IpAddr,
269    event_source: EventSource,
270    event_data: AVTransportEvent,
271) -> EnrichedEvent<AVTransportEvent> {
272    EnrichedEvent::with_registration_id(
273        registration_id,
274        speaker_ip,
275        Service::AVTransport,
276        event_source,
277        event_data,
278    )
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_av_transport_parser_service_type() {
287        let parser = AVTransportEventParser;
288        assert_eq!(parser.service_type(), Service::AVTransport);
289    }
290
291    #[test]
292    fn test_av_transport_event_creation() {
293        let event_data = AVTransportEventData {
294            instance: AVTransportInstance {
295                transport_state: Some(xml_utils::ValueAttribute {
296                    val: "PLAYING".to_string(),
297                }),
298                transport_status: Some(xml_utils::ValueAttribute {
299                    val: "OK".to_string(),
300                }),
301                speed: Some(xml_utils::ValueAttribute {
302                    val: "1".to_string(),
303                }),
304                current_track_uri: None,
305                track_duration: None,
306                rel_time: None,
307                abs_time: None,
308                rel_count: None,
309                play_mode: None,
310                track_metadata: None,
311                next_track_uri: None,
312                next_track_metadata: None,
313                queue_length: None,
314            },
315        };
316
317        let event = AVTransportEvent {
318            property: AVTransportProperty {
319                last_change: event_data,
320            },
321        };
322
323        assert_eq!(event.transport_state(), Some("PLAYING".to_string()));
324        assert_eq!(event.transport_status(), Some("OK".to_string()));
325    }
326
327    #[test]
328    fn test_enriched_event_creation() {
329        let ip: IpAddr = "192.168.1.100".parse().unwrap();
330        let source = EventSource::UPnPNotification {
331            subscription_id: "uuid:123".to_string(),
332        };
333        let event_data = AVTransportEvent {
334            property: AVTransportProperty {
335                last_change: AVTransportEventData {
336                    instance: AVTransportInstance {
337                        transport_state: Some(xml_utils::ValueAttribute {
338                            val: "PLAYING".to_string(),
339                        }),
340                        transport_status: None,
341                        speed: None,
342                        current_track_uri: None,
343                        track_duration: None,
344                        rel_time: None,
345                        abs_time: None,
346                        rel_count: None,
347                        play_mode: None,
348                        track_metadata: None,
349                        next_track_uri: None,
350                        next_track_metadata: None,
351                        queue_length: None,
352                    },
353                },
354            },
355        };
356
357        let enriched = create_enriched_event(ip, source, event_data);
358
359        assert_eq!(enriched.speaker_ip, ip);
360        assert_eq!(enriched.service, Service::AVTransport);
361        assert!(enriched.registration_id.is_none());
362    }
363
364    #[test]
365    fn test_enriched_event_with_registration_id() {
366        let ip: IpAddr = "192.168.1.100".parse().unwrap();
367        let source = EventSource::UPnPNotification {
368            subscription_id: "uuid:123".to_string(),
369        };
370        let event_data = AVTransportEvent {
371            property: AVTransportProperty {
372                last_change: AVTransportEventData {
373                    instance: AVTransportInstance {
374                        transport_state: Some(xml_utils::ValueAttribute {
375                            val: "PLAYING".to_string(),
376                        }),
377                        transport_status: None,
378                        speed: None,
379                        current_track_uri: None,
380                        track_duration: None,
381                        rel_time: None,
382                        abs_time: None,
383                        rel_count: None,
384                        play_mode: None,
385                        track_metadata: None,
386                        next_track_uri: None,
387                        next_track_metadata: None,
388                        queue_length: None,
389                    },
390                },
391            },
392        };
393
394        let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
395
396        assert_eq!(enriched.registration_id, Some(42));
397    }
398
399    #[test]
400    fn test_basic_xml_parsing() {
401        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
402            <e:property>
403                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"&gt;
404                    &lt;InstanceID val="0"&gt;
405                        &lt;TransportState val="PLAYING"/&gt;
406                        &lt;TransportStatus val="OK"/&gt;
407                        &lt;CurrentTrack val="1"/&gt;
408                        &lt;NumberOfTracks val="5"/&gt;
409                    &lt;/InstanceID&gt;
410                &lt;/Event&gt;</LastChange>
411            </e:property>
412        </e:propertyset>"#;
413
414        let event = AVTransportEvent::from_xml(xml).unwrap();
415        assert_eq!(event.transport_state(), Some("PLAYING".to_string()));
416        assert_eq!(event.transport_status(), Some("OK".to_string()));
417        assert_eq!(event.rel_count(), Some(1));
418        assert_eq!(event.queue_length(), Some(5));
419    }
420
421    #[test]
422    fn test_into_state_maps_all_fields() {
423        let event = AVTransportEvent {
424            property: AVTransportProperty {
425                last_change: AVTransportEventData {
426                    instance: AVTransportInstance {
427                        transport_state: Some(xml_utils::ValueAttribute {
428                            val: "PLAYING".to_string(),
429                        }),
430                        transport_status: Some(xml_utils::ValueAttribute {
431                            val: "OK".to_string(),
432                        }),
433                        speed: Some(xml_utils::ValueAttribute {
434                            val: "1".to_string(),
435                        }),
436                        current_track_uri: Some(xml_utils::ValueAttribute {
437                            val: "x-sonos-spotify:track123".to_string(),
438                        }),
439                        track_duration: Some(xml_utils::ValueAttribute {
440                            val: "0:03:45".to_string(),
441                        }),
442                        rel_time: Some(xml_utils::ValueAttribute {
443                            val: "0:01:30".to_string(),
444                        }),
445                        abs_time: None,
446                        rel_count: Some(xml_utils::ValueAttribute {
447                            val: "1".to_string(),
448                        }),
449                        play_mode: Some(xml_utils::ValueAttribute {
450                            val: "NORMAL".to_string(),
451                        }),
452                        track_metadata: None,
453                        next_track_uri: None,
454                        next_track_metadata: None,
455                        queue_length: Some(xml_utils::ValueAttribute {
456                            val: "5".to_string(),
457                        }),
458                    },
459                },
460            },
461        };
462
463        let state = event.into_state();
464
465        assert_eq!(state.transport_state, Some("PLAYING".to_string()));
466        assert_eq!(state.transport_status, Some("OK".to_string()));
467        assert_eq!(state.speed, Some("1".to_string()));
468        assert_eq!(
469            state.current_track_uri,
470            Some("x-sonos-spotify:track123".to_string())
471        );
472        assert_eq!(state.track_duration, Some("0:03:45".to_string()));
473        assert_eq!(state.rel_time, Some("0:01:30".to_string()));
474        assert_eq!(state.abs_time, None);
475        assert_eq!(state.rel_count, Some(1));
476        assert_eq!(state.play_mode, Some("NORMAL".to_string()));
477        assert_eq!(state.queue_length, Some(5));
478    }
479
480    #[test]
481    fn test_into_state_from_xml_round_trip() {
482        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
483            <e:property>
484                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"&gt;
485                    &lt;InstanceID val="0"&gt;
486                        &lt;TransportState val="PLAYING"/&gt;
487                        &lt;TransportStatus val="OK"/&gt;
488                        &lt;CurrentTrack val="1"/&gt;
489                        &lt;NumberOfTracks val="5"/&gt;
490                    &lt;/InstanceID&gt;
491                &lt;/Event&gt;</LastChange>
492            </e:property>
493        </e:propertyset>"#;
494
495        let state = AVTransportEvent::from_xml(xml).unwrap().into_state();
496
497        assert_eq!(state.transport_state, Some("PLAYING".to_string()));
498        assert_eq!(state.transport_status, Some("OK".to_string()));
499        assert_eq!(state.rel_count, Some(1));
500        assert_eq!(state.queue_length, Some(5));
501    }
502}