cleanup pass
This commit is contained in:
23
dashboard/src/error.rs
Normal file
23
dashboard/src/error.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub 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<hass_rs::HassError>),
|
||||||
|
|
||||||
|
#[error("Entity {0} not found")]
|
||||||
|
NoEntity(String),
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::*;
|
use crate::ui::*;
|
||||||
use hass_rs::HassEntity;
|
use hass_rs::HassEntity;
|
||||||
use slint::ToSharedString;
|
use slint::ToSharedString;
|
||||||
|
|
||||||
|
|||||||
145
dashboard/src/home_assistant.rs
Normal file
145
dashboard/src/home_assistant.rs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
use dotenvy::var;
|
||||||
|
use hass_rs::{HassClient, HassEntity};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
messages::{C2sMessage, S2cMessage},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn hass(
|
||||||
|
s2c_tx: UnboundedSender<S2cMessage>,
|
||||||
|
mut c2s_rx: UnboundedReceiver<C2sMessage>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let Ok(url) = var("HASS_URL") else {
|
||||||
|
return Err(AppError::MissingUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(hass_token) = var("HASS_TOKEN") else {
|
||||||
|
return Err(AppError::MissingToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok(url) = url::Url::parse(&url) else {
|
||||||
|
return Err(AppError::InvalidUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
let scheme = match url.scheme() {
|
||||||
|
"http" => "ws",
|
||||||
|
"https" => "wss",
|
||||||
|
scheme => return Err(AppError::InvalidScheme(scheme.to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}://{}/api/websocket", scheme, url.authority());
|
||||||
|
|
||||||
|
let mut client = HassClient::new(&url).await.map_err(Box::new)?;
|
||||||
|
client
|
||||||
|
.auth_with_longlivedtoken(&hass_token)
|
||||||
|
.await
|
||||||
|
.map_err(Box::new)?;
|
||||||
|
|
||||||
|
let mut state = HashMap::<String, HassEntity>::new();
|
||||||
|
let states = client.get_states().await.map_err(Box::new)?;
|
||||||
|
|
||||||
|
states.iter().for_each(|entity| {
|
||||||
|
state.insert(entity.entity_id.clone(), entity.clone());
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut initial_state = state.values().cloned().collect::<Vec<_>>();
|
||||||
|
initial_state.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
|
||||||
|
s2c_tx.send(S2cMessage::InitialState(initial_state)).ok();
|
||||||
|
|
||||||
|
let mut event_receiver = client
|
||||||
|
.subscribe_event("state_changed")
|
||||||
|
.await
|
||||||
|
.map_err(Box::new)?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
Some(message) = event_receiver.recv() => {
|
||||||
|
match (&message.event.data.old_state, &message.event.data.new_state) {
|
||||||
|
(None, Some(entity)) => {
|
||||||
|
// New Entity Added
|
||||||
|
state.insert(entity.entity_id.clone(), entity.clone());
|
||||||
|
s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok();
|
||||||
|
}
|
||||||
|
(Some(entity), None) => {
|
||||||
|
// Entity Removed
|
||||||
|
state.remove(&entity.entity_id);
|
||||||
|
s2c_tx.send(S2cMessage::EntityRemoved(entity.clone())).ok();
|
||||||
|
}
|
||||||
|
(Some(_), Some(entity)) => {
|
||||||
|
// Entity Updated
|
||||||
|
state.insert(entity.entity_id.clone(), entity.clone());
|
||||||
|
s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(message) = c2s_rx.recv() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_action(current_state: &str, on_state: &str) -> &'static str {
|
||||||
|
if current_state == on_state {
|
||||||
|
"turn_off"
|
||||||
|
} else {
|
||||||
|
"turn_on"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,125 +1,14 @@
|
|||||||
|
mod error;
|
||||||
mod ha_ext;
|
mod ha_ext;
|
||||||
|
mod home_assistant;
|
||||||
|
mod messages;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
use paste::paste;
|
use tokio::sync::mpsc::unbounded_channel;
|
||||||
|
|
||||||
macro_rules! apply_model {
|
use slint::ComponentHandle;
|
||||||
($app:expr, $entities:expr, $name:ident, $ty:ty, $filter:expr) => {
|
|
||||||
paste! {
|
|
||||||
if $app
|
|
||||||
.[<get_ $name>]()
|
|
||||||
.as_any()
|
|
||||||
.downcast_ref::<VecModel<$ty>>()
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
$app.[<set_ $name>](ModelRc::new(VecModel::<$ty>::default()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = $app
|
use crate::error::AppError;
|
||||||
.[<get_ $name>]();
|
|
||||||
|
|
||||||
let model = data
|
|
||||||
.as_any()
|
|
||||||
.downcast_ref::<VecModel<$ty>>()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
for entity in $entities.iter().filter($filter) {
|
|
||||||
match model.iter().position(|item| item.id == entity.entity_id) {
|
|
||||||
Some(index) => model.set_row_data(index, entity.into()),
|
|
||||||
None => model.push(entity.into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use dotenvy::var;
|
|
||||||
use hass_rs::{HassClient, HassEntity};
|
|
||||||
use serde_json::json;
|
|
||||||
use slint::language::ColorScheme;
|
|
||||||
use slint::{Model, ModelRc, VecModel, Weak};
|
|
||||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
|
||||||
|
|
||||||
use crate::ha_ext::HassEntityExt;
|
|
||||||
slint::include_modules!();
|
|
||||||
|
|
||||||
fn apply_theme(app: &AppWindow, entities: &Vec<HassEntity>) {
|
|
||||||
for entity in entities {
|
|
||||||
if entity.entity_id == "sensor.diyless_thermostat_3_ambient_light_level"
|
|
||||||
&& let Ok(illuminance) = entity.state.parse::<i32>()
|
|
||||||
{
|
|
||||||
let color_scheme = if illuminance < 250 {
|
|
||||||
ColorScheme::Dark
|
|
||||||
} else {
|
|
||||||
ColorScheme::Light
|
|
||||||
};
|
|
||||||
|
|
||||||
app.set_color_scheme(color_scheme);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_clock(app: &AppWindow, entities: &Vec<HassEntity>) {
|
|
||||||
for entity in entities {
|
|
||||||
if entity.entity_id == "sensor.date_time_iso" {
|
|
||||||
app.set_date_time(entity.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_weather(app: &AppWindow, entities: &Vec<HassEntity>) {
|
|
||||||
for entity in entities {
|
|
||||||
if entity.entity_id == "weather.forecast_home" {
|
|
||||||
app.set_weather(entity.into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if entity.entity_id == "sun.sun" {
|
|
||||||
app.set_is_night(entity.state == "below_horizon");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_lights(app: &AppWindow, entities: &[HassEntity]) {
|
|
||||||
apply_model!(app, entities, lights, LightData, |entity| {
|
|
||||||
entity.domain() == "light"
|
|
||||||
&& !entity.entity_id.ends_with("screen")
|
|
||||||
&& !entity.entity_id.chars().any(char::is_numeric)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_thermometers(app: &AppWindow, entities: &[HassEntity]) {
|
|
||||||
apply_model!(app, entities, thermometers, ThermometerData, |entity| {
|
|
||||||
entity.domain() == "sensor" && entity.entity_id.ends_with("thermometer_temperature")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) {
|
|
||||||
apply_model!(app, entities, thermostats, ThermostatData, |entity| {
|
|
||||||
entity.domain() == "climate" && !entity.entity_id.contains("diyless")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_state(app: &Weak<AppWindow>, entities: Vec<HassEntity>) {
|
|
||||||
if let Err(err) = app.upgrade_in_event_loop(move |app| {
|
|
||||||
apply_theme(&app, &entities);
|
|
||||||
apply_clock(&app, &entities);
|
|
||||||
apply_lights(&app, &entities);
|
|
||||||
apply_thermometers(&app, &entities);
|
|
||||||
apply_weather(&app, &entities);
|
|
||||||
apply_thermostats(&app, &entities);
|
|
||||||
}) {
|
|
||||||
eprintln!("Failed to upgrade in event loop.\n\t{:?}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn toggle_action(current_state: &str, on_state: &str) -> &'static str {
|
|
||||||
if current_state == on_state {
|
|
||||||
"turn_off"
|
|
||||||
} else {
|
|
||||||
"turn_on"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), AppError> {
|
async fn main() -> Result<(), AppError> {
|
||||||
@@ -128,235 +17,18 @@ async fn main() -> Result<(), AppError> {
|
|||||||
#[cfg(not(feature = "dev"))]
|
#[cfg(not(feature = "dev"))]
|
||||||
slint::platform::set_platform(Box::new(trekstor::Trekstor::new())).expect("set platform");
|
slint::platform::set_platform(Box::new(trekstor::Trekstor::new())).expect("set platform");
|
||||||
|
|
||||||
let app = AppWindow::new()?;
|
let app = ui::AppWindow::new()?;
|
||||||
|
|
||||||
let (c2s_tx, c2s_rx) = mpsc::unbounded_channel::<C2sMessage>();
|
let (c2s_tx, c2s_rx) = unbounded_channel::<messages::C2sMessage>();
|
||||||
let (s2c_tx, mut s2c_rx) = mpsc::unbounded_channel::<S2cMessage>();
|
let (s2c_tx, s2c_rx) = unbounded_channel::<messages::S2cMessage>();
|
||||||
|
|
||||||
tokio::spawn({
|
ui::bind_buttons(&app, c2s_tx);
|
||||||
let app_weak = app.as_weak();
|
|
||||||
async move {
|
|
||||||
while let Some(message) = s2c_rx.recv().await {
|
|
||||||
match message {
|
|
||||||
S2cMessage::InitialState(items) => {
|
|
||||||
println!("Initial state");
|
|
||||||
apply_state(&app_weak, items);
|
|
||||||
}
|
|
||||||
S2cMessage::EntityUpdated(hass_entity) => {
|
|
||||||
apply_state(&app_weak, vec![hass_entity]);
|
|
||||||
}
|
|
||||||
S2cMessage::EntityRemoved(hass_entity) => {
|
|
||||||
apply_state(&app_weak, vec![hass_entity]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Button Bindings
|
// listen for data
|
||||||
app.on_toggle_light({
|
tokio::spawn(ui::update_listener(app.as_weak(), s2c_rx));
|
||||||
let c2s_tx = c2s_tx.clone();
|
|
||||||
move |entity_id| {
|
|
||||||
let id = entity_id.to_string();
|
|
||||||
|
|
||||||
if let Err(err) = c2s_tx.send(C2sMessage::ToggleLight { entity_id: id }) {
|
|
||||||
eprintln!("Failed to send message to server.\n\t{:?}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.on_set_temperature({
|
|
||||||
let c2s_tx = c2s_tx.clone();
|
|
||||||
move |entity_id, value| {
|
|
||||||
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.on_toggle_thermostat({
|
|
||||||
let c2s_tx = c2s_tx.clone();
|
|
||||||
move |entity_id| {
|
|
||||||
let id = entity_id.to_string();
|
|
||||||
|
|
||||||
if let Err(err) = c2s_tx.send(C2sMessage::ToggleThermostat { entity_id: id }) {
|
|
||||||
eprintln!("Failed to send message to server.\n\t{:?}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// start pulling data
|
// start pulling data
|
||||||
tokio::spawn(hass(s2c_tx, c2s_rx));
|
tokio::spawn(home_assistant::hass(s2c_tx, c2s_rx));
|
||||||
|
|
||||||
app.run().map_err(Into::into)
|
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<hass_rs::HassError>),
|
|
||||||
|
|
||||||
#[error("Entity {0} not found")]
|
|
||||||
NoEntity(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum C2sMessage {
|
|
||||||
ToggleLight { entity_id: String },
|
|
||||||
|
|
||||||
ToggleThermostat { entity_id: String },
|
|
||||||
|
|
||||||
SetTemperature { entity_id: String, temperature: f32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
enum S2cMessage {
|
|
||||||
InitialState(Vec<HassEntity>),
|
|
||||||
EntityUpdated(HassEntity),
|
|
||||||
EntityRemoved(HassEntity),
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn hass(
|
|
||||||
s2c_tx: UnboundedSender<S2cMessage>,
|
|
||||||
mut c2s_rx: UnboundedReceiver<C2sMessage>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let Ok(url) = var("HASS_URL") else {
|
|
||||||
return Err(AppError::MissingUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(hass_token) = var("HASS_TOKEN") else {
|
|
||||||
return Err(AppError::MissingToken);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(url) = url::Url::parse(&url) else {
|
|
||||||
return Err(AppError::InvalidUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
let scheme = match url.scheme() {
|
|
||||||
"http" => "ws",
|
|
||||||
"https" => "wss",
|
|
||||||
scheme => return Err(AppError::InvalidScheme(scheme.to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let url = format!("{}://{}/api/websocket", scheme, url.authority());
|
|
||||||
|
|
||||||
let mut client = HassClient::new(&url).await.map_err(Box::new)?;
|
|
||||||
client
|
|
||||||
.auth_with_longlivedtoken(&hass_token)
|
|
||||||
.await
|
|
||||||
.map_err(Box::new)?;
|
|
||||||
|
|
||||||
let mut state = HashMap::<String, HassEntity>::new();
|
|
||||||
let states = client.get_states().await.map_err(Box::new)?;
|
|
||||||
|
|
||||||
states.iter().for_each(|entity| {
|
|
||||||
state.insert(entity.entity_id.clone(), entity.clone());
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut initial_state = state.values().cloned().collect::<Vec<_>>();
|
|
||||||
initial_state.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
|
|
||||||
s2c_tx.send(S2cMessage::InitialState(initial_state)).ok();
|
|
||||||
|
|
||||||
let mut event_receiver = client
|
|
||||||
.subscribe_event("state_changed")
|
|
||||||
.await
|
|
||||||
.map_err(Box::new)?;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
Some(message) = event_receiver.recv() => {
|
|
||||||
match (&message.event.data.old_state, &message.event.data.new_state) {
|
|
||||||
(None, Some(entity)) => {
|
|
||||||
// New Entity Added
|
|
||||||
state.insert(entity.entity_id.clone(), entity.clone());
|
|
||||||
s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok();
|
|
||||||
}
|
|
||||||
(Some(entity), None) => {
|
|
||||||
// Entity Removed
|
|
||||||
state.remove(&entity.entity_id);
|
|
||||||
s2c_tx.send(S2cMessage::EntityRemoved(entity.clone())).ok();
|
|
||||||
}
|
|
||||||
(Some(_), Some(entity)) => {
|
|
||||||
// Entity Updated
|
|
||||||
state.insert(entity.entity_id.clone(), entity.clone());
|
|
||||||
s2c_tx.send(S2cMessage::EntityUpdated(entity.clone())).ok();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(message) = c2s_rx.recv() => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|||||||
17
dashboard/src/messages.rs
Normal file
17
dashboard/src/messages.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use hass_rs::HassEntity;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum C2sMessage {
|
||||||
|
ToggleLight { entity_id: String },
|
||||||
|
|
||||||
|
ToggleThermostat { entity_id: String },
|
||||||
|
|
||||||
|
SetTemperature { entity_id: String, temperature: f32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub enum S2cMessage {
|
||||||
|
InitialState(Vec<HassEntity>),
|
||||||
|
EntityUpdated(HassEntity),
|
||||||
|
EntityRemoved(HassEntity),
|
||||||
|
}
|
||||||
163
dashboard/src/ui.rs
Normal file
163
dashboard/src/ui.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
slint::include_modules!();
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ha_ext::*,
|
||||||
|
messages::{C2sMessage, S2cMessage},
|
||||||
|
};
|
||||||
|
|
||||||
|
use hass_rs::HassEntity;
|
||||||
|
use paste::paste;
|
||||||
|
use slint::{Model, ModelRc, VecModel, Weak, language::ColorScheme};
|
||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
macro_rules! apply_model {
|
||||||
|
($app:expr, $entities:expr, $name:ident, $ty:ty, $filter:expr) => {
|
||||||
|
paste! {
|
||||||
|
if $app
|
||||||
|
.[<get_ $name>]()
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<$ty>>()
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
$app.[<set_ $name>](ModelRc::new(VecModel::<$ty>::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = $app
|
||||||
|
.[<get_ $name>]();
|
||||||
|
|
||||||
|
let model = data
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<$ty>>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for entity in $entities.iter().filter($filter) {
|
||||||
|
match model.iter().position(|item| item.id == entity.entity_id) {
|
||||||
|
Some(index) => model.set_row_data(index, entity.into()),
|
||||||
|
None => model.push(entity.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_theme(app: &AppWindow, entities: &Vec<HassEntity>) {
|
||||||
|
for entity in entities {
|
||||||
|
if entity.entity_id == "sensor.diyless_thermostat_3_ambient_light_level"
|
||||||
|
&& let Ok(illuminance) = entity.state.parse::<i32>()
|
||||||
|
{
|
||||||
|
let color_scheme = if illuminance < 250 {
|
||||||
|
ColorScheme::Dark
|
||||||
|
} else {
|
||||||
|
ColorScheme::Light
|
||||||
|
};
|
||||||
|
|
||||||
|
app.set_color_scheme(color_scheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_clock(app: &AppWindow, entities: &Vec<HassEntity>) {
|
||||||
|
for entity in entities {
|
||||||
|
if entity.entity_id == "sensor.date_time_iso" {
|
||||||
|
app.set_date_time(entity.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_weather(app: &AppWindow, entities: &Vec<HassEntity>) {
|
||||||
|
for entity in entities {
|
||||||
|
if entity.entity_id == "weather.forecast_home" {
|
||||||
|
app.set_weather(entity.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
if entity.entity_id == "sun.sun" {
|
||||||
|
app.set_is_night(entity.state == "below_horizon");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_lights(app: &AppWindow, entities: &[HassEntity]) {
|
||||||
|
apply_model!(app, entities, lights, LightData, |entity| {
|
||||||
|
entity.domain() == "light"
|
||||||
|
&& !entity.entity_id.ends_with("screen")
|
||||||
|
&& !entity.entity_id.chars().any(char::is_numeric)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_thermometers(app: &AppWindow, entities: &[HassEntity]) {
|
||||||
|
apply_model!(app, entities, thermometers, ThermometerData, |entity| {
|
||||||
|
entity.domain() == "sensor" && entity.entity_id.ends_with("thermometer_temperature")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) {
|
||||||
|
apply_model!(app, entities, thermostats, ThermostatData, |entity| {
|
||||||
|
entity.domain() == "climate" && !entity.entity_id.contains("diyless")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_state(app: &Weak<AppWindow>, entities: Vec<HassEntity>) {
|
||||||
|
if let Err(err) = app.upgrade_in_event_loop(move |app| {
|
||||||
|
apply_theme(&app, &entities);
|
||||||
|
apply_clock(&app, &entities);
|
||||||
|
apply_lights(&app, &entities);
|
||||||
|
apply_thermometers(&app, &entities);
|
||||||
|
apply_weather(&app, &entities);
|
||||||
|
apply_thermostats(&app, &entities);
|
||||||
|
}) {
|
||||||
|
eprintln!("Failed to upgrade in event loop.\n\t{:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_buttons(app: &AppWindow, c2s_tx: UnboundedSender<C2sMessage>) {
|
||||||
|
// Button Bindings
|
||||||
|
app.on_toggle_light({
|
||||||
|
let c2s_tx = c2s_tx.clone();
|
||||||
|
move |entity_id| {
|
||||||
|
let id = entity_id.to_string();
|
||||||
|
|
||||||
|
if let Err(err) = c2s_tx.send(C2sMessage::ToggleLight { entity_id: id }) {
|
||||||
|
eprintln!("Failed to send message to server.\n\t{:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on_set_temperature({
|
||||||
|
let c2s_tx = c2s_tx.clone();
|
||||||
|
move |entity_id, value| {
|
||||||
|
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.on_toggle_thermostat({
|
||||||
|
let c2s_tx = c2s_tx.clone();
|
||||||
|
move |entity_id| {
|
||||||
|
let id = entity_id.to_string();
|
||||||
|
|
||||||
|
if let Err(err) = c2s_tx.send(C2sMessage::ToggleThermostat { entity_id: id }) {
|
||||||
|
eprintln!("Failed to send message to server.\n\t{:?}", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_listener(app_weak: Weak<AppWindow>, mut s2c_rx: UnboundedReceiver<S2cMessage>) {
|
||||||
|
while let Some(message) = s2c_rx.recv().await {
|
||||||
|
match message {
|
||||||
|
S2cMessage::InitialState(items) => {
|
||||||
|
apply_state(&app_weak, items);
|
||||||
|
}
|
||||||
|
S2cMessage::EntityUpdated(hass_entity) => {
|
||||||
|
apply_state(&app_weak, vec![hass_entity]);
|
||||||
|
}
|
||||||
|
S2cMessage::EntityRemoved(hass_entity) => {
|
||||||
|
apply_state(&app_weak, vec![hass_entity]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user