🏡 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/hass_client.rs                 |  36 ++++++++++++++++++++++++++++++++++++
 shalom/src/main.rs                        |  40 ++++++++++++++++++++++++++++++++++------
 shalom/src/oracle.rs                      |  51 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 shalom/src/context_menus/light_control.rs |  35 +++++++++++++++++++++++++++++++----
 shalom/src/widgets/colour_picker.rs       | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
 shalom/src/widgets/cards/weather.rs       |   5 +----
 8 files changed, 205 insertions(+), 77 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index fb53a26..da419e3 100644
--- a/Cargo.lock
+++ a/Cargo.lock
@@ -2782,6 +2782,7 @@
 "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
+++ a/shalom/Cargo.toml
@@ -13,6 +13,7 @@
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/hass_client.rs b/shalom/src/hass_client.rs
index 08ecfb2..272014b 100644
--- a/shalom/src/hass_client.rs
+++ a/shalom/src/hass_client.rs
@@ -212,6 +212,7 @@
    SubscribeEvents {
        event_type: Option<String>,
    },
    CallService(CallServiceRequest),
}

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

#[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;

@@ -252,6 +283,9 @@
    use yoke::Yokeable;

    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>>);
@@ -635,7 +669,7 @@
        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
+++ a/shalom/src/main.rs
@@ -73,12 +73,15 @@
                    "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 @@
                        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 @@
    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
+++ a/shalom/src/oracle.rs
@@ -13,13 +13,17 @@
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 @@
            .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();
@@ -153,6 +182,12 @@
                                        state_changed.new_state.state.as_ref(),
                                        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 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 @@
            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/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
+++ a/shalom/src/context_menus/light_control.rs
@@ -1,26 +1,31 @@
use iced::{
    font::{Stretch, Weight},
    widget::{column, container, row, text},
    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 @@
                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 @@
            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 @@
    }
}

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/widgets/colour_picker.rs b/shalom/src/widgets/colour_picker.rs
index 863e868..a9de4b9 100644
--- a/shalom/src/widgets/colour_picker.rs
+++ a/shalom/src/widgets/colour_picker.rs
@@ -11,6 +11,7 @@
    },
    Color, Point, Rectangle, Renderer, Size, Theme,
};
use palette::IntoColor;

use crate::widgets::forced_rounded::forced_rounded;

@@ -19,6 +20,7 @@
    saturation: f32,
    brightness: f32,
    on_change: fn(f32, f32, f32) -> Event,
    on_mouse_up: Event,
}

impl<Event> ColourPicker<Event> {
@@ -27,28 +29,31 @@
        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 @@
                self.hue,
                self.saturation,
                self.brightness,
                Message::OnSaturationBrightnessChange,
                Message::SaturationBrightnessChange,
                Message::MouseUp,
            ))
            .height(192)
            .width(192)
@@ -66,10 +72,14 @@
        );

        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 @@

#[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 @@
        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 @@
            } else {
                (Status::Captured, None)
            }
        } else if mouse_up {
            (Status::Captured, Some(self.on_mouse_up.clone()))
        } else {
            (Status::Ignored, None)
        }
@@ -223,6 +243,7 @@
    saturation: f32,
    brightness: f32,
    on_change: fn(f32, f32) -> Message,
    on_mouse_up: Message,
}

impl<Message> SaturationBrightnessPicker<Message> {
@@ -231,17 +252,19 @@
        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 @@
            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 @@
            } else {
                (Status::Ignored, None)
            }
        } else if mouse_up {
            (Status::Captured, Some(self.on_mouse_up.clone()))
        } else {
            (Status::Ignored, None)
        }
@@ -368,25 +395,17 @@
    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
    }
}
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
+++ a/shalom/src/widgets/cards/weather.rs
@@ -111,10 +111,7 @@
        _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.))