Merge branch 'feature/json-output' into develop

This commit is contained in:
Erwin Boskma 2022-04-12 21:42:49 +02:00
commit 44b2984fad
Signed by: erwin
GPG key ID: 270B20D17394F7E5
7 changed files with 339 additions and 78 deletions

1
Cargo.lock generated
View file

@ -432,6 +432,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"tokio", "tokio",
"tracing", "tracing",
"tracing-error", "tracing-error",

View file

@ -15,6 +15,7 @@ html-escape = "0.2.11"
reqwest = { version = "0.11.10", features = ["blocking", "json"] } reqwest = { version = "0.11.10", features = ["blocking", "json"] }
serde = { version = "1.0.136", features = ["derive"] } serde = { version = "1.0.136", features = ["derive"] }
serde_json = "1.0.79" serde_json = "1.0.79"
thiserror = "1.0.30"
tokio = { version = "1.17.0", features = ["full"] } tokio = { version = "1.17.0", features = ["full"] }
tracing = "0.1.32" tracing = "0.1.32"
tracing-error = "0.2.0" tracing-error = "0.2.0"

View file

@ -1,16 +1,82 @@
use std::fmt::Display;
use async_tungstenite::{tokio::connect_async, tungstenite}; use async_tungstenite::{tokio::connect_async, tungstenite};
use color_eyre::{eyre::eyre, Result}; use color_eyre::{eyre::bail, Result};
use futures::{SinkExt, StreamExt}; use futures::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use thiserror::Error;
use tracing::{debug, error, trace}; 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<String>,
entity: Option<String>,
token: Option<String>,
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 { pub(crate) struct HomeAssistant {
token: String, token: String,
entity: String, entity: String,
insecure: bool, insecure: bool,
host: String, host: String,
id: u64, id: u64,
format: OutputFormat,
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize)] #[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 { impl Message {
fn auth() -> Self { fn auth() -> Self {
let mut msg = Self::default(); let mut msg = Self::default();
@ -126,16 +219,27 @@ impl Message {
} }
impl HomeAssistant { 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 { Self {
token, token,
entity, entity,
insecure, insecure,
host, host,
format,
id: 1, id: 1,
} }
} }
pub(crate) fn builder() -> HomeAssistantBuilder {
HomeAssistantBuilder::new()
}
fn incrementing_id(&mut self) -> u64 { fn incrementing_id(&mut self) -> u64 {
let id = self.id; let id = self.id;
self.id = self.id.wrapping_add(1); self.id = self.id.wrapping_add(1);
@ -163,22 +267,34 @@ impl HomeAssistant {
} }
} }
Err(e) => { Err(e) => {
if let Some(err) = e.downcast_ref::<HomeAssistantError>() {
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}"); error!("{e}");
} }
} }
} }
}
Err(eyre!( Ok(())
"TODO: Connect to {} and subscribe to {}",
api_url,
self.entity
))
} }
async fn handle_message( async fn handle_message(
&mut self, &mut self,
msg: tungstenite::Message, msg: tungstenite::Message,
) -> Result<Vec<tungstenite::Message>> { ) -> Result<Vec<tungstenite::Message>> {
if msg.len() == 0 {
bail!(HomeAssistantError::EmptyMessage);
}
trace!("{}", msg.clone().into_text()?); trace!("{}", msg.clone().into_text()?);
let message: Message = serde_json::from_slice(&msg.into_data())?; let message: Message = serde_json::from_slice(&msg.into_data())?;
trace!("Received: {message:?}"); trace!("Received: {message:?}");
@ -205,22 +321,26 @@ impl HomeAssistant {
let event = message.event.unwrap(); let event = message.event.unwrap();
if event.data.new_state.entity_id == self.entity { 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![] vec![]
} }
MessageType::Result => { MessageType::Result => {
debug!("{}", message.to_json()); trace!("{}", message.to_json());
vec![] vec![]
} }
_ => return Err(eyre!("TODO: Handle {:?}", message.message_type)), _ => bail!(HomeAssistantError::UnhandledMessage(
message.message_type.to_string()
)),
}; };
Ok(response) Ok(response)
} }
fn print_state(state: String, attributes: Value) { fn format_state(&self, state: String, attributes: Value) -> String {
if state == "playing" { if state == "playing" {
let maybe_channel = attributes["media_channel"].as_str(); let maybe_channel = attributes["media_channel"].as_str();
let artist = attributes["media_artist"].as_str().unwrap_or("No artist"); let artist = attributes["media_artist"].as_str().unwrap_or("No artist");
@ -242,9 +362,28 @@ impl HomeAssistant {
format!("{volume}{artist} - {title}") 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 { } 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(),
}
} }
} }
} }

View file

@ -5,12 +5,13 @@ use clap::Parser;
use color_eyre::{eyre::WrapErr, Result}; use color_eyre::{eyre::WrapErr, Result};
use serde_json::json; use serde_json::json;
use std::fmt::Display; use std::fmt::Display;
use tracing::{debug, trace}; use tracing::debug;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::{prelude::*, EnvFilter, Registry}; use tracing_subscriber::{prelude::*, EnvFilter, Registry};
use tracing_tree::HierarchicalLayer; use tracing_tree::HierarchicalLayer;
mod homeassistant; mod homeassistant;
mod output;
/// Bert /// Bert
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@ -36,6 +37,10 @@ struct Opts {
#[clap(short, long)] #[clap(short, long)]
insecure: bool, insecure: bool,
/// Output format
#[clap(arg_enum, short, long, default_value = "waybar")]
format: output::OutputFormat,
#[clap(short, long)] #[clap(short, long)]
debug: bool, debug: bool,
@ -78,6 +83,7 @@ async fn main() -> Result<()> {
cmd, cmd,
token, token,
token_file, token_file,
format,
} = opts; } = opts;
setup(debug).wrap_err("Setup failed")?; setup(debug).wrap_err("Setup failed")?;
@ -101,12 +107,17 @@ async fn main() -> Result<()> {
if let Some(cmd) = cmd { if let Some(cmd) = cmd {
debug!("Calling service '{cmd}' on entity '{entity}'"); debug!("Calling service '{cmd}' on entity '{entity}'");
call_service(cmd, host, entity, insecure, token) call_service(cmd, host, entity, insecure, token)
.await
.wrap_err_with(|| format!("Unable to execute command {cmd}"))?; .wrap_err_with(|| format!("Unable to execute command {cmd}"))?;
} else { } else {
debug!("Retrieving state of '{entity}' on '{host}'"); debug!("Retrieving state of '{entity}' on '{host}'");
// get_now_playing(host, entity, insecure, token) let ha = homeassistant::HomeAssistant::builder()
// .wrap_err("Unable to retrieve now playing info")?; .host(host)
let ha = homeassistant::HomeAssistant::new(host, entity, insecure, token); .entity(entity)
.token(token)
.insecure(insecure)
.format(format)
.build();
ha.open_connection().await.unwrap(); ha.open_connection().await.unwrap();
} }
} }
@ -132,63 +143,7 @@ fn setup(debug: bool) -> Result<()> {
} }
#[tracing::instrument] #[tracing::instrument]
fn get_now_playing(host: String, entity: String, insecure: bool, token: String) -> Result<()> { async fn call_service(
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::<serde_json::Value>()?;
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(
command: Command, command: Command,
host: String, host: String,
entity: String, entity: String,
@ -217,15 +172,18 @@ fn call_service(
_ => json!({ "entity_id": entity }).to_string(), _ => 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 let response = client
.post(url) .post(url)
.body(body) .body(body)
.bearer_auth(token) .bearer_auth(token)
.header("Accept", "application/json") .header("Accept", "application/json")
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.send()? .send()
.json::<serde_json::Value>()?; .await?
.json::<serde_json::Value>()
.await?;
debug!("{:#?}", response); debug!("{:#?}", response);

55
src/output/i3blocks.rs Normal file
View file

@ -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)
}
}

27
src/output/mod.rs Normal file
View file

@ -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
}
}

80
src/output/waybar.rs Normal file
View file

@ -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:?}")
}
}
}