use crate::error::{DrivenError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::IpAddr;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SonosConfig {
#[serde(default = "default_discovery_timeout")]
pub discovery_timeout: u64,
#[serde(default)]
pub known_speakers: HashMap<String, String>,
pub default_room: Option<String>,
}
fn default_discovery_timeout() -> u64 {
5
}
impl Default for SonosConfig {
fn default() -> Self {
Self {
discovery_timeout: default_discovery_timeout(),
known_speakers: HashMap::new(),
default_room: None,
}
}
}
impl SonosConfig {
pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
let content = std::fs::read_to_string(path.as_ref())
.map_err(|e| DrivenError::Io(e))?;
Self::parse_sr(&content)
}
fn parse_sr(_content: &str) -> Result<Self> {
Ok(Self::default())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SonosRoom {
pub name: String,
pub ip: IpAddr,
pub model: String,
pub is_coordinator: bool,
pub group_id: Option<String>,
pub volume: u8,
pub muted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SonosPlaybackState {
Playing,
Paused,
Stopped,
Transitioning,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SonosTrack {
pub title: String,
pub artist: String,
pub album: String,
pub album_art: Option<String>,
pub duration: u32,
pub position: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SonosQueueItem {
pub position: u32,
pub track: SonosTrack,
}
pub struct SonosClient {
config: SonosConfig,
rooms: HashMap<String, SonosRoom>,
}
impl SonosClient {
pub fn new(config: &SonosConfig) -> Result<Self> {
Ok(Self {
config: config.clone(),
rooms: HashMap::new(),
})
}
pub async fn discover(&mut self) -> Result<Vec<SonosRoom>> {
let rooms = self.ssdp_discover().await?;
for room in &rooms {
self.rooms.insert(room.name.clone(), room.clone());
}
Ok(rooms)
}
pub fn get_room(&self, name: &str) -> Option<&SonosRoom> {
self.rooms.get(name)
}
pub fn rooms(&self) -> Vec<&SonosRoom> {
self.rooms.values().collect()
}
pub async fn play(&self, room: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action(&room.ip, "Play").await
}
pub async fn pause(&self, room: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action(&room.ip, "Pause").await
}
pub async fn stop(&self, room: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action(&room.ip, "Stop").await
}
pub async fn next(&self, room: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action(&room.ip, "Next").await
}
pub async fn previous(&self, room: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action(&room.ip, "Previous").await
}
pub async fn set_volume(&self, room: &str, volume: u8) -> Result<()> {
let room = self.get_room_or_default(room)?;
let volume = volume.min(100);
self.soap_action_with_args(&room.ip, "SetVolume", &[("DesiredVolume", &volume.to_string())]).await
}
pub async fn get_volume(&self, room: &str) -> Result<u8> {
let room = self.get_room_or_default(room)?;
let response = self.soap_request(&room.ip, "GetVolume", &[]).await?;
self.parse_volume_response(&response)
}
pub async fn set_mute(&self, room: &str, mute: bool) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action_with_args(&room.ip, "SetMute", &[("DesiredMute", if mute { "1" } else { "0" })]).await
}
pub async fn get_state(&self, room: &str) -> Result<SonosPlaybackState> {
let room = self.get_room_or_default(room)?;
let response = self.soap_request(&room.ip, "GetTransportInfo", &[]).await?;
self.parse_transport_state(&response)
}
pub async fn get_current_track(&self, room: &str) -> Result<Option<SonosTrack>> {
let room = self.get_room_or_default(room)?;
let response = self.soap_request(&room.ip, "GetPositionInfo", &[]).await?;
self.parse_track_info(&response)
}
pub async fn play_uri(&self, room: &str, uri: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action_with_args(&room.ip, "SetAVTransportURI", &[
("CurrentURI", uri),
("CurrentURIMetaData", ""),
]).await?;
self.play(&room.name).await
}
pub async fn group(&self, coordinator: &str, members: &[&str]) -> Result<()> {
let coord = self.get_room_or_default(coordinator)?;
let coord_uuid = format!("RINCON_{}", coord.ip.to_string().replace('.', ""));
for member in members {
let room = self.get_room_or_default(member)?;
self.soap_action_with_args(
&room.ip,
"SetAVTransportURI",
&[("CurrentURI", &format!("x-rincon:{}", coord_uuid))],
).await?;
}
Ok(())
}
pub async fn ungroup(&self, room: &str) -> Result<()> {
let room = self.get_room_or_default(room)?;
self.soap_action(&room.ip, "BecomeCoordinatorOfStandaloneGroup").await
}
fn get_room_or_default(&self, name: &str) -> Result<SonosRoom> {
if let Some(room) = self.rooms.get(name) {
return Ok(room.clone());
}
if let Some(ref default) = self.config.default_room {
if let Some(room) = self.rooms.get(default) {
return Ok(room.clone());
}
}
Err(DrivenError::NotFound(format!("Room '{}' not found", name)))
}
async fn ssdp_discover(&self) -> Result<Vec<SonosRoom>> {
tracing::debug!("Discovering Sonos devices...");
Ok(Vec::new())
}
async fn soap_action(&self, ip: &IpAddr, action: &str) -> Result<()> {
self.soap_action_with_args(ip, action, &[]).await
}
async fn soap_action_with_args(&self, ip: &IpAddr, action: &str, args: &[(&str, &str)]) -> Result<()> {
let _ = self.soap_request(ip, action, args).await?;
Ok(())
}
async fn soap_request(&self, ip: &IpAddr, action: &str, args: &[(&str, &str)]) -> Result<String> {
let url = format!("http://{}:1400/MediaRenderer/AVTransport/Control", ip);
let mut args_xml = String::new();
for (name, value) in args {
args_xml.push_str(&format!("<{}>{}</{}>", name, value, name));
}
let body = format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:{action} xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
{args}
</u:{action}>
</s:Body>
</s:Envelope>"#,
action = action,
args = args_xml
);
let client = reqwest::Client::new();
let response = client
.post(&url)
.header("Content-Type", "text/xml; charset=\"utf-8\"")
.header("SOAPACTION", format!("\"urn:schemas-upnp-org:service:AVTransport:1#{}\"", action))
.body(body)
.send()
.await
.map_err(|e| DrivenError::Network(e.to_string()))?;
response
.text()
.await
.map_err(|e| DrivenError::Network(e.to_string()))
}
fn parse_volume_response(&self, _response: &str) -> Result<u8> {
Ok(50) }
fn parse_transport_state(&self, _response: &str) -> Result<SonosPlaybackState> {
Ok(SonosPlaybackState::Stopped) }
fn parse_track_info(&self, _response: &str) -> Result<Option<SonosTrack>> {
Ok(None) }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = SonosConfig::default();
assert_eq!(config.discovery_timeout, 5);
}
#[test]
fn test_client_creation() {
let config = SonosConfig::default();
let client = SonosClient::new(&config);
assert!(client.is_ok());
}
}