slint::include_modules!(); mod ha; mod state; mod trekstor; mod utils; use std::sync::{Arc, Mutex}; use chrono::Local; use crate::{ha::HaClient, state::DashboardSnapshot, utils::*}; #[tokio::main] async fn main() -> Result<(), Box> { dotenvy::dotenv().ok(); #[cfg(not(feature = "dev"))] slint::platform::set_platform(Box::new(crate::trekstor::Trekstor::new())) .expect("set platform"); let app = AppWindow::new()?; app.on_quit(|| std::process::exit(0)); let state = Arc::new(Mutex::new(DashboardSnapshot::loading())); apply_snapshot(&app, &state.lock().expect("dashboard state poisoned"), None); app.on_select_room({ let weak = app.as_weak(); let state = state.clone(); move |index| { let Some(app) = weak.upgrade() else { return }; app.set_selected_index(index); let snapshot = state.lock().expect("dashboard state poisoned"); apply_selected(&app, &snapshot); app.set_action_status("USE - / + OR SELECT A MODE".into()); } }); match HaClient::from_environment() { Ok(client) => { app.on_change_target({ let weak = app.as_weak(); let state = state.clone(); let client = client.clone(); move |delta| { let Some(app) = weak.upgrade() else { return }; let index = app.get_selected_index().max(0) as usize; let (entity_id, target, snapshot) = { let mut snapshot = state.lock().expect("dashboard state poisoned"); let Some(thermostat) = snapshot.thermostats.get_mut(index) else { return; }; if !thermostat.available { app.set_action_status("THERMOSTAT UNAVAILABLE".into()); return; } let target = thermostat.target.or(thermostat.current).unwrap_or(68.0) + f64::from(delta); thermostat.target = Some(target); (thermostat.entity_id.clone(), target, snapshot.clone()) }; apply_snapshot(&app, &snapshot, None); app.set_action_status("SAVING SET POINT".into()); let weak = weak.clone(); let client = client.clone(); std::thread::spawn(move || match client.set_temperature(&entity_id, target) { Ok(()) => set_action_status(&weak, "SET POINT SAVED".into()), Err(error) => { eprintln!("Set temperature failed: {error}"); set_action_status(&weak, "SET POINT FAILED".into()); } }); } }); app.on_set_mode({ let weak = app.as_weak(); let state = state.clone(); let client = client.clone(); move |mode| { let Some(app) = weak.upgrade() else { return }; let index = app.get_selected_index().max(0) as usize; let (entity_id, mode_text, snapshot) = { let mut snapshot = state.lock().expect("dashboard state poisoned"); let Some(thermostat) = snapshot.thermostats.get_mut(index) else { return; }; if !thermostat.available { app.set_action_status("THERMOSTAT UNAVAILABLE".into()); return; } let mode_text = mode.to_string(); let supported = mode_text == "off" || (mode_text == "heat" && thermostat.supports_heat) || (mode_text == "cool" && thermostat.supports_cool); if !supported { app.set_action_status("MODE NOT SUPPORTED".into()); return; } thermostat.mode = mode_text.clone(); thermostat.action = mode_text.clone(); (thermostat.entity_id.clone(), mode_text, snapshot.clone()) }; apply_snapshot(&app, &snapshot, None); app.set_action_status("SAVING MODE".into()); let weak = weak.clone(); let client = client.clone(); std::thread::spawn(move || match client.set_mode(&entity_id, &mode_text) { Ok(()) => set_action_status(&weak, "MODE SAVED".into()), Err(error) => { eprintln!("Set mode failed: {error}"); set_action_status(&weak, "MODE FAILED".into()); } }); } }); app.on_set_fan_mode({ let weak = app.as_weak(); let state = state.clone(); let client = client.clone(); move |requested_mode| { let Some(app) = weak.upgrade() else { return }; let index = app.get_selected_index().max(0) as usize; let (entity_id, fan_mode, snapshot) = { let mut snapshot = state.lock().expect("dashboard state poisoned"); let Some(thermostat) = snapshot.thermostats.get_mut(index) else { return; }; if !thermostat.available { app.set_action_status("THERMOSTAT UNAVAILABLE".into()); return; } let Some(fan_mode) = thermostat .fan_modes .iter() .find(|mode| mode.eq_ignore_ascii_case(requested_mode.as_str())) .cloned() else { app.set_action_status("FAN SPEED NOT SUPPORTED".into()); return; }; thermostat.fan_mode = fan_mode.clone(); (thermostat.entity_id.clone(), fan_mode, snapshot.clone()) }; apply_snapshot(&app, &snapshot, None); app.set_action_status("SAVING FAN SPEED".into()); let weak = weak.clone(); let client = client.clone(); std::thread::spawn(move || match client.set_fan_mode(&entity_id, &fan_mode) { Ok(()) => set_action_status(&weak, "FAN SPEED SAVED".into()), Err(error) => { eprintln!("Set fan mode failed: {error}"); set_action_status(&weak, "FAN SPEED FAILED".into()); } }); } }); app.on_toggle_light({ let weak = app.as_weak(); let state = state.clone(); let client = client.clone(); move |index| { let index = index.max(0) as usize; let (entity_id, turn_on, snapshot) = { let mut snapshot = state.lock().expect("dashboard state poisoned"); let Some(light) = snapshot.lights.get_mut(index) else { return; }; if !light.available { return; } light.on = !light.on; (light.entity_id.clone(), light.on, snapshot.clone()) }; if let Some(app) = weak.upgrade() { apply_snapshot(&app, &snapshot, None); } let weak = weak.clone(); let client = client.clone(); std::thread::spawn(move || match client.set_light(&entity_id, turn_on) { Ok(()) => set_action_status(&weak, "LIGHT UPDATED".into()), Err(error) => { eprintln!("Set light failed: {error}"); set_action_status(&weak, "LIGHT UPDATE FAILED".into()); } }); } }); app.on_refresh({ let weak = app.as_weak(); let state = state.clone(); let client = client.clone(); move || { let Some(app) = weak.upgrade() else { return }; app.set_action_status("REFRESHING".into()); let weak = weak.clone(); let state = state.clone(); let client = client.clone(); std::thread::spawn(move || fetch_once(&client, &state, &weak)); } }); spawn_poll_loop(client, state, app.as_weak()); } Err(error) => { eprintln!("Home Assistant configuration error: {error}"); app.set_action_status("HOME ASSISTANT CONFIG MISSING".into()); } } app.run()?; Ok(()) } fn spawn_poll_loop( client: HaClient, state: Arc>, weak: slint::Weak, ) { std::thread::spawn(move || { loop { fetch_once(&client, &state, &weak); std::thread::sleep(std::time::Duration::from_secs(30)); } }); } fn fetch_once( client: &HaClient, state: &Arc>, weak: &slint::Weak, ) { match client.fetch_snapshot() { Ok(snapshot) => { let changed = { let mut current = state.lock().expect("dashboard state poisoned"); if *current == snapshot { false } else { *current = snapshot.clone(); true } }; if changed { let weak = weak.clone(); let updated = format!("UPDATED {}", Local::now().format("%-I:%M %p")); let _ = slint::invoke_from_event_loop(move || { if let Some(app) = weak.upgrade() { apply_snapshot(&app, &snapshot, Some(updated)); } }); } } Err(error) => { eprintln!("Home Assistant refresh failed: {error}"); set_action_status(weak, "HOME ASSISTANT UNAVAILABLE".into()); } } } fn apply_selected(app: &AppWindow, snapshot: &DashboardSnapshot) { let index = app.get_selected_index().max(0) as usize; let Some(thermostat) = snapshot.thermostats.get(index) else { return; }; app.set_selected_name(thermostat.name.as_str().into()); app.set_selected_target(format_temperature(thermostat.target).into()); app.set_selected_mode(pretty_label(&thermostat.mode).to_uppercase().into()); app.set_selected_supports_heat(thermostat.supports_heat); app.set_selected_supports_cool(thermostat.supports_cool); app.set_selected_supports_fan(!thermostat.fan_modes.is_empty()); app.set_selected_fan_mode(pretty_label(&thermostat.fan_mode).to_uppercase().into()); } fn apply_snapshot(app: &AppWindow, snapshot: &DashboardSnapshot, updated_text: Option) { app.set_outside_temperature(format_temperature(snapshot.outside_temperature).into()); app.set_outside_high( snapshot .outside_high .map(|high| format!("HIGH {}", format_temperature(Some(high)))) .unwrap_or_else(|| "HIGH --".into()) .into(), ); app.set_outside_condition( pretty_label(&snapshot.outside_condition) .to_uppercase() .into(), ); app.set_weather_kind(snapshot.weather_kind.as_str().into()); // app.set_living_fan_on(snapshot.living_fan.on); // app.set_living_fan_available(snapshot.living_fan.available); if let Some(event) = snapshot.calendar_events.first() { app.set_next_event_label( format!("NEXT / {} / {}", event.date, event.calendar.to_uppercase()).into(), ); app.set_next_event_title(event.summary.as_str().into()); app.set_next_event_time(event.time.as_str().into()); } else { app.set_next_event_label("NEXT EVENT".into()); app.set_next_event_title("NO UPCOMING EVENTS".into()); app.set_next_event_time("".into()); } app.set_thermostats(snapshot.into()); app.set_lights(snapshot.into()); app.set_calendar_events(snapshot.into()); let on_count = snapshot.lights.iter().filter(|light| light.on).count(); let available = snapshot .lights .iter() .filter(|light| light.available) .count(); app.set_lights_summary(format!("{on_count} ON / {available} AVAILABLE").into()); apply_selected(app, snapshot); if let Some(updated_text) = updated_text { app.set_updated_text(updated_text.clone().into()); app.set_action_status(updated_text.into()); } } fn set_action_status(weak: &slint::Weak, message: String) { let weak = weak.clone(); let _ = slint::invoke_from_event_loop(move || { if let Some(app) = weak.upgrade() { app.set_action_status(message.into()); } }); }