From d3f9feb96f4282894a610344454f0a1e1e11d7df Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Tue, 2 Sep 2025 01:04:26 +0200 Subject: [PATCH] Added low battery notification and made mqtt message parsing more robust (#1) --- automation_devices/src/contact_sensor.rs | 25 +++++++---- automation_devices/src/hue_switch.rs | 53 +++++++++++++++--------- automation_devices/src/ikea_remote.rs | 45 ++++++++++++-------- automation_lib/src/messages.rs | 18 ++------ config.lua | 47 +++++++++++++++++++++ 5 files changed, 129 insertions(+), 59 deletions(-) diff --git a/automation_devices/src/contact_sensor.rs b/automation_devices/src/contact_sensor.rs index 92cb7dd..6121056 100644 --- a/automation_devices/src/contact_sensor.rs +++ b/automation_devices/src/contact_sensor.rs @@ -36,6 +36,9 @@ pub struct Config { #[device_config(from_lua, default)] pub callback: ActionCallback, + #[device_config(from_lua, default)] + pub battery_callback: ActionCallback, + #[device_config(from_lua)] pub client: WrappedAsyncClient, } @@ -149,21 +152,27 @@ impl OnMqtt for ContactSensor { return; } - let is_closed = match ContactMessage::try_from(message) { - Ok(state) => state.is_closed(), + let message = match ContactMessage::try_from(message) { + Ok(message) => message, Err(err) => { error!(id = self.get_id(), "Failed to parse message: {err}"); return; } }; - if is_closed == self.state().await.is_closed { - return; + if let Some(is_closed) = message.contact { + if is_closed == self.state().await.is_closed { + return; + } + + self.config.callback.call(self, &!is_closed).await; + + debug!(id = self.get_id(), "Updating state to {is_closed}"); + self.state_mut().await.is_closed = is_closed; } - self.config.callback.call(self, &!is_closed).await; - - debug!(id = self.get_id(), "Updating state to {is_closed}"); - self.state_mut().await.is_closed = is_closed; + if let Some(battery) = message.battery { + self.config.battery_callback.call(self, &battery).await; + } } } diff --git a/automation_devices/src/hue_switch.rs b/automation_devices/src/hue_switch.rs index e456463..a517d06 100644 --- a/automation_devices/src/hue_switch.rs +++ b/automation_devices/src/hue_switch.rs @@ -31,9 +31,12 @@ pub struct Config { #[device_config(from_lua, default)] pub right_hold_callback: ActionCallback, + + #[device_config(from_lua, default)] + pub battery_callback: ActionCallback, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Copy, Clone, Deserialize)] #[serde(rename_all = "snake_case")] enum Action { LeftPress, @@ -48,7 +51,8 @@ enum Action { #[derive(Debug, Clone, Deserialize)] struct State { - action: Action, + action: Option, + battery: Option, } #[derive(Debug, Clone, LuaDevice)] @@ -84,32 +88,43 @@ impl OnMqtt for HueSwitch { 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 action = match serde_json::from_slice::(&message.payload) { - Ok(message) => message.action, + let message = match serde_json::from_slice::(&message.payload) { + Ok(message) => message, Err(err) => { warn!(id = Device::get_id(self), "Failed to parse message: {err}"); return; } }; - debug!(id = Device::get_id(self), "Remote action = {:?}", action); - match action { - Action::LeftPressRelease => self.config.left_callback.call(self, &()).await, - Action::RightPressRelease => self.config.right_callback.call(self, &()).await, - Action::LeftHold => self.config.left_hold_callback.call(self, &()).await, - Action::RightHold => self.config.right_hold_callback.call(self, &()).await, - // If there is no hold action, the switch will act like a normal release - Action::RightHoldRelease => { - if !self.config.right_hold_callback.is_set() { - self.config.right_callback.call(self, &()).await + if let Some(action) = message.action { + debug!( + id = Device::get_id(self), + ?message.action, + "Action received", + ); + + match action { + Action::LeftPressRelease => self.config.left_callback.call(self, &()).await, + Action::RightPressRelease => self.config.right_callback.call(self, &()).await, + Action::LeftHold => self.config.left_hold_callback.call(self, &()).await, + Action::RightHold => self.config.right_hold_callback.call(self, &()).await, + // If there is no hold action, the switch will act like a normal release + Action::RightHoldRelease => { + if !self.config.right_hold_callback.is_set() { + self.config.right_callback.call(self, &()).await + } } - } - Action::LeftHoldRelease => { - if !self.config.left_hold_callback.is_set() { - self.config.left_callback.call(self, &()).await + Action::LeftHoldRelease => { + if !self.config.left_hold_callback.is_set() { + self.config.left_callback.call(self, &()).await + } } + _ => {} } - _ => {} + } + + if let Some(battery) = message.battery { + self.config.battery_callback.call(self, &battery).await; } } } diff --git a/automation_devices/src/ikea_remote.rs b/automation_devices/src/ikea_remote.rs index a3602ae..d6c9c59 100644 --- a/automation_devices/src/ikea_remote.rs +++ b/automation_devices/src/ikea_remote.rs @@ -25,6 +25,8 @@ pub struct Config { #[device_config(from_lua)] pub callback: ActionCallback, + #[device_config(from_lua, default)] + pub battery_callback: ActionCallback, } #[derive(Debug, Clone, LuaDevice)] @@ -60,31 +62,38 @@ impl OnMqtt for IkeaRemote { 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 action = match RemoteMessage::try_from(message) { - Ok(message) => message.action(), + let message = match RemoteMessage::try_from(message) { + Ok(message) => message, Err(err) => { error!(id = Device::get_id(self), "Failed to parse message: {err}"); return; } }; - debug!(id = Device::get_id(self), "Remote action = {:?}", action); - let on = if self.config.single_button { - match action { - RemoteAction::On => Some(true), - RemoteAction::BrightnessMoveUp => Some(false), - _ => None, - } - } else { - match action { - RemoteAction::On => Some(true), - RemoteAction::Off => Some(false), - _ => None, - } - }; + if let Some(action) = message.action { + debug!(id = Device::get_id(self), "Remote action = {:?}", action); - if let Some(on) = on { - self.config.callback.call(self, &on).await; + let on = if self.config.single_button { + match action { + RemoteAction::On => Some(true), + RemoteAction::BrightnessMoveUp => Some(false), + _ => None, + } + } else { + match action { + RemoteAction::On => Some(true), + RemoteAction::Off => Some(false), + _ => None, + } + }; + + if let Some(on) = on { + self.config.callback.call(self, &on).await; + } + } + + if let Some(battery) = message.battery { + self.config.battery_callback.call(self, &battery).await; } } } diff --git a/automation_lib/src/messages.rs b/automation_lib/src/messages.rs index dcd504a..348f4f4 100644 --- a/automation_lib/src/messages.rs +++ b/automation_lib/src/messages.rs @@ -68,13 +68,8 @@ pub enum RemoteAction { // Message used to report the action performed by a remote #[derive(Debug, Deserialize)] pub struct RemoteMessage { - action: RemoteAction, -} - -impl RemoteMessage { - pub fn action(&self) -> RemoteAction { - self.action - } + pub action: Option, + pub battery: Option, } impl TryFrom for RemoteMessage { @@ -144,13 +139,8 @@ impl TryFrom for BrightnessMessage { // Message to report the state of a contact sensor #[derive(Debug, Deserialize)] pub struct ContactMessage { - contact: bool, -} - -impl ContactMessage { - pub fn is_closed(&self) -> bool { - self.contact - } + pub contact: Option, + pub battery: Option, } impl TryFrom for ContactMessage { diff --git a/config.lua b/config.lua index 56917a0..545f308 100644 --- a/config.lua +++ b/config.lua @@ -34,6 +34,37 @@ local ntfy = Ntfy.new({ }) automation.device_manager:add(ntfy) +local low_battery = {} +local function check_battery(device, battery) + local id = device:get_id() + if battery < 15 then + print("Device '" .. id .. "' has low battery: " .. tostring(battery)) + low_battery[id] = battery + else + low_battery[id] = nil + end +end +automation.device_manager:schedule("0 0 21 */1 * *", function() + -- Don't send notifications if there are now devices with low battery + if next(low_battery) == nil then + print("No devices with low battery") + return + end + + local lines = {} + for name, battery in pairs(low_battery) do + table.insert(lines, name .. ": " .. tostring(battery) .. "%") + end + local message = table.concat(lines, "\n") + + ntfy:send_notification({ + title = "Low battery", + message = message, + tags = { "battery" }, + priority = "default", + }) +end) + local on_presence = { add = function(self, f) self[#self + 1] = f @@ -171,6 +202,7 @@ automation.device_manager:add(HueSwitch.new({ right_hold_callback = function() living_lights_relax:set_on(true) end, + battery_callback = check_battery, })) automation.device_manager:add(WakeOnLAN.new({ @@ -222,6 +254,7 @@ automation.device_manager:add(IkeaRemote.new({ end end end, + battery_callback = check_battery, })) local function kettle_timeout() @@ -260,6 +293,7 @@ automation.device_manager:add(IkeaRemote.new({ topic = mqtt_z2m("bedroom/remote"), single_button = true, callback = set_kettle, + battery_callback = check_battery, })) automation.device_manager:add(IkeaRemote.new({ @@ -269,6 +303,7 @@ automation.device_manager:add(IkeaRemote.new({ topic = mqtt_z2m("kitchen/remote"), single_button = true, callback = set_kettle, + battery_callback = check_battery, })) local function off_timeout(duration) @@ -356,6 +391,7 @@ automation.device_manager:add(IkeaRemote.new({ workbench_light:set_on(false) end end, + battery_callback = check_battery, })) local hallway_top_light = HueGroup.new({ @@ -373,6 +409,7 @@ automation.device_manager:add(HueSwitch.new({ left_callback = function() hallway_top_light:set_on(not hallway_top_light:on()) end, + battery_callback = check_battery, })) automation.device_manager:add(HueSwitch.new({ name = "SwitchTop", @@ -382,6 +419,7 @@ automation.device_manager:add(HueSwitch.new({ left_callback = function() hallway_top_light:set_on(not hallway_top_light:on()) end, + battery_callback = check_battery, })) local hallway_light_automation = { @@ -488,6 +526,7 @@ automation.device_manager:add(IkeaRemote.new({ callback = function(_, on) hallway_light_automation:switch_callback(on) end, + battery_callback = check_battery, })) local hallway_frontdoor = ContactSensor.new({ name = "Frontdoor", @@ -503,6 +542,7 @@ local hallway_frontdoor = ContactSensor.new({ hallway_light_automation:door_callback(open) frontdoor_presence(open) end, + battery_callback = check_battery, }) automation.device_manager:add(hallway_frontdoor) hallway_light_automation.door = hallway_frontdoor @@ -516,6 +556,7 @@ local hallway_trash = ContactSensor.new({ callback = function(_, open) hallway_light_automation:trash_callback(open) end, + battery_callback = check_battery, }) automation.device_manager:add(hallway_trash) hallway_light_automation.trash = hallway_trash @@ -564,6 +605,7 @@ automation.device_manager:add(HueSwitch.new({ left_hold_callback = function() bedroom_lights_relax:set_on(true) end, + battery_callback = check_battery, })) automation.device_manager:add(ContactSensor.new({ @@ -572,24 +614,28 @@ automation.device_manager:add(ContactSensor.new({ sensor_type = "Door", topic = mqtt_z2m("living/balcony"), client = mqtt_client, + battery_callback = check_battery, })) automation.device_manager:add(ContactSensor.new({ name = "Window", room = "Living Room", topic = mqtt_z2m("living/window"), client = mqtt_client, + battery_callback = check_battery, })) automation.device_manager:add(ContactSensor.new({ name = "Window", room = "Bedroom", topic = mqtt_z2m("bedroom/window"), client = mqtt_client, + battery_callback = check_battery, })) automation.device_manager:add(ContactSensor.new({ name = "Window", room = "Guest Room", topic = mqtt_z2m("guest/window"), client = mqtt_client, + battery_callback = check_battery, })) local storage_light = LightBrightness.new({ @@ -614,6 +660,7 @@ automation.device_manager:add(ContactSensor.new({ storage_light:set_on(false) end end, + battery_callback = check_battery, })) automation.device_manager:schedule("0 0 19 * * *", function()