use std::io::Read; use std::path::PathBuf; use clap::Parser; use color_eyre::{eyre::WrapErr, Result}; use serde_json::json; use std::fmt::Display; use tracing::{debug, trace}; use tracing_subscriber::EnvFilter; /// Bert #[derive(Parser, Debug)] #[clap(version, author, about)] struct Opts { /// Home Assistant host #[clap(short, long)] host: String, /// Media player entity ID #[clap(short, long)] entity: String, /// API token #[clap(short, long, env)] token: Option, /// File with the API token #[clap(long, env)] token_file: Option, /// Use HTTP instead of HTTPS #[clap(short, long)] insecure: bool, #[clap(short, long)] debug: bool, #[clap(subcommand)] cmd: Option, } #[derive(Parser, Debug, Clone, Copy)] enum Command { /// Toggle playback PlayPause, /// Raise volume VolumeUp, /// Lower volume VolumeDown, /// Set volume to value Volume { volume: u8 }, } impl Display for Command { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Command::PlayPause => write!(f, "Play/Pause"), Command::VolumeUp => write!(f, "Volume Up"), Command::VolumeDown => write!(f, "Volume Down"), Command::Volume { volume } => write!(f, "Volume {volume}"), } } } fn main() -> Result<()> { let opts = Opts::parse(); let Opts { host, entity, insecure, debug, cmd, token, token_file, } = opts; setup(debug).wrap_err("Setup failed")?; let token = if let Some(token) = token { Some(token) } else if let Some(token_file) = token_file { if let Ok(mut file) = std::fs::File::open(token_file) { let mut buf = String::new(); file.read_to_string(&mut buf).ok(); Some(buf) } else { None } } else { println!("No API token given. Use either --token or --token-file"); None }; if let Some(token) = token { if let Some(cmd) = cmd { debug!("Calling service '{cmd}' on entity '{entity}'"); call_service(cmd, host, entity, insecure, token) .wrap_err_with(|| format!("Unable to execute command {cmd}"))?; } else { debug!("Retrieving state of '{entity}' on '{host}'"); get_now_playing(host, entity, insecure, token) .wrap_err("Unable to retrieve now playing info")?; } } Ok(()) } fn setup(debug: bool) -> Result<()> { if debug { std::env::set_var("RUST_LOG", "debug"); } color_eyre::install()?; tracing_subscriber::fmt::fmt() .with_env_filter(EnvFilter::from_default_env()) .with_writer(std::io::stderr) .init(); Ok(()) } fn get_now_playing(host: String, entity: String, insecure: bool, token: String) -> Result<()> { let url = format!( "{}://{}/api/states/{}", if insecure { "http" } else { "https" }, host, entity ); let client = reqwest::blocking::Client::new(); let response = client .get(url) .bearer_auth(token) .header("Accept", "application/json") .header("Content-Type", "application/json") .send()? .json::()?; trace!("Response: {:#?}", response); if response["state"] == "playing" { let attributes = &response["attributes"]; let maybe_channel = attributes["media_channel"].as_str(); let artist = attributes["media_artist"] .as_str() .map_or("No artist", |artist| artist); let title = attributes["media_title"] .as_str() .map_or("No title", |title| title); let volume_raw = attributes["volume_level"] .as_f64() .map_or(-1., |volume| volume); let volume = if volume_raw >= 0. { format!("[{:3.0}%] ", volume_raw * 100.) } else { String::new() }; let now_playing = if let Some(channel) = maybe_channel { format!("{volume}[{channel}] {artist} - {title}") } else { format!("{volume}{artist} - {title}") }; println!("{}", html_escape::encode_text(&now_playing)); } else { let state = response["state"] .as_str() .map_or("State unknown", |state| state); println!("Sonos {}", html_escape::encode_text(state)); } Ok(()) } fn call_service( command: Command, host: String, entity: String, insecure: bool, token: String, ) -> Result<()> { let cmd = match command { Command::PlayPause => "media_play_pause", Command::VolumeUp => "volume_up", Command::VolumeDown => "volume_down", Command::Volume { .. } => "volume_set", }; let url = format!( "{}://{}/api/services/media_player/{}", if insecure { "http" } else { "https" }, host, cmd ); let body = match command { Command::Volume { volume } => { json!({ "entity_id": entity, "volume_level": volume.clamp(0, 100) as f32 / 100. }) .to_string() } _ => json!({ "entity_id": entity }).to_string(), }; let client = reqwest::blocking::Client::new(); let response = client .post(url) .body(body) .bearer_auth(token) .header("Accept", "application/json") .header("Content-Type", "application/json") .send()? .json::()?; debug!("{:#?}", response); Ok(()) }