🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2023-11-03 18:58:16.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-11-03 18:58:57.0 +00:00:00
commit
c81817da5f97360d0a077d19d061e86db7edbb7d [patch]
tree
07fc44c0ea233de2a602f813239763503146e7f0
parent
735d2ec4b8fc2ed4013933490a14fca7f009bb96
download
c81817da5f97360d0a077d19d061e86db7edbb7d.tar.gz

Hook HSV selector on light context menu up to HASS

This is the first time we're writing to home assistant from the
application!

Diff

 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<str>,
    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<String>,
    },
    CallService(CallServiceRequest),
}

impl HassRequest {
@@ -220,6 +221,36 @@ impl HassRequest {
    }
}

#[derive(Serialize)]
pub struct CallServiceRequest {
    pub target: Option<CallServiceRequestTarget>,
    #[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<Area<'a>>);

    #[derive(Deserialize, Debug)]
@@ -635,7 +669,7 @@ pub mod responses {
        pub brightness: Option<f32>,
        pub color_temp_kelvin: Option<u16>,
        pub color_temp: Option<u16>,
        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<Self::Theme>> {
@@ -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<Light> {
        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::<CallServiceResponse>(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<Self>) {
        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::<str>::from(state_changed.entity_id.as_ref()).as_ref(),
                                    Light::from(attrs.clone()),
                                );
                            }
                            _ => {
                                // TODO
                            }
@@ -253,7 +288,7 @@ pub struct Light {
    pub brightness: Option<f32>,
    pub color_temp_kelvin: Option<u16>,
    pub color_temp: Option<u16>,
    pub xy_color: Option<(f32, f32)>,
    pub hs_color: Option<(f32, f32)>,
}

impl From<StateLightAttributes<'_>> for Light {
@@ -271,7 +306,7 @@ impl From<StateLightAttributes<'_>> 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<M: Clone> Widget<M, Renderer> for WeatherCard<M> {
        _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<Event> {
    saturation: f32,
    brightness: f32,
    on_change: fn(f32, f32, f32) -> Event,
    on_mouse_up: Event,
}

impl<Event> ColourPicker<Event> {
@@ -27,28 +29,31 @@ impl<Event> ColourPicker<Event> {
        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<Event> Component<Event, Renderer> for ColourPicker<Event> {
impl<Event: Clone> Component<Event, Renderer> for ColourPicker<Event> {
    type State = ();
    type Event = Message;

    fn update(&mut self, _state: &mut Self::State, event: Self::Event) -> Option<Event> {
        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<Event> Component<Event, Renderer> for ColourPicker<Event> {
                self.hue,
                self.saturation,
                self.brightness,
                Message::OnSaturationBrightnessChange,
                Message::SaturationBrightnessChange,
                Message::MouseUp,
            ))
            .height(192)
            .width(192)
@@ -66,10 +72,14 @@ impl<Event> Component<Event, Renderer> for ColourPicker<Event> {
        );

        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<Message> {
    hue: f32,
    on_hue_change: fn(f32) -> Message,
    on_mouse_up: Message,
}

impl<Message> HueSlider<Message> {
    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<Message> canvas::Program<Message> for HueSlider<Message> {
impl<Message: Clone> canvas::Program<Message> for HueSlider<Message> {
    type State = HueSliderState;

    fn update(
@@ -116,26 +132,28 @@ impl<Message> canvas::Program<Message> for HueSlider<Message> {
        bounds: Rectangle,
        cursor: Cursor,
    ) -> (Status, Option<Message>) {
        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<Message> canvas::Program<Message> for HueSlider<Message> {
            } 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<Message> {
    saturation: f32,
    brightness: f32,
    on_change: fn(f32, f32) -> Message,
    on_mouse_up: Message,
}

impl<Message> SaturationBrightnessPicker<Message> {
@@ -231,17 +252,19 @@ impl<Message> SaturationBrightnessPicker<Message> {
        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<Message> canvas::Program<Message> for SaturationBrightnessPicker<Message> {
impl<Message: Clone> canvas::Program<Message> for SaturationBrightnessPicker<Message> {
    type State = SaturationBrightnessPickerState;

    fn update(
@@ -259,26 +282,28 @@ impl<Message> canvas::Program<Message> for SaturationBrightnessPicker<Message> {
            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<Message> canvas::Program<Message> for SaturationBrightnessPicker<Message> {
            } 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
    }
}