🏡 index : ~doyle/shalom.git

author Jordan Doyle <jordan@doyle.la> 2023-11-03 20:01:50.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-11-03 20:01:50.0 +00:00:00
commit
2f16b810cb886af026d879381a144e6bd29d2ebf [patch]
tree
53d8c338cfbe6fc174c1d3a00a11a352e52abff3
parent
c81817da5f97360d0a077d19d061e86db7edbb7d
download
2f16b810cb886af026d879381a144e6bd29d2ebf.tar.gz

Support turning lights on/off



Diff

 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<responses::CallServiceResponse, String> {
        self.request::<responses::CallServiceResponse>(HassRequestKind::CallService(
            CallServiceRequest {
                target: Some(CallServiceRequestTarget { entity_id }),
                data: payload,
            },
        ))
        .await
    }

    pub fn subscribe(&self) -> broadcast::Receiver<Arc<Yoke<Event<'static>, 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<Error<'a>>,
    #[serde(borrow, bound(deserialize = "'a: 'de"))]
    event: Option<Event<'a>>,
}

#[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<u8>,
    #[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::<str>::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::<CallServiceResponse>(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::<str>::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<bool>,
    pub min_color_temp_kelvin: Option<u16>,
    pub max_color_temp_kelvin: Option<u16>,
    pub min_mireds: Option<u16>,
@@ -291,9 +311,17 @@ pub struct Light {
    pub hs_color: Option<(f32, f32)>,
}

impl From<StateLightAttributes<'_>> 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<str>> {
    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<MediaPlayerSpeaker>,
    now_playing_image: Option<Handle>,
    lights: BTreeMap<&'static str, Box<str>>,
    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<Event> {
        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::<Vec<_>>(),
        )
@@ -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<M>(name: &str, active: bool) -> ToggleCard<M> {
pub fn toggle_card<M>(name: &str, active: bool, disabled: bool) -> ToggleCard<M> {
    ToggleCard {
        name: Box::from(name),
        active,
        disabled,
        ..ToggleCard::default()
    }
}
@@ -34,6 +35,7 @@ pub struct ToggleCard<M> {
    height: Length,
    width: Length,
    active: bool,
    disabled: bool,
    on_press: Option<M>,
    on_long_press: Option<M>,
}
@@ -46,6 +48,7 @@ impl<M> Default for ToggleCard<M> {
            height: Length::Shrink,
            width: Length::Fill,
            active: false,
            disabled: false,
            on_press: None,
            on_long_press: None,
        }
@@ -111,11 +114,12 @@ impl<M: Clone> iced::widget::Component<M, Renderer> for ToggleCard<M> {
    }

    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),
            },
        }