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(-)
@@ -603,16 +603,6 @@
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"
@@ -671,12 +661,6 @@
dependencies = [
"instant",
]
[[package]]
name = "fastrand"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
[[package]]
name = "fdeflate"
@@ -1140,16 +1124,17 @@
]
[[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]]
@@ -1547,12 +1532,6 @@
version = "0.2.8"
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"
@@ -1735,24 +1714,6 @@
checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3"
dependencies = [
"getrandom",
]
[[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]]
@@ -2007,50 +1968,12 @@
version = "1.18.0"
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"
@@ -2480,26 +2403,29 @@
"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",
]
@@ -2527,6 +2453,20 @@
checksum = "20ec2d3e3fc7a92ced357df9cebd5a10b6fb2aa1ee797bf7e9ce2f17dffc8f59"
dependencies = [
"bytemuck",
]
[[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]]
@@ -2551,16 +2491,46 @@
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]]
@@ -2628,6 +2598,16 @@
version = "1.2.0"
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"
@@ -2745,6 +2725,7 @@
"strum",
"time",
"tokio",
"tokio-stream",
"tokio-tungstenite",
"toml",
"url",
@@ -2855,7 +2836,7 @@
"cfg_aliases",
"cocoa",
"core-graphics",
"fastrand 1.9.0",
"fastrand",
"foreign-types",
"log",
"nix 0.26.4",
@@ -3029,19 +3010,6 @@
dependencies = [
"core-foundation-sys",
"libc",
]
[[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]]
@@ -3197,13 +3165,25 @@
]
[[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 @@
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-native-certs",
"tokio",
"tokio-rustls",
"tungstenite",
]
@@ -3333,6 +3316,7 @@
"httparse",
"log",
"rand",
"rustls",
"sha1",
"thiserror",
"url",
@@ -3430,6 +3414,12 @@
version = "0.2.4"
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"
@@ -3508,12 +3498,6 @@
version = "0.7.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"
@@ -3755,6 +3739,12 @@
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc"
[[package]]
name = "weezl"
@@ -13,12 +13,13 @@
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"
@@ -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 @@
HassRequestKind,
oneshot::Sender<Yoke<&'static RawValue, String>>,
)>,
broadcast_channel: broadcast::Sender<Arc<Yoke<Event<'static>, String>>>,
}
impl Client {
@@ -36,17 +37,24 @@
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 @@
}
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 @@
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,11 +209,30 @@
EntityRegistry,
#[serde(rename = "config/device_registry/list")]
DeviceRegistry,
SubscribeEvents {
event_type: Option<String>,
},
}
impl HassRequest {
pub fn to_request(&self) -> Message {
Message::text(serde_json::to_string(&self).unwrap())
}
}
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>,
}
}
@@ -287,7 +344,7 @@
#[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 @@
}
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum StateAttributes<'a> {
Sun(StateSunAttributes),
@@ -383,7 +440,7 @@
Unknown,
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone, Copy)]
pub struct StateSunAttributes {
@@ -396,7 +453,7 @@
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 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 @@
}
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
pub struct StateWeatherAttributes<'a> {
pub temperature: f32,
pub dew_point: f32,
@@ -546,7 +603,7 @@
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 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 @@
xy_color: Option<(f32, f32)>,
}
#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum ColorMode {
ColorTemp,
@@ -52,9 +52,7 @@
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 @@
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 @@
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 @@
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(),
}
}
}
@@ -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 @@
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 @@
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);
}
.filter_map(|v| v.device_id.as_deref().zip(Some(v)))
.into_group_map();
acc
});
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);
}
.filter_map(|v| v.area_id.as_deref().zip(all_entities.get(v.id.as_ref())))
.into_group_map();
acc
});
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| {
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 @@
.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 @@
})
.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)> + '_ {
@@ -140,16 +95,131 @@
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,
);
}
_ => {
}
}
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| {
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 entity_picture: Option<Url>,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct MediaPlayerTv {}
#[derive(Debug, Clone)]
@@ -176,18 +246,22 @@
}
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 @@
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 @@
});
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)
}
}
@@ -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 @@
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,13 +72,24 @@
scrollable(
column![
greeting,
crate::widgets::cards::weather::WeatherCard::new(self.oracle.clone()),
crate::widgets::cards::weather::WeatherCard::new(self.weather),
rooms,
]
.spacing(20)
.padding(40),
)
.into()
}
pub fn subscription(&self) -> Subscription<Message> {
pub struct WeatherSubscription;
subscription::run_with_id(
TypeId::of::<WeatherSubscription>(),
self.oracle
.subscribe_weather()
.map(|()| Message::UpdateWeather),
)
}
}
@@ -89,4 +114,5 @@
#[derive(Clone, Debug)]
pub enum Message {
OpenRoom(&'static str),
UpdateWeather,
}
@@ -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 @@
#[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,
@@ -58,7 +65,26 @@
== Some(&url)
{
self.now_playing_image = Some(handle);
}
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 @@
}
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 @@
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 @@
LightToggle(&'static str),
OpenLightOptions(&'static str),
UpdateLightAmount(&'static str, u8),
UpdateSpeaker,
}
@@ -1,5 +1,3 @@
use std::sync::Arc;
use iced::{
advanced::{
layout::{Limits, Node},
@@ -17,30 +15,32 @@
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 @@
_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 @@
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 @@
});
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);
}