From 1766a7b175e3502981432fa9fb777f442640427e Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 07:21:57 +0200 Subject: [PATCH] WIP --- config/battery.lua | 41 +++ config/config.lua | 506 ++++++++-------------------------- config/debug.lua | 36 +++ config/hallway_automation.lua | 83 ++++++ config/helper.lua | 48 ++++ config/hue_bridge.lua | 39 +++ config/light.lua | 42 +++ config/ntfy.lua | 15 + config/presence.lua | 75 +++++ config/windows.lua | 43 +++ 10 files changed, 536 insertions(+), 392 deletions(-) create mode 100644 config/battery.lua create mode 100644 config/debug.lua create mode 100644 config/hallway_automation.lua create mode 100644 config/helper.lua create mode 100644 config/hue_bridge.lua create mode 100644 config/light.lua create mode 100644 config/ntfy.lua create mode 100644 config/presence.lua create mode 100644 config/windows.lua diff --git a/config/battery.lua b/config/battery.lua new file mode 100644 index 0000000..24b5338 --- /dev/null +++ b/config/battery.lua @@ -0,0 +1,41 @@ +local ntfy = require("config.ntfy") + +local module = {} + +--- @type {[string]: number} +local low_battery = {} + +--- @param device DeviceInterface +--- @param battery number +function module.callback(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 + +function module.notify_low_battery() + -- 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.device:send_notification({ + title = "Low battery", + message = message, + tags = { "battery" }, + priority = "default", + }) +end + +return module diff --git a/config/config.lua b/config/config.lua index f683ccf..217543a 100644 --- a/config/config.lua +++ b/config/config.lua @@ -1,307 +1,72 @@ local devices = require("automation:devices") local utils = require("automation:utils") local secrets = require("automation:secrets") -local debug = require("automation:variables").debug and true or false +local helper = require("config.helper") +local presence = require("config.presence") +local windows = require("config.windows") +local ntfy = require("config.ntfy") +local light = require("config.light") +local battery = require("config.battery") +local hue_bridge = require("config.hue_bridge") +local hallway_automation = require("config.hallway_automation") +local debug = require("config.debug") + +hue_bridge.setup() print(_VERSION) local host = utils.get_hostname() print("Running @" .. host) ---- @param topic string ---- @return string -local function mqtt_z2m(topic) - return "zigbee2mqtt/" .. topic -end - ---- @param topic string ---- @return string -local function mqtt_automation(topic) - return "automation/" .. topic -end - ---- @return fun(self: OnOffInterface, state: {state: boolean, power: number}) -local function auto_off() - local timeout = utils.Timeout.new() - - return function(self, state) - if state.state and state.power < 100 then - timeout:start(3, function() - self:set_on(false) - end) - else - timeout:cancel() - end - end -end - ---- @param duration number ---- @return fun(self: OnOffInterface, state: {state: boolean}) -local function off_timeout(duration) - local timeout = utils.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 - -local hallway_light_automation = { - timeout = utils.Timeout.new(), - forced = false, - trash = nil, - door = nil, -} ----@return fun(_, on: boolean) -function hallway_light_automation:switch_callback() - return function(_, on) - self.timeout:cancel() - self.group.set_on(on) - self.forced = on - end -end ----@return fun(_, open: boolean) -function hallway_light_automation:door_callback() - return function(_, open) - if open then - self.timeout:cancel() - - self.group.set_on(true) - elseif not self.forced then - self.timeout:start(debug and 10 or 2 * 60, function() - if self.trash == nil or self.trash:open_percent() == 0 then - self.group.set_on(false) - end - end) - end - end -end ----@return fun(_, open: boolean) -function hallway_light_automation:trash_callback() - return function(_, open) - if open then - self.group.set_on(true) - else - if - not self.timeout:is_waiting() - and (self.door == nil or self.door:open_percent() == 0) - and not self.forced - then - self.group.set_on(false) - end - end - end -end ----@return fun(_, state: { on: boolean }) -function hallway_light_automation:light_callback() - return function(_, state) - if - state.on - and (self.trash == nil or self.trash:open_percent()) == 0 - and (self.door == nil or self.door:open_percent() == 0) - then - -- If the door and trash are not open, that means the light got turned on manually - self.timeout:cancel() - self.forced = true - elseif not state.on then - -- The light is never forced when it is off - self.forced = false - end - end -end - ---- @class OnPresence ---- @field [integer] fun(presence: boolean) -local on_presence = {} ---- @param f fun(presence: boolean) -function on_presence:add(f) - self[#self + 1] = f -end - ---- @param device OnOffInterface -local function turn_off_when_away(device) - on_presence:add(function(presence) - if not presence then - device:set_on(false) - end - end) -end - ---- @class WindowSensor ---- @field [integer] OpenCloseInterface -local window_sensors = {} ---- @param sensor OpenCloseInterface -function window_sensors:add(sensor) - self[#self + 1] = sensor -end - ---- @class OnLight ---- @field [integer] fun(light: boolean) -local on_light = {} ---- @param f fun(light: boolean) -function on_light:add(f) - self[#self + 1] = f -end - ---- @type {[string]: number} -local low_battery = {} ---- @param device DeviceInterface ---- @param battery number -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 - -local ntfy_topic = secrets.ntfy_topic -if ntfy_topic == nil then - error("Ntfy topic is not specified") -end -local ntfy = devices.Ntfy.new({ - topic = ntfy_topic, -}) - -on_presence:add(function(presence) - ntfy:send_notification({ - title = "Presence", - message = presence and "Home" or "Away", - tags = { "house" }, - priority = "low", - actions = { - { - action = "broadcast", - extras = { - cmd = "presence", - state = presence and "0" or "1", - }, - label = presence and "Set away" or "Set home", - clear = true, - }, - }, - }) -end) - -on_presence:add(function(presence) - if not presence then - local open = {} - for _, sensor in ipairs(window_sensors) do - if sensor:open_percent() > 0 then - local id = sensor:get_id() - print("Open window detected: " .. id) - table.insert(open, id) - end - end - - if #open > 0 then - local message = table.concat(open, "\n") - - ntfy:send_notification({ - title = "Windows are open", - message = message, - tags = { "window" }, - priority = "high", - }) - end - end -end) - -local function notify_low_battery() - -- 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 hue_ip = "10.0.0.102" -local hue_token = secrets.hue_token -if hue_token == nil then - error("Hue token is not specified") -end -local hue_bridge = devices.HueBridge.new({ - identifier = "hue_bridge", - ip = hue_ip, - login = hue_token, - flags = { - presence = 41, - darkness = 43, - }, -}) -on_light:add(function(light) - hue_bridge:set_flag("darkness", not light) -end) -on_presence:add(function(presence) - hue_bridge:set_flag("presence", presence) -end) - local kitchen_lights = devices.HueGroup.new({ identifier = "kitchen_lights", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 7, scene_id = "7MJLG27RzeRAEVJ", }) local living_lights = devices.HueGroup.new({ identifier = "living_lights", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 1, scene_id = "SNZw7jUhQ3cXSjkj", }) local living_lights_relax = devices.HueGroup.new({ identifier = "living_lights", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 1, scene_id = "eRJ3fvGHCcb6yNw", }) local hallway_top_light = devices.HueGroup.new({ identifier = "hallway_top_light", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 83, scene_id = "QeufkFDICEHWeKJ7", }) local hallway_bottom_lights = devices.HueGroup.new({ identifier = "hallway_bottom_lights", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 81, scene_id = "3qWKxGVadXFFG4o", }) +hallway_automation.add_callback(function(on) + hallway_bottom_lights:set_on(on) +end) local bedroom_lights = devices.HueGroup.new({ identifier = "bedroom_lights", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 3, scene_id = "PvRs-lGD4VRytL9", }) local bedroom_lights_relax = devices.HueGroup.new({ identifier = "bedroom_lights", - ip = hue_ip, - login = hue_token, + ip = hue_bridge.ip, + login = hue_bridge.token, group_id = 3, scene_id = "60tfTyR168v2csz", }) @@ -313,58 +78,16 @@ local bedroom_air_filter = devices.AirFilter.new({ }) local function create_devs(mqtt_client) - on_presence:add(function(presence) - mqtt_client:send_message(mqtt_automation("debug") .. "/presence", { - state = presence, - updated = utils.get_epoch(), - }) - end) - - on_light:add(function(light) - mqtt_client:send_message(mqtt_automation("debug") .. "/darkness", { - state = not light, - updated = utils.get_epoch(), - }) - end) - local devs = {} function devs:add(device) table.insert(self, device) end - local presence_system = devices.Presence.new({ - topic = mqtt_automation("presence/+/#"), - client = mqtt_client, - callback = function(_, presence) - for _, f in ipairs(on_presence) do - if type(f) == "function" then - f(presence) - end - end - end, - }) - devs:add(presence_system) - - devs:add(devices.LightSensor.new({ - identifier = "living_light_sensor", - topic = mqtt_z2m("living/light"), - client = mqtt_client, - min = 22000, - max = 23500, - callback = function(_, light) - for _, f in ipairs(on_light) do - if type(f) == "function" then - f(light) - end - end - end, - })) - devs:add(devices.HueSwitch.new({ name = "Switch", room = "Living", client = mqtt_client, - topic = mqtt_z2m("living/switch"), + topic = helper.mqtt_z2m("living/switch"), left_callback = function() kitchen_lights:set_on(not kitchen_lights:on()) end, @@ -374,13 +97,13 @@ local function create_devs(mqtt_client) right_hold_callback = function() living_lights_relax:set_on(true) end, - battery_callback = check_battery, + battery_callback = battery.callback, })) devs:add(devices.WakeOnLAN.new({ name = "Zeus", room = "Living Room", - topic = mqtt_automation("appliance/living_room/zeus"), + topic = helper.mqtt_automation("appliance/living_room/zeus"), client = mqtt_client, mac_address = "30:9c:23:60:9c:13", broadcast_ip = "10.0.3.255", @@ -389,25 +112,25 @@ local function create_devs(mqtt_client) local living_mixer = devices.OutletOnOff.new({ name = "Mixer", room = "Living Room", - topic = mqtt_z2m("living/mixer"), + topic = helper.mqtt_z2m("living/mixer"), client = mqtt_client, }) - turn_off_when_away(living_mixer) + presence.turn_off_when_away(living_mixer) devs:add(living_mixer) local living_speakers = devices.OutletOnOff.new({ name = "Speakers", room = "Living Room", - topic = mqtt_z2m("living/speakers"), + topic = helper.mqtt_z2m("living/speakers"), client = mqtt_client, }) - turn_off_when_away(living_speakers) + presence.turn_off_when_away(living_speakers) devs:add(living_speakers) devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Living Room", client = mqtt_client, - topic = mqtt_z2m("living/remote"), + topic = helper.mqtt_z2m("living/remote"), single_button = true, callback = function(_, on) if on then @@ -426,7 +149,7 @@ local function create_devs(mqtt_client) end end end, - battery_callback = check_battery, + battery_callback = battery.callback, })) --- @type OutletPower @@ -434,11 +157,11 @@ local function create_devs(mqtt_client) outlet_type = "Kettle", name = "Kettle", room = "Kitchen", - topic = mqtt_z2m("kitchen/kettle"), + topic = helper.mqtt_z2m("kitchen/kettle"), client = mqtt_client, - callback = auto_off(), + callback = helper.auto_off(), }) - turn_off_when_away(kettle) + presence.turn_off_when_away(kettle) devs:add(kettle) --- @param on boolean @@ -450,38 +173,38 @@ local function create_devs(mqtt_client) name = "Remote", room = "Bedroom", client = mqtt_client, - topic = mqtt_z2m("bedroom/remote"), + topic = helper.mqtt_z2m("bedroom/remote"), single_button = true, callback = set_kettle, - battery_callback = check_battery, + battery_callback = battery.callback, })) devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Kitchen", client = mqtt_client, - topic = mqtt_z2m("kitchen/remote"), + topic = helper.mqtt_z2m("kitchen/remote"), single_button = true, callback = set_kettle, - battery_callback = check_battery, + battery_callback = battery.callback, })) local bathroom_light = devices.LightOnOff.new({ name = "Light", room = "Bathroom", - topic = mqtt_z2m("bathroom/light"), + topic = helper.mqtt_z2m("bathroom/light"), client = mqtt_client, - callback = off_timeout(debug and 60 or 45 * 60), + callback = helper.off_timeout(debug.debug_mode and 60 or 45 * 60), }) devs:add(bathroom_light) devs:add(devices.Washer.new({ identifier = "bathroom_washer", - topic = mqtt_z2m("bathroom/washer"), + topic = helper.mqtt_z2m("bathroom/washer"), client = mqtt_client, threshold = 1, done_callback = function() - ntfy:send_notification({ + ntfy.device:send_notification({ title = "Laundy is done", message = "Don't forget to hang it!", tags = { "womans_clothes" }, @@ -493,27 +216,27 @@ local function create_devs(mqtt_client) devs:add(devices.OutletOnOff.new({ name = "Charger", room = "Workbench", - topic = mqtt_z2m("workbench/charger"), + topic = helper.mqtt_z2m("workbench/charger"), client = mqtt_client, - callback = off_timeout(debug and 5 or 20 * 3600), + callback = helper.off_timeout(debug.debug_mode and 5 or 20 * 3600), })) local workbench_outlet = devices.OutletOnOff.new({ name = "Outlet", room = "Workbench", - topic = mqtt_z2m("workbench/outlet"), + topic = helper.mqtt_z2m("workbench/outlet"), client = mqtt_client, }) - turn_off_when_away(workbench_outlet) + presence.turn_off_when_away(workbench_outlet) devs:add(workbench_outlet) local workbench_light = devices.LightColorTemperature.new({ name = "Light", room = "Workbench", - topic = mqtt_z2m("workbench/light"), + topic = helper.mqtt_z2m("workbench/light"), client = mqtt_client, }) - turn_off_when_away(workbench_light) + presence.turn_off_when_away(workbench_light) devs:add(workbench_light) local delay_color_temp = utils.Timeout.new() @@ -521,7 +244,7 @@ local function create_devs(mqtt_client) name = "Remote", room = "Workbench", client = mqtt_client, - topic = mqtt_z2m("workbench/remote"), + topic = helper.mqtt_z2m("workbench/remote"), callback = function(_, on) delay_color_temp:cancel() if on then @@ -537,79 +260,74 @@ local function create_devs(mqtt_client) workbench_light:set_on(false) end end, - battery_callback = check_battery, + battery_callback = battery.callback, })) devs:add(devices.HueSwitch.new({ name = "SwitchBottom", room = "Hallway", client = mqtt_client, - topic = mqtt_z2m("hallway/switchbottom"), + topic = helper.mqtt_z2m("hallway/switchbottom"), left_callback = function() hallway_top_light:set_on(not hallway_top_light:on()) end, - battery_callback = check_battery, + battery_callback = battery.callback, })) devs:add(devices.HueSwitch.new({ name = "SwitchTop", room = "Hallway", client = mqtt_client, - topic = mqtt_z2m("hallway/switchtop"), + topic = helper.mqtt_z2m("hallway/switchtop"), left_callback = function() hallway_top_light:set_on(not hallway_top_light:on()) end, - battery_callback = check_battery, + battery_callback = battery.callback, })) local hallway_storage = devices.LightBrightness.new({ name = "Storage", room = "Hallway", - topic = mqtt_z2m("hallway/storage"), + topic = helper.mqtt_z2m("hallway/storage"), client = mqtt_client, - callback = hallway_light_automation:light_callback(), + callback = hallway_automation.light_callback, }) - turn_off_when_away(hallway_storage) + presence.turn_off_when_away(hallway_storage) devs:add(hallway_storage) - - -- TODO: Rework - hallway_light_automation.group = { - set_on = function(on) - if on then - hallway_storage:set_brightness(80) - else - hallway_storage:set_on(false) - end - hallway_bottom_lights:set_on(on) - end, - } + hallway_automation.add_callback(function(on) + if on then + hallway_storage:set_brightness(80) + else + hallway_storage:set_on(false) + end + end) devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Hallway", client = mqtt_client, - topic = mqtt_z2m("hallway/remote"), - callback = hallway_light_automation:switch_callback(), - battery_callback = check_battery, + topic = helper.mqtt_z2m("hallway/remote"), + callback = hallway_automation.switch_callback, + battery_callback = battery.callback, })) ---@param duration number ---@return fun(_, open: boolean) - local function presence(duration) + local function frontdoor_presence(duration) local timeout = utils.Timeout.new() return function(_, open) if open then timeout:cancel() - if not presence_system:overall_presence() then - mqtt_client:send_message(mqtt_automation("presence/contact/frontdoor"), { + if presence.device and not presence.device:overall_presence() then + mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), { state = true, updated = utils.get_epoch(), }) end else timeout:start(duration, function() - mqtt_client:send_message(mqtt_automation("presence/contact/frontdoor"), nil) + mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), nil) end) end end @@ -618,105 +336,105 @@ local function create_devs(mqtt_client) name = "Frontdoor", room = "Hallway", sensor_type = "Door", - topic = mqtt_z2m("hallway/frontdoor"), + topic = helper.mqtt_z2m("hallway/frontdoor"), client = mqtt_client, callback = { - presence(debug and 10 or 15 * 60), - hallway_light_automation:door_callback(), + frontdoor_presence(debug.debug_mode and 10 or 15 * 60), + hallway_automation.door_callback, }, - battery_callback = check_battery, + battery_callback = battery.callback, }) devs:add(hallway_frontdoor) - window_sensors:add(hallway_frontdoor) - hallway_light_automation.door = hallway_frontdoor + windows.add(hallway_frontdoor) + hallway_automation.set_door(hallway_frontdoor) local hallway_trash = devices.ContactSensor.new({ name = "Trash", room = "Hallway", sensor_type = "Drawer", - topic = mqtt_z2m("hallway/trash"), + topic = helper.mqtt_z2m("hallway/trash"), client = mqtt_client, - callback = hallway_light_automation:trash_callback(), - battery_callback = check_battery, + callback = hallway_automation.trash_callback, + battery_callback = battery.callback, }) devs:add(hallway_trash) - hallway_light_automation.trash = hallway_trash + hallway_automation.set_trash(hallway_trash) local guest_light = devices.LightOnOff.new({ name = "Light", room = "Guest Room", - topic = mqtt_z2m("guest/light"), + topic = helper.mqtt_z2m("guest/light"), client = mqtt_client, }) - turn_off_when_away(guest_light) + presence.turn_off_when_away(guest_light) devs:add(guest_light) devs:add(devices.HueSwitch.new({ name = "Switch", room = "Bedroom", client = mqtt_client, - topic = mqtt_z2m("bedroom/switch"), + topic = helper.mqtt_z2m("bedroom/switch"), left_callback = function() bedroom_lights:set_on(not bedroom_lights:on()) end, left_hold_callback = function() bedroom_lights_relax:set_on(true) end, - battery_callback = check_battery, + battery_callback = battery.callback, })) local balcony = devices.ContactSensor.new({ name = "Balcony", room = "Living Room", sensor_type = "Door", - topic = mqtt_z2m("living/balcony"), + topic = helper.mqtt_z2m("living/balcony"), client = mqtt_client, - battery_callback = check_battery, + battery_callback = battery.callback, }) devs:add(balcony) - window_sensors:add(balcony) + windows.add(balcony) local living_window = devices.ContactSensor.new({ name = "Window", room = "Living Room", - topic = mqtt_z2m("living/window"), + topic = helper.mqtt_z2m("living/window"), client = mqtt_client, - battery_callback = check_battery, + battery_callback = battery.callback, }) devs:add(living_window) - window_sensors:add(living_window) + windows.add(living_window) local bedroom_window = devices.ContactSensor.new({ name = "Window", room = "Bedroom", - topic = mqtt_z2m("bedroom/window"), + topic = helper.mqtt_z2m("bedroom/window"), client = mqtt_client, - battery_callback = check_battery, + battery_callback = battery.callback, }) devs:add(bedroom_window) - window_sensors:add(bedroom_window) + windows.add(bedroom_window) local guest_window = devices.ContactSensor.new({ name = "Window", room = "Guest Room", - topic = mqtt_z2m("guest/window"), + topic = helper.mqtt_z2m("guest/window"), client = mqtt_client, - battery_callback = check_battery, + battery_callback = battery.callback, }) devs:add(guest_window) - window_sensors:add(guest_window) + windows.add(guest_window) local storage_light = devices.LightBrightness.new({ name = "Light", room = "Storage", - topic = mqtt_z2m("storage/light"), + topic = helper.mqtt_z2m("storage/light"), client = mqtt_client, }) - turn_off_when_away(storage_light) + presence.turn_off_when_away(storage_light) devs:add(storage_light) devs:add(devices.ContactSensor.new({ name = "Door", room = "Storage", sensor_type = "Door", - topic = mqtt_z2m("storage/door"), + topic = helper.mqtt_z2m("storage/door"), client = mqtt_client, callback = function(_, open) if open then @@ -725,7 +443,7 @@ local function create_devs(mqtt_client) storage_light:set_on(false) end end, - battery_callback = check_battery, + battery_callback = battery.callback, })) devs.add = nil @@ -751,8 +469,12 @@ return { mqtt = mqtt_config, devices = { create_devs, - ntfy, - hue_bridge, + ntfy.device, + presence.setup, + light.setup, + hue_bridge.setup, + debug.setup, + windows.setup, kitchen_lights, living_lights, living_lights_relax, @@ -769,6 +491,6 @@ return { ["0 0 20 * * *"] = function() bedroom_air_filter:set_on(false) end, - ["0 0 21 */1 * *"] = notify_low_battery, + ["0 0 21 */1 * *"] = battery.notify_low_battery, }, } diff --git a/config/debug.lua b/config/debug.lua new file mode 100644 index 0000000..e7c356f --- /dev/null +++ b/config/debug.lua @@ -0,0 +1,36 @@ +local variables = require("automation:variables") +local presence = require("config.presence") +local light = require("config.light") +local helper = require("config.helper") +local utils = require("automation:utils") + +local module = {} + +if variables.debug == "true" then + module.debug_mode = true +elseif not variables.debug or variables.debug == "false" then + module.debug_mode = false +else + error("Variable debug has invalid value '" .. variables.debug .. "', expected 'true' or 'false'") +end + +--- @param mqtt_client AsyncClient +function module.setup(mqtt_client) + presence.add_callback(function(p) + mqtt_client:send_message(helper.mqtt_automation("debug") .. "/presence", { + state = p, + updated = utils.get_epoch(), + }) + end) + + light.add_callback(function(l) + mqtt_client:send_message(helper.mqtt_automation("debug") .. "/darkness", { + state = not l, + updated = utils.get_epoch(), + }) + end) + + return {} +end + +return module diff --git a/config/hallway_automation.lua b/config/hallway_automation.lua new file mode 100644 index 0000000..53f30bd --- /dev/null +++ b/config/hallway_automation.lua @@ -0,0 +1,83 @@ +local utils = require("automation:utils") +local debug = require("config.debug") + +local module = {} + +local timeout = utils.Timeout.new() +local forced = false +--- @type OpenCloseInterface? +local trash = nil +--- @type OpenCloseInterface? +local door = nil + +--- @type fun(on: boolean)[] +local callbacks = {} + +local function callback(on) + for _, f in ipairs(callbacks) do + f(on) + end +end + +---@type fun(device: DeviceInterface, on: boolean) +function module.switch_callback(_, on) + timeout:cancel() + callback(on) + forced = on +end + +---@type fun(device: DeviceInterface, open: boolean) +function module.door_callback(_, open) + if open then + timeout:cancel() + + callback(true) + elseif not forced then + timeout:start(debug.debug_mode and 10 or 2 * 60, function() + if trash == nil or trash:open_percent() == 0 then + callback(false) + end + end) + end +end + +---@type fun(device: DeviceInterface, open: boolean) +function module.trash_callback(_, open) + if open then + callback(true) + else + if not forced and not timeout:is_waiting() and (door == nil or door:open_percent() == 0) then + callback(false) + end + end +end + +---@type fun(device: DeviceInterface, state: { state: boolean }) +function module.light_callback(_, state) + print("LIGHT = " .. tostring(state.state)) + if state.state and (trash == nil or trash:open_percent()) == 0 and (door == nil or door:open_percent() == 0) then + -- If the door and trash are not open, that means the light got turned on manually + timeout:cancel() + forced = true + elseif not state.state then + -- The light is never forced when it is off + forced = false + end +end + +--- @param t OpenCloseInterface +function module.set_trash(t) + trash = t +end + +--- @param d OpenCloseInterface +function module.set_door(d) + door = d +end + +--- @param c fun(on: boolean) +function module.add_callback(c) + table.insert(callbacks, c) +end + +return module diff --git a/config/helper.lua b/config/helper.lua new file mode 100644 index 0000000..c62c876 --- /dev/null +++ b/config/helper.lua @@ -0,0 +1,48 @@ +local utils = require("automation:utils") + +local module = {} + +--- @param topic string +--- @return string +function module.mqtt_z2m(topic) + return "zigbee2mqtt/" .. topic +end + +--- @param topic string +--- @return string +function module.mqtt_automation(topic) + return "automation/" .. topic +end + +--- @return fun(self: OnOffInterface, state: {state: boolean, power: number}) +function module.auto_off() + local timeout = utils.Timeout.new() + + return function(self, state) + if state.state and state.power < 100 then + timeout:start(3, function() + self:set_on(false) + end) + else + timeout:cancel() + end + end +end + +--- @param duration number +--- @return fun(self: OnOffInterface, state: {state: boolean}) +function module.off_timeout(duration) + local timeout = utils.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 + +return module diff --git a/config/hue_bridge.lua b/config/hue_bridge.lua new file mode 100644 index 0000000..72f4178 --- /dev/null +++ b/config/hue_bridge.lua @@ -0,0 +1,39 @@ +local devices = require("automation:devices") +local light = require("config.light") +local presence = require("config.presence") +local secrets = require("automation:secrets") + +local module = {} + +module.ip = "10.0.0.102" +module.token = secrets.hue_token + +if module.token == nil then + error("Hue token is not specified") +end + +module.device = devices.HueBridge.new({ + identifier = "hue_bridge", + ip = module.ip, + login = module.token, + flags = { + presence = 41, + darkness = 43, + }, +}) + +function module.setup() + light.add_callback(function(l) + module.device:set_flag("darkness", not l) + end) + + presence.add_callback(function(p) + module.device:set_flag("presence", p) + end) + + return { + module.device, + } +end + +return module diff --git a/config/light.lua b/config/light.lua new file mode 100644 index 0000000..b2e73e8 --- /dev/null +++ b/config/light.lua @@ -0,0 +1,42 @@ +local devices = require("automation:devices") +local helper = require("config.helper") + +local module = {} + +--- @class OnPresence +--- @field [integer] fun(light: boolean) +local callbacks = {} + +--- @param callback fun(light: boolean) +function module.add_callback(callback) + table.insert(callbacks, callback) +end + +--- @param _ DeviceInterface +--- @param light boolean +local function callback(_, light) + for _, f in ipairs(callbacks) do + f(light) + end +end + +--- @type LightSensor? +module.device = nil + +--- @param mqtt_client AsyncClient +function module.setup(mqtt_client) + module.device = devices.LightSensor.new({ + identifier = "living_light_sensor", + topic = helper.mqtt_z2m("living/light"), + client = mqtt_client, + min = 22000, + max = 23500, + callback = callback, + }) + + return { + module.device, + } +end + +return module diff --git a/config/ntfy.lua b/config/ntfy.lua new file mode 100644 index 0000000..646b178 --- /dev/null +++ b/config/ntfy.lua @@ -0,0 +1,15 @@ +local devices = require("automation:devices") +local secrets = require("automation:secrets") + +local module = {} + +local ntfy_topic = secrets.ntfy_topic +if ntfy_topic == nil then + error("Ntfy topic is not specified") +end + +module.device = devices.Ntfy.new({ + topic = ntfy_topic, +}) + +return module diff --git a/config/presence.lua b/config/presence.lua new file mode 100644 index 0000000..64095d5 --- /dev/null +++ b/config/presence.lua @@ -0,0 +1,75 @@ +local devices = require("automation:devices") +local helper = require("config.helper") +local ntfy = require("config.ntfy") + +local module = {} + +--- @class OnPresence +--- @field [integer] fun(presence: boolean) +local callbacks = {} + +--- @param callback fun(presence: boolean) +function module.add_callback(callback) + table.insert(callbacks, callback) +end + +--- @param device OnOffInterface +function module.turn_off_when_away(device) + module.add_callback(function(presence) + if not presence then + device:set_on(false) + end + end) +end + +--- HACK: This is placeholder function until the actual presence device has been created +--- @type fun(): boolean? +function module.overall_presence() + return nil +end + +--- @param _ DeviceInterface +--- @param presence boolean +local function callback(_, presence) + for _, f in ipairs(callbacks) do + f(presence) + end +end + +--- @type Presence? +module.device = nil + +--- @param mqtt_client AsyncClient +function module.setup(mqtt_client) + module.device = devices.Presence.new({ + topic = helper.mqtt_automation("presence/+/#"), + client = mqtt_client, + callback = callback, + }) + + module.add_callback(function(p) + ntfy.device:send_notification({ + title = "Presence", + message = p and "Home" or "Away", + tags = { "house" }, + priority = "low", + actions = { + { + action = "broadcast", + extras = { + cmd = "presence", + state = p and "0" or "1", + }, + label = p and "Set away" or "Set home", + clear = true, + }, + }, + }) + end) + + return { + module.device, + } +end + +return module diff --git a/config/windows.lua b/config/windows.lua new file mode 100644 index 0000000..29da29a --- /dev/null +++ b/config/windows.lua @@ -0,0 +1,43 @@ +local presence = require("config.presence") +local ntfy = require("config.ntfy") + +local module = {} + +--- @class OnPresence +--- @field [integer] OpenCloseInterface +local sensors = {} + +--- @param sensor OpenCloseInterface +function module.add(sensor) + table.insert(sensors, sensor) +end + +function module.setup() + presence.add_callback(function(p) + if not p then + local open = {} + for _, sensor in ipairs(sensors) do + if sensor:open_percent() > 0 then + local id = sensor:get_id() + print("Open window detected: " .. id) + table.insert(open, id) + end + end + + if #open > 0 then + local message = table.concat(open, "\n") + + ntfy.device:send_notification({ + title = "Windows are open", + message = message, + tags = { "window" }, + priority = "high", + }) + end + end + end) + + return {} +end + +return module