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; 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)] token: Option, /// File with the API token #[clap(long)] 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, } 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"), } } } 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 { eprintln!("No API token given. Use either --token or --token-file"); None }; if let Some(token) = token { if let Some(cmd) = cmd { call_service(cmd, host, entity, insecure, token) .wrap_err_with(|| format!("Unable to execute command {cmd}"))?; } else { get_now_playing(host, entity, insecure, token) .wrap_err("Unable to retrieve now playing info")?; } } // println!("{:#?}", response); 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::()?; debug!("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!("{}", now_playing); } else { println!( "Sonos {}", response["state"] .as_str() .map_or("state unknown", |state| 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", }; let url = format!( "{}://{}/api/services/media_player/{}", if insecure { "http" } else { "https" }, host, cmd ); let body = 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::()?; println!("{:#?}", response); Ok(()) }