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

View File

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

View File

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

View File

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

View File

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