diff --git a/Cargo.lock b/Cargo.lock index 313ceb3..e08c6b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,6 +432,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "thiserror", "tokio", "tracing", "tracing-error", diff --git a/Cargo.toml b/Cargo.toml index 94322fa..c63cf21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ html-escape = "0.2.11" reqwest = { version = "0.11.10", features = ["blocking", "json"] } serde = { version = "1.0.136", features = ["derive"] } serde_json = "1.0.79" +thiserror = "1.0.30" tokio = { version = "1.17.0", features = ["full"] } tracing = "0.1.32" tracing-error = "0.2.0" diff --git a/src/homeassistant.rs b/src/homeassistant.rs index 795a637..da4577c 100644 --- a/src/homeassistant.rs +++ b/src/homeassistant.rs @@ -1,16 +1,82 @@ +use std::fmt::Display; + use async_tungstenite::{tokio::connect_async, tungstenite}; -use color_eyre::{eyre::eyre, Result}; +use color_eyre::{eyre::bail, Result}; use futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use thiserror::Error; use tracing::{debug, error, trace}; +use crate::output::{i3blocks::I3Blocks, waybar::Waybar, OutputFormat}; + +#[derive(Debug, Error)] +pub enum HomeAssistantError { + #[error("empty message received")] + EmptyMessage, + #[error("unhandled message {0}")] + UnhandledMessage(String), + #[error("error deserializing message: {0}")] + JsonError(#[from] serde_json::Error), +} + +#[derive(Debug, Default)] +pub(crate) struct HomeAssistantBuilder { + host: Option, + entity: Option, + token: Option, + insecure: bool, + format: OutputFormat, +} + +impl HomeAssistantBuilder { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn host(mut self, host: String) -> Self { + self.host = Some(host); + self + } + + pub(crate) fn entity(mut self, entity: String) -> Self { + self.entity = Some(entity); + self + } + + pub(crate) fn token(mut self, token: String) -> Self { + self.token = Some(token); + self + } + + pub(crate) fn insecure(mut self, insecure: bool) -> Self { + self.insecure = insecure; + self + } + + pub(crate) fn format(mut self, format: OutputFormat) -> Self { + self.format = format; + self + } + + pub(crate) fn build(self) -> HomeAssistant { + HomeAssistant::new( + self.host.unwrap(), + self.entity.unwrap(), + self.insecure, + self.token.unwrap(), + self.format, + ) + } +} + pub(crate) struct HomeAssistant { token: String, entity: String, insecure: bool, host: String, id: u64, + format: OutputFormat, } #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -97,6 +163,33 @@ impl Default for MessageType { } } +impl Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageType::AuthRequired => write!(f, "AuthRequired"), + MessageType::Auth => write!(f, "Auth"), + MessageType::AuthOk => write!(f, "AuthOk"), + MessageType::AuthInvalid => write!(f, "AuthInvalid"), + MessageType::Result => write!(f, "Result"), + MessageType::SubscribeEvents => write!(f, "SubscribeEvents"), + MessageType::Event => write!(f, "Event"), + MessageType::SubscribeTrigger => write!(f, "SubscribeTrigger"), + MessageType::UnsubscribeEvents => write!(f, "UnsubscribeEvents"), + MessageType::FireEvent => write!(f, "FireEvent"), + MessageType::CallService => write!(f, "CallService"), + MessageType::GetStates => write!(f, "GetStates"), + MessageType::GetConfig => write!(f, "GetConfig"), + MessageType::GetServices => write!(f, "GetServices"), + MessageType::GetPanels => write!(f, "GetPanels"), + MessageType::CameraThumbnail => write!(f, "CameraThumbnail"), + MessageType::MediaPlayerThumbnail => write!(f, "MediaPlayerThumbnail"), + MessageType::Ping => write!(f, "Ping"), + MessageType::Pong => write!(f, "Pong"), + MessageType::ValidateConfig => write!(f, "ValidateConfig"), + } + } +} + impl Message { fn auth() -> Self { let mut msg = Self::default(); @@ -126,16 +219,27 @@ impl Message { } impl HomeAssistant { - pub(crate) fn new(host: String, entity: String, insecure: bool, token: String) -> Self { + pub(crate) fn new( + host: String, + entity: String, + insecure: bool, + token: String, + format: OutputFormat, + ) -> Self { Self { token, entity, insecure, host, + format, id: 1, } } + pub(crate) fn builder() -> HomeAssistantBuilder { + HomeAssistantBuilder::new() + } + fn incrementing_id(&mut self) -> u64 { let id = self.id; self.id = self.id.wrapping_add(1); @@ -163,22 +267,34 @@ impl HomeAssistant { } } Err(e) => { - error!("{e}"); + if let Some(err) = e.downcast_ref::() { + match err { + HomeAssistantError::EmptyMessage => { + debug!("Received empty message, ignoring...") + } + HomeAssistantError::UnhandledMessage(message_type) => debug!( + "Received '{message_type}', we're currently not handling that." + ), + HomeAssistantError::JsonError(e) => error!("{e}"), + } + } else { + error!("{e}"); + } } } } - Err(eyre!( - "TODO: Connect to {} and subscribe to {}", - api_url, - self.entity - )) + Ok(()) } async fn handle_message( &mut self, msg: tungstenite::Message, ) -> Result> { + if msg.len() == 0 { + bail!(HomeAssistantError::EmptyMessage); + } + trace!("{}", msg.clone().into_text()?); let message: Message = serde_json::from_slice(&msg.into_data())?; trace!("Received: {message:?}"); @@ -205,22 +321,26 @@ impl HomeAssistant { let event = message.event.unwrap(); if event.data.new_state.entity_id == self.entity { - Self::print_state(event.data.new_state.state, event.data.new_state.attributes); + let output = self + .format_state(event.data.new_state.state, event.data.new_state.attributes); + println!("{output}"); } vec![] } MessageType::Result => { - debug!("{}", message.to_json()); + trace!("{}", message.to_json()); vec![] } - _ => return Err(eyre!("TODO: Handle {:?}", message.message_type)), + _ => bail!(HomeAssistantError::UnhandledMessage( + message.message_type.to_string() + )), }; Ok(response) } - fn print_state(state: String, attributes: Value) { + fn format_state(&self, state: String, attributes: Value) -> String { if state == "playing" { let maybe_channel = attributes["media_channel"].as_str(); let artist = attributes["media_artist"].as_str().unwrap_or("No artist"); @@ -242,9 +362,28 @@ impl HomeAssistant { format!("{volume}{artist} - {title}") }; - println!("{}", html_escape::encode_text(&now_playing)); + let text = html_escape::encode_text(&now_playing).to_string(); + + match self.format { + OutputFormat::Waybar => Waybar::builder() + .text(text) + .percentage((volume_raw * 100.) as u8) + .build() + .to_string(), + OutputFormat::I3Blocks => I3Blocks::builder().text(text).build().to_string(), + } } else { - println!("Sonos {state}"); + // println!("Sonos {state}"); + match self.format { + OutputFormat::Waybar => Waybar::builder() + .text(format!("Sonos {state}")) + .build() + .to_string(), + OutputFormat::I3Blocks => I3Blocks::builder() + .text(format!("Sonos {state}")) + .build() + .to_string(), + } } } } diff --git a/src/main.rs b/src/main.rs index 573e60d..de362f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,13 @@ use clap::Parser; use color_eyre::{eyre::WrapErr, Result}; use serde_json::json; use std::fmt::Display; -use tracing::{debug, trace}; +use tracing::debug; use tracing_error::ErrorLayer; use tracing_subscriber::{prelude::*, EnvFilter, Registry}; use tracing_tree::HierarchicalLayer; mod homeassistant; +mod output; /// Bert #[derive(Parser, Debug)] @@ -36,6 +37,10 @@ struct Opts { #[clap(short, long)] insecure: bool, + /// Output format + #[clap(arg_enum, short, long, default_value = "waybar")] + format: output::OutputFormat, + #[clap(short, long)] debug: bool, @@ -78,6 +83,7 @@ async fn main() -> Result<()> { cmd, token, token_file, + format, } = opts; setup(debug).wrap_err("Setup failed")?; @@ -101,12 +107,17 @@ async fn main() -> Result<()> { if let Some(cmd) = cmd { debug!("Calling service '{cmd}' on entity '{entity}'"); call_service(cmd, host, entity, insecure, token) + .await .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")?; - let ha = homeassistant::HomeAssistant::new(host, entity, insecure, token); + let ha = homeassistant::HomeAssistant::builder() + .host(host) + .entity(entity) + .token(token) + .insecure(insecure) + .format(format) + .build(); ha.open_connection().await.unwrap(); } } @@ -132,63 +143,7 @@ fn setup(debug: bool) -> Result<()> { } #[tracing::instrument] -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(()) -} - -#[tracing::instrument] -fn call_service( +async fn call_service( command: Command, host: String, entity: String, @@ -217,15 +172,18 @@ fn call_service( _ => json!({ "entity_id": entity }).to_string(), }; - let client = reqwest::blocking::Client::new(); + // let client = reqwest::blocking::Client::new(); + let client = reqwest::Client::new(); let response = client .post(url) .body(body) .bearer_auth(token) .header("Accept", "application/json") .header("Content-Type", "application/json") - .send()? - .json::()?; + .send() + .await? + .json::() + .await?; debug!("{:#?}", response); diff --git a/src/output/i3blocks.rs b/src/output/i3blocks.rs new file mode 100644 index 0000000..eff727b --- /dev/null +++ b/src/output/i3blocks.rs @@ -0,0 +1,55 @@ +use std::fmt::Display; + +#[derive(Debug, Default)] +pub(crate) struct I3BlocksBuilder { + text: String, + tooltip: String, + class: String, +} + +impl I3BlocksBuilder { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn text(mut self, text: String) -> Self { + self.text = text; + self + } + + pub(crate) fn tooltip(mut self, tooltip: String) -> Self { + self.tooltip = tooltip; + self + } + + pub(crate) fn class(mut self, class: String) -> Self { + self.class = class; + self + } + + pub(crate) fn build(self) -> I3Blocks { + I3Blocks { + text: self.text, + tooltip: self.tooltip, + class: self.class, + } + } +} + +pub(crate) struct I3Blocks { + text: String, + tooltip: String, + class: String, +} + +impl I3Blocks { + pub(crate) fn builder() -> I3BlocksBuilder { + I3BlocksBuilder::new() + } +} + +impl Display for I3Blocks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}\n{}\n{}", self.text, self.tooltip, self.class) + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs new file mode 100644 index 0000000..8224298 --- /dev/null +++ b/src/output/mod.rs @@ -0,0 +1,27 @@ +use std::fmt::Display; + +use clap::ArgEnum; + +pub(crate) mod i3blocks; +pub(crate) mod waybar; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ArgEnum)] +pub(crate) enum OutputFormat { + Waybar, + I3Blocks, +} + +impl Display for OutputFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OutputFormat::Waybar => write!(f, "waybar format"), + OutputFormat::I3Blocks => write!(f, "i3blocks format"), + } + } +} + +impl Default for OutputFormat { + fn default() -> Self { + Self::Waybar + } +} diff --git a/src/output/waybar.rs b/src/output/waybar.rs new file mode 100644 index 0000000..a488044 --- /dev/null +++ b/src/output/waybar.rs @@ -0,0 +1,80 @@ +// {"text": "$text", "alt": "$alt", "tooltip": "$tooltip", "class": "$class", "percentage": $percentage } + +use std::fmt::Display; + +use serde::Serialize; + +#[derive(Debug, Default)] +pub(crate) struct WaybarBuilder { + text: String, + alt: String, + tooltip: String, + class: String, + percentage: u8, +} + +impl WaybarBuilder { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn text(mut self, text: String) -> Self { + self.text = text; + self + } + + pub(crate) fn alt(mut self, alt: String) -> Self { + self.alt = alt; + self + } + + pub(crate) fn tooltip(mut self, tooltip: String) -> Self { + self.tooltip = tooltip; + self + } + + pub(crate) fn class(mut self, class: String) -> Self { + self.class = class; + self + } + + pub(crate) fn percentage(mut self, percentage: u8) -> Self { + self.percentage = percentage.clamp(0, 100); + self + } + + pub(crate) fn build(self) -> Waybar { + Waybar { + text: self.text, + alt: self.alt, + tooltip: self.tooltip, + class: self.class, + percentage: self.percentage, + } + } +} + +#[derive(Debug, Default, Serialize)] +pub(crate) struct Waybar { + text: String, + alt: String, + tooltip: String, + class: String, + percentage: u8, +} + +impl Waybar { + pub(crate) fn builder() -> WaybarBuilder { + WaybarBuilder::new() + } +} + +impl Display for Waybar { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Ok(out) = serde_json::to_string(self) { + write!(f, "{out}") + } else { + write!(f, "Unable to serialize to JSON: {self:?}") + } + } +}