From ee6bc810a390e90bf67e5b8433ee3b92b48a4ca8 Mon Sep 17 00:00:00 2001 From: Avii Date: Sun, 9 Aug 2026 16:28:10 +0200 Subject: [PATCH] cleanup pass --- dashboard/src/error.rs | 23 +++ dashboard/src/ha_ext.rs | 2 +- dashboard/src/home_assistant.rs | 145 +++++++++++++ dashboard/src/main.rs | 356 ++------------------------------ dashboard/src/messages.rs | 17 ++ dashboard/src/ui.rs | 163 +++++++++++++++ 6 files changed, 363 insertions(+), 343 deletions(-) create mode 100644 dashboard/src/error.rs create mode 100644 dashboard/src/home_assistant.rs create mode 100644 dashboard/src/messages.rs create mode 100644 dashboard/src/ui.rs diff --git a/dashboard/src/error.rs b/dashboard/src/error.rs new file mode 100644 index 0000000..15cefce --- /dev/null +++ b/dashboard/src/error.rs @@ -0,0 +1,23 @@ +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("HASS_URL is not set")] + MissingUrl, + + #[error("HASS_TOKEN is not set")] + MissingToken, + + #[error("invalid HASS_URL")] + InvalidUrl, + + #[error("Unsupported HASS_URL scheme: {0}")] + InvalidScheme(String), + + #[error(transparent)] + SlintPlatform(#[from] slint::PlatformError), + + #[error(transparent)] + HomeAssistant(#[from] Box), + + #[error("Entity {0} not found")] + NoEntity(String), +} diff --git a/dashboard/src/ha_ext.rs b/dashboard/src/ha_ext.rs index 1fd879a..29ae811 100644 --- a/dashboard/src/ha_ext.rs +++ b/dashboard/src/ha_ext.rs @@ -1,4 +1,4 @@ -use crate::*; +use crate::ui::*; use hass_rs::HassEntity; use slint::ToSharedString; diff --git a/dashboard/src/home_assistant.rs b/dashboard/src/home_assistant.rs new file mode 100644 index 0000000..e258a9b --- /dev/null +++ b/dashboard/src/home_assistant.rs @@ -0,0 +1,145 @@ +use dotenvy::var; +use hass_rs::{HassClient, HassEntity}; +use serde_json::json; +use std::collections::HashMap; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +use crate::{ + error::AppError, + messages::{C2sMessage, S2cMessage}, +}; + +pub async fn hass( + s2c_tx: UnboundedSender, + mut c2s_rx: UnboundedReceiver, +) -> Result<(), AppError> { + let Ok(url) = var("HASS_URL") else { + return Err(AppError::MissingUrl); + }; + + let Ok(hass_token) = var("HASS_TOKEN") else { + return Err(AppError::MissingToken); + }; + + let Ok(url) = url::Url::parse(&url) else { + return Err(AppError::InvalidUrl); + }; + + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + scheme => return Err(AppError::InvalidScheme(scheme.to_string())), + }; + + let url = format!("{}://{}/api/websocket", scheme, url.authority()); + + let mut client = HassClient::new(&url).await.map_err(Box::new)?; + client + .auth_with_longlivedtoken(&hass_token) + .await + .map_err(Box::new)?; + + let mut state = HashMap::::new(); + let states = client.get_states().await.map_err(Box::new)?; + + states.iter().for_each(|entity| { + state.insert(entity.entity_id.clone(), entity.clone()); + }); + + let mut initial_state = state.values().cloned().collect::>(); + initial_state.sort_by(|a, b| a.entity_id.cmp(&b.entity_id)); + s2c_tx.send(S2cMessage::InitialState(initial_state)).ok(); + + let mut event_receiver = client + .subscribe_event("state_changed") + .await + .map_err(Box::new)?; + + loop { + tokio::select! { + Some(message) = event_receiver.recv() => { + match (&message.event.data.old_state, &message.event.data.new_state) { + (None, Some(entity)) => { + // New Entity Added + state.insert(entity.entity_id.clone(), entity.clone()); + s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok(); + } + (Some(entity), None) => { + // Entity Removed + state.remove(&entity.entity_id); + s2c_tx.send(S2cMessage::EntityRemoved(entity.clone())).ok(); + } + (Some(_), Some(entity)) => { + // Entity Updated + state.insert(entity.entity_id.clone(), entity.clone()); + s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok(); + } + _ => {} + } + } + + Some(message) = c2s_rx.recv() => { + if let Err(err) = match message { + C2sMessage::ToggleLight { entity_id } => { + if let Some(entity) = state.get(&entity_id) { + let payload = json!({ + "entity_id": entity_id + }); + let service = toggle_action(&entity.state, "on"); + client + .call_service("light".into(), service.into(), Some(payload)) + .await + .map_err(Box::new) + .map_err(Into::into) + } else { + Err(AppError::NoEntity(entity_id)) + } + } + C2sMessage::ToggleThermostat { entity_id } => { + if let Some(entity) = state.get(&entity_id) { + let payload = json!({ + "entity_id": entity_id + }); + let service = toggle_action(&entity.state, "heat"); + client + .call_service("climate".into(), service.into(), Some(payload)) + .await + .map_err(Box::new) + .map_err(Into::into) + } else { + Err(AppError::NoEntity(entity_id)) + } + } + C2sMessage::SetTemperature { + entity_id, + temperature, + } => { + let payload = json!({ + "entity_id": entity_id, + "temperature": temperature + }); + client + .call_service("climate".into(), "set_temperature".into(), Some(payload)) + .await + .map_err(Box::new) + .map_err(Into::into) + } + } { + eprintln!("Failed to execute Service Call.\n\t{:?}", err); + } + } + + else => break, + } + } + + Ok(()) +} + +fn toggle_action(current_state: &str, on_state: &str) -> &'static str { + if current_state == on_state { + "turn_off" + } else { + "turn_on" + } +} diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index ed508ae..927d1bc 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,125 +1,14 @@ +mod error; mod ha_ext; +mod home_assistant; +mod messages; +mod ui; -use paste::paste; +use tokio::sync::mpsc::unbounded_channel; -macro_rules! apply_model { - ($app:expr, $entities:expr, $name:ident, $ty:ty, $filter:expr) => { - paste! { - if $app - .[]() - .as_any() - .downcast_ref::>() - .is_none() - { - $app.[](ModelRc::new(VecModel::<$ty>::default())); - } +use slint::ComponentHandle; - let data = $app - .[](); - - let model = data - .as_any() - .downcast_ref::>() - .unwrap(); - - for entity in $entities.iter().filter($filter) { - match model.iter().position(|item| item.id == entity.entity_id) { - Some(index) => model.set_row_data(index, entity.into()), - None => model.push(entity.into()), - } - } - } - }; -} - -use std::collections::HashMap; - -use dotenvy::var; -use hass_rs::{HassClient, HassEntity}; -use serde_json::json; -use slint::language::ColorScheme; -use slint::{Model, ModelRc, VecModel, Weak}; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; - -use crate::ha_ext::HassEntityExt; -slint::include_modules!(); - -fn apply_theme(app: &AppWindow, entities: &Vec) { - for entity in entities { - if entity.entity_id == "sensor.diyless_thermostat_3_ambient_light_level" - && let Ok(illuminance) = entity.state.parse::() - { - let color_scheme = if illuminance < 250 { - ColorScheme::Dark - } else { - ColorScheme::Light - }; - - app.set_color_scheme(color_scheme); - } - } -} - -fn apply_clock(app: &AppWindow, entities: &Vec) { - for entity in entities { - if entity.entity_id == "sensor.date_time_iso" { - app.set_date_time(entity.into()); - } - } -} - -fn apply_weather(app: &AppWindow, entities: &Vec) { - for entity in entities { - if entity.entity_id == "weather.forecast_home" { - app.set_weather(entity.into()); - } - - if entity.entity_id == "sun.sun" { - app.set_is_night(entity.state == "below_horizon"); - } - } -} - -fn apply_lights(app: &AppWindow, entities: &[HassEntity]) { - apply_model!(app, entities, lights, LightData, |entity| { - entity.domain() == "light" - && !entity.entity_id.ends_with("screen") - && !entity.entity_id.chars().any(char::is_numeric) - }); -} - -fn apply_thermometers(app: &AppWindow, entities: &[HassEntity]) { - apply_model!(app, entities, thermometers, ThermometerData, |entity| { - entity.domain() == "sensor" && entity.entity_id.ends_with("thermometer_temperature") - }); -} - -fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) { - apply_model!(app, entities, thermostats, ThermostatData, |entity| { - entity.domain() == "climate" && !entity.entity_id.contains("diyless") - }); -} - -fn apply_state(app: &Weak, entities: Vec) { - if let Err(err) = app.upgrade_in_event_loop(move |app| { - apply_theme(&app, &entities); - apply_clock(&app, &entities); - apply_lights(&app, &entities); - apply_thermometers(&app, &entities); - apply_weather(&app, &entities); - apply_thermostats(&app, &entities); - }) { - eprintln!("Failed to upgrade in event loop.\n\t{:?}", err); - } -} - -fn toggle_action(current_state: &str, on_state: &str) -> &'static str { - if current_state == on_state { - "turn_off" - } else { - "turn_on" - } -} +use crate::error::AppError; #[tokio::main] async fn main() -> Result<(), AppError> { @@ -128,235 +17,18 @@ async fn main() -> Result<(), AppError> { #[cfg(not(feature = "dev"))] slint::platform::set_platform(Box::new(trekstor::Trekstor::new())).expect("set platform"); - let app = AppWindow::new()?; + let app = ui::AppWindow::new()?; - let (c2s_tx, c2s_rx) = mpsc::unbounded_channel::(); - let (s2c_tx, mut s2c_rx) = mpsc::unbounded_channel::(); + let (c2s_tx, c2s_rx) = unbounded_channel::(); + let (s2c_tx, s2c_rx) = unbounded_channel::(); - tokio::spawn({ - let app_weak = app.as_weak(); - async move { - while let Some(message) = s2c_rx.recv().await { - match message { - S2cMessage::InitialState(items) => { - println!("Initial state"); - apply_state(&app_weak, items); - } - S2cMessage::EntityUpdated(hass_entity) => { - apply_state(&app_weak, vec![hass_entity]); - } - S2cMessage::EntityRemoved(hass_entity) => { - apply_state(&app_weak, vec![hass_entity]); - } - } - } - } - }); + ui::bind_buttons(&app, c2s_tx); - // Button Bindings - app.on_toggle_light({ - let c2s_tx = c2s_tx.clone(); - move |entity_id| { - let id = entity_id.to_string(); - - if let Err(err) = c2s_tx.send(C2sMessage::ToggleLight { entity_id: id }) { - eprintln!("Failed to send message to server.\n\t{:?}", err); - } - } - }); - - app.on_set_temperature({ - let c2s_tx = c2s_tx.clone(); - move |entity_id, value| { - if let Err(err) = c2s_tx.send(C2sMessage::SetTemperature { - entity_id: entity_id.to_string(), - temperature: value, - }) { - eprintln!("Failed to send message to server.\n\t{:?}", err); - } - } - }); - - app.on_toggle_thermostat({ - let c2s_tx = c2s_tx.clone(); - move |entity_id| { - let id = entity_id.to_string(); - - if let Err(err) = c2s_tx.send(C2sMessage::ToggleThermostat { entity_id: id }) { - eprintln!("Failed to send message to server.\n\t{:?}", err); - } - } - }); + // listen for data + tokio::spawn(ui::update_listener(app.as_weak(), s2c_rx)); // start pulling data - tokio::spawn(hass(s2c_tx, c2s_rx)); + tokio::spawn(home_assistant::hass(s2c_tx, c2s_rx)); app.run().map_err(Into::into) } - -#[derive(Debug, thiserror::Error)] -enum AppError { - #[error("HASS_URL is not set")] - MissingUrl, - - #[error("HASS_TOKEN is not set")] - MissingToken, - - #[error("invalid HASS_URL")] - InvalidUrl, - - #[error("Unsupported HASS_URL scheme: {0}")] - InvalidScheme(String), - - #[error(transparent)] - SlintPlatform(#[from] slint::PlatformError), - - #[error(transparent)] - HomeAssistant(#[from] Box), - - #[error("Entity {0} not found")] - NoEntity(String), -} - -#[derive(Clone)] -enum C2sMessage { - ToggleLight { entity_id: String }, - - ToggleThermostat { entity_id: String }, - - SetTemperature { entity_id: String, temperature: f32 }, -} - -#[derive(Clone)] -enum S2cMessage { - InitialState(Vec), - EntityUpdated(HassEntity), - EntityRemoved(HassEntity), -} - -async fn hass( - s2c_tx: UnboundedSender, - mut c2s_rx: UnboundedReceiver, -) -> Result<(), AppError> { - let Ok(url) = var("HASS_URL") else { - return Err(AppError::MissingUrl); - }; - - let Ok(hass_token) = var("HASS_TOKEN") else { - return Err(AppError::MissingToken); - }; - - let Ok(url) = url::Url::parse(&url) else { - return Err(AppError::InvalidUrl); - }; - - let scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - scheme => return Err(AppError::InvalidScheme(scheme.to_string())), - }; - - let url = format!("{}://{}/api/websocket", scheme, url.authority()); - - let mut client = HassClient::new(&url).await.map_err(Box::new)?; - client - .auth_with_longlivedtoken(&hass_token) - .await - .map_err(Box::new)?; - - let mut state = HashMap::::new(); - let states = client.get_states().await.map_err(Box::new)?; - - states.iter().for_each(|entity| { - state.insert(entity.entity_id.clone(), entity.clone()); - }); - - let mut initial_state = state.values().cloned().collect::>(); - initial_state.sort_by(|a, b| a.entity_id.cmp(&b.entity_id)); - s2c_tx.send(S2cMessage::InitialState(initial_state)).ok(); - - let mut event_receiver = client - .subscribe_event("state_changed") - .await - .map_err(Box::new)?; - - loop { - tokio::select! { - Some(message) = event_receiver.recv() => { - match (&message.event.data.old_state, &message.event.data.new_state) { - (None, Some(entity)) => { - // New Entity Added - state.insert(entity.entity_id.clone(), entity.clone()); - s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok(); - } - (Some(entity), None) => { - // Entity Removed - state.remove(&entity.entity_id); - s2c_tx.send(S2cMessage::EntityRemoved(entity.clone())).ok(); - } - (Some(_), Some(entity)) => { - // Entity Updated - state.insert(entity.entity_id.clone(), entity.clone()); - s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok(); - } - _ => {} - } - } - - Some(message) = c2s_rx.recv() => { - if let Err(err) = match message { - C2sMessage::ToggleLight { entity_id } => { - if let Some(entity) = state.get(&entity_id) { - let payload = json!({ - "entity_id": entity_id - }); - let service = toggle_action(&entity.state, "on"); - client - .call_service("light".into(), service.into(), Some(payload)) - .await - .map_err(Box::new) - .map_err(Into::into) - } else { - Err(AppError::NoEntity(entity_id)) - } - } - C2sMessage::ToggleThermostat { entity_id } => { - if let Some(entity) = state.get(&entity_id) { - let payload = json!({ - "entity_id": entity_id - }); - let service = toggle_action(&entity.state, "heat"); - client - .call_service("climate".into(), service.into(), Some(payload)) - .await - .map_err(Box::new) - .map_err(Into::into) - } else { - Err(AppError::NoEntity(entity_id)) - } - } - C2sMessage::SetTemperature { - entity_id, - temperature, - } => { - let payload = json!({ - "entity_id": entity_id, - "temperature": temperature - }); - client - .call_service("climate".into(), "set_temperature".into(), Some(payload)) - .await - .map_err(Box::new) - .map_err(Into::into) - } - } { - eprintln!("Failed to execute Service Call.\n\t{:?}", err); - } - } - - else => break, - } - } - - Ok(()) -} diff --git a/dashboard/src/messages.rs b/dashboard/src/messages.rs new file mode 100644 index 0000000..7f73c3e --- /dev/null +++ b/dashboard/src/messages.rs @@ -0,0 +1,17 @@ +use hass_rs::HassEntity; + +#[derive(Clone)] +pub enum C2sMessage { + ToggleLight { entity_id: String }, + + ToggleThermostat { entity_id: String }, + + SetTemperature { entity_id: String, temperature: f32 }, +} + +#[derive(Clone)] +pub enum S2cMessage { + InitialState(Vec), + EntityUpdated(HassEntity), + EntityRemoved(HassEntity), +} diff --git a/dashboard/src/ui.rs b/dashboard/src/ui.rs new file mode 100644 index 0000000..019b532 --- /dev/null +++ b/dashboard/src/ui.rs @@ -0,0 +1,163 @@ +slint::include_modules!(); + +use crate::{ + ha_ext::*, + messages::{C2sMessage, S2cMessage}, +}; + +use hass_rs::HassEntity; +use paste::paste; +use slint::{Model, ModelRc, VecModel, Weak, language::ColorScheme}; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +macro_rules! apply_model { + ($app:expr, $entities:expr, $name:ident, $ty:ty, $filter:expr) => { + paste! { + if $app + .[]() + .as_any() + .downcast_ref::>() + .is_none() + { + $app.[](ModelRc::new(VecModel::<$ty>::default())); + } + + let data = $app + .[](); + + let model = data + .as_any() + .downcast_ref::>() + .unwrap(); + + for entity in $entities.iter().filter($filter) { + match model.iter().position(|item| item.id == entity.entity_id) { + Some(index) => model.set_row_data(index, entity.into()), + None => model.push(entity.into()), + } + } + } + }; +} + +pub fn apply_theme(app: &AppWindow, entities: &Vec) { + for entity in entities { + if entity.entity_id == "sensor.diyless_thermostat_3_ambient_light_level" + && let Ok(illuminance) = entity.state.parse::() + { + let color_scheme = if illuminance < 250 { + ColorScheme::Dark + } else { + ColorScheme::Light + }; + + app.set_color_scheme(color_scheme); + } + } +} + +pub fn apply_clock(app: &AppWindow, entities: &Vec) { + for entity in entities { + if entity.entity_id == "sensor.date_time_iso" { + app.set_date_time(entity.into()); + } + } +} + +pub fn apply_weather(app: &AppWindow, entities: &Vec) { + for entity in entities { + if entity.entity_id == "weather.forecast_home" { + app.set_weather(entity.into()); + } + + if entity.entity_id == "sun.sun" { + app.set_is_night(entity.state == "below_horizon"); + } + } +} + +pub fn apply_lights(app: &AppWindow, entities: &[HassEntity]) { + apply_model!(app, entities, lights, LightData, |entity| { + entity.domain() == "light" + && !entity.entity_id.ends_with("screen") + && !entity.entity_id.chars().any(char::is_numeric) + }); +} + +pub fn apply_thermometers(app: &AppWindow, entities: &[HassEntity]) { + apply_model!(app, entities, thermometers, ThermometerData, |entity| { + entity.domain() == "sensor" && entity.entity_id.ends_with("thermometer_temperature") + }); +} + +pub fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) { + apply_model!(app, entities, thermostats, ThermostatData, |entity| { + entity.domain() == "climate" && !entity.entity_id.contains("diyless") + }); +} + +pub fn apply_state(app: &Weak, entities: Vec) { + if let Err(err) = app.upgrade_in_event_loop(move |app| { + apply_theme(&app, &entities); + apply_clock(&app, &entities); + apply_lights(&app, &entities); + apply_thermometers(&app, &entities); + apply_weather(&app, &entities); + apply_thermostats(&app, &entities); + }) { + eprintln!("Failed to upgrade in event loop.\n\t{:?}", err); + } +} + +pub fn bind_buttons(app: &AppWindow, c2s_tx: UnboundedSender) { + // Button Bindings + app.on_toggle_light({ + let c2s_tx = c2s_tx.clone(); + move |entity_id| { + let id = entity_id.to_string(); + + if let Err(err) = c2s_tx.send(C2sMessage::ToggleLight { entity_id: id }) { + eprintln!("Failed to send message to server.\n\t{:?}", err); + } + } + }); + + app.on_set_temperature({ + let c2s_tx = c2s_tx.clone(); + move |entity_id, value| { + if let Err(err) = c2s_tx.send(C2sMessage::SetTemperature { + entity_id: entity_id.to_string(), + temperature: value, + }) { + eprintln!("Failed to send message to server.\n\t{:?}", err); + } + } + }); + + app.on_toggle_thermostat({ + let c2s_tx = c2s_tx.clone(); + move |entity_id| { + let id = entity_id.to_string(); + + if let Err(err) = c2s_tx.send(C2sMessage::ToggleThermostat { entity_id: id }) { + eprintln!("Failed to send message to server.\n\t{:?}", err); + } + } + }); +} + +pub async fn update_listener(app_weak: Weak, mut s2c_rx: UnboundedReceiver) { + while let Some(message) = s2c_rx.recv().await { + match message { + S2cMessage::InitialState(items) => { + apply_state(&app_weak, items); + } + S2cMessage::EntityUpdated(hass_entity) => { + apply_state(&app_weak, vec![hass_entity]); + } + S2cMessage::EntityRemoved(hass_entity) => { + apply_state(&app_weak, vec![hass_entity]); + } + } + } +}