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(-)
@@ -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 @@
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 @@
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 @@
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 @@
#[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 {
@@ -103,6 +103,14 @@
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))) => {
@@ -16,12 +16,11 @@
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 @@
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 @@
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 @@
) {
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 @@
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 @@
#[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 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 @@
}
}
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()
}
}
@@ -12,7 +12,7 @@
use url::Url;
use crate::{
oracle::{MediaPlayerSpeaker, Oracle},
oracle::{Light, MediaPlayerSpeaker, Oracle},
subscriptions::download_image,
theme::Icon,
widgets,
@@ -24,7 +24,7 @@
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 @@
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 @@
pub fn update(&mut self, event: Message) -> Option<Event> {
match event {
Message::LightToggle(_name) => {
None
}
Message::OpenLightOptions(name) => Some(Event::OpenLightContextMenu(name)),
Message::UpdateLightAmount(_name, _v) => {
None
Message::SetLightState(id, state) => {
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 @@
None
}
Message::UpdateLight(entity_id) => {
if let Some(light) = self.oracle.fetch_light(entity_id) {
self.lights.insert(entity_id, light);
}
None
}
}
}
@@ -101,12 +101,22 @@
stretch: Stretch::Condensed,
..Font::with_name("Helvetica Neue")
});
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));
}
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))
toggle_card
};
let mut col = Column::new().spacing(20).padding(40).push(header);
@@ -124,7 +134,7 @@
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 @@
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),
}
@@ -12,7 +12,7 @@
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 @@
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 @@
height: Length,
width: Length,
active: bool,
disabled: bool,
on_press: Option<M>,
on_long_press: Option<M>,
}
@@ -46,6 +48,7 @@
height: Length::Shrink,
width: Length::Fill,
active: false,
disabled: false,
on_press: None,
on_long_press: None,
}
@@ -111,11 +114,12 @@
}
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 @@
ActiveHover,
Inactive,
InactiveHover,
Disabled,
}
impl container::StyleSheet for Style {
@@ -192,6 +197,13 @@
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 @@
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),
},
}