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        let clean_xml = xml_utils::strip_namespaces(xml);
237        quick_xml::de::from_str(&clean_xml)
238            .map_err(|e| ApiError::ParseError(format!("Failed to parse AVTransport XML: {e}")))
239    }
240}
241
242/// Minimal parser implementation
243pub struct AVTransportEventParser;
244
245impl EventParser for AVTransportEventParser {
246    type EventData = AVTransportEvent;
247
248    fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
249        AVTransportEvent::from_xml(xml)
250    }
251
252    fn service_type(&self) -> Service {
253        Service::AVTransport
254    }
255}
256
257/// Create enriched event for sonos-stream integration
258pub fn create_enriched_event(
259    speaker_ip: IpAddr,
260    event_source: EventSource,
261    event_data: AVTransportEvent,
262) -> EnrichedEvent<AVTransportEvent> {
263    EnrichedEvent::new(speaker_ip, Service::AVTransport, event_source, event_data)
264}
265
266/// Create enriched event with registration ID
267pub fn create_enriched_event_with_registration_id(
268    registration_id: u64,
269    speaker_ip: IpAddr,
270    event_source: EventSource,
271    event_data: AVTransportEvent,
272) -> EnrichedEvent<AVTransportEvent> {
273    EnrichedEvent::with_registration_id(
274        registration_id,
275        speaker_ip,
276        Service::AVTransport,
277        event_source,
278        event_data,
279    )
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_av_transport_parser_service_type() {
288        let parser = AVTransportEventParser;
289        assert_eq!(parser.service_type(), Service::AVTransport);
290    }
291
292    #[test]
293    fn test_av_transport_event_creation() {
294        let event_data = AVTransportEventData {
295            instance: AVTransportInstance {
296                transport_state: Some(xml_utils::ValueAttribute {
297                    val: "PLAYING".to_string(),
298                }),
299                transport_status: Some(xml_utils::ValueAttribute {
300                    val: "OK".to_string(),
301                }),
302                speed: Some(xml_utils::ValueAttribute {
303                    val: "1".to_string(),
304                }),
305                current_track_uri: None,
306                track_duration: None,
307                rel_time: None,
308                abs_time: None,
309                rel_count: None,
310                play_mode: None,
311                track_metadata: None,
312                next_track_uri: None,
313                next_track_metadata: None,
314                queue_length: None,
315            },
316        };
317
318        let event = AVTransportEvent {
319            property: AVTransportProperty {
320                last_change: event_data,
321            },
322        };
323
324        assert_eq!(event.transport_state(), Some("PLAYING".to_string()));
325        assert_eq!(event.transport_status(), Some("OK".to_string()));
326    }
327
328    #[test]
329    fn test_enriched_event_creation() {
330        let ip: IpAddr = "192.168.1.100".parse().unwrap();
331        let source = EventSource::UPnPNotification {
332            subscription_id: "uuid:123".to_string(),
333        };
334        let event_data = AVTransportEvent {
335            property: AVTransportProperty {
336                last_change: AVTransportEventData {
337                    instance: AVTransportInstance {
338                        transport_state: Some(xml_utils::ValueAttribute {
339                            val: "PLAYING".to_string(),
340                        }),
341                        transport_status: None,
342                        speed: None,
343                        current_track_uri: None,
344                        track_duration: None,
345                        rel_time: None,
346                        abs_time: None,
347                        rel_count: None,
348                        play_mode: None,
349                        track_metadata: None,
350                        next_track_uri: None,
351                        next_track_metadata: None,
352                        queue_length: None,
353                    },
354                },
355            },
356        };
357
358        let enriched = create_enriched_event(ip, source, event_data);
359
360        assert_eq!(enriched.speaker_ip, ip);
361        assert_eq!(enriched.service, Service::AVTransport);
362        assert!(enriched.registration_id.is_none());
363    }
364
365    #[test]
366    fn test_enriched_event_with_registration_id() {
367        let ip: IpAddr = "192.168.1.100".parse().unwrap();
368        let source = EventSource::UPnPNotification {
369            subscription_id: "uuid:123".to_string(),
370        };
371        let event_data = AVTransportEvent {
372            property: AVTransportProperty {
373                last_change: AVTransportEventData {
374                    instance: AVTransportInstance {
375                        transport_state: Some(xml_utils::ValueAttribute {
376                            val: "PLAYING".to_string(),
377                        }),
378                        transport_status: None,
379                        speed: None,
380                        current_track_uri: None,
381                        track_duration: None,
382                        rel_time: None,
383                        abs_time: None,
384                        rel_count: None,
385                        play_mode: None,
386                        track_metadata: None,
387                        next_track_uri: None,
388                        next_track_metadata: None,
389                        queue_length: None,
390                    },
391                },
392            },
393        };
394
395        let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
396
397        assert_eq!(enriched.registration_id, Some(42));
398    }
399
400    #[test]
401    fn test_basic_xml_parsing() {
402        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
403            <e:property>
404                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"&gt;
405                    &lt;InstanceID val="0"&gt;
406                        &lt;TransportState val="PLAYING"/&gt;
407                        &lt;TransportStatus val="OK"/&gt;
408                        &lt;CurrentTrack val="1"/&gt;
409                        &lt;NumberOfTracks val="5"/&gt;
410                    &lt;/InstanceID&gt;
411                &lt;/Event&gt;</LastChange>
412            </e:property>
413        </e:propertyset>"#;
414
415        let event = AVTransportEvent::from_xml(xml).unwrap();
416        assert_eq!(event.transport_state(), Some("PLAYING".to_string()));
417        assert_eq!(event.transport_status(), Some("OK".to_string()));
418        assert_eq!(event.rel_count(), Some(1));
419        assert_eq!(event.queue_length(), Some(5));
420    }
421
422    #[test]
423    fn test_into_state_maps_all_fields() {
424        let event = AVTransportEvent {
425            property: AVTransportProperty {
426                last_change: AVTransportEventData {
427                    instance: AVTransportInstance {
428                        transport_state: Some(xml_utils::ValueAttribute {
429                            val: "PLAYING".to_string(),
430                        }),
431                        transport_status: Some(xml_utils::ValueAttribute {
432                            val: "OK".to_string(),
433                        }),
434                        speed: Some(xml_utils::ValueAttribute {
435                            val: "1".to_string(),
436                        }),
437                        current_track_uri: Some(xml_utils::ValueAttribute {
438                            val: "x-sonos-spotify:track123".to_string(),
439                        }),
440                        track_duration: Some(xml_utils::ValueAttribute {
441                            val: "0:03:45".to_string(),
442                        }),
443                        rel_time: Some(xml_utils::ValueAttribute {
444                            val: "0:01:30".to_string(),
445                        }),
446                        abs_time: None,
447                        rel_count: Some(xml_utils::ValueAttribute {
448                            val: "1".to_string(),
449                        }),
450                        play_mode: Some(xml_utils::ValueAttribute {
451                            val: "NORMAL".to_string(),
452                        }),
453                        track_metadata: None,
454                        next_track_uri: None,
455                        next_track_metadata: None,
456                        queue_length: Some(xml_utils::ValueAttribute {
457                            val: "5".to_string(),
458                        }),
459                    },
460                },
461            },
462        };
463
464        let state = event.into_state();
465
466        assert_eq!(state.transport_state, Some("PLAYING".to_string()));
467        assert_eq!(state.transport_status, Some("OK".to_string()));
468        assert_eq!(state.speed, Some("1".to_string()));
469        assert_eq!(
470            state.current_track_uri,
471            Some("x-sonos-spotify:track123".to_string())
472        );
473        assert_eq!(state.track_duration, Some("0:03:45".to_string()));
474        assert_eq!(state.rel_time, Some("0:01:30".to_string()));
475        assert_eq!(state.abs_time, None);
476        assert_eq!(state.rel_count, Some(1));
477        assert_eq!(state.play_mode, Some("NORMAL".to_string()));
478        assert_eq!(state.queue_length, Some(5));
479    }
480
481    #[test]
482    fn test_into_state_from_xml_round_trip() {
483        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
484            <e:property>
485                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/AVT/"&gt;
486                    &lt;InstanceID val="0"&gt;
487                        &lt;TransportState val="PLAYING"/&gt;
488                        &lt;TransportStatus val="OK"/&gt;
489                        &lt;CurrentTrack val="1"/&gt;
490                        &lt;NumberOfTracks val="5"/&gt;
491                    &lt;/InstanceID&gt;
492                &lt;/Event&gt;</LastChange>
493            </e:property>
494        </e:propertyset>"#;
495
496        let state = AVTransportEvent::from_xml(xml).unwrap().into_state();
497
498        assert_eq!(state.transport_state, Some("PLAYING".to_string()));
499        assert_eq!(state.transport_status, Some("OK".to_string()));
500        assert_eq!(state.rel_count, Some(1));
501        assert_eq!(state.queue_length, Some(5));
502    }
503}