1786278419

This commit is contained in:
2026-08-09 14:26:59 +02:00
parent e4b1e33f1f
commit a95f40cdea
5 changed files with 261 additions and 188 deletions

27
Cargo.lock generated
View File

@@ -57,7 +57,7 @@ dependencies = [
"ndk-context", "ndk-context",
"ndk-sys", "ndk-sys",
"num_enum", "num_enum",
"thiserror 2.0.19", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -312,7 +312,7 @@ dependencies = [
"num-traits", "num-traits",
"pastey", "pastey",
"rayon", "rayon",
"thiserror 2.0.19", "thiserror 2.0.20",
"v_frame", "v_frame",
"y4m", "y4m",
] ]
@@ -851,6 +851,7 @@ dependencies = [
"serde_json", "serde_json",
"slint", "slint",
"slint-build", "slint-build",
"thiserror 2.0.20",
"tokio", "tokio",
"trekstor", "trekstor",
"url", "url",
@@ -1636,7 +1637,7 @@ dependencies = [
"parking_lot", "parking_lot",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.19", "thiserror 2.0.20",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",
] ]
@@ -2304,7 +2305,7 @@ dependencies = [
"jni-sys 0.4.1", "jni-sys 0.4.1",
"log", "log",
"simd_cesu8", "simd_cesu8",
"thiserror 2.0.19", "thiserror 2.0.20",
"walkdir", "walkdir",
"windows-link", "windows-link",
] ]
@@ -2552,7 +2553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72"
dependencies = [ dependencies = [
"lyon_path", "lyon_path",
"thiserror 2.0.19", "thiserror 2.0.20",
] ]
[[package]] [[package]]
@@ -2662,7 +2663,7 @@ dependencies = [
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"once_cell", "once_cell",
"png", "png",
"thiserror 2.0.19", "thiserror 2.0.20",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -3662,7 +3663,7 @@ dependencies = [
"rand", "rand",
"rand_chacha", "rand_chacha",
"simd_helpers", "simd_helpers",
"thiserror 2.0.19", "thiserror 2.0.20",
"v_frame", "v_frame",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -4476,11 +4477,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.19" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [ dependencies = [
"thiserror-impl 2.0.19", "thiserror-impl 2.0.20",
] ]
[[package]] [[package]]
@@ -4496,9 +4497,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.19" version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -4739,7 +4740,7 @@ dependencies = [
"log", "log",
"rand", "rand",
"sha1", "sha1",
"thiserror 2.0.19", "thiserror 2.0.20",
] ]
[[package]] [[package]]

View File

@@ -12,6 +12,7 @@ tokio = { version = "1.53.1", features = ["full"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
url = "2.5.8" url = "2.5.8"
chrono = "0.4.45" chrono = "0.4.45"
thiserror = "2.0.20"
[features] [features]
dev = ["slint/backend-winit-x11"] dev = ["slint/backend-winit-x11"]

View File

@@ -1,84 +1,105 @@
mod ha_ext; mod ha_ext;
use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use dotenvy::var; use dotenvy::var;
use hass_rs::{HassClient, HassEntity}; use hass_rs::{HassClient, HassEntity};
use serde_json::{Value, json}; use serde_json::json;
use slint::language::ColorScheme; use slint::language::ColorScheme;
use slint::{ModelRc, VecModel, Weak}; use slint::{ModelRc, VecModel, Weak};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
slint::include_modules!(); slint::include_modules!();
fn apply_theme(app: &AppWindow, state: &State) {
if let Some(illuminance) = state
.get("sensor.diyless_thermostat_3_ambient_light_level")
.as_ref()
&& 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, state: &State) {
if let Some(date_time_iso) = state.get("sensor.date_time_iso").as_ref() {
app.set_date_time(date_time_iso.into());
}
}
fn apply_lights(app: &AppWindow, state: &State) {
let lights = state
.domain("light")
.iter()
.filter(|light| {
!(light.entity_id.ends_with("screen") || light.entity_id.chars().any(char::is_numeric))
})
.map(Into::into)
.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 thermostats = state
.domain("climate")
.iter()
.filter(|sensor| !sensor.entity_id.contains("diyless"))
.map(Into::into)
.collect::<Vec<ThermostatData>>();
app.set_thermostats(ModelRc::new(VecModel::from(thermostats)));
}
fn apply_state(app: &Weak<AppWindow>, state: State) { fn apply_state(app: &Weak<AppWindow>, state: State) {
app.upgrade_in_event_loop(move |app| { if let Err(err) = app.upgrade_in_event_loop(move |app| {
// Light/Dark mode apply_theme(&app, &state);
// apply_clock(&app, &state);
if let Some(illuminance) = state apply_lights(&app, &state);
.get("sensor.diyless_thermostat_3_ambient_light_level") apply_thermometers(&app, &state);
.as_ref() apply_weather(&app, &state);
&& let Ok(illuminance) = illuminance.state.parse::<i32>() apply_thermostats(&app, &state);
{ }) {
let scheme = if illuminance < 300 { eprintln!("Failed to upgrade in event loop.\n\t{:?}", err);
ColorScheme::Dark }
} else { }
ColorScheme::Light
};
app.set_color_scheme(scheme); fn toggle_action(current_state: &str, on_state: &str) -> &'static str {
} if current_state == on_state {
"turn_off"
// Lights } else {
let lights = state "turn_on"
.domain("light") }
.iter()
.filter(|light| {
!(light.entity_id.ends_with("screen")
|| light.entity_id.chars().any(char::is_numeric))
})
.map(Into::into)
.collect::<Vec<LightData>>();
app.set_lights(ModelRc::new(VecModel::from(lights)));
// Thermometers
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)));
// Clock
if let Some(date_time_iso) = state.get("sensor.date_time_iso").as_ref() {
app.set_date_time(date_time_iso.into());
}
// Weather
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"),
);
// Thermostats
let thermostats = state
.domain("climate")
.iter()
.filter(|sensor| !sensor.entity_id.contains("diyless"))
.map(Into::into)
.collect::<Vec<ThermostatData>>();
app.set_thermostats(ModelRc::new(VecModel::from(thermostats)));
})
.ok();
} }
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), AppError> {
dotenvy::dotenv().ok(); dotenvy::dotenv().ok();
#[cfg(not(feature = "dev"))] #[cfg(not(feature = "dev"))]
@@ -98,37 +119,20 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let app_weak = app.as_weak(); let app_weak = app.as_weak();
let state = state.clone(); let state = state.clone();
async move { async move {
loop { while let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await {
if let Some(S2cMessage::EntityUpdated) = s2c_rx.recv().await { apply_state(&app_weak, state.clone());
apply_state(&app_weak, state.clone());
};
} }
} }
}); });
// Button Bindings
app.on_toggle_light({ app.on_toggle_light({
let state = state.clone();
let c2s_tx = c2s_tx.clone(); let c2s_tx = c2s_tx.clone();
move |entity_id| { move |entity_id| {
let id = entity_id.to_string(); let id = entity_id.to_string();
let payload = Some(json!({ if let Err(err) = c2s_tx.send(C2sMessage::ToggleLight { entity_id: id }) {
"entity_id": id eprintln!("Failed to send message to server.\n\t{:?}", err);
}));
if let Some(entity) = state.get(&id) {
let action = if entity.state == "on" {
"turn_off"
} else {
"turn_on"
};
c2s_tx
.send(C2sMessage::CallService(
"light".into(),
action.into(),
payload.clone(),
))
.ok();
} }
} }
}); });
@@ -136,56 +140,60 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
app.on_set_temperature({ app.on_set_temperature({
let c2s_tx = c2s_tx.clone(); let c2s_tx = c2s_tx.clone();
move |entity_id, value| { move |entity_id, value| {
let payload = Some(json!({ if let Err(err) = c2s_tx.send(C2sMessage::SetTemperature {
"entity_id": entity_id.to_string(), entity_id: entity_id.to_string(),
"temperature": value temperature: value,
})); }) {
eprintln!("Failed to send message to server.\n\t{:?}", err);
c2s_tx
.send(C2sMessage::CallService(
"climate".into(),
"set_temperature".into(),
payload.clone(),
))
.ok();
}
});
app.on_toggle_thermostat({
let state = state.clone();
let c2s_tx = c2s_tx.clone();
move |entity_id| {
let id = entity_id.to_string();
let payload = Some(json!({
"entity_id": id
}));
if let Some(entity) = state.get(&id) {
let action = if entity.state == "heat" {
"turn_off"
} else {
"turn_on"
};
c2s_tx
.send(C2sMessage::CallService(
"climate".into(),
action.into(),
payload.clone(),
))
.ok();
} }
} }
}); });
app.run()?; app.on_toggle_thermostat({
let c2s_tx = c2s_tx.clone();
move |entity_id| {
let id = entity_id.to_string();
Ok(()) if let Err(err) = c2s_tx.send(C2sMessage::ToggleThermostat { entity_id: id }) {
eprintln!("Failed to send message to server.\n\t{:?}", err);
}
}
});
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)] #[derive(Clone)]
enum C2sMessage { enum C2sMessage {
CallService(String, String, Option<Value>), ToggleLight { entity_id: String },
ToggleThermostat { entity_id: String },
SetTemperature { entity_id: String, temperature: f32 },
} }
#[derive(Clone)] #[derive(Clone)]
@@ -194,36 +202,36 @@ enum S2cMessage {
} }
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
struct State(Arc<RwLock<Vec<HassEntity>>>); struct State(Arc<RwLock<HashMap<String, HassEntity>>>);
impl State { impl State {
pub fn update(&self, entity: &HassEntity) { pub fn update(&self, entity: &HassEntity) {
let mut entities = self.0.write().unwrap(); self.0
.write()
if let Some(existing) = entities .unwrap()
.iter_mut() .insert(entity.entity_id.clone(), entity.clone());
.find(|e| e.entity_id == entity.entity_id)
{
*existing = entity.clone();
} else {
entities.push(entity.clone());
}
} }
pub fn domain(&self, domain: &str) -> Vec<HassEntity> { pub fn domain(&self, domain: &str) -> Vec<HassEntity> {
let entities = self.0.read().unwrap(); let entities = self.0.read().unwrap();
entities let mut entities = entities
.iter() .values()
.filter(|e| e.entity_id.starts_with(domain)) .filter(|e| {
e.entity_id
.split_once('.')
.is_some_and(|(d, _)| d == domain)
})
.cloned() .cloned()
.collect::<Vec<_>>() .collect::<Vec<_>>();
entities.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
entities
} }
pub fn get(&self, entity_id: &str) -> Option<HassEntity> { pub fn get(&self, entity_id: &str) -> Option<HassEntity> {
let entities = self.0.read().unwrap(); self.0.read().unwrap().get(entity_id).cloned()
entities.iter().find(|e| e.entity_id == entity_id).cloned()
} }
} }
@@ -231,50 +239,111 @@ async fn hass(
s2c_tx: UnboundedSender<S2cMessage>, s2c_tx: UnboundedSender<S2cMessage>,
mut c2s_rx: UnboundedReceiver<C2sMessage>, mut c2s_rx: UnboundedReceiver<C2sMessage>,
state: State, state: State,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), AppError> {
let Ok(url) = var("HASS_URL") else { let Ok(url) = var("HASS_URL") else {
return Err("NO HASS_URL SET".into()); return Err(AppError::MissingUrl);
}; };
let Ok(hass_token) = var("HASS_TOKEN") else { let Ok(hass_token) = var("HASS_TOKEN") else {
return Err("NO HASS_TOKEN SET".into()); return Err(AppError::MissingToken);
}; };
let Ok(url) = url::Url::parse(&url) else { let Ok(url) = url::Url::parse(&url) else {
return Err("INVALID HASS_URL".into()); return Err(AppError::InvalidUrl);
}; };
let url = format!("ws://{}/api/websocket", url.authority()); let scheme = match url.scheme() {
"http" => "ws",
"https" => "wss",
scheme => return Err(AppError::InvalidScheme(scheme.to_string())),
};
let mut client = HassClient::new(&url).await?; let url = format!("{}://{}/api/websocket", scheme, url.authority());
client.auth_with_longlivedtoken(&hass_token).await?;
let states = client.get_states().await?; let mut client = HassClient::new(&url).await.map_err(Box::new)?;
client
.auth_with_longlivedtoken(&hass_token)
.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.update(entity);
}); });
let mut event_receiver = client.subscribe_event("state_changed").await?; let mut event_receiver = client
tokio::spawn(async move { .subscribe_event("state_changed")
while let Some(message) = event_receiver.recv().await { .await
if let Some(entity) = &message.event.data.new_state { .map_err(Box::new)?;
state.update(entity);
s2c_tx.send(S2cMessage::EntityUpdated).ok(); tokio::spawn({
let state = state.clone();
async move {
while let Some(message) = event_receiver.recv().await {
if let Some(entity) = &message.event.data.new_state {
state.update(entity);
if let Err(err) = s2c_tx.send(S2cMessage::EntityUpdated) {
eprintln!("Failed to send message to client.\n\t{:?}", err);
}
}
} }
} }
}); });
tokio::spawn(async move { tokio::spawn({
loop { let state = state.clone();
if let Some(C2sMessage::CallService(domain, service, service_data)) = async move {
c2s_rx.recv().await while let Some(message) = c2s_rx.recv().await {
{ if let Err(err) = match message {
client C2sMessage::ToggleLight { entity_id } => {
.call_service(domain, service, service_data) if let Some(entity) = state.get(&entity_id) {
.await let payload = json!({
.unwrap(); "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);
}
}
} }
}); });

View File

@@ -96,9 +96,11 @@ export component Climate inherits Rectangle {
} }
for thermostat[index] in thermostats: ListTile { for thermostat[index] in thermostats: ListTile {
avatar_icon: Icons.thermostat;
text: thermostat.name; text: thermostat.name;
supporting-text: thermostat.available ? thermostat.state : "UNAVAILABLE"; supporting-text: thermostat.available ? thermostat.state : "UNAVAILABLE";
height: 72px; height: 72px;
enabled: false;
ElevatedButton { ElevatedButton {
icon: Icons.remove; icon: Icons.remove;
@@ -111,7 +113,7 @@ export component Climate inherits Rectangle {
HorizontalLayout { HorizontalLayout {
Text { Text {
text: thermostat.target; text: thermostat.target;
color: MaterialPalette.primary; // color: MaterialPalette.primary;
font-size: 40px; font-size: 40px;
vertical-alignment: center; vertical-alignment: center;
horizontal-alignment: right; horizontal-alignment: right;
@@ -123,7 +125,7 @@ export component Climate inherits Rectangle {
padding-top: 2px; padding-top: 2px;
Text { Text {
text: thermostat.unit; text: thermostat.unit;
color: MaterialPalette.primary; // color: MaterialPalette.primary;
font-size: 14px; font-size: 14px;
horizontal-alignment: left; horizontal-alignment: left;
vertical-alignment: top; vertical-alignment: top;
@@ -133,7 +135,7 @@ export component Climate inherits Rectangle {
Text { Text {
text: ".\{thermostat.remainder}"; text: ".\{thermostat.remainder}";
color: MaterialPalette.primary; // color: MaterialPalette.primary;
font-size: 14px; font-size: 14px;
horizontal-alignment: left; horizontal-alignment: left;
vertical-alignment: bottom; vertical-alignment: bottom;

View File

@@ -61,7 +61,7 @@ export component ListTile {
if root.avatar_text != "": MaterialText { if root.avatar_text != "": MaterialText {
text: root.avatar_text; text: root.avatar_text;
vertical_alignment: center; vertical_alignment: bottom;
horizontal_alignment: center; horizontal_alignment: center;
color: root.avatar_foreground; color: root.avatar_foreground;
style: MaterialTypography.title_medium; style: MaterialTypography.title_medium;
@@ -100,7 +100,7 @@ export component ListTile {
states [ states [
disabled when !root.enabled: { disabled when !root.enabled: {
state_layer.display_background: false; state_layer.display_background: false;
title_layout.opacity: 38%; // title_layout.opacity: 38%;
} }
] ]
} }