From c81817da5f97360d0a077d19d061e86db7edbb7d Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Fri, 3 Nov 2023 18:58:16 +0000 Subject: [PATCH] Hook HSV selector on light context menu up to HASS This is the first time we're writing to home assistant from the application! --- Cargo.lock | 1 + shalom/Cargo.toml | 1 + shalom/src/context_menus/light_control.rs | 35 ++++++++++++++++++++++++++++------- shalom/src/hass_client.rs | 36 +++++++++++++++++++++++++++++++++++- shalom/src/main.rs | 40 ++++++++++++++++++++++++++++++---------- shalom/src/oracle.rs | 51 +++++++++++++++++++++++++++++++++++++++++++-------- shalom/src/widgets/cards/weather.rs | 5 +---- shalom/src/widgets/colour_picker.rs | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------- 8 files changed, 205 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb53a26..da419e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2782,6 +2782,7 @@ dependencies = [ "keyframe", "lru 0.12.0", "once_cell", + "palette", "reqwest", "serde", "serde_json", diff --git a/shalom/Cargo.toml b/shalom/Cargo.toml index 51d1762..61ee44a 100644 --- a/shalom/Cargo.toml +++ b/shalom/Cargo.toml @@ -13,6 +13,7 @@ internment = "0.7.4" itertools = "0.11" keyframe = "1.1" lru = "0.12" +palette = "0.7" 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"] } diff --git a/shalom/src/context_menus/light_control.rs b/shalom/src/context_menus/light_control.rs index 61f4689..e623e3d 100644 --- a/shalom/src/context_menus/light_control.rs +++ b/shalom/src/context_menus/light_control.rs @@ -4,23 +4,28 @@ use iced::{ Alignment, Element, Font, Length, Renderer, }; -use crate::widgets::colour_picker::ColourPicker; +use crate::{oracle::Light, widgets::colour_picker::ColourPicker}; #[derive(Debug, Clone)] pub struct LightControl { id: &'static str, + name: Box, hue: f32, saturation: f32, brightness: f32, } impl LightControl { - pub fn new(id: &'static str) -> Self { + pub fn new(id: &'static str, light: Light) -> Self { + let (hue, saturation) = light.hs_color.unwrap_or_default(); + let brightness = light.brightness.unwrap_or_default(); + Self { id, - hue: 0.0, - saturation: 0.0, - brightness: 0.0, + name: light.friendly_name, + hue, + saturation: saturation / 100., + brightness: brightness / 255., } } @@ -31,8 +36,15 @@ impl LightControl { self.hue = hue; self.saturation = saturation; self.brightness = brightness; + None } + Message::OnMouseUp => Some(Event::UpdateLightColour { + id: self.id, + hue: self.hue, + saturation: self.saturation, + brightness: self.brightness, + }), } } @@ -42,10 +54,11 @@ impl LightControl { self.saturation, self.brightness, Message::OnColourChange, + Message::OnMouseUp, ); container(column![ - text(self.id).size(40).font(Font { + text(&self.name).size(40).font(Font { weight: Weight::Bold, stretch: Stretch::Condensed, ..Font::with_name("Helvetica Neue") @@ -60,9 +73,17 @@ impl LightControl { } } -pub enum Event {} +pub enum Event { + UpdateLightColour { + id: &'static str, + hue: f32, + saturation: f32, + brightness: f32, + }, +} #[derive(Clone, Debug)] pub enum Message { OnColourChange(f32, f32, f32), + OnMouseUp, } diff --git a/shalom/src/hass_client.rs b/shalom/src/hass_client.rs index 08ecfb2..272014b 100644 --- a/shalom/src/hass_client.rs +++ b/shalom/src/hass_client.rs @@ -212,6 +212,7 @@ pub enum HassRequestKind { SubscribeEvents { event_type: Option, }, + CallService(CallServiceRequest), } impl HassRequest { @@ -220,6 +221,36 @@ impl HassRequest { } } +#[derive(Serialize)] +pub struct CallServiceRequest { + pub target: Option, + #[serde(flatten)] + pub data: CallServiceRequestData, +} + +#[derive(Serialize)] +pub struct CallServiceRequestTarget { + pub entity_id: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case", tag = "domain")] +pub enum CallServiceRequestData { + Light(CallServiceRequestLight), +} + +#[derive(Serialize)] +#[serde(rename_all = "snake_case", tag = "service", content = "service_data")] +pub enum CallServiceRequestLight { + TurnOn(CallServiceRequestLightTurnOn), +} + +#[derive(Serialize)] +pub struct CallServiceRequestLightTurnOn { + pub brightness: u8, + pub hs_color: (f32, f32), +} + pub mod events { use std::borrow::Cow; @@ -254,6 +285,9 @@ pub mod responses { use crate::theme::Icon; #[derive(Deserialize, Yokeable, Debug)] + pub struct CallServiceResponse {} + + #[derive(Deserialize, Yokeable, Debug)] pub struct AreaRegistryList<'a>(#[serde(borrow)] pub Vec>); #[derive(Deserialize, Debug)] @@ -635,7 +669,7 @@ pub mod responses { pub brightness: Option, pub color_temp_kelvin: Option, pub color_temp: Option, - pub xy_color: Option<(f32, f32)>, + pub hs_color: Option<(f32, f32)>, } #[derive(Deserialize, Debug, Clone, Copy)] diff --git a/shalom/src/main.rs b/shalom/src/main.rs index 6bf0aee..4b45497 100644 --- a/shalom/src/main.rs +++ b/shalom/src/main.rs @@ -73,12 +73,15 @@ impl Application for Shalom { "living_room", self.oracle.clone().unwrap(), )); + Command::none() } (Message::CloseContextMenu, _, _) => { self.context_menu = None; + Command::none() } (Message::OpenOmniPage, _, _) => { self.page = ActivePage::Omni(pages::omni::Omni::new(self.oracle.clone().unwrap())); + Command::none() } (Message::OmniEvent(e), ActivePage::Omni(r), _) => match r.update(e) { Some(pages::omni::Event::OpenRoom(room)) => { @@ -86,26 +89,42 @@ impl Application for Shalom { room, self.oracle.clone().unwrap(), )); + Command::none() } - None => {} + None => Command::none(), }, (Message::RoomEvent(e), ActivePage::Room(r), _) => match r.update(e) { - Some(pages::room::Event::OpenLightContextMenu(light)) => { - self.context_menu = Some(ActiveContextMenu::LightControl( - context_menus::light_control::LightControl::new(light), - )); + Some(pages::room::Event::OpenLightContextMenu(id)) => { + if let Some(light) = self.oracle.as_ref().and_then(|o| o.fetch_light(id)) { + self.context_menu = Some(ActiveContextMenu::LightControl( + context_menus::light_control::LightControl::new(id, light), + )); + } + + Command::none() } - None => {} + None => Command::none(), }, (Message::LightControlMenu(e), _, Some(ActiveContextMenu::LightControl(menu))) => { match menu.update(e) { - Some(_) | None => {} + Some(context_menus::light_control::Event::UpdateLightColour { + id, + hue, + saturation, + brightness, + }) => { + let oracle = self.oracle.as_ref().unwrap().clone(); + + Command::perform( + async move { oracle.update_light(id, hue, saturation, brightness).await }, + Message::UpdateLightResult, + ) + } + None => Command::none(), } } - _ => {} + _ => Command::none(), } - - Command::none() } fn view(&self) -> Element<'_, Self::Message, Renderer> { @@ -204,6 +223,7 @@ pub enum Message { OmniEvent(pages::omni::Message), RoomEvent(pages::room::Message), LightControlMenu(context_menus::light_control::Message), + UpdateLightResult(()), } #[derive(Debug)] diff --git a/shalom/src/oracle.rs b/shalom/src/oracle.rs index bb16b0c..aab6c8c 100644 --- a/shalom/src/oracle.rs +++ b/shalom/src/oracle.rs @@ -13,13 +13,17 @@ use tokio::sync::{broadcast, broadcast::error::RecvError}; use tokio_stream::wrappers::BroadcastStream; use url::Url; -use crate::hass_client::{ - responses::{ - Area, AreaRegistryList, ColorMode, DeviceRegistryList, Entity, EntityRegistryList, - StateAttributes, StateLightAttributes, StateMediaPlayerAttributes, StateWeatherAttributes, - StatesList, WeatherCondition, +use crate::{ + hass_client::{ + responses::{ + Area, AreaRegistryList, CallServiceResponse, ColorMode, DeviceRegistryList, Entity, + EntityRegistryList, StateAttributes, StateLightAttributes, StateMediaPlayerAttributes, + StateWeatherAttributes, StatesList, WeatherCondition, + }, + CallServiceRequest, CallServiceRequestData, CallServiceRequestLight, + CallServiceRequestLightTurnOn, CallServiceRequestTarget, Event, HassRequestKind, }, - Event, HassRequestKind, + widgets::colour_picker::clamp_to_u8, }; #[allow(dead_code)] @@ -127,6 +131,31 @@ impl Oracle { .map(|_| ()) } + pub fn fetch_light(&self, entity_id: &'static str) -> Option { + self.lights.lock().unwrap().get(entity_id).cloned() + } + + pub async fn update_light( + &self, + entity_id: &'static str, + hue: f32, + saturation: f32, + brightness: f32, + ) { + let _res = self + .client + .request::(HassRequestKind::CallService(CallServiceRequest { + target: Some(CallServiceRequestTarget { entity_id }), + data: CallServiceRequestData::Light(CallServiceRequestLight::TurnOn( + CallServiceRequestLightTurnOn { + hs_color: (hue, saturation * 100.), + brightness: clamp_to_u8(brightness), + }, + )), + })) + .await; + } + pub fn spawn_worker(self: Arc) { tokio::spawn(async move { let mut recv = self.client.subscribe(); @@ -154,6 +183,12 @@ impl Oracle { attrs, ); } + StateAttributes::Light(attrs) => { + self.lights.lock().unwrap().insert( + Intern::::from(state_changed.entity_id.as_ref()).as_ref(), + Light::from(attrs.clone()), + ); + } _ => { // TODO } @@ -253,7 +288,7 @@ pub struct Light { pub brightness: Option, pub color_temp_kelvin: Option, pub color_temp: Option, - pub xy_color: Option<(f32, f32)>, + pub hs_color: Option<(f32, f32)>, } impl From> for Light { @@ -271,7 +306,7 @@ impl From> for Light { brightness: value.brightness, color_temp_kelvin: value.color_temp_kelvin, color_temp: value.color_temp, - xy_color: value.xy_color, + hs_color: value.hs_color, } } } diff --git a/shalom/src/widgets/cards/weather.rs b/shalom/src/widgets/cards/weather.rs index 6788754..5d8302b 100644 --- a/shalom/src/widgets/cards/weather.rs +++ b/shalom/src/widgets/cards/weather.rs @@ -111,10 +111,7 @@ impl Widget for WeatherCard { _viewport: &Rectangle, ) { // TODO: get sunrise/sunset from somewhere reasonable - let day_time = match OffsetDateTime::now_utc().hour() { - 5..=19 => true, - _ => false, - }; + let day_time = matches!(OffsetDateTime::now_utc().hour(), 5..=19); let gradient = if day_time { Linear::new(Degrees(90.)) diff --git a/shalom/src/widgets/colour_picker.rs b/shalom/src/widgets/colour_picker.rs index 863e868..a9de4b9 100644 --- a/shalom/src/widgets/colour_picker.rs +++ b/shalom/src/widgets/colour_picker.rs @@ -11,6 +11,7 @@ use iced::{ }, Color, Point, Rectangle, Renderer, Size, Theme, }; +use palette::IntoColor; use crate::widgets::forced_rounded::forced_rounded; @@ -19,6 +20,7 @@ pub struct ColourPicker { saturation: f32, brightness: f32, on_change: fn(f32, f32, f32) -> Event, + on_mouse_up: Event, } impl ColourPicker { @@ -27,28 +29,31 @@ impl ColourPicker { saturation: f32, brightness: f32, on_change: fn(f32, f32, f32) -> Event, + on_mouse_up: Event, ) -> Self { Self { hue, saturation, brightness, on_change, + on_mouse_up, } } } -impl Component for ColourPicker { +impl Component for ColourPicker { type State = (); type Event = Message; fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option { match event { - Message::OnSaturationBrightnessChange(saturation, brightness) => { + Message::SaturationBrightnessChange(saturation, brightness) => { Some((self.on_change)(self.hue, saturation, brightness)) } - Message::OnHueChanged(hue) => { + Message::HueChanged(hue) => { Some((self.on_change)(hue, self.saturation, self.brightness)) } + Message::MouseUp => Some(self.on_mouse_up.clone()), } } @@ -58,7 +63,8 @@ impl Component for ColourPicker { self.hue, self.saturation, self.brightness, - Message::OnSaturationBrightnessChange, + Message::SaturationBrightnessChange, + Message::MouseUp, )) .height(192) .width(192) @@ -66,10 +72,14 @@ impl Component for ColourPicker { ); let hue_slider = forced_rounded( - canvas(HueSlider::new(self.hue, Message::OnHueChanged)) - .height(192) - .width(32) - .into(), + canvas(HueSlider::new( + self.hue, + Message::HueChanged, + Message::MouseUp, + )) + .height(192) + .width(32) + .into(), ); Row::new() @@ -91,22 +101,28 @@ where #[derive(Clone)] pub enum Message { - OnSaturationBrightnessChange(f32, f32), - OnHueChanged(f32), + SaturationBrightnessChange(f32, f32), + HueChanged(f32), + MouseUp, } pub struct HueSlider { hue: f32, on_hue_change: fn(f32) -> Message, + on_mouse_up: Message, } impl HueSlider { - fn new(hue: f32, on_hue_change: fn(f32) -> Message) -> Self { - Self { hue, on_hue_change } + fn new(hue: f32, on_hue_change: fn(f32) -> Message, on_mouse_up: Message) -> Self { + Self { + hue, + on_hue_change, + on_mouse_up, + } } } -impl canvas::Program for HueSlider { +impl canvas::Program for HueSlider { type State = HueSliderState; fn update( @@ -116,26 +132,28 @@ impl canvas::Program for HueSlider { bounds: Rectangle, cursor: Cursor, ) -> (Status, Option) { - let update = match event { + let (update, mouse_up) = match event { Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) if cursor.is_over(bounds) => { state.is_dragging = true; - true + (true, false) } Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { + | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) + if state.is_dragging => + { state.is_dragging = false; - false + (false, true) } Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) if state.is_dragging => { - true + (true, false) } - _ => false, + _ => (false, false), }; if update { @@ -147,6 +165,8 @@ impl canvas::Program for HueSlider { } else { (Status::Captured, None) } + } else if mouse_up { + (Status::Captured, Some(self.on_mouse_up.clone())) } else { (Status::Ignored, None) } @@ -223,6 +243,7 @@ pub struct SaturationBrightnessPicker { saturation: f32, brightness: f32, on_change: fn(f32, f32) -> Message, + on_mouse_up: Message, } impl SaturationBrightnessPicker { @@ -231,17 +252,19 @@ impl SaturationBrightnessPicker { saturation: f32, brightness: f32, on_change: fn(f32, f32) -> Message, + on_mouse_up: Message, ) -> Self { Self { hue, saturation, brightness, on_change, + on_mouse_up, } } } -impl canvas::Program for SaturationBrightnessPicker { +impl canvas::Program for SaturationBrightnessPicker { type State = SaturationBrightnessPickerState; fn update( @@ -259,26 +282,28 @@ impl canvas::Program for SaturationBrightnessPicker { state.content_cache.clear(); } - let update = match event { + let (update, mouse_up) = match event { Event::Mouse(mouse::Event::ButtonPressed(Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) if cursor.is_over(bounds) => { state.is_dragging = true; - true + (true, false) } Event::Mouse(mouse::Event::ButtonReleased(Button::Left)) - | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) => { + | Event::Touch(touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }) + if state.is_dragging => + { state.is_dragging = false; - false + (false, true) } Event::Mouse(mouse::Event::CursorMoved { .. }) | Event::Touch(touch::Event::FingerMoved { .. }) if state.is_dragging => { - true + (true, false) } - _ => false, + _ => (false, false), }; if update { @@ -295,6 +320,8 @@ impl canvas::Program for SaturationBrightnessPicker { } else { (Status::Ignored, None) } + } else if mouse_up { + (Status::Captured, Some(self.on_mouse_up.clone())) } else { (Status::Ignored, None) } @@ -368,25 +395,17 @@ pub struct SaturationBrightnessPickerState { hue: f32, } -fn colour_from_hsb(hue: f32, saturation: f32, brightness: f32) -> Color { - let chroma = brightness * saturation; - let hue_prime = hue / 60.0; - let second_largest_component = chroma * (1.0 - (hue_prime % 2.0 - 1.0).abs()); - let match_value = brightness - chroma; - - let (red, green, blue) = if hue < 60.0 { - (chroma, second_largest_component, 0.0) - } else if hue < 120.0 { - (second_largest_component, chroma, 0.0) - } else if hue < 180.0 { - (0.0, chroma, second_largest_component) - } else if hue < 240.0 { - (0.0, second_largest_component, chroma) - } else if hue < 300.0 { - (second_largest_component, 0.0, chroma) - } else { - (chroma, 0.0, second_largest_component) - }; - - Color::from_rgb(red + match_value, green + match_value, blue + match_value) +pub fn colour_from_hsb(hue: f32, saturation: f32, brightness: f32) -> Color { + let rgb: palette::Srgb = palette::Hsv::new(hue, saturation, brightness).into_color(); + Color::from_rgb(rgb.red, rgb.green, rgb.blue) +} + +pub fn clamp_to_u8(v: f32) -> u8 { + let clamped = v.clamp(0., 1.); + let scaled = (clamped * 255.).round(); + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + { + scaled as u8 + } } -- libgit2 1.7.2