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(-)
@@ -2782,6 +2782,7 @@
"keyframe",
"lru 0.12.0",
"once_cell",
"palette",
"reqwest",
"serde",
"serde_json",
@@ -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"] }
@@ -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)]
@@ -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)]
@@ -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()),
);
}
_ => {
@@ -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,
}
}
}
@@ -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,
}
@@ -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
}
}
@@ -111,10 +111,7 @@
_viewport: &Rectangle,
) {
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.))