From b5b3025e8844bee4aee7be087184eb9891a5fbfc Mon Sep 17 00:00:00 2001 From: Avii Date: Sun, 9 Aug 2026 15:55:36 +0200 Subject: [PATCH] 1786283736 --- dashboard/src/ha_ext.rs | 8 ++ dashboard/src/main.rs | 284 +++++++++++++++++++++++----------------- 2 files changed, 170 insertions(+), 122 deletions(-) diff --git a/dashboard/src/ha_ext.rs b/dashboard/src/ha_ext.rs index d452f7d..1fd879a 100644 --- a/dashboard/src/ha_ext.rs +++ b/dashboard/src/ha_ext.rs @@ -7,6 +7,7 @@ pub trait HassEntityExt { fn available(&self) -> bool; fn friendly_name(&self) -> &str; fn unit_of_measurement(&self) -> &str; + fn domain(&self) -> &str; } impl HassEntityExt for HassEntity { @@ -31,6 +32,13 @@ impl HassEntityExt for HassEntity { .and_then(serde_json::Value::as_str) .unwrap_or_default() } + + fn domain(&self) -> &str { + self.entity_id + .split_once(".") + .map(|(d, _)| d) + .unwrap_or_default() + } } impl From<&HassEntity> for LightData { diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index a8f7ac2..dd60bf2 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,90 +1,142 @@ mod ha_ext; use std::collections::HashMap; -use std::sync::{Arc, RwLock}; use dotenvy::var; use hass_rs::{HassClient, HassEntity}; use serde_json::json; use slint::language::ColorScheme; -use slint::{ModelRc, VecModel, Weak}; +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, state: &State) { - if let Some(illuminance) = state - .get("sensor.diyless_thermostat_3_ambient_light_level") - .as_ref() - && let Ok(illuminance) = illuminance.state.parse::() +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]) { + if app + .get_lights() + .as_any() + .downcast_ref::>() + .is_none() { - let color_scheme = if illuminance < 250 { - ColorScheme::Dark - } else { - ColorScheme::Light - }; + app.set_lights(ModelRc::new(VecModel::default())); + } - app.set_color_scheme(color_scheme); + let lights = app.get_lights(); + let model = lights + .as_any() + .downcast_ref::>() + .unwrap(); + + for entity in entities.iter().filter(|entity| { + entity.domain() == "light" + && !entity.entity_id.ends_with("screen") + && !entity.entity_id.chars().any(char::is_numeric) + }) { + match model.iter().position(|light| light.id == entity.entity_id) { + Some(index) => model.set_row_data(index, entity.into()), + None => model.push(entity.into()), + } } } -fn apply_clock(app: &AppWindow, state: &State) { - if let Some(date_time_iso) = state.get("sensor.date_time_iso").as_ref() { - app.set_date_time(date_time_iso.into()); +fn apply_thermometers(app: &AppWindow, entities: &[HassEntity]) { + if app + .get_thermometers() + .as_any() + .downcast_ref::>() + .is_none() + { + app.set_thermometers(ModelRc::new(VecModel::default())); + } + + let data = app.get_thermometers(); + let model = data + .as_any() + .downcast_ref::>() + .unwrap(); + + for entity in entities.iter().filter(|entity| { + entity.domain() == "sensor" && entity.entity_id.ends_with("thermometer_temperature") + }) { + match model.iter().position(|item| item.id == entity.entity_id) { + Some(index) => model.set_row_data(index, entity.into()), + None => model.push(entity.into()), + } } } -fn apply_lights(app: &AppWindow, state: &State) { - let lights = state - .domain("light") - .iter() - .filter(|light| { - !(light.entity_id.ends_with("screen") || light.entity_id.chars().any(char::is_numeric)) - }) - .map(Into::into) - .collect::>(); - app.set_lights(ModelRc::new(VecModel::from(lights))); -} - -fn apply_thermometers(app: &AppWindow, state: &State) { - let thermometers = state - .domain("sensor") - .iter() - .filter(|sensor| sensor.entity_id.ends_with("thermometer_temperature")) - .map(Into::into) - .collect::>(); - app.set_thermometers(ModelRc::new(VecModel::from(thermometers))); -} - -fn apply_weather(app: &AppWindow, state: &State) { - if let Some(weather) = state.get("weather.forecast_home").as_ref() { - app.set_weather(weather.into()); +fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) { + if app + .get_thermostats() + .as_any() + .downcast_ref::>() + .is_none() + { + app.set_thermostats(ModelRc::new(VecModel::default())); } - app.set_is_night( - state - .get("sun.sun") - .is_some_and(|sun| sun.state == "below_horizon"), - ); -} -fn apply_thermostats(app: &AppWindow, state: &State) { - let thermostats = state - .domain("climate") + let data = app.get_thermostats(); + let model = data + .as_any() + .downcast_ref::>() + .unwrap(); + + for entity in entities .iter() - .filter(|sensor| !sensor.entity_id.contains("diyless")) - .map(Into::into) - .collect::>(); - - app.set_thermostats(ModelRc::new(VecModel::from(thermostats))); + .filter(|entity| entity.domain() == "climate" && !entity.entity_id.contains("diyless")) + { + match model.iter().position(|item| item.id == entity.entity_id) { + Some(index) => model.set_row_data(index, entity.into()), + None => model.push(entity.into()), + } + } } -fn apply_state(app: &Weak, state: State) { +fn apply_state(app: &Weak, entities: Vec) { if let Err(err) = app.upgrade_in_event_loop(move |app| { - apply_theme(&app, &state); - apply_clock(&app, &state); - apply_lights(&app, &state); - apply_thermometers(&app, &state); - apply_weather(&app, &state); - apply_thermostats(&app, &state); + 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); } @@ -107,20 +159,25 @@ async fn main() -> Result<(), AppError> { let app = AppWindow::new()?; - let state = State::default(); let (c2s_tx, c2s_rx) = mpsc::unbounded_channel::(); let (s2c_tx, mut s2c_rx) = mpsc::unbounded_channel::(); - hass(s2c_tx, c2s_rx, state.clone()).await?; - let app_weak = app.as_weak(); - apply_state(&app_weak, state.clone()); - tokio::spawn({ let app_weak = app.as_weak(); - let state = state.clone(); async move { - while let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await { - apply_state(&app_weak, state.clone()); + 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]); + } + } } } }); @@ -160,6 +217,9 @@ async fn main() -> Result<(), AppError> { } }); + // start pulling data + tokio::spawn(hass(s2c_tx, c2s_rx)); + app.run().map_err(Into::into) } @@ -198,47 +258,14 @@ enum C2sMessage { #[derive(Clone)] enum S2cMessage { - EntityUpdated, -} - -#[derive(Default, Debug, Clone)] -struct State(Arc>>); - -impl State { - pub fn update(&self, entity: &HassEntity) { - self.0 - .write() - .unwrap() - .insert(entity.entity_id.clone(), entity.clone()); - } - - pub fn domain(&self, domain: &str) -> Vec { - let entities = self.0.read().unwrap(); - - let mut entities = entities - .values() - .filter(|e| { - e.entity_id - .split_once('.') - .is_some_and(|(d, _)| d == domain) - }) - .cloned() - .collect::>(); - - entities.sort_by(|a, b| a.entity_id.cmp(&b.entity_id)); - - entities - } - - pub fn get(&self, entity_id: &str) -> Option { - self.0.read().unwrap().get(entity_id).cloned() - } + InitialState(Vec), + EntityUpdated(HassEntity), + EntityRemoved(HassEntity), } async fn hass( s2c_tx: UnboundedSender, mut c2s_rx: UnboundedReceiver, - state: State, ) -> Result<(), AppError> { let Ok(url) = var("HASS_URL") else { return Err(AppError::MissingUrl); @@ -266,35 +293,46 @@ async fn hass( .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.update(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)?; - tokio::spawn({ - let state = state.clone(); - async move { - while let Some(message) = event_receiver.recv().await { - if let Some(entity) = &message.event.data.new_state { - state.update(entity); - if let Err(err) = s2c_tx.send(S2cMessage::EntityUpdated) { - eprintln!("Failed to send message to client.\n\t{:?}", err); + 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(); + } + _ => {} } } - } - }); - tokio::spawn({ - let state = state.clone(); - async move { - while let Some(message) = c2s_rx.recv().await { + Some(message) = c2s_rx.recv() => { if let Err(err) = match message { C2sMessage::ToggleLight { entity_id } => { if let Some(entity) = state.get(&entity_id) { @@ -344,8 +382,10 @@ async fn hass( eprintln!("Failed to execute Service Call.\n\t{:?}", err); } } + + else => break, } - }); + } Ok(()) }