diff --git a/Cargo.lock b/Cargo.lock index 0cfd0bc..6200031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -57,7 +57,7 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -312,7 +312,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -851,6 +851,7 @@ dependencies = [ "serde_json", "slint", "slint-build", + "thiserror 2.0.20", "tokio", "trekstor", "url", @@ -1636,7 +1637,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-tungstenite", ] @@ -2304,7 +2305,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2552,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" dependencies = [ "lyon_path", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -2662,7 +2663,7 @@ dependencies = [ "objc2-foundation 0.3.2", "once_cell", "png", - "thiserror 2.0.19", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -3662,7 +3663,7 @@ dependencies = [ "rand", "rand_chacha", "simd_helpers", - "thiserror 2.0.19", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -4476,11 +4477,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -4496,9 +4497,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -4739,7 +4740,7 @@ dependencies = [ "log", "rand", "sha1", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml index 10b1671..9cf2be3 100644 --- a/dashboard/Cargo.toml +++ b/dashboard/Cargo.toml @@ -12,6 +12,7 @@ tokio = { version = "1.53.1", features = ["full"] } dotenvy = "0.15.7" url = "2.5.8" chrono = "0.4.45" +thiserror = "2.0.20" [features] dev = ["slint/backend-winit-x11"] diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs index e574601..a8f7ac2 100644 --- a/dashboard/src/main.rs +++ b/dashboard/src/main.rs @@ -1,84 +1,105 @@ mod ha_ext; +use std::collections::HashMap; use std::sync::{Arc, RwLock}; use dotenvy::var; use hass_rs::{HassClient, HassEntity}; -use serde_json::{Value, json}; +use serde_json::json; use slint::language::ColorScheme; use slint::{ModelRc, VecModel, Weak}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; 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::() + { + let color_scheme = if illuminance < 250 { + ColorScheme::Dark + } else { + ColorScheme::Light + }; + + app.set_color_scheme(color_scheme); + } +} + +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_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()); + } + 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") + .iter() + .filter(|sensor| !sensor.entity_id.contains("diyless")) + .map(Into::into) + .collect::>(); + + app.set_thermostats(ModelRc::new(VecModel::from(thermostats))); +} + fn apply_state(app: &Weak, state: State) { - app.upgrade_in_event_loop(move |app| { - // Light/Dark mode - // - if let Some(illuminance) = state - .get("sensor.diyless_thermostat_3_ambient_light_level") - .as_ref() - && let Ok(illuminance) = illuminance.state.parse::() - { - let scheme = if illuminance < 300 { - ColorScheme::Dark - } else { - ColorScheme::Light - }; + 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); + }) { + eprintln!("Failed to upgrade in event loop.\n\t{:?}", err); + } +} - app.set_color_scheme(scheme); - } - - // Lights - 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))); - - // Thermometers - 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))); - - // Clock - if let Some(date_time_iso) = state.get("sensor.date_time_iso").as_ref() { - app.set_date_time(date_time_iso.into()); - } - - // Weather - if let Some(weather) = state.get("weather.forecast_home").as_ref() { - app.set_weather(weather.into()); - } - app.set_is_night( - state - .get("sun.sun") - .is_some_and(|sun| sun.state == "below_horizon"), - ); - - // Thermostats - let thermostats = state - .domain("climate") - .iter() - .filter(|sensor| !sensor.entity_id.contains("diyless")) - .map(Into::into) - .collect::>(); - - app.set_thermostats(ModelRc::new(VecModel::from(thermostats))); - }) - .ok(); +fn toggle_action(current_state: &str, on_state: &str) -> &'static str { + if current_state == on_state { + "turn_off" + } else { + "turn_on" + } } #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> Result<(), AppError> { dotenvy::dotenv().ok(); #[cfg(not(feature = "dev"))] @@ -98,37 +119,20 @@ async fn main() -> Result<(), Box> { let app_weak = app.as_weak(); let state = state.clone(); async move { - loop { - if let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await { - apply_state(&app_weak, state.clone()); - }; + while let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await { + apply_state(&app_weak, state.clone()); } } }); + // Button Bindings app.on_toggle_light({ - let state = state.clone(); let c2s_tx = c2s_tx.clone(); move |entity_id| { let id = entity_id.to_string(); - let payload = Some(json!({ - "entity_id": id - })); - - if let Some(entity) = state.get(&id) { - let action = if entity.state == "on" { - "turn_off" - } else { - "turn_on" - }; - c2s_tx - .send(C2sMessage::CallService( - "light".into(), - action.into(), - payload.clone(), - )) - .ok(); + if let Err(err) = c2s_tx.send(C2sMessage::ToggleLight { entity_id: id }) { + eprintln!("Failed to send message to server.\n\t{:?}", err); } } }); @@ -136,56 +140,60 @@ async fn main() -> Result<(), Box> { app.on_set_temperature({ let c2s_tx = c2s_tx.clone(); move |entity_id, value| { - let payload = Some(json!({ - "entity_id": entity_id.to_string(), - "temperature": value - })); - - c2s_tx - .send(C2sMessage::CallService( - "climate".into(), - "set_temperature".into(), - payload.clone(), - )) - .ok(); - } - }); - - app.on_toggle_thermostat({ - let state = state.clone(); - let c2s_tx = c2s_tx.clone(); - move |entity_id| { - let id = entity_id.to_string(); - - let payload = Some(json!({ - "entity_id": id - })); - - if let Some(entity) = state.get(&id) { - let action = if entity.state == "heat" { - "turn_off" - } else { - "turn_on" - }; - c2s_tx - .send(C2sMessage::CallService( - "climate".into(), - action.into(), - payload.clone(), - )) - .ok(); + 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.run()?; + app.on_toggle_thermostat({ + let c2s_tx = c2s_tx.clone(); + move |entity_id| { + let id = entity_id.to_string(); - Ok(()) + if let Err(err) = c2s_tx.send(C2sMessage::ToggleThermostat { entity_id: id }) { + eprintln!("Failed to send message to server.\n\t{:?}", err); + } + } + }); + + 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 { - CallService(String, String, Option), + ToggleLight { entity_id: String }, + + ToggleThermostat { entity_id: String }, + + SetTemperature { entity_id: String, temperature: f32 }, } #[derive(Clone)] @@ -194,36 +202,36 @@ enum S2cMessage { } #[derive(Default, Debug, Clone)] -struct State(Arc>>); +struct State(Arc>>); impl State { pub fn update(&self, entity: &HassEntity) { - let mut entities = self.0.write().unwrap(); - - if let Some(existing) = entities - .iter_mut() - .find(|e| e.entity_id == entity.entity_id) - { - *existing = entity.clone(); - } else { - entities.push(entity.clone()); - } + self.0 + .write() + .unwrap() + .insert(entity.entity_id.clone(), entity.clone()); } pub fn domain(&self, domain: &str) -> Vec { let entities = self.0.read().unwrap(); - entities - .iter() - .filter(|e| e.entity_id.starts_with(domain)) + let mut entities = entities + .values() + .filter(|e| { + e.entity_id + .split_once('.') + .is_some_and(|(d, _)| d == domain) + }) .cloned() - .collect::>() + .collect::>(); + + entities.sort_by(|a, b| a.entity_id.cmp(&b.entity_id)); + + entities } pub fn get(&self, entity_id: &str) -> Option { - let entities = self.0.read().unwrap(); - - entities.iter().find(|e| e.entity_id == entity_id).cloned() + self.0.read().unwrap().get(entity_id).cloned() } } @@ -231,50 +239,111 @@ async fn hass( s2c_tx: UnboundedSender, mut c2s_rx: UnboundedReceiver, state: State, -) -> Result<(), Box> { +) -> Result<(), AppError> { let Ok(url) = var("HASS_URL") else { - return Err("NO HASS_URL SET".into()); + return Err(AppError::MissingUrl); }; let Ok(hass_token) = var("HASS_TOKEN") else { - return Err("NO HASS_TOKEN SET".into()); + return Err(AppError::MissingToken); }; let Ok(url) = url::Url::parse(&url) else { - return Err("INVALID HASS_URL".into()); + return Err(AppError::InvalidUrl); }; - let url = format!("ws://{}/api/websocket", url.authority()); + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + scheme => return Err(AppError::InvalidScheme(scheme.to_string())), + }; - let mut client = HassClient::new(&url).await?; - client.auth_with_longlivedtoken(&hass_token).await?; + let url = format!("{}://{}/api/websocket", scheme, url.authority()); - let states = client.get_states().await?; + let mut client = HassClient::new(&url).await.map_err(Box::new)?; + client + .auth_with_longlivedtoken(&hass_token) + .await + .map_err(Box::new)?; + + let states = client.get_states().await.map_err(Box::new)?; states.iter().for_each(|entity| { state.update(entity); }); - let mut event_receiver = client.subscribe_event("state_changed").await?; - tokio::spawn(async move { - while let Some(message) = event_receiver.recv().await { - if let Some(entity) = &message.event.data.new_state { - state.update(entity); - s2c_tx.send(S2cMessage::EntityUpdated).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); + } + } } } }); - tokio::spawn(async move { - loop { - if let Some(C2sMessage::CallService(domain, service, service_data)) = - c2s_rx.recv().await - { - client - .call_service(domain, service, service_data) - .await - .unwrap(); - }; + tokio::spawn({ + let state = state.clone(); + async move { + while let Some(message) = c2s_rx.recv().await { + 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); + } + } } }); diff --git a/dashboard/ui/climate.slint b/dashboard/ui/climate.slint index 4565214..cfb674e 100644 --- a/dashboard/ui/climate.slint +++ b/dashboard/ui/climate.slint @@ -96,9 +96,11 @@ export component Climate inherits Rectangle { } for thermostat[index] in thermostats: ListTile { + avatar_icon: Icons.thermostat; text: thermostat.name; supporting-text: thermostat.available ? thermostat.state : "UNAVAILABLE"; height: 72px; + enabled: false; ElevatedButton { icon: Icons.remove; @@ -111,7 +113,7 @@ export component Climate inherits Rectangle { HorizontalLayout { Text { text: thermostat.target; - color: MaterialPalette.primary; + // color: MaterialPalette.primary; font-size: 40px; vertical-alignment: center; horizontal-alignment: right; @@ -123,7 +125,7 @@ export component Climate inherits Rectangle { padding-top: 2px; Text { text: thermostat.unit; - color: MaterialPalette.primary; + // color: MaterialPalette.primary; font-size: 14px; horizontal-alignment: left; vertical-alignment: top; @@ -133,7 +135,7 @@ export component Climate inherits Rectangle { Text { text: ".\{thermostat.remainder}"; - color: MaterialPalette.primary; + // color: MaterialPalette.primary; font-size: 14px; horizontal-alignment: left; vertical-alignment: bottom; diff --git a/dashboard/ui/material/ui/components/list.slint b/dashboard/ui/material/ui/components/list.slint index 82b3ea9..96667ef 100644 --- a/dashboard/ui/material/ui/components/list.slint +++ b/dashboard/ui/material/ui/components/list.slint @@ -61,7 +61,7 @@ export component ListTile { if root.avatar_text != "": MaterialText { text: root.avatar_text; - vertical_alignment: center; + vertical_alignment: bottom; horizontal_alignment: center; color: root.avatar_foreground; style: MaterialTypography.title_medium; @@ -100,7 +100,7 @@ export component ListTile { states [ disabled when !root.enabled: { state_layer.display_background: false; - title_layout.opacity: 38%; + // title_layout.opacity: 38%; } ] }