1786283736
This commit is contained in:
@@ -7,6 +7,7 @@ pub trait HassEntityExt {
|
|||||||
fn available(&self) -> bool;
|
fn available(&self) -> bool;
|
||||||
fn friendly_name(&self) -> &str;
|
fn friendly_name(&self) -> &str;
|
||||||
fn unit_of_measurement(&self) -> &str;
|
fn unit_of_measurement(&self) -> &str;
|
||||||
|
fn domain(&self) -> &str;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HassEntityExt for HassEntity {
|
impl HassEntityExt for HassEntity {
|
||||||
@@ -31,6 +32,13 @@ impl HassEntityExt for HassEntity {
|
|||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn domain(&self) -> &str {
|
||||||
|
self.entity_id
|
||||||
|
.split_once(".")
|
||||||
|
.map(|(d, _)| d)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&HassEntity> for LightData {
|
impl From<&HassEntity> for LightData {
|
||||||
|
|||||||
@@ -1,90 +1,142 @@
|
|||||||
mod ha_ext;
|
mod ha_ext;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, RwLock};
|
|
||||||
|
|
||||||
use dotenvy::var;
|
use dotenvy::var;
|
||||||
use hass_rs::{HassClient, HassEntity};
|
use hass_rs::{HassClient, HassEntity};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use slint::language::ColorScheme;
|
use slint::language::ColorScheme;
|
||||||
use slint::{ModelRc, VecModel, Weak};
|
use slint::{Model, ModelRc, VecModel, Weak};
|
||||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
use crate::ha_ext::HassEntityExt;
|
||||||
slint::include_modules!();
|
slint::include_modules!();
|
||||||
|
|
||||||
fn apply_theme(app: &AppWindow, state: &State) {
|
fn apply_theme(app: &AppWindow, entities: &Vec<HassEntity>) {
|
||||||
if let Some(illuminance) = state
|
for entity in entities {
|
||||||
.get("sensor.diyless_thermostat_3_ambient_light_level")
|
if entity.entity_id == "sensor.diyless_thermostat_3_ambient_light_level"
|
||||||
.as_ref()
|
&& let Ok(illuminance) = entity.state.parse::<i32>()
|
||||||
&& let Ok(illuminance) = illuminance.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]) {
|
||||||
|
if app
|
||||||
|
.get_lights()
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<LightData>>()
|
||||||
|
.is_none()
|
||||||
{
|
{
|
||||||
let color_scheme = if illuminance < 250 {
|
app.set_lights(ModelRc::new(VecModel::default()));
|
||||||
ColorScheme::Dark
|
}
|
||||||
} else {
|
|
||||||
ColorScheme::Light
|
|
||||||
};
|
|
||||||
|
|
||||||
app.set_color_scheme(color_scheme);
|
let lights = app.get_lights();
|
||||||
|
let model = lights
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<LightData>>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for entity in entities.iter().filter(|entity| {
|
||||||
|
entity.domain() == "light"
|
||||||
|
&& !entity.entity_id.ends_with("screen")
|
||||||
|
&& !entity.entity_id.chars().any(char::is_numeric)
|
||||||
|
}) {
|
||||||
|
match model.iter().position(|light| light.id == entity.entity_id) {
|
||||||
|
Some(index) => model.set_row_data(index, entity.into()),
|
||||||
|
None => model.push(entity.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_clock(app: &AppWindow, state: &State) {
|
fn apply_thermometers(app: &AppWindow, entities: &[HassEntity]) {
|
||||||
if let Some(date_time_iso) = state.get("sensor.date_time_iso").as_ref() {
|
if app
|
||||||
app.set_date_time(date_time_iso.into());
|
.get_thermometers()
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<ThermometerData>>()
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
app.set_thermometers(ModelRc::new(VecModel::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = app.get_thermometers();
|
||||||
|
let model = data
|
||||||
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<ThermometerData>>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for entity in entities.iter().filter(|entity| {
|
||||||
|
entity.domain() == "sensor" && entity.entity_id.ends_with("thermometer_temperature")
|
||||||
|
}) {
|
||||||
|
match model.iter().position(|item| item.id == entity.entity_id) {
|
||||||
|
Some(index) => model.set_row_data(index, entity.into()),
|
||||||
|
None => model.push(entity.into()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_lights(app: &AppWindow, state: &State) {
|
fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) {
|
||||||
let lights = state
|
if app
|
||||||
.domain("light")
|
.get_thermostats()
|
||||||
.iter()
|
.as_any()
|
||||||
.filter(|light| {
|
.downcast_ref::<VecModel<ThermostatData>>()
|
||||||
!(light.entity_id.ends_with("screen") || light.entity_id.chars().any(char::is_numeric))
|
.is_none()
|
||||||
})
|
{
|
||||||
.map(Into::into)
|
app.set_thermostats(ModelRc::new(VecModel::default()));
|
||||||
.collect::<Vec<LightData>>();
|
|
||||||
app.set_lights(ModelRc::new(VecModel::from(lights)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_thermometers(app: &AppWindow, state: &State) {
|
|
||||||
let thermometers = state
|
|
||||||
.domain("sensor")
|
|
||||||
.iter()
|
|
||||||
.filter(|sensor| sensor.entity_id.ends_with("thermometer_temperature"))
|
|
||||||
.map(Into::into)
|
|
||||||
.collect::<Vec<ThermometerData>>();
|
|
||||||
app.set_thermometers(ModelRc::new(VecModel::from(thermometers)));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_weather(app: &AppWindow, state: &State) {
|
|
||||||
if let Some(weather) = state.get("weather.forecast_home").as_ref() {
|
|
||||||
app.set_weather(weather.into());
|
|
||||||
}
|
}
|
||||||
app.set_is_night(
|
|
||||||
state
|
|
||||||
.get("sun.sun")
|
|
||||||
.is_some_and(|sun| sun.state == "below_horizon"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply_thermostats(app: &AppWindow, state: &State) {
|
let data = app.get_thermostats();
|
||||||
let thermostats = state
|
let model = data
|
||||||
.domain("climate")
|
.as_any()
|
||||||
|
.downcast_ref::<VecModel<ThermostatData>>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for entity in entities
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|sensor| !sensor.entity_id.contains("diyless"))
|
.filter(|entity| entity.domain() == "climate" && !entity.entity_id.contains("diyless"))
|
||||||
.map(Into::into)
|
{
|
||||||
.collect::<Vec<ThermostatData>>();
|
match model.iter().position(|item| item.id == entity.entity_id) {
|
||||||
|
Some(index) => model.set_row_data(index, entity.into()),
|
||||||
app.set_thermostats(ModelRc::new(VecModel::from(thermostats)));
|
None => model.push(entity.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_state(app: &Weak<AppWindow>, state: State) {
|
fn apply_state(app: &Weak<AppWindow>, entities: Vec<HassEntity>) {
|
||||||
if let Err(err) = app.upgrade_in_event_loop(move |app| {
|
if let Err(err) = app.upgrade_in_event_loop(move |app| {
|
||||||
apply_theme(&app, &state);
|
apply_theme(&app, &entities);
|
||||||
apply_clock(&app, &state);
|
apply_clock(&app, &entities);
|
||||||
apply_lights(&app, &state);
|
apply_lights(&app, &entities);
|
||||||
apply_thermometers(&app, &state);
|
apply_thermometers(&app, &entities);
|
||||||
apply_weather(&app, &state);
|
apply_weather(&app, &entities);
|
||||||
apply_thermostats(&app, &state);
|
apply_thermostats(&app, &entities);
|
||||||
}) {
|
}) {
|
||||||
eprintln!("Failed to upgrade in event loop.\n\t{:?}", err);
|
eprintln!("Failed to upgrade in event loop.\n\t{:?}", err);
|
||||||
}
|
}
|
||||||
@@ -107,20 +159,25 @@ async fn main() -> Result<(), AppError> {
|
|||||||
|
|
||||||
let app = AppWindow::new()?;
|
let app = AppWindow::new()?;
|
||||||
|
|
||||||
let state = State::default();
|
|
||||||
let (c2s_tx, c2s_rx) = mpsc::unbounded_channel::<C2sMessage>();
|
let (c2s_tx, c2s_rx) = mpsc::unbounded_channel::<C2sMessage>();
|
||||||
let (s2c_tx, mut s2c_rx) = mpsc::unbounded_channel::<S2cMessage>();
|
let (s2c_tx, mut s2c_rx) = mpsc::unbounded_channel::<S2cMessage>();
|
||||||
|
|
||||||
hass(s2c_tx, c2s_rx, state.clone()).await?;
|
|
||||||
let app_weak = app.as_weak();
|
|
||||||
apply_state(&app_weak, state.clone());
|
|
||||||
|
|
||||||
tokio::spawn({
|
tokio::spawn({
|
||||||
let app_weak = app.as_weak();
|
let app_weak = app.as_weak();
|
||||||
let state = state.clone();
|
|
||||||
async move {
|
async move {
|
||||||
while let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await {
|
while let Some(message) = s2c_rx.recv().await {
|
||||||
apply_state(&app_weak, state.clone());
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -160,6 +217,9 @@ async fn main() -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// start pulling data
|
||||||
|
tokio::spawn(hass(s2c_tx, c2s_rx));
|
||||||
|
|
||||||
app.run().map_err(Into::into)
|
app.run().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,47 +258,14 @@ enum C2sMessage {
|
|||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum S2cMessage {
|
enum S2cMessage {
|
||||||
EntityUpdated,
|
InitialState(Vec<HassEntity>),
|
||||||
}
|
EntityUpdated(HassEntity),
|
||||||
|
EntityRemoved(HassEntity),
|
||||||
#[derive(Default, Debug, Clone)]
|
|
||||||
struct State(Arc<RwLock<HashMap<String, HassEntity>>>);
|
|
||||||
|
|
||||||
impl State {
|
|
||||||
pub fn update(&self, entity: &HassEntity) {
|
|
||||||
self.0
|
|
||||||
.write()
|
|
||||||
.unwrap()
|
|
||||||
.insert(entity.entity_id.clone(), entity.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn domain(&self, domain: &str) -> Vec<HassEntity> {
|
|
||||||
let entities = self.0.read().unwrap();
|
|
||||||
|
|
||||||
let mut entities = entities
|
|
||||||
.values()
|
|
||||||
.filter(|e| {
|
|
||||||
e.entity_id
|
|
||||||
.split_once('.')
|
|
||||||
.is_some_and(|(d, _)| d == domain)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
entities.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
|
|
||||||
|
|
||||||
entities
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, entity_id: &str) -> Option<HassEntity> {
|
|
||||||
self.0.read().unwrap().get(entity_id).cloned()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn hass(
|
async fn hass(
|
||||||
s2c_tx: UnboundedSender<S2cMessage>,
|
s2c_tx: UnboundedSender<S2cMessage>,
|
||||||
mut c2s_rx: UnboundedReceiver<C2sMessage>,
|
mut c2s_rx: UnboundedReceiver<C2sMessage>,
|
||||||
state: State,
|
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let Ok(url) = var("HASS_URL") else {
|
let Ok(url) = var("HASS_URL") else {
|
||||||
return Err(AppError::MissingUrl);
|
return Err(AppError::MissingUrl);
|
||||||
@@ -266,35 +293,46 @@ async fn hass(
|
|||||||
.await
|
.await
|
||||||
.map_err(Box::new)?;
|
.map_err(Box::new)?;
|
||||||
|
|
||||||
|
let mut state = HashMap::<String, HassEntity>::new();
|
||||||
let states = client.get_states().await.map_err(Box::new)?;
|
let states = client.get_states().await.map_err(Box::new)?;
|
||||||
|
|
||||||
states.iter().for_each(|entity| {
|
states.iter().for_each(|entity| {
|
||||||
state.update(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
|
let mut event_receiver = client
|
||||||
.subscribe_event("state_changed")
|
.subscribe_event("state_changed")
|
||||||
.await
|
.await
|
||||||
.map_err(Box::new)?;
|
.map_err(Box::new)?;
|
||||||
|
|
||||||
tokio::spawn({
|
loop {
|
||||||
let state = state.clone();
|
tokio::select! {
|
||||||
async move {
|
Some(message) = event_receiver.recv() => {
|
||||||
while let Some(message) = event_receiver.recv().await {
|
match (&message.event.data.old_state, &message.event.data.new_state) {
|
||||||
if let Some(entity) = &message.event.data.new_state {
|
(None, Some(entity)) => {
|
||||||
state.update(entity);
|
// New Entity Added
|
||||||
if let Err(err) = s2c_tx.send(S2cMessage::EntityUpdated) {
|
state.insert(entity.entity_id.clone(), entity.clone());
|
||||||
eprintln!("Failed to send message to client.\n\t{:?}", err);
|
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();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tokio::spawn({
|
Some(message) = c2s_rx.recv() => {
|
||||||
let state = state.clone();
|
|
||||||
async move {
|
|
||||||
while let Some(message) = c2s_rx.recv().await {
|
|
||||||
if let Err(err) = match message {
|
if let Err(err) = match message {
|
||||||
C2sMessage::ToggleLight { entity_id } => {
|
C2sMessage::ToggleLight { entity_id } => {
|
||||||
if let Some(entity) = state.get(&entity_id) {
|
if let Some(entity) = state.get(&entity_id) {
|
||||||
@@ -344,8 +382,10 @@ async fn hass(
|
|||||||
eprintln!("Failed to execute Service Call.\n\t{:?}", err);
|
eprintln!("Failed to execute Service Call.\n\t{:?}", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else => break,
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user