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    // Bass/Treble/Loudness/Balance are per-channel state variables in UPnP RCS.
46    // Devices such as stereo pairs and home theater setups emit one element per
47    // channel, so these must be collections: modelling them as a single value
48    // made the whole event fail to deserialize with "duplicate field".
49    #[serde(rename = "Bass", default)]
50    pub bass: Vec<ChannelValueAttribute>,
51
52    #[serde(rename = "Treble", default)]
53    pub treble: Vec<ChannelValueAttribute>,
54
55    #[serde(rename = "Loudness", default)]
56    pub loudness: Vec<ChannelValueAttribute>,
57
58    #[serde(rename = "Balance", default)]
59    pub balance: Vec<ChannelValueAttribute>,
60}
61
62/// Represents an XML element with both val and channel attributes
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct ChannelValueAttribute {
65    #[serde(rename = "@val", default)]
66    pub val: String,
67
68    #[serde(rename = "@channel", default)]
69    pub channel: String,
70}
71
72impl RenderingControlEvent {
73    /// Get master volume
74    pub fn master_volume(&self) -> Option<String> {
75        self.get_volume_for_channel("Master")
76    }
77
78    /// Get left front volume
79    pub fn lf_volume(&self) -> Option<String> {
80        self.get_volume_for_channel("LF")
81    }
82
83    /// Get right front volume
84    pub fn rf_volume(&self) -> Option<String> {
85        self.get_volume_for_channel("RF")
86    }
87
88    /// Get master mute
89    pub fn master_mute(&self) -> Option<String> {
90        self.get_mute_for_channel("Master")
91    }
92
93    /// Get left front mute
94    pub fn lf_mute(&self) -> Option<String> {
95        self.get_mute_for_channel("LF")
96    }
97
98    /// Get right front mute
99    pub fn rf_mute(&self) -> Option<String> {
100        self.get_mute_for_channel("RF")
101    }
102
103    /// Get master bass
104    pub fn bass(&self) -> Option<String> {
105        Self::master_value(&self.property.last_change.instance.bass)
106    }
107
108    /// Get master treble
109    pub fn treble(&self) -> Option<String> {
110        Self::master_value(&self.property.last_change.instance.treble)
111    }
112
113    /// Get master loudness
114    pub fn loudness(&self) -> Option<String> {
115        Self::master_value(&self.property.last_change.instance.loudness)
116    }
117
118    /// Get master balance
119    pub fn balance(&self) -> Option<String> {
120        Self::master_value(&self.property.last_change.instance.balance)
121    }
122
123    /// Get other channels as a map of all non-standard channels
124    pub fn other_channels(&self) -> HashMap<String, String> {
125        let mut channels = HashMap::new();
126
127        // Add all volume channels that aren't Master, LF, or RF
128        for volume in &self.property.last_change.instance.volumes {
129            if !["Master", "LF", "RF"].contains(&volume.channel.as_str()) {
130                channels.insert(format!("{}Volume", volume.channel), volume.val.clone());
131            }
132        }
133
134        // Add all mute channels that aren't Master, LF, or RF
135        for mute in &self.property.last_change.instance.mutes {
136            if !["Master", "LF", "RF"].contains(&mute.channel.as_str()) {
137                channels.insert(format!("{}Mute", mute.channel), mute.val.clone());
138            }
139        }
140
141        channels
142    }
143
144    /// Helper method to get volume for a specific channel
145    fn get_volume_for_channel(&self, channel: &str) -> Option<String> {
146        self.property
147            .last_change
148            .instance
149            .volumes
150            .iter()
151            .find(|v| v.channel == channel)
152            .map(|v| v.val.clone())
153    }
154
155    /// Select the `Master` entry from a per-channel state variable.
156    ///
157    /// Exact-match on `Master`, consistent with [`Self::get_volume_for_channel`]:
158    /// if a device reports only side channels (e.g. `LF`) there is no master
159    /// value, and returning a side-channel value in its place would be wrong.
160    ///
161    /// An entry with no `channel` attribute is treated as the master value,
162    /// since some devices emit the scalar form (e.g. `<Bass val="2"/>`).
163    fn master_value(values: &[ChannelValueAttribute]) -> Option<String> {
164        values
165            .iter()
166            .find(|v| v.channel == "Master" || v.channel.is_empty())
167            .map(|v| v.val.clone())
168    }
169
170    /// Helper method to get mute for a specific channel
171    fn get_mute_for_channel(&self, channel: &str) -> Option<String> {
172        self.property
173            .last_change
174            .instance
175            .mutes
176            .iter()
177            .find(|m| m.channel == channel)
178            .map(|m| m.val.clone())
179    }
180
181    /// Convert parsed UPnP event to canonical state representation.
182    pub fn into_state(&self) -> super::state::RenderingControlState {
183        super::state::RenderingControlState {
184            master_volume: self.master_volume(),
185            master_mute: self.master_mute(),
186            lf_volume: self.lf_volume(),
187            rf_volume: self.rf_volume(),
188            lf_mute: self.lf_mute(),
189            rf_mute: self.rf_mute(),
190            bass: self.bass(),
191            treble: self.treble(),
192            loudness: self.loudness(),
193            balance: self.balance(),
194            other_channels: self.other_channels(),
195        }
196    }
197
198    /// Parse from UPnP event XML using serde
199    pub fn from_xml(xml: &str) -> Result<Self> {
200        let clean_xml = xml_utils::strip_namespaces(xml);
201        quick_xml::de::from_str(&clean_xml)
202            .map_err(|e| ApiError::ParseError(format!("Failed to parse RenderingControl XML: {e}")))
203    }
204}
205
206/// Minimal parser implementation
207pub struct RenderingControlEventParser;
208
209impl EventParser for RenderingControlEventParser {
210    type EventData = RenderingControlEvent;
211
212    fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
213        RenderingControlEvent::from_xml(xml)
214    }
215
216    fn service_type(&self) -> Service {
217        Service::RenderingControl
218    }
219}
220
221/// Create enriched event for sonos-stream integration
222pub fn create_enriched_event(
223    speaker_ip: IpAddr,
224    event_source: EventSource,
225    event_data: RenderingControlEvent,
226) -> EnrichedEvent<RenderingControlEvent> {
227    EnrichedEvent::new(
228        speaker_ip,
229        Service::RenderingControl,
230        event_source,
231        event_data,
232    )
233}
234
235/// Create enriched event with registration ID
236pub fn create_enriched_event_with_registration_id(
237    registration_id: u64,
238    speaker_ip: IpAddr,
239    event_source: EventSource,
240    event_data: RenderingControlEvent,
241) -> EnrichedEvent<RenderingControlEvent> {
242    EnrichedEvent::with_registration_id(
243        registration_id,
244        speaker_ip,
245        Service::RenderingControl,
246        event_source,
247        event_data,
248    )
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    /// Build a `Master`-channel entry for fixtures.
256    fn master(val: &str) -> ChannelValueAttribute {
257        ChannelValueAttribute {
258            val: val.to_string(),
259            channel: "Master".to_string(),
260        }
261    }
262
263    #[test]
264    fn test_rendering_control_parser_service_type() {
265        let parser = RenderingControlEventParser;
266        assert_eq!(parser.service_type(), Service::RenderingControl);
267    }
268
269    #[test]
270    fn test_rendering_control_event_creation() {
271        let event = RenderingControlEvent {
272            property: RenderingControlProperty {
273                last_change: RenderingControlEventData {
274                    instance: RenderingControlInstance {
275                        volumes: vec![ChannelValueAttribute {
276                            val: "75".to_string(),
277                            channel: "Master".to_string(),
278                        }],
279                        mutes: vec![ChannelValueAttribute {
280                            val: "false".to_string(),
281                            channel: "Master".to_string(),
282                        }],
283                        bass: vec![master("0")],
284                        treble: vec![master("0")],
285                        loudness: vec![master("true")],
286                        balance: vec![master("0")],
287                    },
288                },
289            },
290        };
291
292        assert_eq!(event.master_volume(), Some("75".to_string()));
293        assert_eq!(event.master_mute(), Some("false".to_string()));
294        assert!(event.other_channels().is_empty());
295    }
296
297    #[test]
298    fn test_basic_xml_parsing() {
299        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
300            <e:property>
301                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
302                    &lt;InstanceID val="0"&gt;
303                        &lt;Volume channel="Master" val="75"/&gt;
304                        &lt;Mute channel="Master" val="0"/&gt;
305                        &lt;Bass val="2"/&gt;
306                        &lt;Treble val="-1"/&gt;
307                    &lt;/InstanceID&gt;
308                &lt;/Event&gt;</LastChange>
309            </e:property>
310        </e:propertyset>"#;
311
312        let event = RenderingControlEvent::from_xml(xml).unwrap();
313        assert_eq!(event.master_volume(), Some("75".to_string()));
314        assert_eq!(event.master_mute(), Some("0".to_string()));
315        assert_eq!(event.bass(), Some("2".to_string()));
316        assert_eq!(event.treble(), Some("-1".to_string()));
317    }
318
319    #[test]
320    fn test_channel_specific_volume() {
321        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
322            <e:property>
323                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
324                    &lt;InstanceID val="0"&gt;
325                        &lt;Volume channel="Master" val="50"/&gt;
326                        &lt;Volume channel="LF" val="80"/&gt;
327                        &lt;Volume channel="RF" val="85"/&gt;
328                        &lt;Mute channel="LF" val="1"/&gt;
329                    &lt;/InstanceID&gt;
330                &lt;/Event&gt;</LastChange>
331            </e:property>
332        </e:propertyset>"#;
333
334        let event = RenderingControlEvent::from_xml(xml).unwrap();
335        assert_eq!(event.master_volume(), Some("50".to_string()));
336        assert_eq!(event.lf_volume(), Some("80".to_string()));
337        assert_eq!(event.rf_volume(), Some("85".to_string()));
338        assert_eq!(event.lf_mute(), Some("1".to_string()));
339    }
340
341    #[test]
342    fn test_enriched_event_creation() {
343        let ip: IpAddr = "192.168.1.100".parse().unwrap();
344        let source = EventSource::UPnPNotification {
345            subscription_id: "uuid:123".to_string(),
346        };
347        let event_data = RenderingControlEvent {
348            property: RenderingControlProperty {
349                last_change: RenderingControlEventData {
350                    instance: RenderingControlInstance {
351                        volumes: vec![ChannelValueAttribute {
352                            val: "50".to_string(),
353                            channel: "Master".to_string(),
354                        }],
355                        mutes: vec![ChannelValueAttribute {
356                            val: "0".to_string(),
357                            channel: "Master".to_string(),
358                        }],
359                        bass: vec![],
360                        treble: vec![],
361                        loudness: vec![],
362                        balance: vec![],
363                    },
364                },
365            },
366        };
367
368        let enriched = create_enriched_event(ip, source, event_data);
369
370        assert_eq!(enriched.speaker_ip, ip);
371        assert_eq!(enriched.service, Service::RenderingControl);
372        assert!(enriched.registration_id.is_none());
373    }
374
375    #[test]
376    fn test_enriched_event_with_registration_id() {
377        let ip: IpAddr = "192.168.1.100".parse().unwrap();
378        let source = EventSource::UPnPNotification {
379            subscription_id: "uuid:123".to_string(),
380        };
381        let event_data = RenderingControlEvent {
382            property: RenderingControlProperty {
383                last_change: RenderingControlEventData {
384                    instance: RenderingControlInstance {
385                        volumes: vec![ChannelValueAttribute {
386                            val: "50".to_string(),
387                            channel: "Master".to_string(),
388                        }],
389                        mutes: vec![ChannelValueAttribute {
390                            val: "0".to_string(),
391                            channel: "Master".to_string(),
392                        }],
393                        bass: vec![],
394                        treble: vec![],
395                        loudness: vec![],
396                        balance: vec![],
397                    },
398                },
399            },
400        };
401
402        let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
403
404        assert_eq!(enriched.registration_id, Some(42));
405    }
406
407    #[test]
408    fn test_lf_only_loudness_is_not_reported_as_master() {
409        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
410            <e:property>
411                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
412                    &lt;InstanceID val="0"&gt;
413                        &lt;Loudness channel="LF" val="1"/&gt;
414                    &lt;/InstanceID&gt;
415                &lt;/Event&gt;</LastChange>
416            </e:property>
417        </e:propertyset>"#;
418
419        let event = RenderingControlEvent::from_xml(xml).unwrap();
420        assert_eq!(event.loudness(), None);
421    }
422
423    #[test]
424    fn test_duplicate_loudness_channels_do_not_drop_event() {
425        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
426            <e:property>
427                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
428                    &lt;InstanceID val="0"&gt;
429                        &lt;Volume channel="Master" val="42"/&gt;
430                        &lt;Mute channel="Master" val="0"/&gt;
431                        &lt;Loudness channel="Master" val="1"/&gt;
432                        &lt;Loudness channel="LF" val="0"/&gt;
433                    &lt;/InstanceID&gt;
434                &lt;/Event&gt;</LastChange>
435            </e:property>
436        </e:propertyset>"#;
437
438        let parsed = RenderingControlEvent::from_xml(xml);
439        assert!(
440            parsed.is_ok(),
441            "per-channel Loudness must not drop the event"
442        );
443
444        let event = parsed.unwrap();
445        // The whole event previously failed to parse, taking volume/mute with it.
446        assert_eq!(event.master_volume(), Some("42".to_string()));
447        assert_eq!(event.master_mute(), Some("0".to_string()));
448        assert_eq!(event.loudness(), Some("1".to_string()));
449    }
450
451    #[test]
452    fn test_scalar_bass_still_parses() {
453        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
454            <e:property>
455                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
456                    &lt;InstanceID val="0"&gt;
457                        &lt;Bass val="2"/&gt;
458                    &lt;/InstanceID&gt;
459                &lt;/Event&gt;</LastChange>
460            </e:property>
461        </e:propertyset>"#;
462
463        let event = RenderingControlEvent::from_xml(xml).unwrap();
464        assert_eq!(event.bass(), Some("2".to_string()));
465    }
466
467    #[test]
468    fn test_into_state_maps_all_fields() {
469        let event = RenderingControlEvent {
470            property: RenderingControlProperty {
471                last_change: RenderingControlEventData {
472                    instance: RenderingControlInstance {
473                        volumes: vec![
474                            ChannelValueAttribute {
475                                val: "50".to_string(),
476                                channel: "Master".to_string(),
477                            },
478                            ChannelValueAttribute {
479                                val: "45".to_string(),
480                                channel: "LF".to_string(),
481                            },
482                            ChannelValueAttribute {
483                                val: "55".to_string(),
484                                channel: "RF".to_string(),
485                            },
486                        ],
487                        mutes: vec![ChannelValueAttribute {
488                            val: "0".to_string(),
489                            channel: "Master".to_string(),
490                        }],
491                        bass: vec![master("5")],
492                        treble: vec![master("-3")],
493                        loudness: vec![master("1")],
494                        balance: vec![],
495                    },
496                },
497            },
498        };
499
500        let state = event.into_state();
501
502        assert_eq!(state.master_volume, Some("50".to_string()));
503        assert_eq!(state.master_mute, Some("0".to_string()));
504        assert_eq!(state.lf_volume, Some("45".to_string()));
505        assert_eq!(state.rf_volume, Some("55".to_string()));
506        assert_eq!(state.bass, Some("5".to_string()));
507        assert_eq!(state.treble, Some("-3".to_string()));
508        assert_eq!(state.loudness, Some("1".to_string()));
509    }
510}