🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2023-11-01 21:18:35.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-11-01 21:30:57.0 +00:00:00
commit
a395bfe61ae59e7003427436f852cb146c7a738f [patch]
tree
65522f86045222db826095d717a2b6d7ec6c7188
parent
b1684e538752cb1828360d4c0dec8baa1fd47415
download
a395bfe61ae59e7003427436f852cb146c7a738f.tar.gz

Subscribe to state change events from HASS



Diff

 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<Yoke<&'static RawValue, String>>,
    )>,
    broadcast_channel: broadcast::Sender<Arc<Yoke<Event<'static>, 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<Arc<Yoke<Event<'static>, 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<u64>,
    #[serde(rename = "type")]
    type_: HassResponseType,
    #[serde(borrow)]
    result: Option<&'a RawValue>,
    #[serde(borrow, bound(deserialize = "'a: 'de"))]
    event: Option<Event<'a>>,
}

#[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<String>,
    },
}

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<State<'a>>);

    #[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<Cow<'a, str>>,
@@ -428,14 +485,14 @@ pub mod responses {
        pub entity_picture: Option<Cow<'a, str>>,
    }

    #[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<StateWeatherAttributesForecast<'a>>,
    }

    #[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<u16>,
        max_color_temp_kelvin: Option<u16>,
@@ -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<Self::Message> {
        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<Weather>,
    pub media_players: Mutex<BTreeMap<&'static str, MediaPlayer>>,
    entity_updates: broadcast::Sender<Arc<str>>,
}

impl Oracle {
    pub async fn new(hass_client: crate::hass_client::Client) -> Self {
    pub async fn new(hass_client: crate::hass_client::Client) -> Arc<Self> {
        let (rooms, devices, entities, states) = tokio::join!(
            hass_client.request::<AreaRegistryList<'_>>(HassRequestKind::AreaRegistry),
            hass_client.request::<DeviceRegistryList<'_>>(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::<Vec<Intern<str>>>();

                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::<str>::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::<str>::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<Item = (&'static str, &'_ Room)> + '_ {
@@ -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<Item = ()> {
        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<Item = ()> {
        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<Self>) {
        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::<str>::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::<Vec<Intern<str>>>();

    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::<str>::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<Url>,
}

#[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<MediaPlayerSpeaker> {
        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<Oracle>,
    weather: Weather,
}

impl Omni {
    pub fn new(oracle: Arc<Oracle>) -> Self {
        Self { oracle }
        Self {
            weather: oracle.current_weather(),
            oracle,
        }
    }
}

@@ -30,6 +40,10 @@ impl Omni {
    pub fn update(&mut self, event: Message) -> Option<Event> {
        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<Message> {
        pub struct WeatherSubscription;

        subscription::run_with_id(
            TypeId::of::<WeatherSubscription>(),
            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<Oracle>,
    room: crate::oracle::Room,
    speaker: Option<MediaPlayerSpeaker>,
    now_playing_image: Option<Handle>,
}

impl Room {
    pub fn new(id: &'static str, oracle: &Oracle) -> Self {
    pub fn new(id: &'static str, oracle: Arc<Oracle>) -> 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<Message> {
        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<M> {
    pub on_click: Option<M>,
    pub oracle: Arc<Oracle>,
    pub current_weather: Weather,
    pub day_time: bool,
}

impl<M> WeatherCard<M> {
    pub fn new(oracle: Arc<Oracle>) -> 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<M: Clone> Widget<M, Renderer> for WeatherCard<M> {
        _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<M: Clone> Widget<M, Renderer> for WeatherCard<M> {
                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<M: Clone> Widget<M, Renderer> for WeatherCard<M> {
        });

        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);
        }