Files
dashboard/src/main.rs
2026-08-07 17:10:34 +02:00

339 lines
14 KiB
Rust

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<dyn std::error::Error>> {
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<Mutex<DashboardSnapshot>>,
weak: slint::Weak<AppWindow>,
) {
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<Mutex<DashboardSnapshot>>,
weak: &slint::Weak<AppWindow>,
) {
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<String>) {
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<AppWindow>, 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());
}
});
}