1786298825
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#[allow(clippy::all)]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("HASS_URL is not set")]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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>),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user