From 2f16b810cb886af026d879381a144e6bd29d2ebf Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Fri, 3 Nov 2023 20:01:50 +0000 Subject: [PATCH] Support turning lights on/off --- shalom/src/hass_client.rs | 38 +++++++++++++++++++++++++++++++++++--- shalom/src/main.rs | 8 ++++++++ shalom/src/oracle.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++-------------------- shalom/src/pages/room.rs | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- shalom/src/widgets/toggle_card.rs | 28 ++++++++++++++++++++-------- 5 files changed, 158 insertions(+), 58 deletions(-) diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs index 272014b..9881634 100644 --- a/shalom/src/hass_client.rs +++ b/shalom/src/hass_client.rs @@ -1,6 +1,6 @@ #![allow(clippy::forget_non_drop, dead_code)] -use std::{collections::HashMap, sync::Arc, time::Duration}; +use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration}; use iced::futures::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; @@ -38,11 +38,26 @@ impl Client { resp.map_project(move |value, _| serde_json::from_str(value.get()).unwrap()) } + pub async fn call_service( + &self, + entity_id: &'static str, + payload: CallServiceRequestData, + ) -> Yoke { + self.request::(HassRequestKind::CallService( + CallServiceRequest { + target: Some(CallServiceRequestTarget { entity_id }), + data: payload, + }, + )) + .await + } + pub fn subscribe(&self) -> broadcast::Receiver, String>>> { self.broadcast_channel.subscribe() } } +#[allow(clippy::too_many_lines)] pub async fn create(config: HomeAssistantConfig) -> Client { let (sender, mut recv) = mpsc::channel(10); @@ -79,6 +94,10 @@ pub async fn create(config: HomeAssistantConfig) -> Client { let payload: &HassResponse = yoked_payload.get(); + if let Some(error) = &payload.error { + eprintln!("error: {error:?}"); + } + match payload.type_ { HassResponseType::AuthRequired => { let payload = HassRequest { @@ -168,10 +187,20 @@ struct HassResponse<'a> { type_: HassResponseType, #[serde(borrow)] result: Option<&'a RawValue>, + #[serde(borrow)] + error: Option>, #[serde(borrow, bound(deserialize = "'a: 'de"))] event: Option>, } +#[derive(Deserialize, Debug)] +pub struct Error<'a> { + #[serde(borrow)] + pub code: Cow<'a, str>, + #[serde(borrow)] + pub message: Cow<'a, str>, +} + #[derive(Deserialize, Clone, Debug, Yokeable)] #[serde(rename_all = "snake_case", tag = "event_type", content = "data")] pub enum Event<'a> { @@ -243,12 +272,15 @@ pub enum CallServiceRequestData { #[serde(rename_all = "snake_case", tag = "service", content = "service_data")] pub enum CallServiceRequestLight { TurnOn(CallServiceRequestLightTurnOn), + TurnOff, } #[derive(Serialize)] pub struct CallServiceRequestLightTurnOn { - pub brightness: u8, - pub hs_color: (f32, f32), + #[serde(skip_serializing_if = "Option::is_none")] + pub brightness: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hs_color: Option<(f32, f32)>, } pub mod events { diff --git a/shalom/src/main.rs b/shalom/src/main.rs index 4b45497..1677f96 100644 --- a/shalom/src/main.rs +++ b/shalom/src/main.rs @@ -103,6 +103,14 @@ impl Application for Shalom { Command::none() } + Some(pages::room::Event::SetLightState(id, state)) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.set_light_state(id, state).await }, + Message::UpdateLightResult, + ) + } None => Command::none(), }, (Message::LightControlMenu(e), _, Some(ActiveContextMenu::LightControl(menu))) => { diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs index aab6c8c..3e0eda7 100644 --- a/shalom/src/oracle.rs +++ b/shalom/src/oracle.rs @@ -16,12 +16,11 @@ use url::Url; use crate::{ hass_client::{ responses::{ - Area, AreaRegistryList, CallServiceResponse, ColorMode, DeviceRegistryList, Entity, + Area, AreaRegistryList, ColorMode, DeviceRegistryList, Entity, EntityRegistryList, StateAttributes, StateLightAttributes, StateMediaPlayerAttributes, StateWeatherAttributes, StatesList, WeatherCondition, - }, - CallServiceRequest, CallServiceRequestData, CallServiceRequestLight, - CallServiceRequestLightTurnOn, CallServiceRequestTarget, Event, HassRequestKind, + }, CallServiceRequestData, CallServiceRequestLight, + CallServiceRequestLightTurnOn, Event, HassRequestKind, }, widgets::colour_picker::clamp_to_u8, }; @@ -82,7 +81,7 @@ impl Oracle { StateAttributes::Light(attr) => { lights.insert( Intern::::from(state.entity_id.as_ref()).as_ref(), - Light::from(attr.clone()), + Light::from((attr.clone(), state.state.as_ref())), ); } _ => {} @@ -135,6 +134,23 @@ impl Oracle { self.lights.lock().unwrap().get(entity_id).cloned() } + pub async fn set_light_state(&self, entity_id: &'static str, on: bool) { + let _res = self + .client + .call_service( + entity_id, + CallServiceRequestData::Light(if on { + CallServiceRequestLight::TurnOn(CallServiceRequestLightTurnOn { + brightness: None, + hs_color: None, + }) + } else { + CallServiceRequestLight::TurnOff + }), + ) + .await; + } + pub async fn update_light( &self, entity_id: &'static str, @@ -144,15 +160,15 @@ impl Oracle { ) { let _res = self .client - .request::(HassRequestKind::CallService(CallServiceRequest { - target: Some(CallServiceRequestTarget { entity_id }), - data: CallServiceRequestData::Light(CallServiceRequestLight::TurnOn( + .call_service( + entity_id, + CallServiceRequestData::Light(CallServiceRequestLight::TurnOn( CallServiceRequestLightTurnOn { - hs_color: (hue, saturation * 100.), - brightness: clamp_to_u8(brightness), + hs_color: Some((hue, saturation * 100.)), + brightness: Some(clamp_to_u8(brightness)), }, )), - })) + ) .await; } @@ -186,7 +202,10 @@ impl Oracle { StateAttributes::Light(attrs) => { self.lights.lock().unwrap().insert( Intern::::from(state_changed.entity_id.as_ref()).as_ref(), - Light::from(attrs.clone()), + Light::from(( + attrs.clone(), + state_changed.new_state.state.as_ref(), + )), ); } _ => { @@ -276,6 +295,7 @@ impl MediaPlayer { #[derive(Debug, Clone)] pub struct Light { + pub on: Option, pub min_color_temp_kelvin: Option, pub max_color_temp_kelvin: Option, pub min_mireds: Option, @@ -291,9 +311,17 @@ pub struct Light { pub hs_color: Option<(f32, f32)>, } -impl From> for Light { - fn from(value: StateLightAttributes<'_>) -> Self { +impl From<(StateLightAttributes<'_>, &str)> for Light { + fn from((value, state): (StateLightAttributes<'_>, &str)) -> Self { + let on = match state { + "on" => Some(true), + "off" => Some(false), + "unavailable" => None, + v => panic!("unknown light state: {v}"), + }; + Self { + on, min_color_temp_kelvin: value.min_color_temp_kelvin, max_color_temp_kelvin: value.max_color_temp_kelvin, min_mireds: value.min_mireds, @@ -352,16 +380,12 @@ impl Room { } } - pub fn light_names(&self, oracle: &Oracle) -> BTreeMap<&'static str, Box> { + pub fn lights(&self, oracle: &Oracle) -> BTreeMap<&'static str, Light> { let lights = oracle.lights.lock().unwrap(); self.lights .iter() - .filter_map(|v| Some((*v).as_ref()).zip(lights.get(v.as_ref()))) - .map(|(id, light)| { - eprintln!("{light:?}"); - (id, light.friendly_name.clone()) - }) + .filter_map(|v| Some((*v).as_ref()).zip(lights.get(v.as_ref()).cloned())) .collect() } } diff --git a/shalom/src/pages/room.rs b/shalom/src/pages/room.rs index 9052cb4..1715e53 100644 --- a/shalom/src/pages/room.rs +++ b/shalom/src/pages/room.rs @@ -12,7 +12,7 @@ use internment::Intern; use url::Url; use crate::{ - oracle::{MediaPlayerSpeaker, Oracle}, + oracle::{Light, MediaPlayerSpeaker, Oracle}, subscriptions::download_image, theme::Icon, widgets, @@ -24,7 +24,7 @@ pub struct Room { room: crate::oracle::Room, speaker: Option, now_playing_image: Option, - lights: BTreeMap<&'static str, Box>, + lights: BTreeMap<&'static str, Light>, } impl Room { @@ -32,7 +32,7 @@ impl Room { let room = oracle.room(id).clone(); let speaker = room.speaker(&oracle); - let lights = room.light_names(&oracle); + let lights = room.lights(&oracle); Self { oracle, @@ -45,22 +45,15 @@ impl Room { pub fn update(&mut self, event: Message) -> Option { match event { - Message::LightToggle(_name) => { - // let x = state.lights.entry(name).or_default(); - // if *x == 0 { - // *x = 1; - // } else { - // *x = 0; - // } - // - None - } - Message::OpenLightOptions(name) => Some(Event::OpenLightContextMenu(name)), - Message::UpdateLightAmount(_name, _v) => { - // let x = state.lights.entry(name).or_default(); - // *x = v; - None + Message::SetLightState(id, state) => { + // give instant feedback before we get the event back from hass + if let Some(light) = self.lights.get_mut(id) { + light.on = Some(state); + } + + Some(Event::SetLightState(id, state)) } + Message::OpenLightOptions(id) => Some(Event::OpenLightContextMenu(id)), Message::NowPlayingImageLoaded(url, handle) => { if self .speaker @@ -92,6 +85,13 @@ impl Room { None } + Message::UpdateLight(entity_id) => { + if let Some(light) = self.oracle.fetch_light(entity_id) { + self.lights.insert(entity_id, light); + } + + None + } } } @@ -102,11 +102,21 @@ impl Room { ..Font::with_name("Helvetica Neue") }); - let light = |id, name| { - widgets::toggle_card::toggle_card(name, false) - .icon(Icon::Bulb) - .on_press(Message::LightToggle(id)) - .on_long_press(Message::OpenLightOptions(id)) + let light = |id, light: &Light| { + let mut toggle_card = widgets::toggle_card::toggle_card( + &light.friendly_name, + light.on.unwrap_or_default(), + light.on.is_none(), + ) + .icon(Icon::Bulb); + + if let Some(state) = light.on { + toggle_card = toggle_card + .on_press(Message::SetLightState(id, !state)) + .on_long_press(Message::OpenLightOptions(id)); + } + + toggle_card }; let mut col = Column::new().spacing(20).padding(40).push(header); @@ -124,7 +134,7 @@ impl Room { let lights = Row::with_children( self.lights .iter() - .map(|(id, name)| light(*id, name)) + .map(|(id, item)| light(*id, item)) .map(Element::from) .collect::>(), ) @@ -158,19 +168,33 @@ impl Room { Subscription::none() }; - Subscription::batch([image_subscription, speaker_subscription]) + let light_subscriptions = Subscription::batch(self.lights.keys().copied().map(|key| { + subscription::run_with_id( + key, + self.oracle + .subscribe_id(key) + .map(|()| Message::UpdateLight(key)), + ) + })); + + Subscription::batch([ + image_subscription, + speaker_subscription, + light_subscriptions, + ]) } } pub enum Event { OpenLightContextMenu(&'static str), + SetLightState(&'static str, bool), } #[derive(Clone, Debug)] pub enum Message { NowPlayingImageLoaded(Url, Handle), - LightToggle(&'static str), + SetLightState(&'static str, bool), OpenLightOptions(&'static str), - UpdateLightAmount(&'static str, u8), UpdateSpeaker, + UpdateLight(&'static str), } diff --git a/shalom/src/widgets/toggle_card.rs b/shalom/src/widgets/toggle_card.rs index 2cacfb0..60241db 100644 --- a/shalom/src/widgets/toggle_card.rs +++ b/shalom/src/widgets/toggle_card.rs @@ -12,7 +12,7 @@ use iced::{ use crate::{ theme::{ - colours::{AMBER_200, SKY_400, SKY_500, SLATE_200, SLATE_300}, + colours::{AMBER_200, SKY_400, SKY_500, SLATE_200, SLATE_300, SLATE_600}, Icon, }, widgets::mouse_area::mouse_area, @@ -20,10 +20,11 @@ use crate::{ pub const LONG_PRESS_LENGTH: Duration = Duration::from_millis(350); -pub fn toggle_card(name: &str, active: bool) -> ToggleCard { +pub fn toggle_card(name: &str, active: bool, disabled: bool) -> ToggleCard { ToggleCard { name: Box::from(name), active, + disabled, ..ToggleCard::default() } } @@ -34,6 +35,7 @@ pub struct ToggleCard { height: Length, width: Length, active: bool, + disabled: bool, on_press: Option, on_long_press: Option, } @@ -46,6 +48,7 @@ impl Default for ToggleCard { height: Length::Shrink, width: Length::Fill, active: false, + disabled: false, on_press: None, on_long_press: None, } @@ -111,11 +114,12 @@ impl iced::widget::Component for ToggleCard { } fn view(&self, state: &Self::State) -> Element<'_, Self::Event, Renderer> { - let style = match (self.active, state.mouse_down_start) { - (true, None) => Style::Active, - (true, Some(_)) => Style::ActiveHover, - (false, None) => Style::Inactive, - (false, Some(_)) => Style::InactiveHover, + let style = match (self.disabled, self.active, state.mouse_down_start) { + (true, _, _) => Style::Disabled, + (_, true, None) => Style::Active, + (_, true, Some(_)) => Style::ActiveHover, + (_, false, None) => Style::Inactive, + (_, false, Some(_)) => Style::InactiveHover, }; let icon = self.icon.map(|icon| { @@ -185,6 +189,7 @@ pub enum Style { ActiveHover, Inactive, InactiveHover, + Disabled, } impl container::StyleSheet for Style { @@ -192,6 +197,13 @@ impl container::StyleSheet for Style { fn appearance(&self, _style: &Self::Style) -> container::Appearance { match self { + Style::Disabled => container::Appearance { + text_color: Some(Color::BLACK), + background: Some(Background::Color(SLATE_600)), + border_radius: 5.0.into(), + border_width: 0.0, + border_color: Color::default(), + }, Style::Inactive => container::Appearance { text_color: Some(Color::BLACK), background: Some(Background::Color(SLATE_200)), @@ -232,7 +244,7 @@ impl svg::StyleSheet for Style { Style::Active | Style::ActiveHover => svg::Appearance { color: Some(AMBER_200), }, - Style::Inactive | Style::InactiveHover => svg::Appearance { + Style::Inactive | Style::InactiveHover | Style::Disabled => svg::Appearance { color: Some(Color::BLACK), }, } -- libgit2 1.7.2