├── .editorconfig ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── device.rs ├── discovery.rs ├── error.rs └── lib.rs └── tests └── integration_test.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cmake-build-debug/ 2 | .idea/ 3 | 4 | /target/ 5 | **/*.rs.bk 6 | Cargo.lock 7 | 8 | /target/ 9 | **/*.rs.bk 10 | Cargo.lock 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sonos" 3 | version = "0.2.0" 4 | authors = ["Jordan Doyle "] 5 | license = "MIT" 6 | description = "Sonos controller library for.. controlling Sonos" 7 | repository = "https://github.com/w4/sonos.rs" 8 | keywords = ["sonos", "controller", "music"] 9 | readme = "README.md" 10 | edition = "2018" 11 | 12 | [dependencies] 13 | reqwest = "0.11" 14 | log = "0.4" 15 | ssdp-client = "1" 16 | futures = "0.3" 17 | xmltree = "0.10" 18 | failure = "0.1" 19 | regex = "1" 20 | lazy_static = "1" 21 | 22 | [dev-dependencies] 23 | tokio = { version = "1", features = [ "rt", "macros", "test-util" ], default-features = false } 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jordan Doyle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sonos.rs 2 | 3 | [![License](https://img.shields.io/github/license/w4/reaper.svg)](https://github.com/w4/sonos.rs) [![Downloads](https://img.shields.io/crates/d/sonos.svg)](https://crates.io/crates/sonos) [![Version](https://img.shields.io/crates/v/sonos.svg)](https://crates.io/crates/sonos) [![Docs](https://docs.rs/sonos/badge.svg)](https://docs.rs/sonos) 4 | 5 | sonos.rs is a Sonos controller library written in Rust. Currently it only supports playback operations (play, 6 | pause, stop, skip, add track to queue, remove track from queue) with no support for search operations as of yet. 7 | 8 | Example: 9 | 10 | ```rust 11 | extern crate sonos; 12 | 13 | let devices = sonos::discover().unwrap(); 14 | let bedroom = devices.iter() 15 | .find(|d| d.name == "Bedroom") 16 | .expect("Couldn't find bedroom"); 17 | 18 | let track = bedroom.track().unwrap(); 19 | let volume = bedroom.volume().unwrap(); 20 | 21 | bedroom.play(); 22 | println!("Now playing {} - {} at {}% volume.", track.title, track.artist, volume); 23 | ``` 24 | 25 | For a reference implementation of a CLI for Sonos please see [sonos-cli](https://github.com/w4/sonos-cli). 26 | -------------------------------------------------------------------------------- /src/device.rs: -------------------------------------------------------------------------------- 1 | use std::net::IpAddr; 2 | use std::time::Duration; 3 | 4 | use xmltree::{Element, XMLNode}; 5 | use reqwest::header::HeaderMap; 6 | use regex::Regex; 7 | 8 | use crate::error::*; 9 | use failure::Error; 10 | use std::borrow::Cow; 11 | use std::num::ParseIntError; 12 | 13 | #[derive(Debug)] 14 | pub struct Speaker { 15 | pub ip: IpAddr, 16 | pub model: String, 17 | pub model_number: String, 18 | pub software_version: String, 19 | pub hardware_version: String, 20 | pub serial_number: String, 21 | pub name: String, 22 | pub uuid: String, 23 | } 24 | 25 | #[derive(Debug)] 26 | pub struct Track { 27 | pub title: String, 28 | pub artist: String, 29 | pub album: Option, 30 | pub queue_position: u64, 31 | pub uri: String, 32 | pub duration: Duration, 33 | pub running_time: Duration, 34 | } 35 | 36 | #[derive(Debug, PartialEq)] 37 | pub enum TransportState { 38 | Stopped, 39 | Playing, 40 | PausedPlayback, 41 | PausedRecording, 42 | Recording, 43 | Transitioning, 44 | } 45 | 46 | lazy_static! { 47 | static ref COORDINATOR_REGEX: Regex = Regex::new(r"^https?://(.+?):1400/xml") 48 | .expect("Failed to create regex"); 49 | } 50 | 51 | fn get_child_element<'a>(el: &'a Element, name: &str) -> Result<&'a Element, Error> { 52 | el.get_child(name) 53 | .ok_or_else(|| SonosError::ParseError(format!("missing {} element", name)).into()) 54 | } 55 | 56 | fn get_child_element_text<'a>(el: &'a Element, name: &str) -> Result, Error> { 57 | get_child_element(el, name)? 58 | .get_text() 59 | .ok_or_else(|| SonosError::ParseError(format!("no text on {} element", name)).into()) 60 | } 61 | 62 | impl Speaker { 63 | /// Create a new instance of this struct from an IP address 64 | pub async fn from_ip(ip: IpAddr) -> Result { 65 | let resp = reqwest::get(&format!("http://{}:1400/xml/device_description.xml", ip)).await?; 66 | 67 | if !resp.status().is_success() { 68 | return Err(SonosError::BadResponse(resp.status().as_u16()).into()); 69 | } 70 | 71 | let root = Element::parse(resp.bytes().await?.as_ref())?; 72 | let device_description = get_child_element(&root, "device")?; 73 | 74 | Ok(Speaker { 75 | ip, 76 | model: get_child_element_text(device_description, "modelName")?.into_owned(), 77 | model_number: get_child_element_text(device_description, "modelNumber")?.into_owned(), 78 | software_version: get_child_element_text(device_description, "softwareVersion")?.into_owned(), 79 | hardware_version: get_child_element_text(device_description, "hardwareVersion")?.into_owned(), 80 | serial_number: get_child_element_text(device_description, "serialNum")?.into_owned(), 81 | name: get_child_element_text(device_description, "roomName")?.into_owned(), 82 | // we slice the UDN to remove "uuid:" 83 | uuid: get_child_element_text(device_description, "UDN")?[5..].to_string(), 84 | }) 85 | } 86 | 87 | /// Get the coordinator for this speaker. 88 | #[deprecated(note = "Broken on Sonos 9.1")] 89 | pub async fn coordinator(&self) -> Result { 90 | let resp = reqwest::get(&format!("http://{}:1400/status/topology", self.ip)).await?; 91 | 92 | if !resp.status().is_success() { 93 | return Err(SonosError::BadResponse(resp.status().as_u16()).into()); 94 | } 95 | 96 | let content = resp.text().await?; 97 | 98 | // parse the topology xml 99 | let elements = Element::parse(content.as_bytes())?; 100 | 101 | if elements.children.is_empty() { 102 | // on Sonos 9.1 this API will always return an empty string in which case we'll return 103 | // the current speaker's IP as the 'coordinator' 104 | return Ok(self.ip); 105 | } 106 | 107 | let zone_players = get_child_element(&elements, "ZonePlayers")?; 108 | 109 | // get the group identifier from the given player 110 | let group = &zone_players 111 | .children 112 | .iter() 113 | .map(XMLNode::as_element) 114 | .filter(Option::is_some) 115 | .map(Option::unwrap) 116 | .find(|child| child.attributes["uuid"] == self.uuid) 117 | .ok_or_else(|| SonosError::DeviceNotFound(self.uuid.to_string()))? 118 | .attributes["group"]; 119 | 120 | let parent = zone_players.children.iter() 121 | // get the coordinator for the given group 122 | .map(XMLNode::as_element) 123 | .filter(Option::is_some) 124 | .map(Option::unwrap) 125 | .find(|child| 126 | child.attributes.get("coordinator").unwrap_or(&"false".to_string()) == "true" && 127 | child.attributes.get("group").unwrap_or(&"".to_string()) == group) 128 | .ok_or_else(|| SonosError::DeviceNotFound(self.uuid.to_string()))? 129 | .attributes 130 | .get("location") 131 | .ok_or_else(|| SonosError::ParseError("missing group identifier".to_string()))?; 132 | 133 | Ok(COORDINATOR_REGEX 134 | .captures(parent) 135 | .ok_or_else(|| SonosError::ParseError("couldn't parse coordinator url".to_string()))?[1] 136 | .parse()?) 137 | } 138 | 139 | /// Call the Sonos SOAP endpoint 140 | /// 141 | /// # Arguments 142 | /// * `endpoint` - The SOAP endpoint to call (eg. MediaRenderer/AVTransport/Control) 143 | /// * `service` - The SOAP service to call (eg. urn:schemas-upnp-org:service:AVTransport:1) 144 | /// * `action` - The action to call on the soap service (eg. Play) 145 | /// * `payload` - XML doc to pass inside the action call body 146 | /// * `coordinator` - Whether this SOAP call should be performed on the group coordinator or 147 | /// the speaker it was called on 148 | pub async fn soap( 149 | &self, 150 | endpoint: &str, 151 | service: &str, 152 | action: &str, 153 | payload: &str, 154 | coordinator: bool, 155 | ) -> Result { 156 | let mut headers = HeaderMap::new(); 157 | headers.insert("Content-Type", "application/xml".parse()?); 158 | headers.insert("SOAPAction", format!("\"{}#{}\"", service, action).parse()?); 159 | 160 | let client = reqwest::Client::new(); 161 | let coordinator = if coordinator { 162 | self.coordinator().await? 163 | } else { 164 | self.ip 165 | }; 166 | 167 | debug!("Running {}#{} on {}", service, action, coordinator); 168 | 169 | let request = client 170 | .post(&format!("http://{}:1400/{}", coordinator, endpoint)) 171 | .headers(headers) 172 | .body(format!( 173 | r#" 174 | 176 | 177 | 178 | {payload} 179 | 180 | 181 | "#, 182 | service = service, 183 | action = action, 184 | payload = payload 185 | )) 186 | .send() 187 | .await?; 188 | 189 | let element = Element::parse(request.bytes().await?.as_ref())?; 190 | 191 | let body = get_child_element(&element, "Body")?; 192 | 193 | if let Some(fault) = body.get_child("Fault") { 194 | let error_code = fault 195 | .get_child("detail") 196 | .and_then(|c| c.get_child("UPnPError")) 197 | .and_then(|c| c.get_child("errorCode")) 198 | .and_then(|c| c.get_text()) 199 | .ok_or_else(|| SonosError::ParseError("failed to parse error".to_string()))? 200 | .parse::()?; 201 | 202 | let state = AVTransportError::from(error_code); 203 | error!("Got state {:?} from {}#{} call.", state, service, action); 204 | Err(SonosError::from(state).into()) 205 | } else { 206 | Ok(get_child_element(body, &format!("{}Response", action))?.clone()) 207 | } 208 | } 209 | 210 | /// Play the current track 211 | pub async fn play(&self) -> Result<(), Error> { 212 | self.soap( 213 | "MediaRenderer/AVTransport/Control", 214 | "urn:schemas-upnp-org:service:AVTransport:1", 215 | "Play", 216 | "01", 217 | true, 218 | ).await?; 219 | 220 | Ok(()) 221 | } 222 | 223 | /// Pause the current track 224 | pub async fn pause(&self) -> Result<(), Error> { 225 | self.soap( 226 | "MediaRenderer/AVTransport/Control", 227 | "urn:schemas-upnp-org:service:AVTransport:1", 228 | "Pause", 229 | "0", 230 | true, 231 | ).await?; 232 | 233 | Ok(()) 234 | } 235 | 236 | /// Stop the current queue 237 | pub async fn stop(&self) -> Result<(), Error> { 238 | self.soap( 239 | "MediaRenderer/AVTransport/Control", 240 | "urn:schemas-upnp-org:service:AVTransport:1", 241 | "Stop", 242 | "0", 243 | true, 244 | ).await?; 245 | 246 | Ok(()) 247 | } 248 | 249 | /// Seek to a time on the current track 250 | pub async fn seek(&self, time: &Duration) -> Result<(), Error> { 251 | const SECS_PER_MINUTE: u64 = 60; 252 | const MINS_PER_HOUR: u64 = 60; 253 | const SECS_PER_HOUR: u64 = 3600; 254 | 255 | let seconds = time.as_secs() % SECS_PER_MINUTE; 256 | let minutes = (time.as_secs() / SECS_PER_MINUTE) % MINS_PER_HOUR; 257 | let hours = time.as_secs() / SECS_PER_HOUR; 258 | 259 | self.soap( 260 | "MediaRenderer/AVTransport/Control", 261 | "urn:schemas-upnp-org:service:AVTransport:1", 262 | "Seek", 263 | &format!( 264 | "0REL_TIME{:02}:{:02}:{:02}", 265 | hours, minutes, seconds 266 | ), 267 | true, 268 | ).await?; 269 | 270 | Ok(()) 271 | } 272 | 273 | /// Play the Line In connected to this Speaker 274 | pub async fn play_line_in(&self) -> Result<(), Error> { 275 | self.play_track(&format!("x-rincon-stream:{}", self.uuid)).await 276 | } 277 | 278 | /// Play the optical input connected to this Speaker 279 | pub async fn play_tv(&self) -> Result<(), Error> { 280 | self.play_track(&format!("x-sonos-htastream:{}:spdif", self.uuid)).await 281 | } 282 | 283 | /// Replace the current track with a new one 284 | pub async fn play_track(&self, uri: &str) -> Result<(), Error> { 285 | self.soap( 286 | "MediaRenderer/AVTransport/Control", 287 | "urn:schemas-upnp-org:service:AVTransport:1", 288 | "SetAVTransportURI", 289 | &format!( 290 | r#" 291 | 0 292 | {} 293 | "#, 294 | uri 295 | ), 296 | true, 297 | ).await?; 298 | 299 | Ok(()) 300 | } 301 | 302 | /// Get the current volume 303 | pub async fn volume(&self) -> Result { 304 | let res = self.soap( 305 | "MediaRenderer/RenderingControl/Control", 306 | "urn:schemas-upnp-org:service:RenderingControl:1", 307 | "GetVolume", 308 | "0Master", 309 | false, 310 | ).await?; 311 | 312 | Ok(get_child_element_text(&res, "CurrentVolume")?.parse::()?) 313 | } 314 | 315 | /// Set a new volume from 0-100. 316 | pub async fn set_volume(&self, volume: u8) -> Result<(), Error> { 317 | if volume > 100 { 318 | panic!("Volume must be between 0 and 100, got {}.", volume); 319 | } 320 | 321 | self.soap( 322 | "MediaRenderer/RenderingControl/Control", 323 | "urn:schemas-upnp-org:service:RenderingControl:1", 324 | "SetVolume", 325 | &format!( 326 | r#" 327 | 0 328 | Master 329 | {}"#, 330 | volume 331 | ), 332 | false, 333 | ).await?; 334 | 335 | Ok(()) 336 | } 337 | 338 | /// Check if this player is currently muted 339 | pub async fn muted(&self) -> Result { 340 | let resp = self.soap( 341 | "MediaRenderer/RenderingControl/Control", 342 | "urn:schemas-upnp-org:service:RenderingControl:1", 343 | "GetMute", 344 | "0Master", 345 | false, 346 | ).await?; 347 | 348 | Ok(match get_child_element_text(&resp, "CurrentMute")?.as_ref() { 349 | "1" => true, 350 | "0" | _ => false, 351 | }) 352 | } 353 | 354 | /// Mute this Speaker 355 | pub async fn mute(&self) -> Result<(), Error> { 356 | self.soap( 357 | "MediaRenderer/RenderingControl/Control", 358 | "urn:schemas-upnp-org:service:RenderingControl:1", 359 | "SetMute", 360 | "0Master1", 361 | false, 362 | ).await?; 363 | 364 | Ok(()) 365 | } 366 | 367 | /// Unmute this Speaker 368 | pub async fn unmute(&self) -> Result<(), Error> { 369 | self.soap( 370 | "MediaRenderer/RenderingControl/Control", 371 | "urn:schemas-upnp-org:service:RenderingControl:1", 372 | "SetMute", 373 | "0Master0", 374 | false, 375 | ).await?; 376 | 377 | Ok(()) 378 | } 379 | 380 | /// Get the transport state of this Speaker 381 | pub async fn transport_state(&self) -> Result { 382 | let resp = self.soap( 383 | "MediaRenderer/AVTransport/Control", 384 | "urn:schemas-upnp-org:service:AVTransport:1", 385 | "GetTransportInfo", 386 | "0", 387 | false, 388 | ).await?; 389 | 390 | Ok(match get_child_element_text(&resp, "CurrentTransportState")?.as_ref() { 391 | "PLAYING" => TransportState::Playing, 392 | "PAUSED_PLAYBACK" => TransportState::PausedPlayback, 393 | "PAUSED_RECORDING" => TransportState::PausedRecording, 394 | "RECORDING" => TransportState::Recording, 395 | "TRANSITIONING" => TransportState::Transitioning, 396 | "STOPPED" | _ => TransportState::Stopped, 397 | }) 398 | } 399 | 400 | /// Groups this Speaker with the given master. 401 | /// 402 | /// This speaker will be synchronised with the master. 403 | pub async fn group(&self, master: &Speaker) -> Result<(), Error> { 404 | self.play_track(&format!("x-rincon:{}", master.uuid)).await 405 | } 406 | 407 | /// Ungroups this Speaker from any master it might've had. 408 | pub async fn ungroup(&self) -> Result<(), Error> { 409 | self.soap( 410 | "MediaRenderer/AVTransport/Control", 411 | "urn:schemas-upnp-org:service:AVTransport:1", 412 | "BecomeCoordinatorOfStandaloneGroup", 413 | "0", 414 | true, 415 | ).await?; 416 | 417 | Ok(()) 418 | } 419 | 420 | /// Grab this Speaker's queue to manipulate. 421 | pub fn queue(&self) -> Queue { 422 | Queue::for_speaker(self) 423 | } 424 | 425 | /// Get information about what's currently playing on this Speaker. 426 | pub async fn track(&self) -> Result { 427 | let resp = self.soap( 428 | "MediaRenderer/AVTransport/Control", 429 | "urn:schemas-upnp-org:service:AVTransport:1", 430 | "GetPositionInfo", 431 | "0", 432 | true, 433 | ).await?; 434 | 435 | let metadata = get_child_element_text(&resp, "TrackMetaData")?; 436 | 437 | if metadata.as_ref() == "NOT_IMPLEMENTED" { 438 | return Err(SonosError::ParseError("track information is not supported from the current source".to_string()).into()); 439 | } 440 | 441 | let metadata = Element::parse(metadata.as_bytes())?; 442 | 443 | let metadata = get_child_element(&metadata, "item")?; 444 | 445 | // convert the given hh:mm:ss to a Duration 446 | let mut duration = get_child_element_text(&resp, "TrackDuration")? 447 | .splitn(3, ':') 448 | .map(|s| s.parse::()) 449 | .collect::>>() 450 | .into_iter(); 451 | let duration = ( 452 | duration.next().ok_or_else(|| SonosError::ParseError("invalid TrackDuration".to_string()))?, 453 | duration.next().ok_or_else(|| SonosError::ParseError("invalid TrackDuration".to_string()))?, 454 | duration.next().ok_or_else(|| SonosError::ParseError("invalid TrackDuration".to_string()))?, 455 | ); 456 | let duration = Duration::from_secs((duration.0? * 3600) + (duration.1? * 60) + duration.2?); 457 | 458 | let mut running_time = get_child_element_text(&resp, "RelTime")? 459 | .splitn(3, ':') 460 | .map(|s| s.parse::()) 461 | .collect::>>() 462 | .into_iter(); 463 | let running_time = ( 464 | running_time.next().ok_or_else(|| SonosError::ParseError("invalid RelTime".to_string()))?, 465 | running_time.next().ok_or_else(|| SonosError::ParseError("invalid RelTime".to_string()))?, 466 | running_time.next().ok_or_else(|| SonosError::ParseError("invalid RelTime".to_string()))?, 467 | ); 468 | let running_time = Duration::from_secs((running_time.0? * 3600) + (running_time.1? * 60) + running_time.2?); 469 | 470 | Ok(Track { 471 | title: get_child_element_text(&metadata, "title")?.into_owned(), 472 | artist: get_child_element_text(&metadata, "creator")?.into_owned(), 473 | album: get_child_element_text(&metadata, "album").ok().map(Cow::into_owned), 474 | queue_position: get_child_element_text(&resp, "Track")?.parse::()?, 475 | uri: get_child_element_text(&resp, "TrackURI")?.into_owned(), 476 | duration, 477 | running_time, 478 | }) 479 | } 480 | } 481 | 482 | /// An item in the queue. 483 | pub struct QueueItem { 484 | pub position: u64, 485 | pub uri: String, 486 | pub title: String, 487 | pub artist: String, 488 | pub album: String, 489 | pub album_art: String, 490 | pub duration: Duration, 491 | } 492 | 493 | /// Provides some methods for manipulating the queue of the 494 | /// [Speaker] that spawned this [Queue]. 495 | pub struct Queue<'a> { 496 | speaker: &'a Speaker, 497 | } 498 | impl<'a> Queue<'a> { 499 | pub fn for_speaker(speaker: &'a Speaker) -> Self { 500 | Self { 501 | speaker, 502 | } 503 | } 504 | 505 | pub async fn list(&self) -> Result, Error> { 506 | let res = self.speaker.soap( 507 | "MediaServer/ContentDirectory/Control", 508 | "urn:schemas-upnp-org:service:ContentDirectory:1", 509 | "Browse", 510 | r" 511 | Q:0 512 | BrowseDirectChildren 513 | 514 | 0 515 | 1000 516 | ", 517 | true 518 | ).await?; 519 | 520 | let results = Element::parse( 521 | res.get_child("Result") 522 | .and_then(Element::get_text) 523 | .ok_or_else(|| SonosError::ParseError("missing Result element".to_string()))? 524 | .as_bytes() 525 | )?; 526 | 527 | let mut tracks = Vec::new(); 528 | 529 | for child in results.children { 530 | if let Some(child) = child.as_element() { 531 | tracks.push(QueueItem { 532 | position: child.attributes.get("id").cloned().unwrap_or_default().split('/').next_back().unwrap().parse().unwrap(), 533 | uri: child.get_child("res") 534 | .and_then(Element::get_text) 535 | .map(|e| e.to_string()) 536 | .unwrap_or_default(), 537 | title: child.get_child("title") 538 | .and_then(Element::get_text) 539 | .map(|e| e.to_string()) 540 | .unwrap_or_default(), 541 | artist: child.get_child("creator") 542 | .and_then(Element::get_text) 543 | .map(|e| e.to_string()) 544 | .unwrap_or_default(), 545 | album: child.get_child("album") 546 | .and_then(Element::get_text) 547 | .map(|e| e.to_string()) 548 | .unwrap_or_default(), 549 | album_art: child.get_child("albumArtURI") 550 | .and_then(Element::get_text) 551 | .map(|e| e.to_string()) 552 | .unwrap_or_default(), 553 | duration: { 554 | let mut duration = child.get_child("res") 555 | .map(|e| e.attributes.get("duration").cloned().unwrap_or_default()) 556 | .unwrap() 557 | .splitn(3, ':') 558 | .map(|s| s.parse::()) 559 | .collect::>>(); 560 | Duration::from_secs((duration.remove(0)? * 3600) + (duration.remove(0)? * 60) + duration.remove(0)?) 561 | } 562 | }); 563 | } 564 | } 565 | 566 | Ok(tracks) 567 | } 568 | 569 | /// Skip the current track 570 | pub async fn next(&self) -> Result<(), Error> { 571 | self.speaker.soap( 572 | "MediaRenderer/AVTransport/Control", 573 | "urn:schemas-upnp-org:service:AVTransport:1", 574 | "Next", 575 | "0", 576 | true, 577 | ).await?; 578 | 579 | self.speaker.play().await?; 580 | 581 | Ok(()) 582 | } 583 | 584 | /// Go to the previous track 585 | pub async fn previous(&self) -> Result<(), Error> { 586 | self.speaker.soap( 587 | "MediaRenderer/AVTransport/Control", 588 | "urn:schemas-upnp-org:service:AVTransport:1", 589 | "Previous", 590 | "0", 591 | true, 592 | ).await?; 593 | 594 | self.speaker.play().await?; 595 | 596 | Ok(()) 597 | } 598 | 599 | /// Change the track, beginning at 1 600 | pub async fn skip_to(&self, track: &u64) -> Result<(), Error> { 601 | self.speaker.play_track(&format!("x-rincon-queue:{}#0", self.speaker.uuid)).await?; 602 | 603 | self.speaker.soap( 604 | "MediaRenderer/AVTransport/Control", 605 | "urn:schemas-upnp-org:service:AVTransport:1", 606 | "Seek", 607 | &format!( 608 | "0TRACK_NR{}", 609 | track 610 | ), 611 | true, 612 | ).await?; 613 | 614 | self.speaker.play().await?; 615 | 616 | Ok(()) 617 | } 618 | 619 | /// Remove track at index from queue, beginning at 1 620 | pub async fn remove(&self, track: &u64) -> Result<(), Error> { 621 | self.speaker.soap( 622 | "MediaRenderer/AVTransport/Control", 623 | "urn:schemas-upnp-org:service:AVTransport:1", 624 | "RemoveTrackFromQueue", 625 | &format!( 626 | "0Q:0/{}", 627 | track 628 | ), 629 | true, 630 | ).await?; 631 | 632 | Ok(()) 633 | } 634 | 635 | /// Add a new track to the end of the queue 636 | pub async fn add_end(&self, uri: &str) -> Result<(), Error> { 637 | self.speaker.soap( 638 | "MediaRenderer/AVTransport/Control", 639 | "urn:schemas-upnp-org:service:AVTransport:1", 640 | "AddURIToQueue", 641 | &format!( 642 | r#" 643 | 0 644 | {} 645 | 646 | 0 647 | 0"#, 648 | uri 649 | ), 650 | true, 651 | ).await?; 652 | 653 | Ok(()) 654 | } 655 | 656 | /// Add a track to the queue to play next 657 | pub async fn add_next(&self, uri: &str) -> Result<(), Error> { 658 | self.speaker.soap( 659 | "MediaRenderer/AVTransport/Control", 660 | "urn:schemas-upnp-org:service:AVTransport:1", 661 | "AddURIToQueue", 662 | &format!( 663 | r#" 664 | 0 665 | {} 666 | 667 | 0 668 | 1"#, 669 | uri 670 | ), 671 | true, 672 | ).await?; 673 | 674 | Ok(()) 675 | } 676 | 677 | /// Remove every track from the queue 678 | pub async fn clear(&self) -> Result<(), Error> { 679 | self.speaker.soap( 680 | "MediaRenderer/AVTransport/Control", 681 | "urn:schemas-upnp-org:service:AVTransport:1", 682 | "RemoveAllTracksFromQueue", 683 | "0", 684 | true, 685 | ).await?; 686 | 687 | Ok(()) 688 | } 689 | } 690 | -------------------------------------------------------------------------------- /src/discovery.rs: -------------------------------------------------------------------------------- 1 | use crate::device::Speaker; 2 | 3 | use std::time::Duration; 4 | use regex::Regex; 5 | 6 | use ssdp_client::URN; 7 | use failure::Error; 8 | 9 | use futures::prelude::*; 10 | 11 | lazy_static! { 12 | static ref LOCATION_REGEX: Regex = Regex::new(r"^https?://(.+?):1400/xml") 13 | .expect("Failed to create regex"); 14 | } 15 | 16 | /// Discover all speakers on the current network. 17 | /// 18 | /// This method **will** block for 2 seconds while waiting for broadcast responses. 19 | pub async fn discover() -> Result, Error> { 20 | let search_target = URN::device("schemas-upnp-org", "ZonePlayer", 1).into(); 21 | let timeout = Duration::from_secs(2); 22 | let responses = ssdp_client::search(&search_target, timeout, 1).await?; 23 | futures::pin_mut!(responses); 24 | 25 | let mut speakers = Vec::new(); 26 | 27 | while let Some(response) = responses.next().await { 28 | let response = response?; 29 | 30 | if let Some(ip) = LOCATION_REGEX.captures(response.location()).and_then(|x| x.get(1)).map(|x| x.as_str()) { 31 | speakers.push(Speaker::from_ip(ip.parse()?).await?); 32 | } 33 | } 34 | 35 | Ok(speakers) 36 | } 37 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Fail)] 2 | pub enum SonosError { 3 | #[fail(display = "Received error {:?} from Sonos speaker", 0)] 4 | AVTransportError(AVTransportError), 5 | #[fail(display = "Failed to parse Sonos response XML ({})", 0)] 6 | ParseError(String), 7 | #[fail(display = "Failed to call Sonos endpoint")] 8 | DeviceUnreachable, 9 | #[fail(display = "Received a non-success ({}) response from Sonos", 0)] 10 | BadResponse(u16), 11 | #[fail(display = "Couldn't find a device by the given identifier ({})", 0)] 12 | DeviceNotFound(String), 13 | } 14 | 15 | impl From for SonosError { 16 | fn from(error: AVTransportError) -> Self { 17 | SonosError::AVTransportError(error) 18 | } 19 | } 20 | 21 | #[derive(Debug)] 22 | pub enum AVTransportError { 23 | /// No action by that name at this service. 24 | InvalidAction = 401, 25 | /// Could be any of the following: not enough in args, too many in args, no in arg by that name, 26 | /// one or more in args are of the wrong data type. 27 | InvalidArgs = 402, 28 | /// No state variable by that name at this service. 29 | InvalidVar = 404, 30 | /// May be returned in current state of service prevents invoking that action. 31 | ActionFailed = 501, 32 | /// The immediate transition from current transport state to desired transport state is not 33 | /// supported by this device. 34 | TransitionNotAvailable = 701, 35 | /// The media does not contain any contents that can be played. 36 | NoContents = 702, 37 | /// The media cannot be read (e.g., because of dust or a scratch). 38 | ReadError = 703, 39 | /// The storage format of the currently loaded media is not supported 40 | FormatNotSupported = 704, 41 | /// The transport is “hold locked”. 42 | TransportLocked = 705, 43 | /// The media cannot be written (e.g., because of dust or a scratch) 44 | WriteError = 706, 45 | /// The media is write-protected or is of a not writable type. 46 | MediaNotWriteable = 707, 47 | /// The storage format of the currently loaded media is not supported for recording by this 48 | /// device 49 | RecordingFormatNotSupported = 708, 50 | /// There is no free space left on the loaded media 51 | MediaFull = 709, 52 | /// The specified seek mode is not supported by the device 53 | SeekModeNotSupported = 710, 54 | /// The specified seek target is not specified in terms of the seek mode, or is not present on 55 | /// the media 56 | IllegalSeekTarget = 711, 57 | /// The specified play mode is not supported by the device 58 | PlayModeNotSupported = 712, 59 | /// The specified record quality is not supported by the device 60 | RecordQualityNotSupported = 713, 61 | /// The resource to be played has a mimetype which is not supported by the AVTransport service 62 | IllegalMimeType = 714, 63 | /// This indicates the resource is already being played by other means 64 | ContentBusy = 715, 65 | /// The specified playback speed is not supported by the AVTransport service 66 | PlaySpeedNotSupported = 717, 67 | /// The specified instanceID is invalid for this AVTransport 68 | InvalidInstanceId = 718, 69 | /// The DNS Server is not available (HTTP error 503) 70 | NoDnsServer = 737, 71 | /// Unable to resolve the Fully Qualified Domain Name. (HTTP error 502) 72 | BadDomainName = 738, 73 | /// The server that hosts the resource is unreachable or unresponsive (HTTP error 404/410). 74 | ServerError = 739, 75 | /// Error we've not come across before 76 | Unknown, 77 | } 78 | 79 | impl From for AVTransportError { 80 | fn from(code: u64) -> AVTransportError { 81 | match code { 82 | 401 => AVTransportError::InvalidAction, 83 | 402 => AVTransportError::InvalidArgs, 84 | 404 => AVTransportError::InvalidVar, 85 | 501 => AVTransportError::ActionFailed, 86 | 701 => AVTransportError::TransitionNotAvailable, 87 | 702 => AVTransportError::NoContents, 88 | 703 => AVTransportError::ReadError, 89 | 704 => AVTransportError::FormatNotSupported, 90 | 705 => AVTransportError::TransportLocked, 91 | 706 => AVTransportError::WriteError, 92 | 707 => AVTransportError::MediaNotWriteable, 93 | 708 => AVTransportError::RecordingFormatNotSupported, 94 | 709 => AVTransportError::MediaFull, 95 | 710 => AVTransportError::SeekModeNotSupported, 96 | 711 => AVTransportError::IllegalSeekTarget, 97 | 712 => AVTransportError::PlayModeNotSupported, 98 | 713 => AVTransportError::RecordQualityNotSupported, 99 | 714 => AVTransportError::IllegalMimeType, 100 | 715 => AVTransportError::ContentBusy, 101 | 717 => AVTransportError::PlaySpeedNotSupported, 102 | 718 => AVTransportError::InvalidInstanceId, 103 | 737 => AVTransportError::NoDnsServer, 104 | 738 => AVTransportError::BadDomainName, 105 | 739 => AVTransportError::ServerError, 106 | _ => AVTransportError::Unknown, 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] extern crate log; 2 | #[macro_use] extern crate failure; 3 | #[macro_use] extern crate lazy_static; 4 | 5 | mod discovery; 6 | mod device; 7 | mod error; 8 | 9 | pub use device::Speaker; 10 | pub use device::Track; 11 | pub use device::TransportState; 12 | pub use error::*; 13 | 14 | pub use discovery::discover; 15 | -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | extern crate sonos; 2 | 3 | use sonos::TransportState; 4 | 5 | async fn get_speaker() -> sonos::Speaker { 6 | let devices = sonos::discover().await.unwrap(); 7 | 8 | devices 9 | .into_iter() 10 | .find(|d| d.name == "Living Room") 11 | .ok_or("Couldn't find bedroom") 12 | .unwrap() 13 | } 14 | 15 | #[tokio::test] 16 | async fn can_discover_devices() { 17 | let devices = sonos::discover().await.unwrap(); 18 | assert!(!devices.is_empty(), "No devices discovered"); 19 | } 20 | 21 | #[tokio::test] 22 | async fn volume() { 23 | let device = get_speaker().await; 24 | device.set_volume(2).await.expect("Failed to get volume"); 25 | assert_eq!( 26 | device.volume().await.expect("Failed to get volume"), 27 | 2 as u8, 28 | "Volume was not updated." 29 | ); 30 | } 31 | 32 | #[tokio::test] 33 | async fn muted() { 34 | let device = get_speaker().await; 35 | device.mute().await.expect("Couldn't mute player"); 36 | assert_eq!( 37 | device 38 | .muted() 39 | .await 40 | .expect("Failed to get current mute status"), 41 | true 42 | ); 43 | device.unmute().await.expect("Couldn't unmute player"); 44 | assert_eq!( 45 | device 46 | .muted() 47 | .await 48 | .expect("Failed to get current mute status"), 49 | false 50 | ); 51 | } 52 | 53 | #[tokio::test] 54 | async fn playback_state() { 55 | let device = get_speaker().await; 56 | 57 | device.play().await.expect("Couldn't play track"); 58 | assert!(match device.transport_state().await.unwrap() { 59 | TransportState::Playing | TransportState::Transitioning => true, 60 | _ => false, 61 | }); 62 | 63 | device.pause().await.expect("Couldn't pause track"); 64 | assert!(match device.transport_state().await.unwrap() { 65 | TransportState::PausedPlayback | TransportState::Transitioning => true, 66 | _ => false, 67 | }); 68 | 69 | device.stop().await.expect("Couldn't stop track"); 70 | let state = device.transport_state().await.unwrap(); 71 | // eprintln!("{:#?}", state); 72 | // This returns PausedPlayback on my speaker - is stop no longer supported? 73 | assert!(match state { 74 | TransportState::Stopped | TransportState::Transitioning => true, 75 | _ => false, 76 | }); 77 | } 78 | 79 | #[tokio::test] 80 | async fn track_info() { 81 | let device = get_speaker().await; 82 | device.track().await.expect("Failed to get track info"); 83 | } 84 | 85 | #[tokio::test] 86 | async fn seek() { 87 | let device = get_speaker().await; 88 | device 89 | .seek(&std::time::Duration::from_secs(30)) 90 | .await 91 | .expect("Failed to seek to 30 seconds"); 92 | assert_eq!( 93 | device 94 | .track() 95 | .await 96 | .expect("Failed to get track info") 97 | .running_time 98 | .as_secs(), 99 | 30 100 | ); 101 | } 102 | 103 | #[tokio::test] 104 | async fn play() { 105 | let device = get_speaker().await; 106 | device.play().await.expect("Failed to play"); 107 | device.pause().await.expect("Failed to pause"); 108 | } 109 | 110 | #[tokio::test] 111 | #[should_panic] 112 | async fn fail_on_set_invalid_volume() { 113 | get_speaker() 114 | .await 115 | .set_volume(101) 116 | .await 117 | .expect_err("Didn't fail on invalid volume"); 118 | } 119 | --------------------------------------------------------------------------------