Skip to main content

sonos_api/services/rendering_control/
events.rs

1//! RenderingControl 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::collections::HashMap;
8use std::net::IpAddr;
9
10use crate::events::{xml_utils, EnrichedEvent, EventParser, EventSource};
11use crate::{ApiError, Result, Service};
12
13/// Minimal RenderingControl event - direct serde mapping from UPnP event XML
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename = "propertyset")]
16pub struct RenderingControlEvent {
17    #[serde(rename = "property")]
18    property: RenderingControlProperty,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22struct RenderingControlProperty {
23    #[serde(
24        rename = "LastChange",
25        deserialize_with = "xml_utils::deserialize_nested"
26    )]
27    last_change: RenderingControlEventData,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename = "Event")]
32pub struct RenderingControlEventData {
33    #[serde(rename = "InstanceID")]
34    instance: RenderingControlInstance,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct RenderingControlInstance {
39    #[serde(rename = "Volume", default)]
40    pub volumes: Vec<ChannelValueAttribute>,
41
42    #[serde(rename = "Mute", default)]
43    pub mutes: Vec<ChannelValueAttribute>,
44
45    #[serde(rename = "Bass", default)]
46    pub bass: Option<xml_utils::ValueAttribute>,
47
48    #[serde(rename = "Treble", default)]
49    pub treble: Option<xml_utils::ValueAttribute>,
50
51    #[serde(rename = "Loudness", default)]
52    pub loudness: Option<xml_utils::ValueAttribute>,
53
54    #[serde(rename = "Balance", default)]
55    pub balance: Option<xml_utils::ValueAttribute>,
56}
57
58/// Represents an XML element with both val and channel attributes
59#[derive(Debug, Clone, Deserialize, Serialize)]
60pub struct ChannelValueAttribute {
61    #[serde(rename = "@val", default)]
62    pub val: String,
63
64    #[serde(rename = "@channel", default)]
65    pub channel: String,
66}
67
68impl RenderingControlEvent {
69    /// Get master volume
70    pub fn master_volume(&self) -> Option<String> {
71        self.get_volume_for_channel("Master")
72    }
73
74    /// Get left front volume
75    pub fn lf_volume(&self) -> Option<String> {
76        self.get_volume_for_channel("LF")
77    }
78
79    /// Get right front volume
80    pub fn rf_volume(&self) -> Option<String> {
81        self.get_volume_for_channel("RF")
82    }
83
84    /// Get master mute
85    pub fn master_mute(&self) -> Option<String> {
86        self.get_mute_for_channel("Master")
87    }
88
89    /// Get left front mute
90    pub fn lf_mute(&self) -> Option<String> {
91        self.get_mute_for_channel("LF")
92    }
93
94    /// Get right front mute
95    pub fn rf_mute(&self) -> Option<String> {
96        self.get_mute_for_channel("RF")
97    }
98
99    /// Get bass
100    pub fn bass(&self) -> Option<String> {
101        self.property
102            .last_change
103            .instance
104            .bass
105            .as_ref()
106            .map(|v| v.val.clone())
107    }
108
109    /// Get treble
110    pub fn treble(&self) -> Option<String> {
111        self.property
112            .last_change
113            .instance
114            .treble
115            .as_ref()
116            .map(|v| v.val.clone())
117    }
118
119    /// Get loudness
120    pub fn loudness(&self) -> Option<String> {
121        self.property
122            .last_change
123            .instance
124            .loudness
125            .as_ref()
126            .map(|v| v.val.clone())
127    }
128
129    /// Get balance
130    pub fn balance(&self) -> Option<String> {
131        self.property
132            .last_change
133            .instance
134            .balance
135            .as_ref()
136            .map(|v| v.val.clone())
137    }
138
139    /// Get other channels as a map of all non-standard channels
140    pub fn other_channels(&self) -> HashMap<String, String> {
141        let mut channels = HashMap::new();
142
143        // Add all volume channels that aren't Master, LF, or RF
144        for volume in &self.property.last_change.instance.volumes {
145            if !["Master", "LF", "RF"].contains(&volume.channel.as_str()) {
146                channels.insert(format!("{}Volume", volume.channel), volume.val.clone());
147            }
148        }
149
150        // Add all mute channels that aren't Master, LF, or RF
151        for mute in &self.property.last_change.instance.mutes {
152            if !["Master", "LF", "RF"].contains(&mute.channel.as_str()) {
153                channels.insert(format!("{}Mute", mute.channel), mute.val.clone());
154            }
155        }
156
157        channels
158    }
159
160    /// Helper method to get volume for a specific channel
161    fn get_volume_for_channel(&self, channel: &str) -> Option<String> {
162        self.property
163            .last_change
164            .instance
165            .volumes
166            .iter()
167            .find(|v| v.channel == channel)
168            .map(|v| v.val.clone())
169    }
170
171    /// Helper method to get mute for a specific channel
172    fn get_mute_for_channel(&self, channel: &str) -> Option<String> {
173        self.property
174            .last_change
175            .instance
176            .mutes
177            .iter()
178            .find(|m| m.channel == channel)
179            .map(|m| m.val.clone())
180    }
181
182    /// Convert parsed UPnP event to canonical state representation.
183    pub fn into_state(&self) -> super::state::RenderingControlState {
184        super::state::RenderingControlState {
185            master_volume: self.master_volume(),
186            master_mute: self.master_mute(),
187            lf_volume: self.lf_volume(),
188            rf_volume: self.rf_volume(),
189            lf_mute: self.lf_mute(),
190            rf_mute: self.rf_mute(),
191            bass: self.bass(),
192            treble: self.treble(),
193            loudness: self.loudness(),
194            balance: self.balance(),
195            other_channels: self.other_channels(),
196        }
197    }
198
199    /// Parse from UPnP event XML using serde
200    pub fn from_xml(xml: &str) -> Result<Self> {
201        let clean_xml = xml_utils::strip_namespaces(xml);
202        quick_xml::de::from_str(&clean_xml)
203            .map_err(|e| ApiError::ParseError(format!("Failed to parse RenderingControl XML: {e}")))
204    }
205}
206
207/// Minimal parser implementation
208pub struct RenderingControlEventParser;
209
210impl EventParser for RenderingControlEventParser {
211    type EventData = RenderingControlEvent;
212
213    fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
214        RenderingControlEvent::from_xml(xml)
215    }
216
217    fn service_type(&self) -> Service {
218        Service::RenderingControl
219    }
220}
221
222/// Create enriched event for sonos-stream integration
223pub fn create_enriched_event(
224    speaker_ip: IpAddr,
225    event_source: EventSource,
226    event_data: RenderingControlEvent,
227) -> EnrichedEvent<RenderingControlEvent> {
228    EnrichedEvent::new(
229        speaker_ip,
230        Service::RenderingControl,
231        event_source,
232        event_data,
233    )
234}
235
236/// Create enriched event with registration ID
237pub fn create_enriched_event_with_registration_id(
238    registration_id: u64,
239    speaker_ip: IpAddr,
240    event_source: EventSource,
241    event_data: RenderingControlEvent,
242) -> EnrichedEvent<RenderingControlEvent> {
243    EnrichedEvent::with_registration_id(
244        registration_id,
245        speaker_ip,
246        Service::RenderingControl,
247        event_source,
248        event_data,
249    )
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_rendering_control_parser_service_type() {
258        let parser = RenderingControlEventParser;
259        assert_eq!(parser.service_type(), Service::RenderingControl);
260    }
261
262    #[test]
263    fn test_rendering_control_event_creation() {
264        let event = RenderingControlEvent {
265            property: RenderingControlProperty {
266                last_change: RenderingControlEventData {
267                    instance: RenderingControlInstance {
268                        volumes: vec![ChannelValueAttribute {
269                            val: "75".to_string(),
270                            channel: "Master".to_string(),
271                        }],
272                        mutes: vec![ChannelValueAttribute {
273                            val: "false".to_string(),
274                            channel: "Master".to_string(),
275                        }],
276                        bass: Some(xml_utils::ValueAttribute {
277                            val: "0".to_string(),
278                        }),
279                        treble: Some(xml_utils::ValueAttribute {
280                            val: "0".to_string(),
281                        }),
282                        loudness: Some(xml_utils::ValueAttribute {
283                            val: "true".to_string(),
284                        }),
285                        balance: Some(xml_utils::ValueAttribute {
286                            val: "0".to_string(),
287                        }),
288                    },
289                },
290            },
291        };
292
293        assert_eq!(event.master_volume(), Some("75".to_string()));
294        assert_eq!(event.master_mute(), Some("false".to_string()));
295        assert!(event.other_channels().is_empty());
296    }
297
298    #[test]
299    fn test_basic_xml_parsing() {
300        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
301            <e:property>
302                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
303                    &lt;InstanceID val="0"&gt;
304                        &lt;Volume channel="Master" val="75"/&gt;
305                        &lt;Mute channel="Master" val="0"/&gt;
306                        &lt;Bass val="2"/&gt;
307                        &lt;Treble val="-1"/&gt;
308                    &lt;/InstanceID&gt;
309                &lt;/Event&gt;</LastChange>
310            </e:property>
311        </e:propertyset>"#;
312
313        let event = RenderingControlEvent::from_xml(xml).unwrap();
314        assert_eq!(event.master_volume(), Some("75".to_string()));
315        assert_eq!(event.master_mute(), Some("0".to_string()));
316        assert_eq!(event.bass(), Some("2".to_string()));
317        assert_eq!(event.treble(), Some("-1".to_string()));
318    }
319
320    #[test]
321    fn test_channel_specific_volume() {
322        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
323            <e:property>
324                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
325                    &lt;InstanceID val="0"&gt;
326                        &lt;Volume channel="Master" val="50"/&gt;
327                        &lt;Volume channel="LF" val="80"/&gt;
328                        &lt;Volume channel="RF" val="85"/&gt;
329                        &lt;Mute channel="LF" val="1"/&gt;
330                    &lt;/InstanceID&gt;
331                &lt;/Event&gt;</LastChange>
332            </e:property>
333        </e:propertyset>"#;
334
335        let event = RenderingControlEvent::from_xml(xml).unwrap();
336        assert_eq!(event.master_volume(), Some("50".to_string()));
337        assert_eq!(event.lf_volume(), Some("80".to_string()));
338        assert_eq!(event.rf_volume(), Some("85".to_string()));
339        assert_eq!(event.lf_mute(), Some("1".to_string()));
340    }
341
342    #[test]
343    fn test_enriched_event_creation() {
344        let ip: IpAddr = "192.168.1.100".parse().unwrap();
345        let source = EventSource::UPnPNotification {
346            subscription_id: "uuid:123".to_string(),
347        };
348        let event_data = RenderingControlEvent {
349            property: RenderingControlProperty {
350                last_change: RenderingControlEventData {
351                    instance: RenderingControlInstance {
352                        volumes: vec![ChannelValueAttribute {
353                            val: "50".to_string(),
354                            channel: "Master".to_string(),
355                        }],
356                        mutes: vec![ChannelValueAttribute {
357                            val: "0".to_string(),
358                            channel: "Master".to_string(),
359                        }],
360                        bass: None,
361                        treble: None,
362                        loudness: None,
363                        balance: None,
364                    },
365                },
366            },
367        };
368
369        let enriched = create_enriched_event(ip, source, event_data);
370
371        assert_eq!(enriched.speaker_ip, ip);
372        assert_eq!(enriched.service, Service::RenderingControl);
373        assert!(enriched.registration_id.is_none());
374    }
375
376    #[test]
377    fn test_enriched_event_with_registration_id() {
378        let ip: IpAddr = "192.168.1.100".parse().unwrap();
379        let source = EventSource::UPnPNotification {
380            subscription_id: "uuid:123".to_string(),
381        };
382        let event_data = RenderingControlEvent {
383            property: RenderingControlProperty {
384                last_change: RenderingControlEventData {
385                    instance: RenderingControlInstance {
386                        volumes: vec![ChannelValueAttribute {
387                            val: "50".to_string(),
388                            channel: "Master".to_string(),
389                        }],
390                        mutes: vec![ChannelValueAttribute {
391                            val: "0".to_string(),
392                            channel: "Master".to_string(),
393                        }],
394                        bass: None,
395                        treble: None,
396                        loudness: None,
397                        balance: None,
398                    },
399                },
400            },
401        };
402
403        let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
404
405        assert_eq!(enriched.registration_id, Some(42));
406    }
407
408    #[test]
409    fn test_into_state_maps_all_fields() {
410        let event = RenderingControlEvent {
411            property: RenderingControlProperty {
412                last_change: RenderingControlEventData {
413                    instance: RenderingControlInstance {
414                        volumes: vec![
415                            ChannelValueAttribute {
416                                val: "50".to_string(),
417                                channel: "Master".to_string(),
418                            },
419                            ChannelValueAttribute {
420                                val: "45".to_string(),
421                                channel: "LF".to_string(),
422                            },
423                            ChannelValueAttribute {
424                                val: "55".to_string(),
425                                channel: "RF".to_string(),
426                            },
427                        ],
428                        mutes: vec![ChannelValueAttribute {
429                            val: "0".to_string(),
430                            channel: "Master".to_string(),
431                        }],
432                        bass: Some(xml_utils::ValueAttribute {
433                            val: "5".to_string(),
434                        }),
435                        treble: Some(xml_utils::ValueAttribute {
436                            val: "-3".to_string(),
437                        }),
438                        loudness: Some(xml_utils::ValueAttribute {
439                            val: "1".to_string(),
440                        }),
441                        balance: None,
442                    },
443                },
444            },
445        };
446
447        let state = event.into_state();
448
449        assert_eq!(state.master_volume, Some("50".to_string()));
450        assert_eq!(state.master_mute, Some("0".to_string()));
451        assert_eq!(state.lf_volume, Some("45".to_string()));
452        assert_eq!(state.rf_volume, Some("55".to_string()));
453        assert_eq!(state.bass, Some("5".to_string()));
454        assert_eq!(state.treble, Some("-3".to_string()));
455        assert_eq!(state.loudness, Some("1".to_string()));
456    }
457}