From a395bfe61ae59e7003427436f852cb146c7a738f Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Wed, 1 Nov 2023 21:18:35 +0000 Subject: [PATCH] Subscribe to state change events from HASS --- Cargo.lock | 228 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------------------------------------------- shalom/Cargo.toml | 5 +++-- shalom/src/hass_client.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ shalom/src/main.rs | 11 +++++------ shalom/src/oracle.rs | 266 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------- shalom/src/pages/omni.rs | 36 +++++++++++++++++++++++++++++++----- shalom/src/pages/room.rs | 49 +++++++++++++++++++++++++++++++++++++++++++++---- shalom/src/widgets/cards/weather.rs | 34 ++++++++++++++++++++-------------- 8 files changed, 460 insertions(+), 262 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4307e9..40c7dc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -603,16 +603,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] -name = "errno" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" -dependencies = [ - "libc", - "windows-sys 0.48.0", -] - -[[package]] name = "error-code" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -673,12 +663,6 @@ dependencies = [ ] [[package]] -name = "fastrand" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" - -[[package]] name = "fdeflate" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1140,16 +1124,17 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper-rustls" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "8d78e1e73ec14cf7375674f74d7dde185c8206fd9dea6fb6295e8a98098aaa97" dependencies = [ - "bytes", + "futures-util", + "http", "hyper", - "native-tls", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", ] [[package]] @@ -1549,12 +1534,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" [[package]] -name = "linux-raw-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" - -[[package]] name = "lock_api" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1738,24 +1717,6 @@ dependencies = [ ] [[package]] -name = "native-tls" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e" -dependencies = [ - "lazy_static", - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] name = "ndk" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2009,50 +1970,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" [[package]] -name = "openssl" -version = "0.10.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" -dependencies = [ - "bitflags 2.4.1", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.38", -] - -[[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] -name = "openssl-sys" -version = "0.9.93" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] name = "orbclient" version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2480,26 +2403,29 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-tls", + "hyper-rustls", "ipnet", "js-sys", "log", "mime", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", "system-configuration", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] @@ -2530,6 +2456,20 @@ dependencies = [ ] [[package]] +name = "ring" +version = "0.17.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb0205304757e5d899b9c2e448b867ffd03ae7f988002e47cd24954391394d0b" +dependencies = [ + "cc", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.48.0", +] + +[[package]] name = "roxmltree" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2551,16 +2491,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] -name = "rustix" -version = "0.38.21" +name = "rustls" +version = "0.21.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "446e14c5cda4f3f30fe71863c34ec70f5ac79d6087097ad0bb433e1be5edf04c" dependencies = [ - "bitflags 2.4.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.48.0", + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3987094b1d07b653b7dfdc3f70ce9a1da9c51ac18c1b06b662e4f9a0e9f4b2" +dependencies = [ + "base64", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", ] [[package]] @@ -2630,6 +2600,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] name = "sctk-adwaita" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2745,6 +2725,7 @@ dependencies = [ "strum", "time", "tokio", + "tokio-stream", "tokio-tungstenite", "toml", "url", @@ -2855,7 +2836,7 @@ dependencies = [ "cfg_aliases", "cocoa", "core-graphics", - "fastrand 1.9.0", + "fastrand", "foreign-types", "log", "nix 0.26.4", @@ -3032,19 +3013,6 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef1adac450ad7f4b3c28589471ade84f25f731a7a0fe30d71dfa9f60fd808e5" -dependencies = [ - "cfg-if", - "fastrand 2.0.1", - "redox_syscall 0.4.1", - "rustix", - "windows-sys 0.48.0", -] - -[[package]] name = "termcolor" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3197,13 +3165,25 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "397c988d37662c7dda6d2208364a706264bf3d6138b11d436cbac0ad38832842" dependencies = [ - "native-tls", + "futures-core", + "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -3214,7 +3194,10 @@ checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", "tokio", + "tokio-rustls", "tungstenite", ] @@ -3333,6 +3316,7 @@ dependencies = [ "httparse", "log", "rand", + "rustls", "sha1", "thiserror", "url", @@ -3432,6 +3416,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f962df74c8c05a667b5ee8bcf162993134c104e96440b663c8daa176dc772d8c" [[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] name = "url" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3510,12 +3500,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] name = "vec_map" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3757,6 +3741,12 @@ dependencies = [ ] [[package]] +name = "webpki-roots" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" + +[[package]] name = "weezl" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/shalom/Cargo.toml b/shalom/Cargo.toml index 88319fb..41c58ff 100644 --- a/shalom/Cargo.toml +++ b/shalom/Cargo.toml @@ -13,12 +13,13 @@ internment = "0.7.4" itertools = "0.11" keyframe = "1.1" lru = "0.12" -reqwest = "0.11.22" +reqwest = { version = "0.11.22", default-features = false, features = ["rustls-tls", "rustls-tls-native-roots"] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["raw_value"] } strum = { version = "0.25", features = ["derive"] } tokio = { version = "1.33", features = ["net", "sync", "rt", "macros", "time", "fs"] } -tokio-tungstenite = "0.20" +tokio-stream = { version = "0.1", features = ["sync"] } +tokio-tungstenite = { version = "0.20", features = ["rustls-tls-native-roots"] } toml = "0.8" time = { version = "0.3", features = ["std"] } url = "2.4.1" diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs index fac6d2d..bfab17a 100644 --- a/shalom/src/hass_client.rs +++ b/shalom/src/hass_client.rs @@ -1,12 +1,12 @@ #![allow(clippy::forget_non_drop, dead_code)] -use std::{collections::HashMap, time::Duration}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use iced::futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use time::OffsetDateTime; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{broadcast, mpsc, oneshot}; use tokio_tungstenite::tungstenite::Message; use url::Url; use yoke::{Yoke, Yokeable}; @@ -20,6 +20,7 @@ pub struct Client { HassRequestKind, oneshot::Sender>, )>, + broadcast_channel: broadcast::Sender, String>>>, } impl Client { @@ -36,17 +37,24 @@ impl Client { resp.map_project(move |value, _| serde_json::from_str(value.get()).unwrap()) } + + pub fn subscribe(&self) -> broadcast::Receiver, String>>> { + self.broadcast_channel.subscribe() + } } pub async fn create(config: HomeAssistantConfig) -> Client { let (sender, mut recv) = mpsc::channel(10); - let uri = format!("ws://{}/api/websocket", config.uri); + let uri = format!("wss://{}/api/websocket", config.uri); let (mut connection, _response) = tokio_tungstenite::connect_async(&uri).await.unwrap(); let (ready_send, ready_recv) = oneshot::channel(); let mut ready_send = Some(ready_send); + let (broadcast_channel, _broadcast_recv) = broadcast::channel(10); + + let broadcast_send = broadcast_channel.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(10)); let mut counter: u64 = 0; @@ -91,11 +99,31 @@ pub async fn create(config: HomeAssistantConfig) -> Client { } HassResponseType::AuthOk => { ready_send.take().unwrap().send(()).unwrap(); + + counter += 1; + let counter = counter; + + connection + .send(HassRequest { + id: Some(counter), + inner: HassRequestKind::SubscribeEvents { + event_type: Some("state_changed".to_string()), + }, + }.to_request()) + .await + .unwrap(); } HassResponseType::Result => { let id = payload.id.unwrap(); - let payload = yoked_payload.map_project(move |yk, _| yk.result.unwrap()); - pending.remove(&id).unwrap().send(payload).unwrap(); + let payload = yoked_payload.try_map_project(move |yk, _| yk.result.ok_or(())); + + if let (Some(channel), Ok(payload)) = (pending.remove(&id), payload) { + let _res = channel.send(payload); + } + } + HassResponseType::Event => { + let payload = yoked_payload.map_project(move |yk, _| yk.event.unwrap()); + let _res = broadcast_send.send(Arc::new(payload)); } } } @@ -127,27 +155,37 @@ pub async fn create(config: HomeAssistantConfig) -> Client { ready_recv.await.unwrap(); Client { - base: Url::parse(&format!("http://{}/", config.uri)).unwrap(), + base: Url::parse(&format!("https://{}/", config.uri)).unwrap(), sender, + broadcast_channel, } } -#[derive(Deserialize, Yokeable)] +#[derive(Deserialize, Yokeable, Debug)] struct HassResponse<'a> { id: Option, #[serde(rename = "type")] type_: HassResponseType, #[serde(borrow)] result: Option<&'a RawValue>, + #[serde(borrow, bound(deserialize = "'a: 'de"))] + event: Option>, } -#[derive(Deserialize, Copy, Clone)] +#[derive(Deserialize, Clone, Debug, Yokeable)] +#[serde(rename_all = "snake_case", tag = "event_type", content = "data")] +pub enum Event<'a> { + StateChanged(#[serde(borrow, bound(deserialize = "'a: 'de"))] events::StateChanged<'a>), +} + +#[derive(Deserialize, Copy, Clone, Debug)] #[serde(rename_all = "snake_case")] pub enum HassResponseType { AuthRequired, AuthOk, AuthInvalid, Result, + Event, } #[derive(Serialize)] @@ -171,6 +209,9 @@ pub enum HassRequestKind { EntityRegistry, #[serde(rename = "config/device_registry/list")] DeviceRegistry, + SubscribeEvents { + event_type: Option, + }, } impl HassRequest { @@ -179,6 +220,22 @@ impl HassRequest { } } +pub mod events { + use std::borrow::Cow; + + use serde::Deserialize; + + #[derive(Deserialize, Clone, Debug)] + pub struct StateChanged<'a> { + #[serde(borrow)] + pub entity_id: Cow<'a, str>, + #[serde(borrow, bound(deserialize = "'a: 'de"))] + pub old_state: super::responses::State<'a>, + #[serde(borrow, bound(deserialize = "'a: 'de"))] + pub new_state: super::responses::State<'a>, + } +} + pub mod responses { use std::{ borrow::Cow, @@ -287,7 +344,7 @@ pub mod responses { #[derive(Yokeable, Debug, Deserialize)] pub struct StatesList<'a>(#[serde(borrow, bound(deserialize = "'a: 'de"))] pub Vec>); - #[derive(Debug)] + #[derive(Debug, Clone)] pub struct State<'a> { pub entity_id: Cow<'a, str>, pub state: Cow<'a, str>, @@ -372,7 +429,7 @@ pub mod responses { } } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum StateAttributes<'a> { Sun(StateSunAttributes), @@ -383,7 +440,7 @@ pub mod responses { Unknown, } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone, Copy)] pub struct StateSunAttributes { // next_dawn: time::OffsetDateTime, // next_dusk: time::OffsetDateTime, @@ -396,7 +453,7 @@ pub mod responses { rising: bool, } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] pub struct StateMediaPlayerAttributes<'a> { #[serde(borrow, default)] pub source_list: Vec>, @@ -428,14 +485,14 @@ pub mod responses { pub entity_picture: Option>, } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] #[serde(untagged)] pub enum MediaContentId<'a> { Uri(#[serde(borrow)] Cow<'a, str>), Int(u32), } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] pub struct StateCameraAttributes<'a> { #[serde(borrow)] access_token: Cow<'a, str>, @@ -523,7 +580,7 @@ pub mod responses { } } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] pub struct StateWeatherAttributes<'a> { pub temperature: f32, pub dew_point: f32, @@ -546,7 +603,7 @@ pub mod responses { pub forecast: Vec>, } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] pub struct StateWeatherAttributesForecast<'a> { #[serde(borrow)] pub condition: Cow<'a, str>, @@ -560,7 +617,7 @@ pub mod responses { pub humidity: f32, } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone)] pub struct StateLightAttributes<'a> { min_color_temp_kelvin: Option, max_color_temp_kelvin: Option, @@ -581,7 +638,7 @@ pub mod responses { xy_color: Option<(f32, f32)>, } - #[derive(Deserialize, Debug)] + #[derive(Deserialize, Debug, Clone, Copy)] #[serde(rename_all = "snake_case")] pub enum ColorMode { ColorTemp, diff --git a/shalom/src/main.rs b/shalom/src/main.rs index c85ebd5..1086acf 100644 --- a/shalom/src/main.rs +++ b/shalom/src/main.rs @@ -52,9 +52,7 @@ impl Application for Shalom { async { let config = load_config().await; let client = hass_client::create(config.home_assistant).await; - let oracle = Oracle::new(client.clone()).await; - - Arc::new(oracle) + Oracle::new(client.clone()).await }, Message::Loaded, ); @@ -73,7 +71,7 @@ impl Application for Shalom { self.oracle = Some(oracle); self.page = ActivePage::Room(pages::room::Room::new( "living_room", - self.oracle.as_deref().unwrap(), + self.oracle.clone().unwrap(), )); } (Message::CloseContextMenu, _) => { @@ -86,7 +84,7 @@ impl Application for Shalom { Some(pages::omni::Event::OpenRoom(room)) => { self.page = ActivePage::Room(pages::room::Room::new( room, - self.oracle.as_deref().unwrap(), + self.oracle.clone().unwrap(), )); } None => {} @@ -190,7 +188,8 @@ impl Application for Shalom { fn subscription(&self) -> Subscription { match &self.page { ActivePage::Room(room) => room.subscription().map(Message::RoomEvent), - _ => Subscription::none(), + ActivePage::Omni(omni) => omni.subscription().map(Message::OmniEvent), + ActivePage::Loading => Subscription::none(), } } } diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs index 9420c1f..50369c4 100644 --- a/shalom/src/oracle.rs +++ b/shalom/src/oracle.rs @@ -1,18 +1,23 @@ use std::{ collections::{BTreeMap, HashMap}, str::FromStr, + sync::{Arc, Mutex}, time::Duration, }; +use iced::futures::{future, Stream, StreamExt}; use internment::Intern; +use itertools::Itertools; +use tokio::sync::{broadcast, broadcast::error::RecvError}; +use tokio_stream::wrappers::BroadcastStream; use url::Url; use crate::hass_client::{ responses::{ - AreaRegistryList, DeviceRegistryList, EntityRegistryList, StateAttributes, StatesList, - WeatherCondition, + Area, AreaRegistryList, DeviceRegistryList, Entity, EntityRegistryList, StateAttributes, + StateMediaPlayerAttributes, StateWeatherAttributes, StatesList, WeatherCondition, }, - HassRequestKind, + Event, HassRequestKind, }; #[allow(dead_code)] @@ -20,12 +25,13 @@ use crate::hass_client::{ pub struct Oracle { client: crate::hass_client::Client, rooms: BTreeMap<&'static str, Room>, - pub weather: Weather, - pub media_players: BTreeMap<&'static str, MediaPlayer>, + pub weather: Mutex, + pub media_players: Mutex>, + entity_updates: broadcast::Sender>, } impl Oracle { - pub async fn new(hass_client: crate::hass_client::Client) -> Self { + pub async fn new(hass_client: crate::hass_client::Client) -> Arc { let (rooms, devices, entities, states) = tokio::join!( hass_client.request::>(HassRequestKind::AreaRegistry), hass_client.request::>(HassRequestKind::DeviceRegistry), @@ -40,55 +46,17 @@ impl Oracle { let all_entities = entities .iter() - .fold(HashMap::<_, Vec<_>>::new(), |mut acc, curr| { - if let Some(device_id) = curr.device_id.as_deref() { - acc.entry(device_id).or_default().push(curr); - } - - acc - }); + .filter_map(|v| v.device_id.as_deref().zip(Some(v))) + .into_group_map(); let room_devices = devices .iter() - .fold(HashMap::<_, Vec<_>>::new(), |mut acc, curr| { - if let (Some(area_id), Some(entity)) = - (curr.area_id.as_deref(), all_entities.get(curr.id.as_ref())) - { - acc.entry(area_id).or_default().push(entity); - } - - acc - }); + .filter_map(|v| v.area_id.as_deref().zip(all_entities.get(v.id.as_ref()))) + .into_group_map(); let rooms = rooms .iter() - .map(|room| { - let entities = room_devices - .get(room.area_id.as_ref()) - .iter() - .flat_map(|v| v.iter()) - .flat_map(|v| v.iter()) - .map(|v| Intern::from(v.entity_id.as_ref())) - .collect::>>(); - - let speaker_id = entities - .iter() - .filter(|v| { - // TODO: support multiple media players in one room - v.as_ref() != "media_player.lg_webos_smart_tv" - }) - .find(|v| v.starts_with("media_player.")) - .copied(); - - let area = Intern::::from(room.area_id.as_ref()).as_ref(); - let room = Room { - name: Intern::from(room.name.as_ref()), - entities, - speaker_id, - }; - - (area, room) - }) + .map(|room| build_room(&room_devices, room)) .collect(); eprintln!("{rooms:#?}"); @@ -98,27 +66,7 @@ impl Oracle { .iter() .filter_map(|state| { if let StateAttributes::MediaPlayer(attr) = &state.attributes { - let kind = if attr.volume_level.is_some() { - MediaPlayer::Speaker(MediaPlayerSpeaker { - volume: attr.volume_level.unwrap(), - muted: attr.is_volume_muted.unwrap(), - source: Box::from(attr.source.as_deref().unwrap_or("")), - media_duration: attr.media_duration.map(Duration::from_secs), - media_position: attr.media_position.map(Duration::from_secs), - media_title: attr.media_title.as_deref().map(Box::from), - media_artist: attr.media_artist.as_deref().map(Box::from), - media_album_name: attr.media_album_name.as_deref().map(Box::from), - shuffle: attr.shuffle.unwrap_or(false), - repeat: Box::from(attr.repeat.as_deref().unwrap_or("")), - entity_picture: attr - .entity_picture - .as_deref() - .map(|path| hass_client.base.join(path).unwrap()), - }) - } else { - MediaPlayer::Tv(MediaPlayerTv {}) - }; - + let kind = MediaPlayer::new(attr, &hass_client.base); Some((Intern::::from(state.entity_id.as_ref()).as_ref(), kind)) } else { None @@ -126,12 +74,19 @@ impl Oracle { }) .collect(); - Self { + let (entity_updates, _) = broadcast::channel(10); + + let this = Arc::new(Self { client: hass_client, rooms, - weather: Weather::parse_from_states(states), - media_players, - } + weather: Mutex::new(Weather::parse_from_states(states)), + media_players: Mutex::new(media_players), + entity_updates: entity_updates.clone(), + }); + + this.clone().spawn_worker(); + + this } pub fn rooms(&self) -> impl Iterator + '_ { @@ -141,15 +96,130 @@ impl Oracle { pub fn room(&self, id: &str) -> &Room { self.rooms.get(id).unwrap() } + + pub fn current_weather(&self) -> Weather { + *self.weather.lock().unwrap() + } + + pub fn subscribe_weather(&self) -> impl Stream { + BroadcastStream::new(self.entity_updates.subscribe()) + .filter_map(|v| future::ready(v.ok())) + .filter(|v| future::ready(v.starts_with("weather."))) + .map(|_| ()) + } + + pub fn subscribe_id(&self, id: &'static str) -> impl Stream { + BroadcastStream::new(self.entity_updates.subscribe()) + .filter_map(|v| future::ready(v.ok())) + .filter(move |v| future::ready(&**v == id)) + .map(|_| ()) + } + + pub fn spawn_worker(self: Arc) { + tokio::spawn(async move { + let mut recv = self.client.subscribe(); + + loop { + let msg = match recv.recv().await { + Ok(msg) => msg, + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, + }; + + match msg.get() { + Event::StateChanged(state_changed) => { + match &state_changed.new_state.attributes { + StateAttributes::MediaPlayer(attrs) => { + self.media_players.lock().unwrap().insert( + Intern::::from(state_changed.entity_id.as_ref()).as_ref(), + MediaPlayer::new(attrs, &self.client.base), + ); + } + StateAttributes::Weather(attrs) => { + *self.weather.lock().unwrap() = + Weather::parse_from_state_and_attributes( + state_changed.new_state.state.as_ref(), + attrs, + ); + } + _ => { + // TODO + } + } + + let _res = self + .entity_updates + .send(Arc::from(state_changed.entity_id.as_ref())); + } + } + } + }); + } } -#[derive(Debug)] +fn build_room( + room_devices: &HashMap<&str, Vec<&Vec<&Entity>>>, + room: &Area, +) -> (&'static str, Room) { + let entities = room_devices + .get(room.area_id.as_ref()) + .iter() + .flat_map(|v| v.iter()) + .flat_map(|v| v.iter()) + .map(|v| Intern::from(v.entity_id.as_ref())) + .collect::>>(); + + let speaker_id = entities + .iter() + .filter(|v| { + // TODO: support multiple media players in one room + v.as_ref() != "media_player.lg_webos_smart_tv" + }) + .find(|v| v.starts_with("media_player.")) + .copied(); + + let area = Intern::::from(room.area_id.as_ref()).as_ref(); + let room = Room { + name: Intern::from(room.name.as_ref()), + entities, + speaker_id, + }; + + (area, room) +} + +#[derive(Debug, Clone)] #[allow(clippy::large_enum_variant)] pub enum MediaPlayer { Speaker(MediaPlayerSpeaker), Tv(MediaPlayerTv), } +impl MediaPlayer { + fn new(attr: &StateMediaPlayerAttributes, base: &Url) -> Self { + if attr.volume_level.is_some() { + MediaPlayer::Speaker(MediaPlayerSpeaker { + volume: attr.volume_level.unwrap(), + muted: attr.is_volume_muted.unwrap(), + source: Box::from(attr.source.as_deref().unwrap_or("")), + media_duration: attr.media_duration.map(Duration::from_secs), + media_position: attr.media_position.map(Duration::from_secs), + media_title: attr.media_title.as_deref().map(Box::from), + media_artist: attr.media_artist.as_deref().map(Box::from), + media_album_name: attr.media_album_name.as_deref().map(Box::from), + shuffle: attr.shuffle.unwrap_or(false), + repeat: Box::from(attr.repeat.as_deref().unwrap_or("")), + entity_picture: attr + .entity_picture + .as_deref() + .map(|path| base.join(path).unwrap()), + }) + } else { + MediaPlayer::Tv(MediaPlayerTv {}) + } + } +} + #[derive(Debug, Clone)] pub struct MediaPlayerSpeaker { pub volume: f32, @@ -165,7 +235,7 @@ pub struct MediaPlayerSpeaker { pub entity_picture: Option, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct MediaPlayerTv {} #[derive(Debug, Clone)] @@ -176,18 +246,22 @@ pub struct Room { } impl Room { - pub fn speaker<'a>(&self, oracle: &'a Oracle) -> Option<&'a MediaPlayerSpeaker> { - match self - .speaker_id - .and_then(|v| oracle.media_players.get(v.as_ref()))? - { + pub fn speaker(&self, oracle: &Oracle) -> Option { + match self.speaker_id.and_then(|v| { + oracle + .media_players + .lock() + .unwrap() + .get(v.as_ref()) + .cloned() + })? { MediaPlayer::Speaker(v) => Some(v), MediaPlayer::Tv(_) => None, } } } -#[derive(Debug)] +#[derive(Debug, Copy, Clone)] pub struct Weather { pub temperature: i16, pub high: i16, @@ -197,20 +271,11 @@ pub struct Weather { impl Weather { #[allow(clippy::cast_possible_truncation)] - fn parse_from_states(states: &StatesList) -> Self { - let (state, weather) = states - .0 - .iter() - .find_map(|v| match &v.attributes { - StateAttributes::Weather(attr) => Some((&v.state, attr)), - _ => None, - }) - .unwrap(); - + fn parse_from_state_and_attributes(state: &str, attributes: &StateWeatherAttributes) -> Self { let condition = WeatherCondition::from_str(state).unwrap_or_default(); let (high, low) = - weather + attributes .forecast .iter() .fold((i16::MIN, i16::MAX), |(high, low), curr| { @@ -220,10 +285,23 @@ impl Weather { }); Self { - temperature: weather.temperature.round() as i16, + temperature: attributes.temperature.round() as i16, condition, high, low, } } + + fn parse_from_states(states: &StatesList) -> Self { + let (state, attrs) = states + .0 + .iter() + .find_map(|v| match &v.attributes { + StateAttributes::Weather(attr) => Some((&v.state, attr)), + _ => None, + }) + .unwrap(); + + Self::parse_from_state_and_attributes(state.as_ref(), attrs) + } } diff --git a/shalom/src/pages/omni.rs b/shalom/src/pages/omni.rs index 846a210..ecd0641 100644 --- a/shalom/src/pages/omni.rs +++ b/shalom/src/pages/omni.rs @@ -1,23 +1,33 @@ -use std::sync::Arc; +use std::{any::TypeId, sync::Arc}; use iced::{ advanced::graphics::core::Element, font::{Stretch, Weight}, + futures::StreamExt, + subscription, widget::{column, scrollable, text, Column, Row}, - Font, Renderer, + Font, Renderer, Subscription, }; use itertools::Itertools; -use crate::{oracle::Oracle, theme::Image, widgets::image_card}; +use crate::{ + oracle::{Oracle, Weather}, + theme::Image, + widgets::image_card, +}; #[derive(Debug)] pub struct Omni { oracle: Arc, + weather: Weather, } impl Omni { pub fn new(oracle: Arc) -> Self { - Self { oracle } + Self { + weather: oracle.current_weather(), + oracle, + } } } @@ -30,6 +40,10 @@ impl Omni { pub fn update(&mut self, event: Message) -> Option { match event { Message::OpenRoom(room) => Some(Event::OpenRoom(room)), + Message::UpdateWeather => { + self.weather = self.oracle.current_weather(); + None + } } } @@ -58,7 +72,7 @@ impl Omni { scrollable( column![ greeting, - crate::widgets::cards::weather::WeatherCard::new(self.oracle.clone()), + crate::widgets::cards::weather::WeatherCard::new(self.weather), rooms, ] .spacing(20) @@ -66,6 +80,17 @@ impl Omni { ) .into() } + + pub fn subscription(&self) -> Subscription { + pub struct WeatherSubscription; + + subscription::run_with_id( + TypeId::of::(), + self.oracle + .subscribe_weather() + .map(|()| Message::UpdateWeather), + ) + } } fn determine_image(name: &str) -> Image { @@ -89,4 +114,5 @@ pub enum Event { #[derive(Clone, Debug)] pub enum Message { OpenRoom(&'static str), + UpdateWeather, } diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs index 0969069..79995c1 100644 --- a/shalom/src/pages/room.rs +++ b/shalom/src/pages/room.rs @@ -1,9 +1,14 @@ +use std::sync::Arc; + use iced::{ advanced::graphics::core::Element, font::{Stretch, Weight}, + futures::StreamExt, + subscription, widget::{container, image::Handle, row, text, Column}, Font, Renderer, Subscription, }; +use internment::Intern; use url::Url; use crate::{ @@ -15,17 +20,19 @@ use crate::{ #[derive(Debug)] pub struct Room { + oracle: Arc, room: crate::oracle::Room, speaker: Option, now_playing_image: Option, } impl Room { - pub fn new(id: &'static str, oracle: &Oracle) -> Self { + pub fn new(id: &'static str, oracle: Arc) -> Self { let room = oracle.room(id).clone(); - let speaker = room.speaker(oracle).cloned(); + let speaker = room.speaker(&oracle); Self { + oracle, room, speaker, now_playing_image: None, @@ -62,6 +69,25 @@ impl Room { None } + Message::UpdateSpeaker => { + let new = self.room.speaker(&self.oracle); + + if self + .speaker + .as_ref() + .and_then(|v| v.entity_picture.as_ref()) + != new + .as_ref() + .as_ref() + .and_then(|v| v.entity_picture.as_ref()) + { + self.now_playing_image = None; + } + + self.speaker = new; + + None + } } } @@ -97,7 +123,7 @@ impl Room { } pub fn subscription(&self) -> Subscription { - if let (Some(uri), None) = ( + let image_subscription = if let (Some(uri), None) = ( self.speaker .as_ref() .and_then(|v| v.entity_picture.as_ref()), @@ -106,7 +132,21 @@ impl Room { download_image(uri.clone(), uri.clone(), Message::NowPlayingImageLoaded) } else { Subscription::none() - } + }; + + let speaker_subscription = + if let Some(speaker_id) = self.room.speaker_id.map(Intern::as_ref) { + subscription::run_with_id( + speaker_id, + self.oracle + .subscribe_id(speaker_id) + .map(|()| Message::UpdateSpeaker), + ) + } else { + Subscription::none() + }; + + Subscription::batch([image_subscription, speaker_subscription]) } } @@ -120,4 +160,5 @@ pub enum Message { LightToggle(&'static str), OpenLightOptions(&'static str), UpdateLightAmount(&'static str, u8), + UpdateSpeaker, } diff --git a/shalom/src/widgets/cards/weather.rs b/shalom/src/widgets/cards/weather.rs index 43443d4..8700f9b 100644 --- a/shalom/src/widgets/cards/weather.rs +++ b/shalom/src/widgets/cards/weather.rs @@ -1,5 +1,3 @@ -use std::sync::Arc; - use iced::{ advanced::{ layout::{Limits, Node}, @@ -17,30 +15,32 @@ use iced::{ Size, Theme, }; -use crate::oracle::Oracle; +use crate::oracle::Weather; #[allow(clippy::module_name_repetitions)] pub struct WeatherCard { pub on_click: Option, - pub oracle: Arc, + pub current_weather: Weather, + pub day_time: bool, } impl WeatherCard { - pub fn new(oracle: Arc) -> Self { + pub fn new(current_weather: Weather) -> Self { Self { + current_weather, on_click: None, - oracle, + day_time: true, } } fn build_temperature(&self) -> String { - format!("{}°", self.oracle.weather.temperature) + format!("{}°", self.current_weather.temperature) } fn build_conditions(&self) -> String { format!( "{}\nH:{}° L:{}°", - self.oracle.weather.condition, self.oracle.weather.high, self.oracle.weather.low, + self.current_weather.condition, self.current_weather.high, self.current_weather.low, ) } } @@ -111,6 +111,16 @@ impl Widget for WeatherCard { _cursor: Cursor, _viewport: &Rectangle, ) { + let gradient = if self.day_time { + Linear::new(Degrees(90.)) + .add_stop(0.0, Color::from_rgba8(104, 146, 190, 1.0)) + .add_stop(1.0, Color::from_rgba8(10, 54, 120, 1.0)) + } else { + Linear::new(Degrees(90.)) + .add_stop(0.0, Color::from_rgba8(43, 44, 66, 1.0)) + .add_stop(1.0, Color::from_rgba8(15, 18, 27, 1.0)) + }; + renderer.fill_quad( Quad { bounds: layout.bounds(), @@ -118,11 +128,7 @@ impl Widget for WeatherCard { border_width: 0., border_color: Color::WHITE, }, - Background::Gradient(Gradient::Linear( - Linear::new(Degrees(90.)) - .add_stop(0.0, Color::from_rgba8(43, 44, 66, 1.0)) - .add_stop(1.0, Color::from_rgba8(15, 18, 27, 1.0)), - )), + Background::Gradient(Gradient::Linear(gradient)), ); let mut children = layout.children(); @@ -143,7 +149,7 @@ impl Widget for WeatherCard { }); let icon_bounds = children.next().unwrap().bounds(); - if let Some(icon) = self.oracle.weather.condition.icon(false) { + if let Some(icon) = self.current_weather.condition.icon(self.day_time) { renderer.draw(icon.handle(), None, icon_bounds); } -- libgit2 1.7.2