sonos/
lib.rs

1use instant_xml::{FromXmlOwned, ToXml};
2use reqwest::{StatusCode, Url};
3use std::net::Ipv4Addr;
4use thiserror::Error;
5
6mod didl;
7mod discovery;
8mod generated;
9mod upnp;
10mod xmlutil;
11mod zone;
12
13pub use didl::*;
14pub use discovery::*;
15pub use generated::*;
16pub use upnp::*;
17pub use xmlutil::DecodeXmlString;
18pub use zone::*;
19
20pub type Result<T> = std::result::Result<T, Error>;
21
22#[derive(Debug, Error)]
23pub enum Error {
24    #[error("XML Error: {0}")]
25    Xml(#[from] instant_xml::Error),
26    #[error("XML Error: {error:#} while parsing {text}")]
27    XmlParse {
28        error: instant_xml::Error,
29        text: String,
30    },
31    #[error("Service {0:?} is not supported by this device")]
32    UnsupportedService(String),
33    #[error("Invalid URI: {0:#?}")]
34    InvalidUri(#[from] url::ParseError),
35    #[error("Reqwest Error: {0:#?}")]
36    Reqwest(#[from] reqwest::Error),
37    #[error("Failed Request: {status:?} {body}")]
38    FailedRequest {
39        status: StatusCode,
40        body: String,
41        headers: reqwest::header::HeaderMap,
42    },
43    #[error("Device has no name!?")]
44    NoName,
45    #[error("I/O Error: {0:#}")]
46    Io(#[from] std::io::Error),
47    #[error("Invalid enum variant value")]
48    InvalidEnumVariantValue,
49    #[error("Room {0} not found")]
50    RoomNotFound(String),
51    #[error("Cannot find IP from device URL! {0:?}")]
52    NoIpInDeviceUrl(Url),
53    #[error("Subscription failed because SID header is missing")]
54    SubscriptionFailedNoSid,
55    #[error("TrackMetaData list is empty!?")]
56    EmptyTrackMetaData,
57    #[error("TrackMetaData has multiple items but expect a single item")]
58    MoreThanOneTrackMetaData,
59    #[error("LastChange format unexpected {0}")]
60    LastChangeFormatUnexpected(String),
61}
62
63impl Error {
64    pub async fn with_failed_http_response(response: reqwest::Response) -> Error {
65        let status = response.status();
66        let headers = response.headers().clone();
67        let body = match response.bytes().await {
68            Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
69            Err(err) => format!("Failed to retrieve body from failed request: {err:#}"),
70        };
71
72        return Error::FailedRequest {
73            status,
74            body,
75            headers,
76        };
77    }
78
79    pub async fn check_response(response: reqwest::Response) -> Result<reqwest::Response> {
80        let status = response.status();
81        if !status.is_success() {
82            Err(Self::with_failed_http_response(response).await)
83        } else {
84            Ok(response)
85        }
86    }
87}
88
89#[derive(Debug, Clone)]
90pub struct SonosDevice {
91    url: Url,
92    device: DeviceSpec,
93}
94
95impl SonosDevice {
96    /// Constructs a SonosDevice from the supplied IP Address.
97    /// Validates that the device is actually a Sonos device
98    /// before returning successfully.
99    pub async fn from_ip(addr: Ipv4Addr) -> Result<Self> {
100        Self::from_url(format!("http://{addr}:1400/xml/device_description.xml").parse()?).await
101    }
102
103    /// Resolves the SonosDevice whose name is equal to the provided
104    /// name.  If no matching device is found within a reasonably
105    /// short, unspecified, implementation-defined timeout, then
106    /// an `Error::RoomNotFound` is produced.
107    pub async fn for_room(room_name: &str) -> Result<Self> {
108        let mut rx = discover(std::time::Duration::from_secs(15)).await?;
109        while let Some(device) = rx.recv().await {
110            if let Ok(name) = device.name().await {
111                if name == room_name {
112                    return Ok(device);
113                }
114            }
115        }
116
117        Err(Error::RoomNotFound(room_name.to_string()))
118    }
119
120    /// Constructs a SonosDevice from the supplied URL, which must
121    /// be the device_description.xml URL for that device.
122    /// Validates that the device is actually a Sonos device
123    /// before returning successfully.
124    pub async fn from_url(url: Url) -> Result<Self> {
125        let response = reqwest::get(url.clone()).await?;
126
127        let response = Error::check_response(response).await?;
128        let body = response.text().await?;
129        let device = DeviceSpec::parse_xml(&body)?;
130
131        Ok(Self { url, device })
132    }
133
134    /// Returns the room/zone name of the device
135    pub async fn name(&self) -> Result<String> {
136        let attr = self.get_zone_attributes().await?;
137        attr.current_zone_name.ok_or(Error::NoName)
138    }
139
140    /// Returns information about the zone to which this device belongs
141    pub async fn get_zone_group_state(&self) -> Result<Vec<ZoneGroup>> {
142        let state = <Self as ZoneGroupTopology>::get_zone_group_state(self).await?;
143        Ok(match state.zone_group_state {
144            Some(state) => state
145                .into_inner()
146                .map(|s| s.groups)
147                .unwrap_or_else(Vec::new),
148            None => vec![],
149        })
150    }
151
152    /// Stops playback
153    pub async fn stop(&self) -> Result<()> {
154        <Self as AVTransport>::stop(self, Default::default()).await
155    }
156
157    /// Begin playback
158    pub async fn play(&self) -> Result<()> {
159        <Self as AVTransport>::play(
160            self,
161            av_transport::PlayRequest {
162                instance_id: 0,
163                speed: "1".to_string(),
164            },
165        )
166        .await
167    }
168
169    /// pause playback
170    pub async fn pause(&self) -> Result<()> {
171        <Self as AVTransport>::pause(self, av_transport::PauseRequest { instance_id: 0 }).await
172    }
173
174    /// Clears the queue
175    pub async fn queue_clear(&self) -> Result<()> {
176        <Self as AVTransport>::remove_all_tracks_from_queue(self, Default::default()).await
177    }
178
179    pub async fn set_play_mode(&self, new_play_mode: CurrentPlayMode) -> Result<()> {
180        <Self as AVTransport>::set_play_mode(
181            self,
182            av_transport::SetPlayModeRequest {
183                instance_id: 0,
184                new_play_mode: new_play_mode,
185            },
186        )
187        .await
188    }
189
190    pub async fn set_av_transport_uri(
191        &self,
192        uri: &str,
193        metadata: Option<TrackMetaData>,
194    ) -> Result<()> {
195        <Self as AVTransport>::set_av_transport_uri(
196            self,
197            av_transport::SetAvTransportUriRequest {
198                instance_id: 0,
199                current_uri: uri.to_string(),
200                current_uri_meta_data: metadata.into(),
201            },
202        )
203        .await
204    }
205
206    pub async fn queue_prepend(
207        &self,
208        uri: &str,
209        metadata: Option<TrackMetaData>,
210    ) -> Result<av_transport::AddUriToQueueResponse> {
211        <Self as AVTransport>::add_uri_to_queue(
212            self,
213            av_transport::AddUriToQueueRequest {
214                instance_id: 0,
215                enqueued_uri: uri.to_string(),
216                enqueued_uri_meta_data: metadata.into(),
217                desired_first_track_number_enqueued: 0,
218                enqueue_as_next: true,
219            },
220        )
221        .await
222    }
223
224    pub async fn queue_append(
225        &self,
226        uri: &str,
227        metadata: Option<TrackMetaData>,
228    ) -> Result<av_transport::AddUriToQueueResponse> {
229        <Self as AVTransport>::add_uri_to_queue(
230            self,
231            av_transport::AddUriToQueueRequest {
232                instance_id: 0,
233                enqueued_uri: uri.to_string(),
234                enqueued_uri_meta_data: metadata.into(),
235                desired_first_track_number_enqueued: 0,
236                enqueue_as_next: false,
237            },
238        )
239        .await
240    }
241
242    pub async fn queue_browse(
243        &self,
244        starting_index: u32,
245        requested_count: u32,
246    ) -> Result<Vec<TrackMetaData>> {
247        let result = <Self as Queue>::browse(
248            self,
249            queue::BrowseRequest {
250                queue_id: 0,
251                starting_index: starting_index,
252                requested_count: requested_count,
253            },
254        )
255        .await?;
256
257        match result.result {
258            Some(list) => Ok(list.into_inner().map(|i| i.tracks).unwrap_or_else(Vec::new)),
259            None => Ok(vec![]),
260        }
261    }
262
263    pub fn url(&self) -> &Url {
264        &self.url
265    }
266}
267
268const SOAP_ENCODING: &str = "http://schemas.xmlsoap.org/soap/encoding/";
269const SOAP_ENVELOPE: &str = "http://schemas.xmlsoap.org/soap/envelope/";
270
271mod soap {
272    use super::SOAP_ENVELOPE;
273    use instant_xml::ToXml;
274
275    #[derive(Debug, Eq, PartialEq, ToXml)]
276    pub struct Unit;
277
278    #[derive(Debug, Eq, PartialEq, ToXml)]
279    #[xml(rename="s:Envelope", ns("", s = SOAP_ENVELOPE))]
280    pub struct Envelope<T: ToXml> {
281        #[xml(attribute, rename = "s:encodingStyle")]
282        pub encoding_style: &'static str,
283        pub body: Body<T>,
284    }
285
286    #[derive(Debug, Eq, PartialEq, ToXml)]
287    #[xml(rename = "s:Body")]
288    pub struct Body<T: ToXml> {
289        pub payload: T,
290    }
291}
292
293mod soap_resp {
294    use super::SOAP_ENVELOPE;
295    use instant_xml::FromXml;
296
297    #[derive(Debug, Eq, PartialEq, FromXml)]
298    #[xml(ns(SOAP_ENVELOPE))]
299    pub struct Envelope<T> {
300        #[xml(rename = "encodingStyle", attribute, ns(SOAP_ENVELOPE))]
301        pub encoding_style: String,
302        pub body: Body<T>,
303    }
304
305    #[derive(Debug, Eq, PartialEq, FromXml)]
306    #[xml(ns(SOAP_ENVELOPE))]
307    pub struct Body<T> {
308        pub payload: T,
309    }
310}
311
312/// Special case for decoding (), as instant_xml considers the empty
313/// body in the `soap_resp::Body<T>` case to be an error
314mod soap_empty_resp {
315    use super::SOAP_ENVELOPE;
316    use instant_xml::FromXml;
317
318    #[derive(Debug, Eq, PartialEq, FromXml)]
319    #[xml(ns(SOAP_ENVELOPE))]
320    pub struct Envelope {
321        #[xml(rename = "encodingStyle", attribute, ns(SOAP_ENVELOPE))]
322        pub encoding_style: String,
323        pub body: Body,
324    }
325
326    #[derive(Debug, Eq, PartialEq, FromXml)]
327    #[xml(ns(SOAP_ENVELOPE))]
328    pub struct Body {}
329}
330
331/// This trait decodes a SOAP response envelope into Self
332pub trait DecodeSoapResponse {
333    /// xml is a complete Soap `<Envelope>` element.
334    /// This method decodes and returns Self from that Envelope.
335    fn decode_soap_xml(xml: &str) -> Result<Self>
336    where
337        Self: Sized;
338}
339
340impl DecodeSoapResponse for () {
341    fn decode_soap_xml(xml: &str) -> Result<()> {
342        // Verify that it parses, but discard because it has no
343        // useful content for us
344        let _envelope: soap_empty_resp::Envelope = instant_xml::from_str(xml)?;
345        Ok(())
346    }
347}
348
349impl SonosDevice {
350    pub fn device_spec(&self) -> &DeviceSpec {
351        &self.device
352    }
353
354    pub async fn subscribe_helper<T: DecodeXml + 'static>(
355        &self,
356        service: &str,
357    ) -> Result<EventStream<T>> {
358        let service = self
359            .device
360            .get_service(service)
361            .ok_or_else(|| Error::UnsupportedService(service.to_string()))?;
362        service.subscribe(&self.url).await
363    }
364
365    /// This is a low level helper function for performing a SOAP Action
366    /// request. You most likely want to use one of the methods
367    /// implemented by the various service traits instead of this.
368    pub async fn action<REQ: ToXml, RESP>(
369        &self,
370        service: &str,
371        action: &str,
372        payload: REQ,
373    ) -> Result<RESP>
374    where
375        RESP: FromXmlOwned + std::fmt::Debug + DecodeSoapResponse,
376    {
377        let service = self
378            .device
379            .get_service(service)
380            .ok_or_else(|| Error::UnsupportedService(service.to_string()))?;
381
382        let envelope = soap::Envelope {
383            encoding_style: SOAP_ENCODING,
384            body: soap::Body { payload },
385        };
386
387        let body = instant_xml::to_string(&envelope)?;
388        log::trace!("Sending: {body}");
389
390        let soap_action = format!("\"{}#{action}\"", service.service_type);
391        let url = service.control_url(&self.url);
392
393        let response = reqwest::Client::new()
394            .post(url)
395            .header("CONTENT-TYPE", "text/xml; charset=\"utf-8\"")
396            .header("SOAPAction", soap_action)
397            .body::<String>(body.into())
398            .send()
399            .await?;
400
401        let response = Error::check_response(response).await?;
402
403        let body = response.text().await?;
404        log::trace!("Got response: {body}");
405
406        RESP::decode_soap_xml(&body)
407    }
408}
409
410#[cfg(test)]
411mod test {
412    use super::*;
413
414    #[test]
415    fn test_xml() {
416        use crate::av_transport::StopRequest;
417        let stop = StopRequest { instance_id: 32 };
418        k9::snapshot!(
419            instant_xml::to_string(&stop).unwrap(),
420            r#"<Stop xmlns="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID xmlns="">32</InstanceID></Stop>"#
421        );
422    }
423
424    #[test]
425    fn test_soap_envelope() {
426        use crate::av_transport::StopRequest;
427
428        let action = soap::Envelope {
429            encoding_style: crate::SOAP_ENCODING,
430            body: soap::Body {
431                payload: StopRequest { instance_id: 0 },
432            },
433        };
434
435        k9::snapshot!(
436            instant_xml::to_string(&action).unwrap(),
437            r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><Stop xmlns="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID xmlns="">0</InstanceID></Stop></s:Body></s:Envelope>"#
438        );
439    }
440}