From fbabc978b1cd1a7b4e3bcc59043c2e8c74f93929 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 26 Jan 2025 04:48:50 +0100 Subject: [PATCH] Reworked IkeaOutlet into more generic outlet that also (optionally) supports power measurement This new power measurement feature is used to turn the kettle off automatically once it is done boiling --- automation_devices/src/ikea_outlet.rs | 187 ---------------- automation_devices/src/lib.rs | 9 +- automation_devices/src/zigbee/mod.rs | 1 + automation_devices/src/zigbee/outlet.rs | 275 ++++++++++++++++++++++++ config.lua | 36 +++- 5 files changed, 306 insertions(+), 202 deletions(-) delete mode 100644 automation_devices/src/ikea_outlet.rs create mode 100644 automation_devices/src/zigbee/outlet.rs diff --git a/automation_devices/src/ikea_outlet.rs b/automation_devices/src/ikea_outlet.rs deleted file mode 100644 index 776a121..0000000 --- a/automation_devices/src/ikea_outlet.rs +++ /dev/null @@ -1,187 +0,0 @@ -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::messages::OnOffMessage; -use automation_lib::mqtt::WrappedAsyncClient; -use automation_macro::LuaDeviceConfig; -use google_home::device; -use google_home::errors::ErrorCode; -use google_home::traits::{self, OnOff}; -use google_home::types::Type; -use rumqttc::{matches, Publish}; -use serde::Deserialize; -use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; -use tracing::{debug, error, trace, warn}; - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)] -pub enum OutletType { - Outlet, - Kettle, - Charger, -} - -#[derive(Debug, Clone, LuaDeviceConfig)] -pub struct Config { - #[device_config(flatten)] - pub info: InfoConfig, - #[device_config(flatten)] - pub mqtt: MqttDeviceConfig, - #[device_config(default(OutletType::Outlet))] - pub outlet_type: OutletType, - - #[device_config(from_lua, default)] - pub callback: ActionCallback, - - #[device_config(from_lua)] - pub client: WrappedAsyncClient, -} - -#[derive(Debug, Default)] -pub struct State { - last_known_state: bool, -} - -#[derive(Debug, Clone)] -pub struct IkeaOutlet { - config: Config, - - state: Arc>, -} - -impl IkeaOutlet { - async fn state(&self) -> RwLockReadGuard { - self.state.read().await - } - - async fn state_mut(&self) -> RwLockWriteGuard { - self.state.write().await - } -} - -#[async_trait] -impl LuaDeviceCreate for IkeaOutlet { - 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 IkeaOutlet { - fn get_id(&self) -> String { - self.config.info.identifier() - } -} - -#[async_trait] -impl OnMqtt for IkeaOutlet { - 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) { - // Update the internal state based on what the device has reported - let state = match OnOffMessage::try_from(message) { - Ok(state) => state.state(), - Err(err) => { - error!(id = Device::get_id(self), "Failed to parse message: {err}"); - return; - } - }; - - // No need to do anything if the state has not changed - if state == self.state().await.last_known_state { - return; - } - - self.config.callback.call(self, &state).await; - - debug!(id = Device::get_id(self), "Updating state to {state}"); - self.state_mut().await.last_known_state = state; - } - } -} - -#[async_trait] -impl OnPresence for IkeaOutlet { - async fn on_presence(&self, presence: bool) { - // Turn off the outlet when we leave the house (Not if it is a battery charger) - if !presence && self.config.outlet_type != OutletType::Charger { - debug!(id = Device::get_id(self), "Turning device off"); - self.set_on(false).await.ok(); - } - } -} - -#[async_trait] -impl google_home::Device for IkeaOutlet { - fn get_device_type(&self) -> Type { - match self.config.outlet_type { - OutletType::Outlet => Type::Outlet, - OutletType::Kettle => Type::Kettle, - OutletType::Charger => Type::Outlet, // Find a better device type for this, ideally would like to use charger, but that needs more work - } - } - - fn get_device_name(&self) -> device::Name { - device::Name::new(&self.config.info.name) - } - - fn get_id(&self) -> String { - Device::get_id(self) - } - - async 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 traits::OnOff for IkeaOutlet { - async fn on(&self) -> Result { - Ok(self.state().await.last_known_state) - } - - async fn set_on(&self, on: bool) -> Result<(), ErrorCode> { - let message = OnOffMessage::new(on); - - 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/lib.rs b/automation_devices/src/lib.rs index e5ab41a..826f9dc 100644 --- a/automation_devices/src/lib.rs +++ b/automation_devices/src/lib.rs @@ -4,7 +4,6 @@ mod debug_bridge; mod hue_bridge; mod hue_group; mod hue_switch; -mod ikea_outlet; mod ikea_remote; mod kasa_outlet; mod light_sensor; @@ -17,6 +16,7 @@ use std::ops::Deref; use automation_cast::Cast; use automation_lib::device::{Device, LuaDeviceCreate}; use zigbee::light::{LightBrightness, LightOnOff}; +use zigbee::outlet::{OutletOnOff, OutletPower}; pub use self::air_filter::AirFilter; pub use self::contact_sensor::ContactSensor; @@ -24,7 +24,6 @@ pub use self::debug_bridge::DebugBridge; pub use self::hue_bridge::HueBridge; pub use self::hue_group::HueGroup; pub use self::hue_switch::HueSwitch; -pub use self::ikea_outlet::IkeaOutlet; pub use self::ikea_remote::IkeaRemote; pub use self::kasa_outlet::KasaOutlet; pub use self::light_sensor::LightSensor; @@ -125,13 +124,14 @@ macro_rules! impl_device { impl_device!(LightOnOff); impl_device!(LightBrightness); +impl_device!(OutletOnOff); +impl_device!(OutletPower); impl_device!(AirFilter); impl_device!(ContactSensor); impl_device!(DebugBridge); impl_device!(HueBridge); impl_device!(HueGroup); impl_device!(HueSwitch); -impl_device!(IkeaOutlet); impl_device!(IkeaRemote); impl_device!(KasaOutlet); impl_device!(LightSensor); @@ -141,13 +141,14 @@ impl_device!(Washer); pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> { register_device!(lua, LightOnOff); register_device!(lua, LightBrightness); + register_device!(lua, OutletOnOff); + register_device!(lua, OutletPower); register_device!(lua, AirFilter); register_device!(lua, ContactSensor); register_device!(lua, DebugBridge); register_device!(lua, HueBridge); register_device!(lua, HueGroup); register_device!(lua, HueSwitch); - register_device!(lua, IkeaOutlet); register_device!(lua, IkeaRemote); register_device!(lua, KasaOutlet); register_device!(lua, LightSensor); diff --git a/automation_devices/src/zigbee/mod.rs b/automation_devices/src/zigbee/mod.rs index ad8f30e..c061765 100644 --- a/automation_devices/src/zigbee/mod.rs +++ b/automation_devices/src/zigbee/mod.rs @@ -1 +1,2 @@ pub mod light; +pub mod outlet; diff --git a/automation_devices/src/zigbee/outlet.rs b/automation_devices/src/zigbee/outlet.rs new file mode 100644 index 0000000..c83c192 --- /dev/null +++ b/automation_devices/src/zigbee/outlet.rs @@ -0,0 +1,275 @@ +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::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 OutletState: + Debug + Clone + Default + Sync + Send + Serialize + Into + 'static +{ +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)] +pub enum OutletType { + Outlet, + Kettle, +} + +impl From for Type { + fn from(outlet: OutletType) -> Self { + match outlet { + OutletType::Outlet => Type::Outlet, + OutletType::Kettle => Type::Kettle, + } + } +} + +#[derive(Debug, Clone, LuaDeviceConfig)] +pub struct Config { + #[device_config(flatten)] + pub info: InfoConfig, + #[device_config(flatten)] + pub mqtt: MqttDeviceConfig, + #[device_config(default(OutletType::Outlet))] + pub outlet_type: OutletType, + + // TODO: One presence is reworked, this should be removed! + #[device_config(default(true))] + pub presence_auto_off: bool, + + #[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 OutletState for StateOnOff {} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StatePower { + #[serde(deserialize_with = "state_deserializer")] + state: bool, + power: f64, +} + +impl OutletState for StatePower {} + +impl From for StateOnOff { + fn from(state: StatePower) -> Self { + StateOnOff { state: state.state } + } +} + +#[derive(Debug, Clone)] +pub struct Outlet { + config: Config, + + state: Arc>, +} + +pub type OutletOnOff = Outlet; +pub type OutletPower = Outlet; + +impl Outlet { + async fn state(&self) -> RwLockReadGuard { + self.state.read().await + } + + async fn state_mut(&self) -> RwLockWriteGuard { + self.state.write().await + } +} + +#[async_trait] +impl LuaDeviceCreate for Outlet { + 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 Outlet { + fn get_id(&self) -> String { + self.config.info.identifier() + } +} + +#[async_trait] +impl OnMqtt for Outlet { + 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 Outlet { + 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.power == current_state.power { + return; + } + } + + self.state_mut().await.state = state.state; + self.state_mut().await.power = state.power; + 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 Outlet { + async fn on_presence(&self, presence: bool) { + if self.config.presence_auto_off && !presence { + debug!(id = Device::get_id(self), "Turning device off"); + self.set_on(false).await.ok(); + } + } +} + +#[async_trait] +impl google_home::Device for Outlet { + fn get_device_type(&self) -> Type { + self.config.outlet_type.into() + } + + fn get_device_name(&self) -> device::Name { + device::Name::new(&self.config.info.name) + } + + fn get_id(&self) -> String { + Device::get_id(self) + } + + async 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 Outlet +where + T: OutletState, +{ + 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(()) + } +} diff --git a/config.lua b/config.lua index 3a1f350..7fbbfd2 100644 --- a/config.lua +++ b/config.lua @@ -78,14 +78,14 @@ automation.device_manager:add(WakeOnLAN.new({ })) -- TODO: Update this to 10.0.0.101 when DHCP want to finally work -local living_mixer = IkeaOutlet.new({ +local living_mixer = OutletOnOff.new({ name = "Mixer", room = "Living Room", topic = mqtt_z2m("living/mixer"), client = mqtt_client, }) automation.device_manager:add(living_mixer) -local living_speakers = IkeaOutlet.new({ +local living_speakers = OutletOnOff.new({ name = "Speakers", room = "Living Room", topic = mqtt_z2m("living/speakers"), @@ -118,12 +118,12 @@ automation.device_manager:add(IkeaRemote.new({ end, })) -local function off_timeout(duration) +local function kettle_timeout() local timeout = Timeout.new() - return function(self, on) - if on then - timeout:start(duration, function() + return function(self, state) + if state.state and state.power < 100 then + timeout:start(3, function() self:set_on(false) end) else @@ -132,13 +132,13 @@ local function off_timeout(duration) end end -local kettle = IkeaOutlet.new({ +local kettle = OutletPower.new({ outlet_type = "Kettle", name = "Kettle", room = "Kitchen", topic = mqtt_z2m("kitchen/kettle"), client = mqtt_client, - callback = off_timeout(debug and 5 or 300), + callback = kettle_timeout(), }) automation.device_manager:add(kettle) @@ -164,6 +164,20 @@ automation.device_manager:add(IkeaRemote.new({ callback = set_kettle, })) +local function off_timeout(duration) + local timeout = Timeout.new() + + return function(self, state) + if state.state then + timeout:start(duration, function() + self:set_on(false) + end) + else + timeout:cancel() + end + end +end + automation.device_manager:add(LightOnOff.new({ name = "Light", room = "Bathroom", @@ -180,8 +194,8 @@ automation.device_manager:add(Washer.new({ event_channel = automation.device_manager:event_channel(), })) -automation.device_manager:add(IkeaOutlet.new({ - outlet_type = "Charger", +automation.device_manager:add(OutletOnOff.new({ + presence_auto_off = false, name = "Charger", room = "Workbench", topic = mqtt_z2m("workbench/charger"), @@ -189,7 +203,7 @@ automation.device_manager:add(IkeaOutlet.new({ callback = off_timeout(debug and 5 or 20 * 3600), })) -automation.device_manager:add(IkeaOutlet.new({ +automation.device_manager:add(OutletOnOff.new({ name = "Outlet", room = "Workbench", topic = mqtt_z2m("workbench/outlet"),