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        quick_xml::de::from_str(xml)
201            .map_err(|e| ApiError::ParseError(format!("Failed to parse RenderingControl XML: {e}")))
202    }
203}
204
205/// Minimal parser implementation
206pub struct RenderingControlEventParser;
207
208impl EventParser for RenderingControlEventParser {
209    type EventData = RenderingControlEvent;
210
211    fn parse_upnp_event(&self, xml: &str) -> Result<Self::EventData> {
212        RenderingControlEvent::from_xml(xml)
213    }
214
215    fn service_type(&self) -> Service {
216        Service::RenderingControl
217    }
218}
219
220/// Create enriched event for sonos-stream integration
221pub fn create_enriched_event(
222    speaker_ip: IpAddr,
223    event_source: EventSource,
224    event_data: RenderingControlEvent,
225) -> EnrichedEvent<RenderingControlEvent> {
226    EnrichedEvent::new(
227        speaker_ip,
228        Service::RenderingControl,
229        event_source,
230        event_data,
231    )
232}
233
234/// Create enriched event with registration ID
235pub fn create_enriched_event_with_registration_id(
236    registration_id: u64,
237    speaker_ip: IpAddr,
238    event_source: EventSource,
239    event_data: RenderingControlEvent,
240) -> EnrichedEvent<RenderingControlEvent> {
241    EnrichedEvent::with_registration_id(
242        registration_id,
243        speaker_ip,
244        Service::RenderingControl,
245        event_source,
246        event_data,
247    )
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// Build a `Master`-channel entry for fixtures.
255    fn master(val: &str) -> ChannelValueAttribute {
256        ChannelValueAttribute {
257            val: val.to_string(),
258            channel: "Master".to_string(),
259        }
260    }
261
262    #[test]
263    fn test_rendering_control_parser_service_type() {
264        let parser = RenderingControlEventParser;
265        assert_eq!(parser.service_type(), Service::RenderingControl);
266    }
267
268    #[test]
269    fn test_rendering_control_event_creation() {
270        let event = RenderingControlEvent {
271            property: RenderingControlProperty {
272                last_change: RenderingControlEventData {
273                    instance: RenderingControlInstance {
274                        volumes: vec![ChannelValueAttribute {
275                            val: "75".to_string(),
276                            channel: "Master".to_string(),
277                        }],
278                        mutes: vec![ChannelValueAttribute {
279                            val: "false".to_string(),
280                            channel: "Master".to_string(),
281                        }],
282                        bass: vec![master("0")],
283                        treble: vec![master("0")],
284                        loudness: vec![master("true")],
285                        balance: vec![master("0")],
286                    },
287                },
288            },
289        };
290
291        assert_eq!(event.master_volume(), Some("75".to_string()));
292        assert_eq!(event.master_mute(), Some("false".to_string()));
293        assert!(event.other_channels().is_empty());
294    }
295
296    #[test]
297    fn test_basic_xml_parsing() {
298        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
299            <e:property>
300                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
301                    &lt;InstanceID val="0"&gt;
302                        &lt;Volume channel="Master" val="75"/&gt;
303                        &lt;Mute channel="Master" val="0"/&gt;
304                        &lt;Bass val="2"/&gt;
305                        &lt;Treble val="-1"/&gt;
306                    &lt;/InstanceID&gt;
307                &lt;/Event&gt;</LastChange>
308            </e:property>
309        </e:propertyset>"#;
310
311        let event = RenderingControlEvent::from_xml(xml).unwrap();
312        assert_eq!(event.master_volume(), Some("75".to_string()));
313        assert_eq!(event.master_mute(), Some("0".to_string()));
314        assert_eq!(event.bass(), Some("2".to_string()));
315        assert_eq!(event.treble(), Some("-1".to_string()));
316    }
317
318    #[test]
319    fn test_channel_specific_volume() {
320        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
321            <e:property>
322                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
323                    &lt;InstanceID val="0"&gt;
324                        &lt;Volume channel="Master" val="50"/&gt;
325                        &lt;Volume channel="LF" val="80"/&gt;
326                        &lt;Volume channel="RF" val="85"/&gt;
327                        &lt;Mute channel="LF" val="1"/&gt;
328                    &lt;/InstanceID&gt;
329                &lt;/Event&gt;</LastChange>
330            </e:property>
331        </e:propertyset>"#;
332
333        let event = RenderingControlEvent::from_xml(xml).unwrap();
334        assert_eq!(event.master_volume(), Some("50".to_string()));
335        assert_eq!(event.lf_volume(), Some("80".to_string()));
336        assert_eq!(event.rf_volume(), Some("85".to_string()));
337        assert_eq!(event.lf_mute(), Some("1".to_string()));
338    }
339
340    #[test]
341    fn test_enriched_event_creation() {
342        let ip: IpAddr = "192.168.1.100".parse().unwrap();
343        let source = EventSource::UPnPNotification {
344            subscription_id: "uuid:123".to_string(),
345        };
346        let event_data = RenderingControlEvent {
347            property: RenderingControlProperty {
348                last_change: RenderingControlEventData {
349                    instance: RenderingControlInstance {
350                        volumes: vec![ChannelValueAttribute {
351                            val: "50".to_string(),
352                            channel: "Master".to_string(),
353                        }],
354                        mutes: vec![ChannelValueAttribute {
355                            val: "0".to_string(),
356                            channel: "Master".to_string(),
357                        }],
358                        bass: vec![],
359                        treble: vec![],
360                        loudness: vec![],
361                        balance: vec![],
362                    },
363                },
364            },
365        };
366
367        let enriched = create_enriched_event(ip, source, event_data);
368
369        assert_eq!(enriched.speaker_ip, ip);
370        assert_eq!(enriched.service, Service::RenderingControl);
371        assert!(enriched.registration_id.is_none());
372    }
373
374    #[test]
375    fn test_enriched_event_with_registration_id() {
376        let ip: IpAddr = "192.168.1.100".parse().unwrap();
377        let source = EventSource::UPnPNotification {
378            subscription_id: "uuid:123".to_string(),
379        };
380        let event_data = RenderingControlEvent {
381            property: RenderingControlProperty {
382                last_change: RenderingControlEventData {
383                    instance: RenderingControlInstance {
384                        volumes: vec![ChannelValueAttribute {
385                            val: "50".to_string(),
386                            channel: "Master".to_string(),
387                        }],
388                        mutes: vec![ChannelValueAttribute {
389                            val: "0".to_string(),
390                            channel: "Master".to_string(),
391                        }],
392                        bass: vec![],
393                        treble: vec![],
394                        loudness: vec![],
395                        balance: vec![],
396                    },
397                },
398            },
399        };
400
401        let enriched = create_enriched_event_with_registration_id(42, ip, source, event_data);
402
403        assert_eq!(enriched.registration_id, Some(42));
404    }
405
406    #[test]
407    fn test_lf_only_loudness_is_not_reported_as_master() {
408        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
409            <e:property>
410                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
411                    &lt;InstanceID val="0"&gt;
412                        &lt;Loudness channel="LF" val="1"/&gt;
413                    &lt;/InstanceID&gt;
414                &lt;/Event&gt;</LastChange>
415            </e:property>
416        </e:propertyset>"#;
417
418        let event = RenderingControlEvent::from_xml(xml).unwrap();
419        assert_eq!(event.loudness(), None);
420    }
421
422    #[test]
423    fn test_duplicate_loudness_channels_do_not_drop_event() {
424        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
425            <e:property>
426                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
427                    &lt;InstanceID val="0"&gt;
428                        &lt;Volume channel="Master" val="42"/&gt;
429                        &lt;Mute channel="Master" val="0"/&gt;
430                        &lt;Loudness channel="Master" val="1"/&gt;
431                        &lt;Loudness channel="LF" val="0"/&gt;
432                    &lt;/InstanceID&gt;
433                &lt;/Event&gt;</LastChange>
434            </e:property>
435        </e:propertyset>"#;
436
437        let parsed = RenderingControlEvent::from_xml(xml);
438        assert!(
439            parsed.is_ok(),
440            "per-channel Loudness must not drop the event"
441        );
442
443        let event = parsed.unwrap();
444        // The whole event previously failed to parse, taking volume/mute with it.
445        assert_eq!(event.master_volume(), Some("42".to_string()));
446        assert_eq!(event.master_mute(), Some("0".to_string()));
447        assert_eq!(event.loudness(), Some("1".to_string()));
448    }
449
450    #[test]
451    fn test_scalar_bass_still_parses() {
452        let xml = r#"<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0">
453            <e:property>
454                <LastChange>&lt;Event xmlns="urn:schemas-upnp-org:metadata-1-0/RCS/"&gt;
455                    &lt;InstanceID val="0"&gt;
456                        &lt;Bass val="2"/&gt;
457                    &lt;/InstanceID&gt;
458                &lt;/Event&gt;</LastChange>
459            </e:property>
460        </e:propertyset>"#;
461
462        let event = RenderingControlEvent::from_xml(xml).unwrap();
463        assert_eq!(event.bass(), Some("2".to_string()));
464    }
465
466    #[test]
467    fn test_into_state_maps_all_fields() {
468        let event = RenderingControlEvent {
469            property: RenderingControlProperty {
470                last_change: RenderingControlEventData {
471                    instance: RenderingControlInstance {
472                        volumes: vec![
473                            ChannelValueAttribute {
474                                val: "50".to_string(),
475                                channel: "Master".to_string(),
476                            },
477                            ChannelValueAttribute {
478                                val: "45".to_string(),
479                                channel: "LF".to_string(),
480                            },
481                            ChannelValueAttribute {
482                                val: "55".to_string(),
483                                channel: "RF".to_string(),
484                            },
485                        ],
486                        mutes: vec![ChannelValueAttribute {
487                            val: "0".to_string(),
488                            channel: "Master".to_string(),
489                        }],
490                        bass: vec![master("5")],
491                        treble: vec![master("-3")],
492                        loudness: vec![master("1")],
493                        balance: vec![],
494                    },
495                },
496            },
497        };
498
499        let state = event.into_state();
500
501        assert_eq!(state.master_volume, Some("50".to_string()));
502        assert_eq!(state.master_mute, Some("0".to_string()));
503        assert_eq!(state.lf_volume, Some("45".to_string()));
504        assert_eq!(state.rf_volume, Some("55".to_string()));
505        assert_eq!(state.bass, Some("5".to_string()));
506        assert_eq!(state.treble, Some("-3".to_string()));
507        assert_eq!(state.loudness, Some("1".to_string()));
508    }
509}