1786105678
This commit is contained in:
412
src/ha.rs
Normal file
412
src/ha.rs
Normal file
@@ -0,0 +1,412 @@
|
||||
use std::{collections::HashMap, time::Duration};
|
||||
|
||||
use chrono::{DateTime, Local, NaiveDate};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::state::{DashboardSnapshot, LIGHTS, THERMOSTATS};
|
||||
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(12);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HaClient {
|
||||
pub agent: ureq::Agent,
|
||||
pub base_url: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaState {
|
||||
pub entity_id: String,
|
||||
pub state: String,
|
||||
#[serde(default)]
|
||||
pub attributes: HaAttributes,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct HaAttributes {
|
||||
pub current_temperature: Option<f64>,
|
||||
pub temperature: Option<f64>,
|
||||
pub hvac_action: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hvac_modes: Vec<String>,
|
||||
pub fan_mode: Option<String>,
|
||||
#[serde(default)]
|
||||
pub fan_modes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaForecastServiceResponse {
|
||||
pub service_response: HashMap<String, HaForecast>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaForecast {
|
||||
#[serde(default)]
|
||||
pub forecast: Vec<HaForecastItem>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaForecastItem {
|
||||
pub temperature: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaCalendar {
|
||||
pub entity_id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaCalendarEvent {
|
||||
pub summary: String,
|
||||
pub start: HaCalendarTime,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HaCalendarTime {
|
||||
#[serde(rename = "dateTime")]
|
||||
pub date_time: Option<String>,
|
||||
pub date: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ClimateState {
|
||||
pub entity_id: String,
|
||||
pub name: String,
|
||||
pub current: Option<f64>,
|
||||
pub target: Option<f64>,
|
||||
pub mode: String,
|
||||
pub action: String,
|
||||
pub available: bool,
|
||||
pub supports_heat: bool,
|
||||
pub supports_cool: bool,
|
||||
pub fan_mode: String,
|
||||
pub fan_modes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LightState {
|
||||
pub entity_id: String,
|
||||
pub name: String,
|
||||
pub on: bool,
|
||||
pub available: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CalendarEvent {
|
||||
pub summary: String,
|
||||
pub date: String,
|
||||
pub time: String,
|
||||
pub calendar: String,
|
||||
pub sort_epoch: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BatteryState {
|
||||
pub level: u8,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
|
||||
impl HaClient {
|
||||
pub fn from_environment() -> Result<Self, String> {
|
||||
let base_url = std::env::var("HASS_URL")
|
||||
.map_err(|_| "HASS_URL is not configured".to_string())?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let token =
|
||||
std::env::var("HASS_TOKEN").map_err(|_| "HASS_TOKEN is not configured".to_string())?;
|
||||
let agent = ureq::Agent::new_with_config(
|
||||
ureq::config::Config::builder()
|
||||
.timeout_send_request(Some(REQUEST_TIMEOUT))
|
||||
.timeout_recv_response(Some(REQUEST_TIMEOUT))
|
||||
.timeout_send_body(Some(REQUEST_TIMEOUT))
|
||||
.build(),
|
||||
);
|
||||
Ok(Self {
|
||||
agent,
|
||||
base_url,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_all_states(&self) -> Result<Vec<HaState>, String> {
|
||||
self.agent
|
||||
.get(&format!("{}/api/states", self.base_url))
|
||||
.header("Authorization", &format!("Bearer {}", self.token))
|
||||
.header("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|error| format!("states: {error}"))?
|
||||
.body_mut()
|
||||
.read_json()
|
||||
.map_err(|error| format!("states: invalid response: {error}"))
|
||||
}
|
||||
|
||||
pub fn get_calendars(&self) -> Result<Vec<HaCalendar>, String> {
|
||||
self.agent
|
||||
.get(&format!("{}/api/calendars", self.base_url))
|
||||
.header("Authorization", &format!("Bearer {}", self.token))
|
||||
.header("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|error| format!("calendars: {error}"))?
|
||||
.body_mut()
|
||||
.read_json()
|
||||
.map_err(|error| format!("calendars: invalid response: {error}"))
|
||||
}
|
||||
|
||||
pub fn get_calendar_events(
|
||||
&self,
|
||||
entity_id: &str,
|
||||
start: &str,
|
||||
end: &str,
|
||||
) -> Result<Vec<HaCalendarEvent>, String> {
|
||||
self.agent
|
||||
.get(&format!("{}/api/calendars/{entity_id}", self.base_url))
|
||||
.query("start", start)
|
||||
.query("end", end)
|
||||
.header("Authorization", &format!("Bearer {}", self.token))
|
||||
.header("Accept", "application/json")
|
||||
.call()
|
||||
.map_err(|error| format!("{entity_id}: {error}"))?
|
||||
.body_mut()
|
||||
.read_json()
|
||||
.map_err(|error| format!("{entity_id}: invalid response: {error}"))
|
||||
}
|
||||
|
||||
pub fn fetch_calendar_events(&self) -> Result<Vec<CalendarEvent>, String> {
|
||||
let now = Local::now();
|
||||
let start = now.to_rfc3339();
|
||||
let end = (now + chrono::Duration::days(30)).to_rfc3339();
|
||||
let mut upcoming = Vec::new();
|
||||
|
||||
for calendar in self.get_calendars()? {
|
||||
let Ok(events) = self.get_calendar_events(&calendar.entity_id, &start, &end) else {
|
||||
continue;
|
||||
};
|
||||
for event in events {
|
||||
let Some((sort_epoch, date, time)) = format_calendar_time(&event.start) else {
|
||||
continue;
|
||||
};
|
||||
upcoming.push(CalendarEvent {
|
||||
summary: event.summary,
|
||||
date,
|
||||
time,
|
||||
calendar: calendar.name.clone(),
|
||||
sort_epoch,
|
||||
});
|
||||
}
|
||||
}
|
||||
upcoming.sort_by_key(|event| event.sort_epoch);
|
||||
upcoming.truncate(7);
|
||||
Ok(upcoming)
|
||||
}
|
||||
|
||||
pub fn call_service(
|
||||
&self,
|
||||
domain: &str,
|
||||
service: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<(), String> {
|
||||
self.agent
|
||||
.post(&format!(
|
||||
"{}/api/services/{domain}/{service}",
|
||||
self.base_url
|
||||
))
|
||||
.header("Authorization", &format!("Bearer {}", self.token))
|
||||
.header("Accept", "application/json")
|
||||
.send_json(body)
|
||||
.map_err(|error| format!("{domain}.{service}: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_daily_high(&self, entity_id: &str) -> Result<Option<f64>, String> {
|
||||
let response: HaForecastServiceResponse = self
|
||||
.agent
|
||||
.post(&format!(
|
||||
"{}/api/services/weather/get_forecasts?return_response",
|
||||
self.base_url
|
||||
))
|
||||
.header("Authorization", &format!("Bearer {}", self.token))
|
||||
.header("Accept", "application/json")
|
||||
.send_json(json!({ "entity_id": entity_id, "type": "daily" }))
|
||||
.map_err(|error| format!("weather.get_forecasts: {error}"))?
|
||||
.body_mut()
|
||||
.read_json()
|
||||
.map_err(|error| format!("weather.get_forecasts: invalid response: {error}"))?;
|
||||
|
||||
Ok(response
|
||||
.service_response
|
||||
.get(entity_id)
|
||||
.and_then(|weather| weather.forecast.first())
|
||||
.and_then(|forecast| forecast.temperature))
|
||||
}
|
||||
|
||||
pub fn set_temperature(&self, entity_id: &str, temperature: f64) -> Result<(), String> {
|
||||
self.call_service(
|
||||
"climate",
|
||||
"set_temperature",
|
||||
json!({ "entity_id": entity_id, "temperature": temperature }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_mode(&self, entity_id: &str, mode: &str) -> Result<(), String> {
|
||||
self.call_service(
|
||||
"climate",
|
||||
"set_hvac_mode",
|
||||
json!({ "entity_id": entity_id, "hvac_mode": mode }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_fan_mode(&self, entity_id: &str, fan_mode: &str) -> Result<(), String> {
|
||||
self.call_service(
|
||||
"climate",
|
||||
"set_fan_mode",
|
||||
json!({ "entity_id": entity_id, "fan_mode": fan_mode }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn set_light(&self, entity_id: &str, on: bool) -> Result<(), String> {
|
||||
self.call_service(
|
||||
"light",
|
||||
if on { "turn_on" } else { "turn_off" },
|
||||
json!({ "entity_id": entity_id }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fetch_snapshot(&self) -> Result<DashboardSnapshot, String> {
|
||||
let states: HashMap<String, HaState> = self
|
||||
.get_all_states()?
|
||||
.into_iter()
|
||||
.map(|state| (state.entity_id.clone(), state))
|
||||
.collect();
|
||||
|
||||
let weather = states
|
||||
.get("weather.forecast_home")
|
||||
.or_else(|| states.get("weather.kmco"))
|
||||
.ok_or_else(|| "weather entity unavailable".to_string())?;
|
||||
let is_night = states
|
||||
.get("sun.sun")
|
||||
.is_some_and(|sun| sun.state == "below_horizon");
|
||||
let outside_high = self
|
||||
.get_daily_high(&weather.entity_id)
|
||||
.unwrap_or_else(|error| {
|
||||
eprintln!("Daily forecast refresh failed: {error}");
|
||||
None
|
||||
});
|
||||
|
||||
let thermostats = THERMOSTATS
|
||||
.iter()
|
||||
.map(|(entity_id, name)| match states.get(*entity_id) {
|
||||
Some(state) if state.state != "unavailable" && state.state != "unknown" => {
|
||||
let action = state
|
||||
.attributes
|
||||
.hvac_action
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.state.clone());
|
||||
ClimateState {
|
||||
entity_id: (*entity_id).into(),
|
||||
name: (*name).into(),
|
||||
current: state.attributes.current_temperature,
|
||||
target: state.attributes.temperature,
|
||||
mode: state.state.clone(),
|
||||
action,
|
||||
available: true,
|
||||
supports_heat: state
|
||||
.attributes
|
||||
.hvac_modes
|
||||
.iter()
|
||||
.any(|mode| mode == "heat"),
|
||||
supports_cool: state
|
||||
.attributes
|
||||
.hvac_modes
|
||||
.iter()
|
||||
.any(|mode| mode == "cool"),
|
||||
fan_mode: state.attributes.fan_mode.clone().unwrap_or_default(),
|
||||
fan_modes: state.attributes.fan_modes.clone(),
|
||||
}
|
||||
}
|
||||
_ => ClimateState {
|
||||
entity_id: (*entity_id).into(),
|
||||
name: (*name).into(),
|
||||
current: None,
|
||||
target: None,
|
||||
mode: "unavailable".into(),
|
||||
action: "unavailable".into(),
|
||||
available: false,
|
||||
supports_heat: false,
|
||||
supports_cool: false,
|
||||
fan_mode: String::new(),
|
||||
fan_modes: Vec::new(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let lights = LIGHTS
|
||||
.iter()
|
||||
.map(|(entity_id, name)| match states.get(*entity_id) {
|
||||
Some(state) if state.state != "unavailable" && state.state != "unknown" => {
|
||||
LightState {
|
||||
entity_id: (*entity_id).into(),
|
||||
name: (*name).into(),
|
||||
on: state.state == "on",
|
||||
available: true,
|
||||
}
|
||||
}
|
||||
_ => LightState {
|
||||
entity_id: (*entity_id).into(),
|
||||
name: (*name).into(),
|
||||
on: false,
|
||||
available: false,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let calendar_events = self.fetch_calendar_events().unwrap_or_else(|error| {
|
||||
eprintln!("Calendar refresh failed: {error}");
|
||||
Vec::new()
|
||||
});
|
||||
|
||||
Ok(DashboardSnapshot {
|
||||
outside_temperature: weather.attributes.temperature,
|
||||
outside_high,
|
||||
outside_condition: weather.state.clone(),
|
||||
weather_kind: weather_kind(&weather.state, is_night).into(),
|
||||
thermostats,
|
||||
lights,
|
||||
calendar_events,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_calendar_time(value: &HaCalendarTime) -> Option<(i64, String, String)> {
|
||||
if let Some(date_time) = value.date_time.as_deref() {
|
||||
let parsed = DateTime::parse_from_rfc3339(date_time).ok()?;
|
||||
return Some((
|
||||
parsed.timestamp(),
|
||||
parsed.format("%a %b %-d").to_string().to_uppercase(),
|
||||
parsed.format("%-I:%M %p").to_string().to_uppercase(),
|
||||
));
|
||||
}
|
||||
let date = NaiveDate::parse_from_str(value.date.as_deref()?, "%Y-%m-%d").ok()?;
|
||||
let sort_epoch = date.and_hms_opt(0, 0, 0)?.and_utc().timestamp();
|
||||
Some((
|
||||
sort_epoch,
|
||||
date.format("%a %b %-d").to_string().to_uppercase(),
|
||||
"ALL DAY".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn weather_kind(condition: &str, is_night: bool) -> &'static str {
|
||||
match condition {
|
||||
"sunny" => "sun",
|
||||
"clear-night" => "moon",
|
||||
"partlycloudy" if is_night => "cloud-moon",
|
||||
"partlycloudy" => "cloud-sun",
|
||||
"rainy" => "cloud-rain",
|
||||
"pouring" | "windy" | "windy-variant" => "cloud-rain-wind",
|
||||
"lightning" | "lightning-rainy" => "cloud-lightning",
|
||||
"snowy" | "snowy-rainy" | "hail" => "snowflake",
|
||||
"fog" => "cloud-fog",
|
||||
_ => "cloud",
|
||||
}
|
||||
}
|
||||
479
src/main.rs
479
src/main.rs
@@ -1,193 +1,338 @@
|
||||
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 ha;
|
||||
mod state;
|
||||
mod trekstor;
|
||||
mod utils;
|
||||
|
||||
// #[derive(Clone, Debug, PartialEq)]
|
||||
// struct LightState {
|
||||
// entity_id: String,
|
||||
// name: String,
|
||||
// on: bool,
|
||||
// available: bool,
|
||||
// }
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
struct Database {
|
||||
client: HassClient,
|
||||
entities: HashMap<String, HassEntity>,
|
||||
}
|
||||
use chrono::Local;
|
||||
|
||||
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<LightData> {
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>();
|
||||
lights.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
lights
|
||||
}
|
||||
}
|
||||
|
||||
type DB = Arc<Mutex<Database>>;
|
||||
use crate::{ha::HaClient, state::DashboardSnapshot, utils::*};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), slint::PlatformError> {
|
||||
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 main_window = MainWindow::new()?;
|
||||
let app = AppWindow::new()?;
|
||||
app.on_quit(|| std::process::exit(0));
|
||||
|
||||
// start home_assistant webscoket
|
||||
let db = home_assisstant_websocket_client().await;
|
||||
let state = Arc::new(Mutex::new(DashboardSnapshot::loading()));
|
||||
apply_snapshot(&app, &state.lock().expect("dashboard state poisoned"), None);
|
||||
|
||||
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 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));
|
||||
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());
|
||||
}
|
||||
});
|
||||
|
||||
main_window.run()
|
||||
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(())
|
||||
}
|
||||
|
||||
// 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());
|
||||
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(5));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
println!("{}", entity);
|
||||
db_weak
|
||||
.lock()
|
||||
.expect("Database poisoned")
|
||||
.entities
|
||||
.insert(entity.entity_id.clone(), entity.clone());
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
db
|
||||
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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
112
src/state.rs
Normal file
112
src/state.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use slint::{ModelRc, SharedString, VecModel};
|
||||
|
||||
use crate::{CalendarData, LightData, ThermostatData, ha::*, utils::*};
|
||||
|
||||
pub const THERMOSTATS: [(&str, &str); 1] = [("climate.thermostat", "Thermostat")];
|
||||
|
||||
pub const LIGHTS: [(&str, &str); 8] = [
|
||||
("light.armory_light", "Armory"),
|
||||
("light.bedroom_light", "Bedroom"),
|
||||
("light.hallway_light", "Hallway"),
|
||||
("light.kitchen_light", "Kitchen"),
|
||||
("light.living_room", "Living Room"),
|
||||
("light.shower_light", "Shower"),
|
||||
("light.utilities", "Utilities"),
|
||||
("light.light.wc_light", "WC"),
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DashboardSnapshot {
|
||||
pub outside_temperature: Option<f64>,
|
||||
pub outside_high: Option<f64>,
|
||||
pub outside_condition: String,
|
||||
pub weather_kind: String,
|
||||
pub thermostats: Vec<ClimateState>,
|
||||
pub lights: Vec<LightState>,
|
||||
pub calendar_events: Vec<CalendarEvent>,
|
||||
}
|
||||
|
||||
impl DashboardSnapshot {
|
||||
pub fn loading() -> Self {
|
||||
Self {
|
||||
outside_temperature: None,
|
||||
outside_high: None,
|
||||
outside_condition: "Connecting".into(),
|
||||
weather_kind: "cloud".into(),
|
||||
thermostats: THERMOSTATS
|
||||
.iter()
|
||||
.map(|(entity_id, name)| ClimateState {
|
||||
entity_id: (*entity_id).into(),
|
||||
name: (*name).into(),
|
||||
current: None,
|
||||
target: None,
|
||||
mode: "unknown".into(),
|
||||
action: "unknown".into(),
|
||||
available: false,
|
||||
supports_heat: false,
|
||||
supports_cool: false,
|
||||
fan_mode: String::new(),
|
||||
fan_modes: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
lights: LIGHTS
|
||||
.iter()
|
||||
.map(|(entity_id, name)| LightState {
|
||||
entity_id: (*entity_id).into(),
|
||||
name: (*name).into(),
|
||||
on: false,
|
||||
available: false,
|
||||
})
|
||||
.collect(),
|
||||
calendar_events: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DashboardSnapshot> for ModelRc<ThermostatData> {
|
||||
fn from(value: &DashboardSnapshot) -> Self {
|
||||
let rows = value
|
||||
.thermostats
|
||||
.iter()
|
||||
.map(|thermostat| ThermostatData {
|
||||
name: SharedString::from(thermostat.name.as_str()),
|
||||
current: SharedString::from(format_temperature(thermostat.current)),
|
||||
target: SharedString::from(format_temperature(thermostat.target)),
|
||||
state: SharedString::from(pretty_label(&thermostat.action).to_uppercase()),
|
||||
available: thermostat.available,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ModelRc::new(VecModel::from(rows))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DashboardSnapshot> for ModelRc<LightData> {
|
||||
fn from(value: &DashboardSnapshot) -> Self {
|
||||
let rows = value
|
||||
.lights
|
||||
.iter()
|
||||
.map(|light| LightData {
|
||||
name: SharedString::from(light.name.as_str()),
|
||||
on: light.on,
|
||||
available: light.available,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ModelRc::new(VecModel::from(rows))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&DashboardSnapshot> for ModelRc<CalendarData> {
|
||||
fn from(value: &DashboardSnapshot) -> Self {
|
||||
let rows = value
|
||||
.calendar_events
|
||||
.iter()
|
||||
.map(|event| CalendarData {
|
||||
date: SharedString::from(event.date.as_str()),
|
||||
time: SharedString::from(event.time.as_str()),
|
||||
summary: SharedString::from(event.summary.as_str()),
|
||||
calendar: SharedString::from(event.calendar.as_str()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
ModelRc::new(VecModel::from(rows))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
mod input;
|
||||
mod rgba;
|
||||
mod screen;
|
||||
|
||||
pub use rgba::Rgba;
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -11,9 +17,6 @@ use slint::{EventLoopError, LogicalPosition, PhysicalSize, Rgb8Pixel, WindowSize
|
||||
use self::input::InputState;
|
||||
use self::screen::Screen;
|
||||
|
||||
mod input;
|
||||
mod screen;
|
||||
|
||||
pub struct Context {
|
||||
pub screen: Screen,
|
||||
pub input: InputState,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use embedded_graphics::{pixelcolor::Rgb888, prelude::*, primitives::Rectangle};
|
||||
use linfb::{Framebuffer, shape::Color};
|
||||
|
||||
use crate::rgba::Rgba;
|
||||
use crate::trekstor::Rgba;
|
||||
|
||||
pub struct Screen {
|
||||
pub framebuffer: Framebuffer,
|
||||
|
||||
24
src/utils.rs
Normal file
24
src/utils.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
pub fn pretty_label(value: &str) -> String {
|
||||
let value = match value {
|
||||
"partlycloudy" => "partly cloudy",
|
||||
other => other,
|
||||
};
|
||||
value
|
||||
.split(['_', '-', ' '])
|
||||
.filter(|word| !word.is_empty())
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn format_temperature(value: Option<f64>) -> String {
|
||||
value
|
||||
.map(|temperature| format!("{temperature:.0}°"))
|
||||
.unwrap_or_else(|| "--".into())
|
||||
}
|
||||
Reference in New Issue
Block a user