Merge branch 'feature/json-output' into develop
This commit is contained in:
commit
44b2984fad
7 changed files with 339 additions and 78 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -432,6 +432,7 @@ dependencies = [
|
|||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-error",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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<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 {
|
||||
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::<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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(eyre!(
|
||||
"TODO: Connect to {} and subscribe to {}",
|
||||
api_url,
|
||||
self.entity
|
||||
))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_message(
|
||||
&mut self,
|
||||
msg: tungstenite::Message,
|
||||
) -> Result<Vec<tungstenite::Message>> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
86
src/main.rs
86
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::<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(
|
||||
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::<serde_json::Value>()?;
|
||||
.send()
|
||||
.await?
|
||||
.json::<serde_json::Value>()
|
||||
.await?;
|
||||
|
||||
debug!("{:#?}", response);
|
||||
|
||||
|
|
55
src/output/i3blocks.rs
Normal file
55
src/output/i3blocks.rs
Normal 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
27
src/output/mod.rs
Normal 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
80
src/output/waybar.rs
Normal 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:?}")
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue