1786298825

This commit is contained in:
2026-08-09 20:07:05 +02:00
parent bc19895faa
commit b8e235a93e
10 changed files with 475 additions and 140 deletions

View File

@@ -14,6 +14,8 @@ url = "2.5.8"
chrono = "0.4.45"
thiserror = "2.0.20"
paste = "1.0.15"
ureq = { version = "3.4.0", default-features = false, features = ["json", "rustls"] }
serde = { version = "1.0.229", features = ["derive"] }
[features]
dev = ["slint/backend-winit-x11"]

View File

@@ -1,4 +1,3 @@
#[allow(clippy::all)]
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("HASS_URL is not set")]

View File

@@ -44,7 +44,7 @@ impl HassEntityExt for HassEntity {
impl From<&HassEntity> for LightData {
fn from(value: &HassEntity) -> Self {
Self {
id: value.entity_id.clone().into(),
id: value.entity_id.to_shared_string(),
name: value
.friendly_name()
.replace(" Lights", "")
@@ -61,7 +61,7 @@ impl From<&HassEntity> for ThermometerData {
let v: f32 = value.state.parse().unwrap_or_default();
Self {
id: value.entity_id.clone().into(),
id: value.entity_id.to_shared_string(),
name: value.friendly_name().into(),
value: format!("{:.1}", v).into(),
unit: value.unit_of_measurement().into(),
@@ -71,21 +71,19 @@ impl From<&HassEntity> for ThermometerData {
impl From<&HassEntity> for DateTimeData {
fn from(value: &HassEntity) -> Self {
let mut data = Self {
id: value.entity_id.to_shared_string(),
..Default::default()
};
if let Ok(datetime) =
chrono::NaiveDateTime::parse_from_str(&value.state, "%Y-%m-%dT%H:%M:%S")
{
return Self {
id: value.entity_id.clone().into(),
date: datetime.format("%a %m %b").to_shared_string(),
time: datetime.format("%H:%M").to_shared_string(),
};
data.date = datetime.format("%a %m %b").to_shared_string();
data.time = datetime.format("%H:%M").to_shared_string();
}
Self {
id: value.entity_id.clone().into(),
date: "0-0-0000".into(),
time: "00:00".into(),
}
data
}
}
@@ -139,64 +137,6 @@ impl From<&HassEntity> for WeatherData {
let label = pretty_label(&value.state).to_shared_string();
let attribution = value
.attributes
.get("attribution")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_shared_string();
let cloud_coverage = value
.attributes
.get("cloud_coverage")
.and_then(|x| x.as_i64())
.unwrap_or_default() as i32;
let dew_point = value
.attributes
.get("dew_point")
.and_then(|x| x.as_f64())
.unwrap_or_default() as f32;
let friendly_name = value
.attributes
.get("friendly_name")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_shared_string();
let humidity = value
.attributes
.get("humidity")
.and_then(|x| x.as_i64())
.unwrap_or_default() as i32;
let precipitation_unit = value
.attributes
.get("precipitation_unit")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_shared_string();
let pressure = value
.attributes
.get("pressure")
.and_then(|x| x.as_f64())
.unwrap_or_default() as f32;
let pressure_unit = value
.attributes
.get("pressure_unit")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_shared_string();
let supported_features = value
.attributes
.get("supported_features")
.and_then(|x| x.as_i64())
.unwrap_or_default() as i32;
let temperature = value
.attributes
.get("temperature")
@@ -210,57 +150,24 @@ impl From<&HassEntity> for WeatherData {
.unwrap_or_default()
.to_shared_string();
let uv_index = value
.attributes
.get("uv_index")
.and_then(|x| x.as_f64())
.unwrap_or_default() as f32;
let visibility_unit = value
.attributes
.get("visibility_unit")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_shared_string();
let wind_bearing = value
.attributes
.get("wind_bearing")
.and_then(|x| x.as_f64())
.unwrap_or_default() as f32;
let wind_speed = value
.attributes
.get("wind_speed")
.and_then(|x| x.as_f64())
.unwrap_or_default() as f32;
let wind_speed_unit = value
.attributes
.get("wind_speed_unit")
.and_then(|x| x.as_str())
.unwrap_or_default()
.to_shared_string();
Self {
icon_index,
label,
attribution,
cloud_coverage,
dew_point,
friendly_name,
humidity,
precipitation_unit,
pressure,
pressure_unit,
supported_features,
temperature,
temperature_unit,
uv_index,
visibility_unit,
wind_bearing,
wind_speed,
wind_speed_unit,
}
}
}
// weird spot for this but okay
impl From<&crate::home_assistant::CalendarEvent> for CalendarEventData {
fn from(value: &crate::home_assistant::CalendarEvent) -> Self {
Self {
calendar: value.calendar.to_shared_string(),
date: value.date.to_shared_string(),
id: value.entity_id.to_shared_string(),
summary: value.summary.to_shared_string(),
time: value.time.to_shared_string(),
}
}
}

View File

@@ -1,8 +1,13 @@
use chrono::{DateTime, Local, NaiveDate};
use dotenvy::var;
use hass_rs::{HassClient, HassEntity};
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
use std::{collections::HashMap, time::Duration};
use tokio::{
sync::mpsc::{UnboundedReceiver, UnboundedSender},
time::sleep,
};
use crate::{
error::AppError,
@@ -114,6 +119,18 @@ pub async fn hass(
initial_state.sort_by(|a, b| a.entity_id.cmp(&b.entity_id));
s2c_tx.send(EventMessage::InitialState(initial_state)).ok();
tokio::spawn({
let s2c_tx = s2c_tx.clone();
async move {
loop {
let ha_client = HaClient::from_environment().unwrap();
let events = ha_client.fetch_calendar_events().unwrap();
s2c_tx.send(EventMessage::CalendarUpdated(events)).ok();
sleep(Duration::from_secs(30)).await;
}
}
});
let mut event_receiver = client
.subscribe_event("state_changed")
.await
@@ -162,3 +179,143 @@ fn toggle_action(current_state: &str, on_state: &str) -> &'static str {
"turn_on"
}
}
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 HaCalendar {
pub entity_id: String,
pub name: String,
}
#[derive(Deserialize)]
pub struct HaCalendarEvent {
pub uid: String,
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 CalendarEvent {
pub entity_id: String,
pub summary: String,
pub date: String,
pub time: String,
pub calendar: String,
pub sort_epoch: i64,
}
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,
})
}
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}"))
}
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 {
entity_id: event.uid,
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 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 %d %b").to_string().to_uppercase(),
parsed.format("%H:%M").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 %d %b").to_string().to_uppercase(),
"ALL DAY".into(),
))
}

View File

@@ -1,5 +1,7 @@
use hass_rs::HassEntity;
use crate::home_assistant::CalendarEvent;
#[derive(Clone)]
pub enum CommandMessage {
ToggleLight { entity_id: String },
@@ -14,4 +16,5 @@ pub enum EventMessage {
InitialState(Vec<HassEntity>),
EntityUpdated(HassEntity),
EntityRemoved(HassEntity),
CalendarUpdated(Vec<CalendarEvent>),
}

View File

@@ -2,6 +2,7 @@ slint::include_modules!();
use crate::{
ha_ext::*,
home_assistant::CalendarEvent,
messages::{CommandMessage, EventMessage},
};
@@ -96,6 +97,18 @@ pub fn apply_thermostats(app: &AppWindow, entities: &[HassEntity]) {
});
}
pub fn apply_calendars(app: &Weak<AppWindow>, entities: Vec<CalendarEvent>) {
if let Err(err) = app.upgrade_in_event_loop(move |app| {
let mut entities = entities.clone();
entities.sort_by_key(|a| a.sort_epoch);
let entities = entities.iter().map(Into::into).collect::<Vec<_>>();
app.set_calendar_events(ModelRc::new(VecModel::<CalendarEventData>::from(entities)));
}) {
eprintln!("Failed to upgrade in event loop.\n\t{:?}", err);
}
}
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);
@@ -161,6 +174,9 @@ pub async fn update_listener(
EventMessage::EntityRemoved(hass_entity) => {
apply_state(&app_weak, vec![hass_entity]);
}
EventMessage::CalendarUpdated(events) => {
apply_calendars(&app_weak, events);
}
}
}
}

View File

@@ -1,7 +1,41 @@
import { FilledButton, SnackBar } from "material/material.slint";
import { Palette } from "std-widgets.slint";
import {
FilledButton,
SnackBar,
ListTile,
MaterialText,
MaterialPalette,
MaterialTypography,
} from "material/material.slint";
import { Icons } from "material/ui/icons/icons.slint";
export struct CalendarEventData {
id: string,
date: string,
time: string,
summary: string,
calendar: string,
}
export component Calendar inherits Rectangle {
in property <[CalendarEventData]> calendar-events;
VerticalLayout {
// ..
for event[index] in calendar-events: ListTile {
avatar_icon: Icons.calendar_today;
avatar_foreground: Palette.foreground;
text: event.date;
supporting-text: event.summary;
height: 72px;
MaterialText {
text: event.time;
color: MaterialPalette.on_surface;
overflow: elide;
style: MaterialTypography.title_medium;
}
}
Rectangle { }
}
}

View File

@@ -1,4 +1,5 @@
import { Palette, HorizontalBox, VerticalBox } from "std-widgets.slint";
import { Icons } from "material/ui/icons/icons.slint";
import {
ListTile,
Vertical,
@@ -7,26 +8,12 @@ import {
MaterialPalette,
FilledButton,
} from "material/material.slint";
import { Icons } from "material/ui/icons/icons.slint";
export struct WeatherData {
icon-index: int,
label: string,
temperature: float,
dew_point: float,
temperature_unit: string,
humidity: int,
cloud_coverage: int,
uv_index: float,
pressure: float,
pressure_unit: string,
wind_bearing: float,
wind_speed: float,
wind_speed_unit: string,
visibility_unit: string,
precipitation_unit: string,
attribution: string,
friendly_name: string,
supported_features: int,
}
export struct ThermostatData {
@@ -114,7 +101,6 @@ export component Climate inherits Rectangle {
HorizontalLayout {
Text {
text: thermostat.target;
// color: MaterialPalette.primary;
font-size: 40px;
vertical-alignment: center;
horizontal-alignment: right;
@@ -126,7 +112,6 @@ export component Climate inherits Rectangle {
padding-top: 2px;
Text {
text: thermostat.unit;
// color: MaterialPalette.primary;
font-size: 14px;
horizontal-alignment: left;
vertical-alignment: top;

View File

@@ -9,6 +9,8 @@ import {
} from "std-widgets.slint";
import { Climate, WeatherData, ThermostatData } from "./climate.slint";
import { CalendarEventData } from "./calendar.slint";
import { Calendar } from "calendar.slint";
import { Energy } from "energy.slint";
import {
@@ -132,6 +134,8 @@ export component AppWindow inherits Window {
callback set_temperature(string, float);
callback toggle_thermostat(string);
in property <[CalendarEventData]> calendar-events;
in property <[ThermometerData]> thermometers;
in property <[LightData]> lights;
@@ -177,7 +181,11 @@ export component AppWindow inherits Window {
set_temperature(entity_id, value) => { root.set_temperature(entity_id, value); }
toggle_thermostat(entity_id) => { toggle_thermostat(entity_id) }
}
if tab-bar.current-index == 1: Calendar { }
if tab-bar.current-index == 1: Calendar {
calendar-events: calendar-events;
}
if tab-bar.current-index == 2: Energy { }
Rectangle {