From e4c211a2786601da73ebfd75bede79ca60ad991a Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 8 Dec 2024 05:34:51 +0100 Subject: [PATCH] Added dedicated light device and updated hallway logic --- automation_devices/src/ikea_outlet.rs | 2 - automation_devices/src/lib.rs | 6 + automation_devices/src/zigbee/light.rs | 298 ++++++++++++++++++++ automation_devices/src/zigbee/mod.rs | 1 + automation_lib/src/helpers/mod.rs | 1 + automation_lib/src/helpers/serialization.rs | 16 ++ config.lua | 105 ++++--- 7 files changed, 389 insertions(+), 40 deletions(-) create mode 100644 automation_devices/src/zigbee/light.rs create mode 100644 automation_devices/src/zigbee/mod.rs create mode 100644 automation_lib/src/helpers/serialization.rs diff --git a/automation_devices/src/ikea_outlet.rs b/automation_devices/src/ikea_outlet.rs index 6e200ef..40a4278 100644 --- a/automation_devices/src/ikea_outlet.rs +++ b/automation_devices/src/ikea_outlet.rs @@ -23,7 +23,6 @@ pub enum OutletType { Outlet, Kettle, Charger, - Light, } #[derive(Debug, Clone, LuaDeviceConfig)] @@ -133,7 +132,6 @@ impl google_home::Device for IkeaOutlet { match self.config.outlet_type { OutletType::Outlet => Type::Outlet, OutletType::Kettle => Type::Kettle, - OutletType::Light => Type::Light, // Find a better device type for this, ideally would like to use charger, but that needs more work OutletType::Charger => Type::Outlet, // Find a better device type for this, ideally would like to use charger, but that needs more work } } diff --git a/automation_devices/src/lib.rs b/automation_devices/src/lib.rs index 2a60878..ca21961 100644 --- a/automation_devices/src/lib.rs +++ b/automation_devices/src/lib.rs @@ -10,11 +10,13 @@ mod kasa_outlet; mod light_sensor; mod wake_on_lan; mod washer; +mod zigbee; use std::ops::Deref; use automation_cast::Cast; use automation_lib::device::{Device, LuaDeviceCreate}; +use zigbee::light::{LightBrightness, LightOnOff}; pub use self::air_filter::AirFilter; pub use self::contact_sensor::ContactSensor; @@ -99,6 +101,8 @@ macro_rules! impl_device { }; } +impl_device!(LightOnOff); +impl_device!(LightBrightness); impl_device!(AirFilter); impl_device!(ContactSensor); impl_device!(DebugBridge); @@ -113,6 +117,8 @@ impl_device!(WakeOnLAN); impl_device!(Washer); pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> { + register_device!(lua, LightOnOff); + register_device!(lua, LightBrightness); register_device!(lua, AirFilter); register_device!(lua, ContactSensor); register_device!(lua, DebugBridge); diff --git a/automation_devices/src/zigbee/light.rs b/automation_devices/src/zigbee/light.rs new file mode 100644 index 0000000..54d1d80 --- /dev/null +++ b/automation_devices/src/zigbee/light.rs @@ -0,0 +1,298 @@ +use std::fmt::Debug; +use std::ops::Deref; +use std::sync::Arc; + +use anyhow::Result; +use async_trait::async_trait; +use automation_lib::action_callback::ActionCallback; +use automation_lib::config::{InfoConfig, MqttDeviceConfig}; +use automation_lib::device::{Device, LuaDeviceCreate}; +use automation_lib::event::{OnMqtt, OnPresence}; +use automation_lib::helpers::serialization::state_deserializer; +use automation_lib::mqtt::WrappedAsyncClient; +use automation_macro::LuaDeviceConfig; +use google_home::device; +use google_home::errors::ErrorCode; +use google_home::traits::{Brightness, OnOff}; +use google_home::types::Type; +use rumqttc::{matches, Publish}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; +use tracing::{debug, trace, warn}; + +pub trait LightState: + Debug + Clone + Default + Sync + Send + Serialize + Into + 'static +{ +} + +#[derive(Debug, Clone, LuaDeviceConfig)] +pub struct Config { + #[device_config(flatten)] + pub info: InfoConfig, + #[device_config(flatten)] + pub mqtt: MqttDeviceConfig, + + #[device_config(from_lua, default)] + pub callback: ActionCallback, T>, + + #[device_config(from_lua)] + pub client: WrappedAsyncClient, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StateOnOff { + #[serde(deserialize_with = "state_deserializer")] + state: bool, +} + +impl LightState for StateOnOff {} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StateBrightness { + #[serde(deserialize_with = "state_deserializer")] + state: bool, + brightness: f64, +} + +impl LightState for StateBrightness {} + +impl From for StateOnOff { + fn from(state: StateBrightness) -> Self { + StateOnOff { state: state.state } + } +} + +#[derive(Debug, Clone)] +pub struct Light { + config: Config, + + state: Arc>, +} + +pub type LightOnOff = Light; +pub type LightBrightness = Light; + +impl Light { + async fn state(&self) -> RwLockReadGuard { + self.state.read().await + } + + async fn state_mut(&self) -> RwLockWriteGuard { + self.state.write().await + } +} + +#[async_trait] +impl LuaDeviceCreate for Light { + type Config = Config; + type Error = rumqttc::ClientError; + + async fn create(config: Self::Config) -> Result { + trace!(id = config.info.identifier(), "Setting up IkeaOutlet"); + + config + .client + .subscribe(&config.mqtt.topic, rumqttc::QoS::AtLeastOnce) + .await?; + + Ok(Self { + config, + state: Default::default(), + }) + } +} + +impl Device for Light { + fn get_id(&self) -> String { + self.config.info.identifier() + } +} + +#[async_trait] +impl OnMqtt for Light { + async fn on_mqtt(&self, message: Publish) { + // Check if the message is from the device itself or from a remote + if matches(&message.topic, &self.config.mqtt.topic) { + let state = match serde_json::from_slice::(&message.payload) { + Ok(state) => state, + Err(err) => { + warn!(id = Device::get_id(self), "Failed to parse message: {err}"); + return; + } + }; + + // No need to do anything if the state has not changed + if state.state == self.state().await.state { + return; + } + + self.state_mut().await.state = state.state; + debug!( + id = Device::get_id(self), + "Updating state to {:?}", + self.state().await + ); + + self.config + .callback + .call(self, self.state().await.deref()) + .await; + } + } +} + +#[async_trait] +impl OnMqtt for Light { + async fn on_mqtt(&self, message: Publish) { + // Check if the message is from the deviec itself or from a remote + if matches(&message.topic, &self.config.mqtt.topic) { + let state = match serde_json::from_slice::(&message.payload) { + Ok(state) => state, + Err(err) => { + warn!(id = Device::get_id(self), "Failed to parse message: {err}"); + return; + } + }; + + { + let current_state = self.state().await; + // No need to do anything if the state has not changed + if state.state == current_state.state + && state.brightness == current_state.brightness + { + return; + } + } + + self.state_mut().await.state = state.state; + self.state_mut().await.brightness = state.brightness; + debug!( + id = Device::get_id(self), + "Updating state to {:?}", + self.state().await + ); + + self.config + .callback + .call(self, self.state().await.deref()) + .await; + } + } +} + +#[async_trait] +impl OnPresence for Light { + async fn on_presence(&self, presence: bool) { + if !presence { + debug!(id = Device::get_id(self), "Turning device off"); + self.set_on(false).await.ok(); + } + } +} + +impl google_home::Device for Light { + fn get_device_type(&self) -> Type { + Type::Light + } + + fn get_device_name(&self) -> device::Name { + device::Name::new(&self.config.info.name) + } + + fn get_id(&self) -> String { + Device::get_id(self) + } + + fn is_online(&self) -> bool { + true + } + + fn get_room_hint(&self) -> Option<&str> { + self.config.info.room.as_deref() + } + + fn will_report_state(&self) -> bool { + // TODO: Implement state reporting + false + } +} + +#[async_trait] +impl OnOff for Light +where + T: LightState, +{ + async fn on(&self) -> Result { + let state = self.state().await; + let state: StateOnOff = state.deref().clone().into(); + Ok(state.state) + } + + async fn set_on(&self, on: bool) -> Result<(), ErrorCode> { + let message = json!({ + "state": if on { "ON" } else { "OFF"} + }); + + debug!(id = Device::get_id(self), "{message}"); + + let topic = format!("{}/set", self.config.mqtt.topic); + // TODO: Handle potential errors here + self.config + .client + .publish( + &topic, + rumqttc::QoS::AtLeastOnce, + false, + serde_json::to_string(&message).unwrap(), + ) + .await + .map_err(|err| warn!("Failed to update state on {topic}: {err}")) + .ok(); + + Ok(()) + } +} + +const FACTOR: f64 = 30.0; + +#[async_trait] +impl Brightness for Light +where + T: LightState, + T: Into, +{ + async fn brightness(&self) -> Result { + let state = self.state().await; + let state: StateBrightness = state.deref().clone().into(); + let brightness = + 100.0 * f64::log10(state.brightness / FACTOR + 1.0) / f64::log10(254.0 / FACTOR + 1.0); + + Ok(brightness.clamp(0.0, 100.0).round() as u8) + } + + async fn set_brightness(&self, brightness: u8) -> Result<(), ErrorCode> { + let brightness = + FACTOR * ((FACTOR / (FACTOR + 254.0)).powf(-(brightness as f64) / 100.0) - 1.0); + + let message = json!({ + "brightness": brightness.clamp(0.0, 254.0).round() as u8 + }); + + let topic = format!("{}/set", self.config.mqtt.topic); + // TODO: Handle potential errors here + self.config + .client + .publish( + &topic, + rumqttc::QoS::AtLeastOnce, + false, + serde_json::to_string(&message).unwrap(), + ) + .await + .map_err(|err| warn!("Failed to update state on {topic}: {err}")) + .ok(); + + Ok(()) + } +} diff --git a/automation_devices/src/zigbee/mod.rs b/automation_devices/src/zigbee/mod.rs new file mode 100644 index 0000000..ad8f30e --- /dev/null +++ b/automation_devices/src/zigbee/mod.rs @@ -0,0 +1 @@ +pub mod light; diff --git a/automation_lib/src/helpers/mod.rs b/automation_lib/src/helpers/mod.rs index d4e0f95..09284ea 100644 --- a/automation_lib/src/helpers/mod.rs +++ b/automation_lib/src/helpers/mod.rs @@ -1,3 +1,4 @@ +pub mod serialization; mod timeout; pub use timeout::Timeout; diff --git a/automation_lib/src/helpers/serialization.rs b/automation_lib/src/helpers/serialization.rs new file mode 100644 index 0000000..1cf5782 --- /dev/null +++ b/automation_lib/src/helpers/serialization.rs @@ -0,0 +1,16 @@ +use serde::de::{self, Unexpected}; +use serde::{Deserialize, Deserializer}; + +pub fn state_deserializer<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + match String::deserialize(deserializer)?.as_ref() { + "ON" => Ok(true), + "OFF" => Ok(false), + other => Err(de::Error::invalid_value( + Unexpected::Str(other), + &"Value expected was either ON or OFF", + )), + } +} diff --git a/config.lua b/config.lua index 4b9b711..20bf039 100644 --- a/config.lua +++ b/config.lua @@ -155,8 +155,7 @@ automation.device_manager:add(IkeaRemote.new({ callback = set_kettle, })) -automation.device_manager:add(IkeaOutlet.new({ - outlet_type = "Light", +automation.device_manager:add(LightOnOff.new({ name = "Light", room = "Bathroom", topic = mqtt_z2m("bathroom/light"), @@ -215,6 +214,65 @@ automation.device_manager:add(HueSwitch.new({ end, })) +local hallway_light_automation = { + timeout = Timeout.new(), + state = { + door_open = false, + trash_open = false, + forced = false, + }, + switch_callback = function(self, on) + self.timeout:cancel() + self.group.set_on(on) + self.state.forced = on + end, + door_callback = function(self, open) + self.state.door_open = open + if open then + self.timeout:cancel() + + self.group.set_on(true) + elseif not self.state.forced then + self.timeout:start(debug and 10 or 60, function() + if not self.state.trash_open then + self.group.set_on(false) + end + end) + end + end, + trash_callback = function(self, open) + self.state.trash_open = open + if open then + self.group.set_on(true) + else + if not self.timeout:is_waiting() and not self.state.door_open and not self.state.forced then + self.group.set_on(false) + end + end + end, + light_callback = function(self, on) + if on and not self.state.trash_open and not self.state.door_open then + -- If the door and trash are not open, that means the light got turned on manually + self.timeout:cancel() + self.state.forced = true + elseif not on then + -- The light is never forced when it is off + self.state.forced = false + end + end, +} + +local hallway_storage = LightBrightness.new({ + name = "Storage", + room = "Hallway", + topic = mqtt_z2m("hallway/storage"), + client = mqtt_client, + callback = function(_, state) + hallway_light_automation:light_callback(state.state) + end, +}) +automation.device_manager:add(hallway_storage) + local hallway_bottom_lights = HueGroup.new({ identifier = "hallway_bottom_lights", ip = hue_ip, @@ -225,42 +283,14 @@ local hallway_bottom_lights = HueGroup.new({ }) automation.device_manager:add(hallway_bottom_lights) -local hallway_light_automation = { - group = hallway_bottom_lights, - timeout = Timeout.new(), - state = { - door_open = false, - trash_open = false, - forced = false, - }, - switch_callback = function(self, on) - self.timeout:cancel() - self.group:set_on(on) - self.state.forced = on - end, - door_callback = function(self, open) - self.state.door_open = open - if open then - self.timeout:cancel() - - self.group:set_on(true) - elseif not self.state.forced then - self.timeout:start(debug and 10 or 60, function() - if not self.state.trash_open then - self.group:set_on(false) - end - end) - end - end, - trash_callback = function(self, open) - self.state.trash_open = open - if open then - self.group:set_on(true) +hallway_light_automation.group = { + set_on = function(on) + if on then + hallway_storage:set_brightness(80) else - if not self.timeout:is_waiting() and not self.state.door_open and not self.state.forced then - self.group:set_on(false) - end + hallway_storage:set_on(false) end + hallway_bottom_lights:set_on(on) end, } @@ -294,8 +324,7 @@ automation.device_manager:add(ContactSensor.new({ end, })) -automation.device_manager:add(IkeaOutlet.new({ - outlet_type = "Light", +automation.device_manager:add(LightOnOff.new({ name = "Light", room = "Guest", topic = mqtt_z2m("guest/light"),