Added low battery notification and made mqtt message parsing more robust
All checks were successful
Build and deploy / build (push) Successful in 9m38s
Build and deploy / Deploy container (push) Successful in 1m58s

This commit is contained in:
2025-09-02 01:04:26 +02:00
parent 4a83250258
commit 9eadd138ec
5 changed files with 129 additions and 59 deletions

View File

@@ -36,6 +36,9 @@ pub struct Config {
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
pub callback: ActionCallback<ContactSensor, bool>, pub callback: ActionCallback<ContactSensor, bool>,
#[device_config(from_lua, default)]
pub battery_callback: ActionCallback<ContactSensor, f32>,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
@@ -149,21 +152,27 @@ impl OnMqtt for ContactSensor {
return; return;
} }
let is_closed = match ContactMessage::try_from(message) { let message = match ContactMessage::try_from(message) {
Ok(state) => state.is_closed(), Ok(message) => message,
Err(err) => { Err(err) => {
error!(id = self.get_id(), "Failed to parse message: {err}"); error!(id = self.get_id(), "Failed to parse message: {err}");
return; return;
} }
}; };
if is_closed == self.state().await.is_closed { if let Some(is_closed) = message.contact {
return; 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; if let Some(battery) = message.battery {
self.config.battery_callback.call(self, &battery).await;
debug!(id = self.get_id(), "Updating state to {is_closed}"); }
self.state_mut().await.is_closed = is_closed;
} }
} }

View File

@@ -31,9 +31,12 @@ pub struct Config {
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
pub right_hold_callback: ActionCallback<HueSwitch, ()>, pub right_hold_callback: ActionCallback<HueSwitch, ()>,
#[device_config(from_lua, default)]
pub battery_callback: ActionCallback<HueSwitch, f32>,
} }
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Copy, Clone, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
enum Action { enum Action {
LeftPress, LeftPress,
@@ -48,7 +51,8 @@ enum Action {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct State { struct State {
action: Action, action: Option<Action>,
battery: Option<f32>,
} }
#[derive(Debug, Clone, LuaDevice)] #[derive(Debug, Clone, LuaDevice)]
@@ -84,32 +88,43 @@ impl OnMqtt for HueSwitch {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the device itself or from a remote // Check if the message is from the device itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
let action = match serde_json::from_slice::<State>(&message.payload) { let message = match serde_json::from_slice::<State>(&message.payload) {
Ok(message) => message.action, Ok(message) => message,
Err(err) => { Err(err) => {
warn!(id = Device::get_id(self), "Failed to parse message: {err}"); warn!(id = Device::get_id(self), "Failed to parse message: {err}");
return; return;
} }
}; };
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
match action { if let Some(action) = message.action {
Action::LeftPressRelease => self.config.left_callback.call(self, &()).await, debug!(
Action::RightPressRelease => self.config.right_callback.call(self, &()).await, id = Device::get_id(self),
Action::LeftHold => self.config.left_hold_callback.call(self, &()).await, ?message.action,
Action::RightHold => self.config.right_hold_callback.call(self, &()).await, "Action received",
// If there is no hold action, the switch will act like a normal release );
Action::RightHoldRelease => {
if !self.config.right_hold_callback.is_set() { match action {
self.config.right_callback.call(self, &()).await 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 => {
Action::LeftHoldRelease => { if !self.config.left_hold_callback.is_set() {
if !self.config.left_hold_callback.is_set() { self.config.left_callback.call(self, &()).await
self.config.left_callback.call(self, &()).await }
} }
_ => {}
} }
_ => {} }
if let Some(battery) = message.battery {
self.config.battery_callback.call(self, &battery).await;
} }
} }
} }

View File

@@ -25,6 +25,8 @@ pub struct Config {
#[device_config(from_lua)] #[device_config(from_lua)]
pub callback: ActionCallback<IkeaRemote, bool>, pub callback: ActionCallback<IkeaRemote, bool>,
#[device_config(from_lua, default)]
pub battery_callback: ActionCallback<IkeaRemote, f32>,
} }
#[derive(Debug, Clone, LuaDevice)] #[derive(Debug, Clone, LuaDevice)]
@@ -60,31 +62,38 @@ impl OnMqtt for IkeaRemote {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote // Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
let action = match RemoteMessage::try_from(message) { let message = match RemoteMessage::try_from(message) {
Ok(message) => message.action(), Ok(message) => message,
Err(err) => { Err(err) => {
error!(id = Device::get_id(self), "Failed to parse message: {err}"); error!(id = Device::get_id(self), "Failed to parse message: {err}");
return; return;
} }
}; };
debug!(id = Device::get_id(self), "Remote action = {:?}", action);
let on = if self.config.single_button { if let Some(action) = message.action {
match action { debug!(id = Device::get_id(self), "Remote action = {:?}", 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 { let on = if self.config.single_button {
self.config.callback.call(self, &on).await; 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;
} }
} }
} }

View File

@@ -68,13 +68,8 @@ pub enum RemoteAction {
// Message used to report the action performed by a remote // Message used to report the action performed by a remote
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct RemoteMessage { pub struct RemoteMessage {
action: RemoteAction, pub action: Option<RemoteAction>,
} pub battery: Option<f32>,
impl RemoteMessage {
pub fn action(&self) -> RemoteAction {
self.action
}
} }
impl TryFrom<Publish> for RemoteMessage { impl TryFrom<Publish> for RemoteMessage {
@@ -144,13 +139,8 @@ impl TryFrom<Publish> for BrightnessMessage {
// Message to report the state of a contact sensor // Message to report the state of a contact sensor
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ContactMessage { pub struct ContactMessage {
contact: bool, pub contact: Option<bool>,
} pub battery: Option<f32>,
impl ContactMessage {
pub fn is_closed(&self) -> bool {
self.contact
}
} }
impl TryFrom<Publish> for ContactMessage { impl TryFrom<Publish> for ContactMessage {

View File

@@ -34,6 +34,37 @@ local ntfy = Ntfy.new({
}) })
automation.device_manager:add(ntfy) 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 = { local on_presence = {
add = function(self, f) add = function(self, f)
self[#self + 1] = f self[#self + 1] = f
@@ -171,6 +202,7 @@ automation.device_manager:add(HueSwitch.new({
right_hold_callback = function() right_hold_callback = function()
living_lights_relax:set_on(true) living_lights_relax:set_on(true)
end, end,
battery_callback = check_battery,
})) }))
automation.device_manager:add(WakeOnLAN.new({ automation.device_manager:add(WakeOnLAN.new({
@@ -222,6 +254,7 @@ automation.device_manager:add(IkeaRemote.new({
end end
end end
end, end,
battery_callback = check_battery,
})) }))
local function kettle_timeout() local function kettle_timeout()
@@ -260,6 +293,7 @@ automation.device_manager:add(IkeaRemote.new({
topic = mqtt_z2m("bedroom/remote"), topic = mqtt_z2m("bedroom/remote"),
single_button = true, single_button = true,
callback = set_kettle, callback = set_kettle,
battery_callback = check_battery,
})) }))
automation.device_manager:add(IkeaRemote.new({ automation.device_manager:add(IkeaRemote.new({
@@ -269,6 +303,7 @@ automation.device_manager:add(IkeaRemote.new({
topic = mqtt_z2m("kitchen/remote"), topic = mqtt_z2m("kitchen/remote"),
single_button = true, single_button = true,
callback = set_kettle, callback = set_kettle,
battery_callback = check_battery,
})) }))
local function off_timeout(duration) local function off_timeout(duration)
@@ -356,6 +391,7 @@ automation.device_manager:add(IkeaRemote.new({
workbench_light:set_on(false) workbench_light:set_on(false)
end end
end, end,
battery_callback = check_battery,
})) }))
local hallway_top_light = HueGroup.new({ local hallway_top_light = HueGroup.new({
@@ -373,6 +409,7 @@ automation.device_manager:add(HueSwitch.new({
left_callback = function() left_callback = function()
hallway_top_light:set_on(not hallway_top_light:on()) hallway_top_light:set_on(not hallway_top_light:on())
end, end,
battery_callback = check_battery,
})) }))
automation.device_manager:add(HueSwitch.new({ automation.device_manager:add(HueSwitch.new({
name = "SwitchTop", name = "SwitchTop",
@@ -382,6 +419,7 @@ automation.device_manager:add(HueSwitch.new({
left_callback = function() left_callback = function()
hallway_top_light:set_on(not hallway_top_light:on()) hallway_top_light:set_on(not hallway_top_light:on())
end, end,
battery_callback = check_battery,
})) }))
local hallway_light_automation = { local hallway_light_automation = {
@@ -488,6 +526,7 @@ automation.device_manager:add(IkeaRemote.new({
callback = function(_, on) callback = function(_, on)
hallway_light_automation:switch_callback(on) hallway_light_automation:switch_callback(on)
end, end,
battery_callback = check_battery,
})) }))
local hallway_frontdoor = ContactSensor.new({ local hallway_frontdoor = ContactSensor.new({
name = "Frontdoor", name = "Frontdoor",
@@ -503,6 +542,7 @@ local hallway_frontdoor = ContactSensor.new({
hallway_light_automation:door_callback(open) hallway_light_automation:door_callback(open)
frontdoor_presence(open) frontdoor_presence(open)
end, end,
battery_callback = check_battery,
}) })
automation.device_manager:add(hallway_frontdoor) automation.device_manager:add(hallway_frontdoor)
hallway_light_automation.door = hallway_frontdoor hallway_light_automation.door = hallway_frontdoor
@@ -516,6 +556,7 @@ local hallway_trash = ContactSensor.new({
callback = function(_, open) callback = function(_, open)
hallway_light_automation:trash_callback(open) hallway_light_automation:trash_callback(open)
end, end,
battery_callback = check_battery,
}) })
automation.device_manager:add(hallway_trash) automation.device_manager:add(hallway_trash)
hallway_light_automation.trash = hallway_trash hallway_light_automation.trash = hallway_trash
@@ -564,6 +605,7 @@ automation.device_manager:add(HueSwitch.new({
left_hold_callback = function() left_hold_callback = function()
bedroom_lights_relax:set_on(true) bedroom_lights_relax:set_on(true)
end, end,
battery_callback = check_battery,
})) }))
automation.device_manager:add(ContactSensor.new({ automation.device_manager:add(ContactSensor.new({
@@ -572,24 +614,28 @@ automation.device_manager:add(ContactSensor.new({
sensor_type = "Door", sensor_type = "Door",
topic = mqtt_z2m("living/balcony"), topic = mqtt_z2m("living/balcony"),
client = mqtt_client, client = mqtt_client,
battery_callback = check_battery,
})) }))
automation.device_manager:add(ContactSensor.new({ automation.device_manager:add(ContactSensor.new({
name = "Window", name = "Window",
room = "Living Room", room = "Living Room",
topic = mqtt_z2m("living/window"), topic = mqtt_z2m("living/window"),
client = mqtt_client, client = mqtt_client,
battery_callback = check_battery,
})) }))
automation.device_manager:add(ContactSensor.new({ automation.device_manager:add(ContactSensor.new({
name = "Window", name = "Window",
room = "Bedroom", room = "Bedroom",
topic = mqtt_z2m("bedroom/window"), topic = mqtt_z2m("bedroom/window"),
client = mqtt_client, client = mqtt_client,
battery_callback = check_battery,
})) }))
automation.device_manager:add(ContactSensor.new({ automation.device_manager:add(ContactSensor.new({
name = "Window", name = "Window",
room = "Guest Room", room = "Guest Room",
topic = mqtt_z2m("guest/window"), topic = mqtt_z2m("guest/window"),
client = mqtt_client, client = mqtt_client,
battery_callback = check_battery,
})) }))
local storage_light = LightBrightness.new({ local storage_light = LightBrightness.new({
@@ -614,6 +660,7 @@ automation.device_manager:add(ContactSensor.new({
storage_light:set_on(false) storage_light:set_on(false)
end end
end, end,
battery_callback = check_battery,
})) }))
automation.device_manager:schedule("0 0 19 * * *", function() automation.device_manager:schedule("0 0 19 * * *", function()