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 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 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 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 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 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 pub async fn stop(&self) -> Result<()> {
154 <Self as AVTransport>::stop(self, Default::default()).await
155 }
156
157 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 pub async fn pause(&self) -> Result<()> {
171 <Self as AVTransport>::pause(self, av_transport::PauseRequest { instance_id: 0 }).await
172 }
173
174 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
312mod 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
331pub trait DecodeSoapResponse {
333 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 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 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}