From 7e7a93e87b6b932c9fd5e262b2cd4613b76833a1 Mon Sep 17 00:00:00 2001 From: Avii Date: Fri, 7 Aug 2026 11:40:13 +0200 Subject: [PATCH] 1786095613 --- .env.example | 2 + .gitignore | 1 + Cargo.lock | 8 +++ Cargo.toml | 2 + src/main.rs | 188 +++++++++++++++++++++++++++++++++++++++++++++----- ui/main.slint | 19 ++--- 6 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f170ad5 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +HA_DOMAIN=home.local.avii.nl +HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkNzc4MTBkOWY0NjY0NWVlOGNlZjY4MzZlYWNiOWFlNyIsImlhdCI6MTc4NjA5MDcwNiwiZXhwIjoyMTAxNDUwNzA2fQ.jGZKkOzKQ6qEtMfvrfwL-L9cN5-AVB1VAECgLqAjDKM diff --git a/.gitignore b/.gitignore index ea8c4bf..fedaa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.env diff --git a/Cargo.lock b/Cargo.lock index 0ac98e3..70aed36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -898,12 +898,14 @@ dependencies = [ name = "dashboard" version = "0.1.0" dependencies = [ + "dotenvy", "embedded-graphics", "embedded-graphics-core", "evdev", "framebuffer", "hass-rs", "linfb", + "serde_json", "slint", "slint-build", "tokio", @@ -1026,6 +1028,12 @@ dependencies = [ "libloading", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 0312a97..404897c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,12 +4,14 @@ version = "0.1.0" edition = "2024" [dependencies] +dotenvy = "0.15.7" embedded-graphics = "0.8.2" embedded-graphics-core = "0.4.1" evdev = "0.13.2" framebuffer = "0.3.1" hass-rs = "0.5.0" linfb = { version = "0.2.1", default-features = false } +serde_json = "1.0.151" slint = { version = "1.17.1", default-features = false, features = ["std", "compat-1-2", "libm", "renderer-software", "unsafe-single-threaded"] } tokio = { version = "1.53.1", features = ["full"] } diff --git a/src/main.rs b/src/main.rs index ae8cf22..cd1ef0e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,36 +1,192 @@ +use hass_rs::{HassClient, HassEntity}; use slint::{ModelRc, VecModel}; +use std::{ + collections::HashMap, + env::var, + sync::{Arc, Mutex}, + thread::sleep, + time::Duration, +}; + +slint::include_modules!(); mod rgba; #[cfg(not(feature = "dev"))] mod trekstor; -slint::include_modules!(); +// #[derive(Clone, Debug, PartialEq)] +// struct LightState { +// entity_id: String, +// name: String, +// on: bool, +// available: bool, +// } + +struct Database { + client: HassClient, + entities: HashMap, +} + +impl Database { + pub async fn update_light(&mut self, light: &str, checked: bool) { + self.client + .call_service( + "light".to_string(), + if checked { + "turn_on".to_string() + } else { + "turn_off".to_string() + }, + Some(serde_json::json!({ + "entity_id": light.to_string() + })), + ) + .await + .ok(); + } + + pub fn get_lights(&self) -> Vec { + let mut hidden = vec![]; + let lights = self + .entities + .iter() + .filter_map(|(id, e)| { + if id.starts_with("light.") { + if let Some(children) = e.attributes["entity_id"].as_array() { + for child in children { + hidden.push(child.as_str().unwrap().to_string()); + } + } + return Some(LightData { + available: e.state != "unavailable", + entity_id: e.entity_id.clone().into(), + name: e.attributes["friendly_name"].as_str().unwrap().into(), + on: e.state == "on", + }); + } + + None + }) + .collect::>(); + + let mut lights = lights + .iter() + .filter(|e| { + !e.entity_id.ends_with("screen") && !hidden.contains(&e.entity_id.to_string()) + }) + .map(|e| e.to_owned()) + .collect::>(); + lights.sort_by(|a, b| a.name.cmp(&b.name)); + lights + } +} + +type DB = Arc>; #[tokio::main] async fn main() -> Result<(), slint::PlatformError> { + dotenvy::dotenv().ok(); + #[cfg(not(feature = "dev"))] slint::platform::set_platform(Box::new(crate::trekstor::Trekstor::new())) .expect("set platform"); let main_window = MainWindow::new()?; - let lights = vec![LightData { - available: true, - entity_id: "light.living_room".into(), - name: "Living Room Lights".into(), - on: false, - }]; + // start home_assistant webscoket + let db = home_assisstant_websocket_client().await; - main_window.set_lights(ModelRc::new(VecModel::from(lights))); + let db_weak = db.clone(); + let weak_window = main_window.as_weak(); + main_window.on_toggle_light(move |light, checked| { + let db_weaker = db_weak.clone(); + slint::spawn_local(async move { + db_weaker + .lock() + .unwrap() + .update_light(&light.entity_id.to_string(), checked) + .await; + }) + .ok(); - // let weak_window = main_window.as_weak(); - // slint::spawn_local(async move { - // sleep(Duration::from_secs(5)); - // weak_window.upgrade_in_event_loop(move |ui| { - // ui.set_text("GoodBye!".into()); - // }) - // }) - // .unwrap(); + let lights = db_weak.lock().unwrap().get_lights(); + weak_window + .upgrade_in_event_loop(move |ui| { + ui.set_lights(ModelRc::new(VecModel::from(lights))); + }) + .ok(); + // println!("{} {}", entity_id, checked); + }); + + let weak_window = main_window.as_weak(); + tokio::spawn(async move { + loop { + let lights = db.lock().unwrap().get_lights(); + weak_window + .upgrade_in_event_loop(move |ui| { + ui.set_lights(ModelRc::new(VecModel::from(lights))); + }) + .ok(); + sleep(Duration::from_secs(1)); + } + }); main_window.run() } + +// todo: add channel to send actions to HA, maybe on the db object? prolly not, right? +async fn home_assisstant_websocket_client() -> DB { + let hass_ws_url = var("HASS_WS_URL").expect("HASS_WS_URL not set in environment"); + let hass_token = var("HASS_TOKEN").expect("HA_TOKEN not set in environment"); + + let mut client = HassClient::new(&hass_ws_url) + .await + .expect("Failed to connect"); + + client + .auth_with_longlivedtoken(&hass_token) + .await + .expect("Not able to authenticate"); + + let db = Arc::new(Mutex::new(Database { + client, + entities: Default::default(), + })); + + let mut event_receiver = db + .lock() + .unwrap() + .client + .subscribe_event("state_changed") + .await + .expect("Failed to subscribe"); + + let states = db + .lock() + .unwrap() + .client + .get_states() + .await + .expect("unable to get states"); + states.iter().for_each(|entity| { + db.lock() + .expect("Database poisoned") + .entities + .insert(entity.entity_id.clone(), entity.clone()); + }); + + let db_weak = db.clone(); + tokio::spawn(async move { + while let Some(message) = event_receiver.recv().await { + if let Some(entity) = &message.event.data.new_state { + db_weak + .lock() + .expect("Database poisoned") + .entities + .insert(entity.entity_id.clone(), entity.clone()); + } + } + }); + + db +} diff --git a/ui/main.slint b/ui/main.slint index 7f93d44..f4d1727 100644 --- a/ui/main.slint +++ b/ui/main.slint @@ -18,11 +18,7 @@ export struct LightData { component LightSwitch inherits Horizontal { in property light; - callback toggled(string); - - TouchArea { - clicked => { if root.light.available { root.toggled(root.light.entity_id); } } - } + callback toggled(LightData, bool); alignment: end; label := Text { @@ -33,8 +29,9 @@ component LightSwitch inherits Horizontal { font-weight: root.light.on ? 700 : 500; } switch := Switch { - enabled: root.light.on; - checked: root.light.available; + enabled: root.light.available; + checked: root.light.on; + checked_state_changed(checked) => { root.toggled(root.light, checked) } } } @@ -48,15 +45,13 @@ export component MainWindow inherits Window { default-font-family: "Roboto"; in property <[LightData]> lights; - callback toggle-light(string); + callback toggle-light(LightData, bool); in-out property current-page: 0; in property next-event-label: "NEXT EVENT"; in property next-event-title: "LOADING CALENDAR"; in property next-event-time: "6:00 pm"; - callback user-activity(); - Rectangle { x: 0; y: 0; @@ -78,7 +73,7 @@ export component MainWindow inherits Window { y: 5px; width: parent.width - 78px; height: 78px; - clicked => { root.user-activity(); root.current-page = 2; } + clicked => { root.current-page = 2; } } Text { @@ -136,7 +131,7 @@ export component MainWindow inherits Window { for light_data in root.lights: LightSwitch { light: light_data; - toggled(entity_id) => { root.user-activity(); root.toggle-light(entity_id); } + toggled(light, checked) => { root.toggle-light(light, checked); } } } }