339 lines
14 KiB
Rust
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());
|
|
}
|
|
});
|
|
}
|