From 19bbe49ba8baf964cdd740ec740faf6d32aef7db Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Mon, 27 Nov 2017 02:48:29 +0000 Subject: [PATCH] Initial commit --- .editorconfig | 12 ++++++++++++ .gitignore | 10 ++++++++++ Cargo.toml | 13 +++++++++++++ .idea/vcs.xml | 6 ++++++ src/device.rs | 467 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/discovery.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/error.rs | 1 + src/lib.rs | 20 ++++++++++++++++++++ tests/integration_test.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 650 insertions(+) diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d1f040a 100644 --- /dev/null +++ a/.editorconfig @@ -1,0 +1,12 @@ +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abb2003 100644 --- /dev/null +++ a/.gitignore @@ -1,0 +1,10 @@ +cmake-build-debug/ +.idea/ + +/target/ +**/*.rs.bk +Cargo.lock + +/target/ +**/*.rs.bk +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..109d781 100644 --- /dev/null +++ a/Cargo.toml @@ -1,0 +1,13 @@ +[package] +name = "soncon" +version = "0.1.0" +authors = ["Jordan Doyle "] +license = "MIT" + +[dependencies] +clippy = {version = "*", optional = true} +reqwest = "0.8" +log = "0.3" +ssdp = "0.6" +xmltree = "0.6" +error-chain = "0.11"diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 100644 --- /dev/null +++ a/.idea/vcs.xml @@ -1,0 +1,6 @@ + + + + + +diff --git a/src/device.rs b/src/device.rs new file mode 100644 index 0000000..fbcae89 100644 --- /dev/null +++ a/src/device.rs @@ -1,0 +1,467 @@ +extern crate reqwest; +extern crate xmltree; + +use std::net::IpAddr; +use error::*; +use self::xmltree::Element; +use self::reqwest::header::{ContentType, Headers}; + +#[derive(Debug)] +pub struct Device { + pub ip: IpAddr, + pub model: String, + pub model_number: String, + pub software_version: String, + pub hardware_version: String, + pub serial_number: String, + pub room: Room, +} + +#[derive(Debug)] +pub struct Track { + pub title: String, + pub artist: String, + pub album: String, + pub queue_position: u64, + pub uri: String, + pub duration: String, + pub relative_time: String, +} + +#[derive(Debug, PartialEq)] +pub struct Room { + pub room: String, +} + +#[derive(Debug, PartialEq)] +pub enum TransportState { + Stopped, + Playing, + PausedPlayback, + Transitioning, +} + +impl From for Room { + fn from(str: String) -> Self { + Room { room: str } + } +} + +impl Device { + // Create a new instance of this struct from an IP address + pub fn from_ip(ip: IpAddr) -> Result { + let resp = reqwest::get(&format!("http://{}:1400/xml/device_description.xml", ip)) + .chain_err(|| "Failed to grab device description")?; + + if !resp.status().is_success() { + return Err("Received a bad response from device".into()); + } + + let mut device = Device { + ip, + model: "".to_string(), + model_number: "".to_string(), + software_version: "".to_string(), + hardware_version: "".to_string(), + serial_number: "".to_string(), + room: "".to_string().into(), + }; + + Device::parse_response(&mut device, resp); + + Ok(device) + } + + fn element_to_string(el: &Element) -> String { + el.text.to_owned().unwrap() + } + + fn parse_response(device: &mut Device, r: reqwest::Response) { + let elements = Element::parse(r).unwrap(); + let device_description = elements + .get_child("device") + .expect("The device gave us a bad response."); + + for el in &device_description.children { + match el.name.as_str() { + "modelName" => device.model = Device::element_to_string(el), + "modelNumber" => device.model_number = Device::element_to_string(el), + "softwareVersion" => device.software_version = Device::element_to_string(el), + "hardwareVersion" => device.hardware_version = Device::element_to_string(el), + "serialNum" => device.serial_number = Device::element_to_string(el), + "roomName" => device.room = Device::element_to_string(el).into(), + _ => {} + } + } + } + + // Call the Sonos SOAP endpoint + fn soap(&self, endpoint: &str, service: &str, action: &str, payload: &str) -> Result { + let mut headers = Headers::new(); + headers.set(ContentType::xml()); + headers.set_raw("SOAPAction", format!("{}#{}", service, action)); + + let client = reqwest::Client::new(); + + let request = client + .post(&format!("http://{}:1400/{}", self.ip, endpoint)) + .headers(headers) + .body(format!( + r#" + + + + {payload} + + + "#, + service = service, + action = action, + payload = payload + )) + .send() + .chain_err(|| "Failed to call Sonos controller.")?; + + let element = + Element::parse(request).chain_err(|| "Failed to parse XML from Sonos controller")?; + + Ok( + element + .get_child("Body") + .ok_or("Failed to get body element")? + .get_child(format!("{}Response", action)) + .ok_or("Failed to find response element")? + .clone(), + ) + } + + // Play the current track + pub fn play(&self) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Play", + "01", + )?; + + Ok(()) + } + + // Pause the current track + pub fn pause(&self) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Pause", + "0", + )?; + + Ok(()) + } + + // Stop the current queue + pub fn stop(&self) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Stop", + "0", + )?; + + Ok(()) + } + + // Skip the current track + pub fn next(&self) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Next", + "0", + )?; + + Ok(()) + } + + // Go to the previous track + pub fn previous(&self) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Previous", + "0", + )?; + + Ok(()) + } + + // Seek to a time on the current track + pub fn seek(&self, hours: &u8, minutes: &u8, seconds: &u8) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Seek", + &format!( + "0REL_TIME{:02}:{:02}:{:02}", + hours, + minutes, + seconds + ), + )?; + + Ok(()) + } + + // Change the track, beginning at 1 + pub fn play_queue_item(&self, track: &u64) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "Seek", + &format!( + "0TRACK_NR{}", + track + ), + )?; + + Ok(()) + } + + // Remove track at index from queue, beginning at 1 + pub fn remove_track(&self, track: &u64) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "RemoveTrackFromQueue", + &format!( + "0Q:0/{}", + track + ), + )?; + + Ok(()) + } + + // Add a new track to the end of the queue + pub fn queue_track(&self, uri: &str) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "RemoveTrackFromQueue", + &format!( + r#" + 0 + {} + + 0 + 0"#, + uri + ), + )?; + + Ok(()) + } + + // Add a track to the queue to play next + pub fn play_next(&self, uri: &str) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "RemoveTrackFromQueue", + &format!( + r#" + 0 + {} + + 0 + 1"#, + uri + ), + )?; + + Ok(()) + } + + // Replace the current track with a new one + pub fn play_track(&self, uri: &str) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "RemoveTrackFromQueue", + &format!( + r#" + 0 + {} + "#, + uri + ), + )?; + + Ok(()) + } + + // Remove every track from the queue + pub fn clear_queue(&self) -> Result<()> { + self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "RemoveAllTracksFromQueue", + "0", + )?; + + Ok(()) + } + + // Get the current volume + pub fn volume(&self) -> Result { + let res = self.soap( + "MediaRenderer/RenderingControl/Control", + "urn:schemas-upnp-org:service:RenderingControl:1", + "GetVolume", + "0Master", + )?; + + let volume = res.get_child("CurrentVolume") + .ok_or("Failed to get current volume")? + .text + .to_owned() + .ok_or("Failed to get text")? + .parse::() + .unwrap(); + + Ok(volume) + } + + // Set a new volume from 0-100. + pub fn set_volume(&self, volume: u8) -> Result<()> { + if volume > 100 { + panic!("Volume must be between 0 and 100, got {}.", volume); + } + + self.soap( + "MediaRenderer/RenderingControl/Control", + "urn:schemas-upnp-org:service:RenderingControl:1", + "SetVolume", + &format!( + r#" + 0 + Master + {}"#, + volume + ), + )?; + Ok(()) + } + + // Check if this player is currently muted + pub fn muted(&self) -> Result { + let resp = self.soap( + "MediaRenderer/RenderingControl/Control", + "urn:schemas-upnp-org:service:RenderingControl:1", + "GetMute", + &format!("0Master"), + )?; + + Ok( + match Device::element_to_string(resp.get_child("CurrentMute") + .ok_or("Failed to get current mute status")?) + .as_str() + { + "1" => true, + "0" => false, + _ => false, + }, + ) + } + + // Mute the current player + pub fn mute(&self) -> Result<()> { + self.soap( + "MediaRenderer/RenderingControl/Control", + "urn:schemas-upnp-org:service:RenderingControl:1", + "SetMute", + "0Master1", + )?; + + Ok(()) + } + + // Unmute the current player + pub fn unmute(&self) -> Result<()> { + self.soap( + "MediaRenderer/RenderingControl/Control", + "urn:schemas-upnp-org:service:RenderingControl:1", + "SetMute", + "0Master0", + )?; + + Ok(()) + } + + // Get the transport state of the current player + pub fn transport_state(&self) -> Result { + let resp = self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "GetTransportInfo", + "0", + )?; + + + Ok( + match Device::element_to_string(resp.get_child("CurrentTransportState") + .ok_or("Failed to get current transport status")?) + .as_str() + { + "STOPPED" => TransportState::Stopped, + "PLAYING" => TransportState::Playing, + "PAUSED_PLAYBACK" => TransportState::PausedPlayback, + "TRANSITIONING" => TransportState::Transitioning, + _ => TransportState::Stopped, + }, + ) + } + + // Get information about the current track + pub fn track(&self) -> Result { + let resp = self.soap( + "MediaRenderer/AVTransport/Control", + "urn:schemas-upnp-org:service:AVTransport:1", + "GetPositionInfo", + "0", + )?; + + let metadata = Element::parse( + Device::element_to_string(resp.get_child("TrackMetaData") + .ok_or("Failed to get track metadata")?) + .as_bytes(), + ).chain_err(|| "Failed to parse XML from Sonos controller")?; + + let metadata = metadata + .get_child("item") + .chain_err(|| "Failed to parse XML from Sonos controller")?; + + Ok(Track { + title: Device::element_to_string(metadata + .get_child("title") + .chain_err(|| "Failed to get title")?), + artist: Device::element_to_string(metadata + .get_child("creator") + .chain_err(|| "Failed to get artist")?), + album: Device::element_to_string(metadata + .get_child("album") + .chain_err(|| "Failed to get album")?), + queue_position: Device::element_to_string(resp.get_child("Track") + .chain_err(|| "Failed to get queue position")?) + .parse::() + .unwrap(), + uri: Device::element_to_string(resp.get_child("TrackURI") + .chain_err(|| "Failed to get track uri")?), + duration: Device::element_to_string(resp.get_child("TrackDuration") + .chain_err(|| "Failed to get track duration")?), + relative_time: Device::element_to_string(resp.get_child("RelTime") + .chain_err(|| "Failed to get relative time")?), + }) + } +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..ce65f66 100644 --- /dev/null +++ a/src/discovery.rs @@ -1,0 +1,41 @@ +extern crate ssdp; + +use self::ssdp::FieldMap; +use self::ssdp::header::{HeaderMut, HeaderRef, Man, MX, ST}; +use self::ssdp::message::{Multicast, SearchRequest, SearchResponse}; +use device::Device; +use error::*; + +const SONOS_URN: &str = "schemas-upnp-org:device:ZonePlayer:1"; + +fn get_header(msg: &SearchResponse, header: &str) -> Result { + let bytes = msg.get_raw(header) + .chain_err(|| "Failed to get header from discovery response")?; + + String::from_utf8(bytes.get(0).unwrap().to_vec()) + .chain_err(|| "Failed to convert header to UTF-8") +} + +pub fn discover() -> Result> { + let mut request = SearchRequest::new(); + + request.set(Man); // required header for discovery + request.set(MX(2)); // set maximum wait to 2 seconds + request.set(ST::Target(FieldMap::URN(String::from(SONOS_URN)))); // we're only looking for sonos + + let mut devices: Vec = Vec::new(); + + for (msg, src) in request.multicast().unwrap() { + let usn = get_header(&msg, "USN")?; + + if !usn.contains(SONOS_URN) { + error!("Misbehaving client responded to our discovery ({})", usn); + continue; + } + + devices.push(Device::from_ip(src.ip()) + .chain_err(|| "Failed to get device information")?) + } + + Ok(devices) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..0c6d43c 100644 --- /dev/null +++ a/src/error.rs @@ -1,0 +1,1 @@ +error_chain!{} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..be28cdf 100644 --- /dev/null +++ a/src/lib.rs @@ -1,0 +1,20 @@ +#![cfg_attr(feature = "clippy", feature(plugin))] +#![cfg_attr(feature = "clippy", plugin(clippy))] + +#[macro_use] +extern crate log; + +#[macro_use] +extern crate error_chain; + +mod discovery; +mod device; +mod error; + +pub use device::Device; +pub use device::TransportState; +pub use error::*; + +pub fn discover() -> Result> { + discovery::discover() +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..47b49cf 100644 --- /dev/null +++ a/tests/integration_test.rs @@ -1,0 +1,80 @@ +extern crate soncon; + +#[test] +fn can_discover_devices() { + let devices = soncon::discover().unwrap(); + assert!(devices.len() > 0, "No devices discovered"); +} + +#[test] +fn volume() { + let device = &soncon::discover().unwrap()[0]; + device.set_volume(2).expect("Failed to get volume"); + assert_eq!( + device.volume().expect("Failed to get volume"), + 2 as u8, + "Volume was not updated." + ); +} + +#[test] +fn muted() { + let device = &soncon::discover().unwrap()[0]; + device.mute().expect("Couldn't mute player"); + assert_eq!( + device.muted().expect("Failed to get current mute status"), + true + ); + device.unmute().expect("Couldn't unmute player"); + assert_eq!( + device.muted().expect("Failed to get current mute status"), + false + ); +} + +#[test] +fn playback_state() { + let device = &soncon::discover().unwrap()[0]; + + device.play().expect("Couldn't play track"); + assert!(match device.transport_state().unwrap() { + soncon::TransportState::Playing => true, + soncon::TransportState::Transitioning => true, + _ => false, + }); + + device.pause().expect("Couldn't pause track"); + assert!(match device.transport_state().unwrap() { + soncon::TransportState::PausedPlayback => true, + soncon::TransportState::Transitioning => true, + _ => false, + }); + + device.stop().expect("Couldn't stop track"); + assert!(match device.transport_state().unwrap() { + soncon::TransportState::Stopped => true, + soncon::TransportState::Transitioning => true, + _ => false, + }); +} + +#[test] +fn track_info() { + let device = &soncon::discover().unwrap()[0]; + println!("{:#?}", device.track().expect("Failed to get track info")); +} + +#[test] +fn play() { + let device = &soncon::discover().unwrap()[0]; + device.play().expect("Failed to play"); + device.pause().expect("Failed to pause"); +} + +#[test] +#[should_panic] +fn fail_on_set_invalid_volume() { + soncon::discover().unwrap()[0] + .set_volume(101) + .expect_err("Didn't fail on invalid volume"); +} -- rgit 0.1.3