diff --git a/config/battery.lua b/config/battery.lua new file mode 100644 index 0000000..7eb82f2 --- /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.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 aad7046..cb5cbdc 100644 --- a/config/config.lua +++ b/config/config.lua @@ -1,774 +1,42 @@ -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 - -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, - group_id = 7, - scene_id = "7MJLG27RzeRAEVJ", -}) -local living_lights = devices.HueGroup.new({ - identifier = "living_lights", - ip = hue_ip, - login = hue_token, - group_id = 1, - scene_id = "SNZw7jUhQ3cXSjkj", -}) -local living_lights_relax = devices.HueGroup.new({ - identifier = "living_lights", - ip = hue_ip, - login = hue_token, - group_id = 1, - scene_id = "eRJ3fvGHCcb6yNw", -}) -local hallway_top_light = devices.HueGroup.new({ - identifier = "hallway_top_light", - ip = hue_ip, - login = hue_token, - group_id = 83, - scene_id = "QeufkFDICEHWeKJ7", -}) -local hallway_bottom_lights = devices.HueGroup.new({ - identifier = "hallway_bottom_lights", - ip = hue_ip, - login = hue_token, - group_id = 81, - scene_id = "3qWKxGVadXFFG4o", -}) -local bedroom_lights = devices.HueGroup.new({ - identifier = "bedroom_lights", - ip = hue_ip, - login = hue_token, - group_id = 3, - scene_id = "PvRs-lGD4VRytL9", -}) -local bedroom_lights_relax = devices.HueGroup.new({ - identifier = "bedroom_lights", - ip = hue_ip, - login = hue_token, - group_id = 3, - scene_id = "60tfTyR168v2csz", -}) - -local bedroom_air_filter = devices.AirFilter.new({ - name = "Air Filter", - room = "Bedroom", - url = "http://10.0.0.103", -}) - -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"), - left_callback = function() - kitchen_lights:set_on(not kitchen_lights:on()) - end, - right_callback = function() - living_lights:set_on(not living_lights:on()) - end, - right_hold_callback = function() - living_lights_relax:set_on(true) - end, - battery_callback = check_battery, - })) - - devs:add(devices.WakeOnLAN.new({ - name = "Zeus", - room = "Living Room", - topic = mqtt_automation("appliance/living_room/zeus"), - client = mqtt_client, - mac_address = "30:9c:23:60:9c:13", - broadcast_ip = "10.0.3.255", - })) - - local living_mixer = devices.OutletOnOff.new({ - name = "Mixer", - room = "Living Room", - topic = mqtt_z2m("living/mixer"), - client = mqtt_client, - }) - 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"), - client = mqtt_client, - }) - 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"), - single_button = true, - callback = function(_, on) - if on then - if living_mixer:on() then - living_mixer:set_on(false) - living_speakers:set_on(false) - else - living_mixer:set_on(true) - living_speakers:set_on(true) - end - else - if not living_mixer:on() then - living_mixer:set_on(true) - else - living_speakers:set_on(not living_speakers:on()) - end - end - end, - battery_callback = check_battery, - })) - - --- @type OutletPower - local kettle = devices.OutletPower.new({ - outlet_type = "Kettle", - name = "Kettle", - room = "Kitchen", - topic = mqtt_z2m("kitchen/kettle"), - client = mqtt_client, - callback = auto_off(), - }) - turn_off_when_away(kettle) - devs:add(kettle) - - --- @param on boolean - local function set_kettle(_, on) - kettle:set_on(on) - end - - devs:add(devices.IkeaRemote.new({ - name = "Remote", - room = "Bedroom", - client = mqtt_client, - topic = mqtt_z2m("bedroom/remote"), - single_button = true, - callback = set_kettle, - battery_callback = check_battery, - })) - - devs:add(devices.IkeaRemote.new({ - name = "Remote", - room = "Kitchen", - client = mqtt_client, - topic = mqtt_z2m("kitchen/remote"), - single_button = true, - callback = set_kettle, - battery_callback = check_battery, - })) - - local bathroom_light = devices.LightOnOff.new({ - name = "Light", - room = "Bathroom", - topic = mqtt_z2m("bathroom/light"), - client = mqtt_client, - callback = off_timeout(debug and 60 or 45 * 60), - }) - devs:add(bathroom_light) - - devs:add(devices.Washer.new({ - identifier = "bathroom_washer", - topic = mqtt_z2m("bathroom/washer"), - client = mqtt_client, - threshold = 1, - done_callback = function() - ntfy:send_notification({ - title = "Laundy is done", - message = "Don't forget to hang it!", - tags = { "womans_clothes" }, - priority = "high", - }) - end, - })) - - devs:add(devices.OutletOnOff.new({ - name = "Charger", - room = "Workbench", - topic = mqtt_z2m("workbench/charger"), - client = mqtt_client, - callback = off_timeout(debug and 5 or 20 * 3600), - })) - - local workbench_outlet = devices.OutletOnOff.new({ - name = "Outlet", - room = "Workbench", - topic = mqtt_z2m("workbench/outlet"), - client = mqtt_client, - }) - 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"), - client = mqtt_client, - }) - turn_off_when_away(workbench_light) - devs:add(workbench_light) - - local delay_color_temp = utils.Timeout.new() - devs:add(devices.IkeaRemote.new({ - name = "Remote", - room = "Workbench", - client = mqtt_client, - topic = mqtt_z2m("workbench/remote"), - callback = function(_, on) - delay_color_temp:cancel() - if on then - workbench_light:set_brightness(82) - -- NOTE: This light does NOT support changing both the brightness and color - -- temperature at the same time, so we first change the brightness and once - -- that is complete we change the color temperature, as that is less likely - -- to have to actually change. - delay_color_temp:start(0.5, function() - workbench_light:set_color_temperature(3333) - end) - else - workbench_light:set_on(false) - end - end, - battery_callback = check_battery, - })) - - devs:add(devices.HueSwitch.new({ - name = "SwitchBottom", - room = "Hallway", - client = mqtt_client, - topic = mqtt_z2m("hallway/switchbottom"), - left_callback = function() - hallway_top_light:set_on(not hallway_top_light:on()) - end, - battery_callback = check_battery, - })) - devs:add(devices.HueSwitch.new({ - name = "SwitchTop", - room = "Hallway", - client = mqtt_client, - topic = mqtt_z2m("hallway/switchtop"), - left_callback = function() - hallway_top_light:set_on(not hallway_top_light:on()) - end, - battery_callback = check_battery, - })) - - local hallway_storage = devices.LightBrightness.new({ - name = "Storage", - room = "Hallway", - topic = mqtt_z2m("hallway/storage"), - client = mqtt_client, - callback = hallway_light_automation:light_callback(), - }) - 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, - } - - 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, - })) - - ---@param duration number - ---@return fun(_, open: boolean) - local function 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"), { - state = true, - updated = utils.get_epoch(), - }) - end - else - timeout:start(duration, function() - mqtt_client:send_message(mqtt_automation("presence/contact/frontdoor"), nil) - end) - end - end - end - local hallway_frontdoor = devices.ContactSensor.new({ - name = "Frontdoor", - room = "Hallway", - sensor_type = "Door", - topic = mqtt_z2m("hallway/frontdoor"), - client = mqtt_client, - callback = { - presence(debug and 10 or 15 * 60), - hallway_light_automation:door_callback(), - }, - battery_callback = check_battery, - }) - devs:add(hallway_frontdoor) - window_sensors:add(hallway_frontdoor) - hallway_light_automation.door = hallway_frontdoor - - local hallway_trash = devices.ContactSensor.new({ - name = "Trash", - room = "Hallway", - sensor_type = "Drawer", - topic = mqtt_z2m("hallway/trash"), - client = mqtt_client, - callback = hallway_light_automation:trash_callback(), - battery_callback = check_battery, - }) - devs:add(hallway_trash) - hallway_light_automation.trash = hallway_trash - - local guest_light = devices.LightOnOff.new({ - name = "Light", - room = "Guest Room", - topic = mqtt_z2m("guest/light"), - client = mqtt_client, - }) - 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"), - 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, - })) - - local balcony = devices.ContactSensor.new({ - name = "Balcony", - room = "Living Room", - sensor_type = "Door", - topic = mqtt_z2m("living/balcony"), - client = mqtt_client, - battery_callback = check_battery, - }) - devs:add(balcony) - window_sensors:add(balcony) - local living_window = devices.ContactSensor.new({ - name = "Window", - room = "Living Room", - topic = mqtt_z2m("living/window"), - client = mqtt_client, - battery_callback = check_battery, - }) - devs:add(living_window) - window_sensors:add(living_window) - local bedroom_window = devices.ContactSensor.new({ - name = "Window", - room = "Bedroom", - topic = mqtt_z2m("bedroom/window"), - client = mqtt_client, - battery_callback = check_battery, - }) - devs:add(bedroom_window) - window_sensors:add(bedroom_window) - local guest_window = devices.ContactSensor.new({ - name = "Window", - room = "Guest Room", - topic = mqtt_z2m("guest/window"), - client = mqtt_client, - battery_callback = check_battery, - }) - devs:add(guest_window) - window_sensors:add(guest_window) - - local storage_light = devices.LightBrightness.new({ - name = "Light", - room = "Storage", - topic = mqtt_z2m("storage/light"), - client = mqtt_client, - }) - 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"), - client = mqtt_client, - callback = function(_, open) - if open then - storage_light:set_brightness(100) - else - storage_light:set_on(false) - end - end, - battery_callback = check_battery, - })) - - devs.add = nil - - return devs -end - ---- @type MqttConfig -local mqtt_config = { - host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto", - port = 8883, - client_name = "automation-" .. host, - username = "mqtt", - password = secrets.mqtt_password, - tls = host == "zeus" or host == "hephaestus", -} +print("Lua " .. _VERSION .. " running on " .. utils.get_hostname()) ---@type Config return { fulfillment = { openid_url = "https://login.huizinga.dev/api/oidc", }, - mqtt = mqtt_config, - modules = { - setup = create_devs, - ntfy, - hue_bridge, - kitchen_lights, - living_lights, - living_lights_relax, - hallway_top_light, - hallway_bottom_lights, - bedroom_lights, - bedroom_lights_relax, - bedroom_air_filter, + mqtt = { + host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto", + port = 8883, + client_name = "automation-" .. host, + username = "mqtt", + password = secrets.mqtt_password, + tls = host == "zeus" or host == "hephaestus", }, + modules = { + require("config.battery"), + require("config.debug"), + require("config.hallway_automation"), + require("config.helper"), + require("config.hue_bridge"), + require("config.light"), + require("config.ntfy"), + require("config.presence"), + require("config.rooms"), + require("config.windows"), + }, + -- TODO: Make this also part of the modules schedule = { ["0 0 19 * * *"] = function() - bedroom_air_filter:set_on(true) + require("config.rooms.bedroom").set_airfilter_on(true) end, ["0 0 20 * * *"] = function() - bedroom_air_filter:set_on(false) + require("config.rooms.bedroom").set_airfilter_on(false) end, - ["0 0 21 */1 * *"] = notify_low_battery, + ["0 0 21 */1 * *"] = require("config.battery").notify_low_battery, }, } diff --git a/config/debug.lua b/config/debug.lua new file mode 100644 index 0000000..639a64a --- /dev/null +++ b/config/debug.lua @@ -0,0 +1,34 @@ +local helper = require("config.helper") +local light = require("config.light") +local presence = require("config.presence") +local utils = require("automation:utils") +local variables = require("automation:variables") + +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 + +--- @type SetupFunction +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) +end + +return module diff --git a/config/hallway_automation.lua b/config/hallway_automation.lua new file mode 100644 index 0000000..7ebe7fb --- /dev/null +++ b/config/hallway_automation.lua @@ -0,0 +1,84 @@ +local debug = require("config.debug") +local utils = require("automation:utils") + +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 = {} + +--- @param on boolean +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..54bee7a --- /dev/null +++ b/config/hue_bridge.lua @@ -0,0 +1,40 @@ +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 + +--- @type SetupFunction +function module.setup() + local bridge = devices.HueBridge.new({ + identifier = "hue_bridge", + ip = module.ip, + login = module.token, + flags = { + presence = 41, + darkness = 43, + }, + }) + + light.add_callback(function(l) + bridge:set_flag("darkness", not l) + end) + + presence.add_callback(function(p) + bridge:set_flag("presence", p) + end) + + return { + bridge, + } +end + +return module diff --git a/config/light.lua b/config/light.lua new file mode 100644 index 0000000..79ed303 --- /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 + +--- @type SetupFunction +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..587b799 --- /dev/null +++ b/config/ntfy.lua @@ -0,0 +1,32 @@ +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 + +--- @type Ntfy? +local ntfy = nil + +--- @param notification Notification +function module.send_notification(notification) + if ntfy then + ntfy:send_notification(notification) + end +end + +--- @type SetupFunction +function module.setup() + ntfy = devices.Ntfy.new({ + topic = ntfy_topic, + }) + + return { + ntfy, + } +end + +return module diff --git a/config/presence.lua b/config/presence.lua new file mode 100644 index 0000000..e2f18ce --- /dev/null +++ b/config/presence.lua @@ -0,0 +1,78 @@ +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 + +--- @param _ DeviceInterface +--- @param presence boolean +local function callback(_, presence) + for _, f in ipairs(callbacks) do + f(presence) + end +end + +--- @type Presence? +local presence = nil + +--- @type SetupFunction +function module.setup(mqtt_client) + presence = devices.Presence.new({ + topic = helper.mqtt_automation("presence/+/#"), + client = mqtt_client, + callback = callback, + }) + + module.add_callback(function(p) + ntfy.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 { + presence, + } +end + +function module.overall_presence() + -- Default to no presence when the device has not been created yet + if not presence then + return false + end + + return presence:overall_presence() +end + +return module diff --git a/config/rooms.lua b/config/rooms.lua new file mode 100644 index 0000000..3627eaf --- /dev/null +++ b/config/rooms.lua @@ -0,0 +1,12 @@ +--- @type SetupInner +return { + require("config.rooms.bathroom"), + require("config.rooms.bedroom"), + require("config.rooms.guest_bedroom"), + require("config.rooms.hallway_bottom"), + require("config.rooms.hallway_top"), + require("config.rooms.kitchen"), + require("config.rooms.living_room"), + require("config.rooms.storage"), + require("config.rooms.workbench"), +} diff --git a/config/rooms/bathroom.lua b/config/rooms/bathroom.lua new file mode 100644 index 0000000..f2c20bb --- /dev/null +++ b/config/rooms/bathroom.lua @@ -0,0 +1,39 @@ +local debug = require("config.debug") +local devices = require("automation:devices") +local helper = require("config.helper") +local ntfy = require("config.ntfy") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local light = devices.LightOnOff.new({ + name = "Light", + room = "Bathroom", + topic = helper.mqtt_z2m("bathroom/light"), + client = mqtt_client, + callback = helper.off_timeout(debug.debug_mode and 60 or 45 * 60), + }) + + local washer = devices.Washer.new({ + identifier = "bathroom_washer", + topic = helper.mqtt_z2m("bathroom/washer"), + client = mqtt_client, + threshold = 1, + done_callback = function() + ntfy.send_notification({ + title = "Laundy is done", + message = "Don't forget to hang it!", + tags = { "womans_clothes" }, + priority = "high", + }) + end, + }) + + return { + light, + washer, + } +end + +return module diff --git a/config/rooms/bedroom.lua b/config/rooms/bedroom.lua new file mode 100644 index 0000000..ee8a561 --- /dev/null +++ b/config/rooms/bedroom.lua @@ -0,0 +1,74 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local hue_bridge = require("config.hue_bridge") +local windows = require("config.windows") + +local module = {} + +--- @type AirFilter? +local air_filter = nil + +--- @type SetupFunction +function module.setup(mqtt_client) + local lights = devices.HueGroup.new({ + identifier = "bedroom_lights", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 3, + scene_id = "PvRs-lGD4VRytL9", + }) + local lights_relax = devices.HueGroup.new({ + identifier = "bedroom_lights_relax", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 3, + scene_id = "60tfTyR168v2csz", + }) + + air_filter = devices.AirFilter.new({ + name = "Air Filter", + room = "Bedroom", + url = "http://10.0.0.103", + }) + + local switch = devices.HueSwitch.new({ + name = "Switch", + room = "Bedroom", + client = mqtt_client, + topic = helper.mqtt_z2m("bedroom/switch"), + left_callback = function() + lights:set_on(not lights:on()) + end, + left_hold_callback = function() + lights_relax:set_on(true) + end, + battery_callback = battery.callback, + }) + + local window = devices.ContactSensor.new({ + name = "Window", + room = "Bedroom", + topic = helper.mqtt_z2m("bedroom/window"), + client = mqtt_client, + battery_callback = battery.callback, + }) + windows.add(window) + + return { + lights, + lights_relax, + air_filter, + switch, + window, + } +end + +--- @param on boolean +function module.set_airfilter_on(on) + if air_filter then + air_filter:set_on(on) + end +end + +return module diff --git a/config/rooms/guest_bedroom.lua b/config/rooms/guest_bedroom.lua new file mode 100644 index 0000000..2022cc2 --- /dev/null +++ b/config/rooms/guest_bedroom.lua @@ -0,0 +1,34 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local presence = require("config.presence") +local windows = require("config.windows") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local light = devices.LightOnOff.new({ + name = "Light", + room = "Guest Room", + topic = helper.mqtt_z2m("guest/light"), + client = mqtt_client, + }) + presence.turn_off_when_away(light) + + local window = devices.ContactSensor.new({ + name = "Window", + room = "Guest Room", + topic = helper.mqtt_z2m("guest/window"), + client = mqtt_client, + battery_callback = battery.callback, + }) + windows.add(window) + + return { + light, + window, + } +end + +return module diff --git a/config/rooms/hallway_bottom.lua b/config/rooms/hallway_bottom.lua new file mode 100644 index 0000000..1e08858 --- /dev/null +++ b/config/rooms/hallway_bottom.lua @@ -0,0 +1,109 @@ +local battery = require("config.battery") +local debug = require("config.debug") +local devices = require("automation:devices") +local hallway_automation = require("config.hallway_automation") +local helper = require("config.helper") +local hue_bridge = require("config.hue_bridge") +local presence = require("config.presence") +local utils = require("automation:utils") +local windows = require("config.windows") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local main_light = devices.HueGroup.new({ + identifier = "hallway_main_light", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 81, + scene_id = "3qWKxGVadXFFG4o", + }) + hallway_automation.add_callback(function(on) + main_light:set_on(on) + end) + + local storage_light = devices.LightBrightness.new({ + name = "Storage", + room = "Hallway", + topic = helper.mqtt_z2m("hallway/storage"), + client = mqtt_client, + callback = hallway_automation.light_callback, + }) + presence.turn_off_when_away(storage_light) + hallway_automation.add_callback(function(on) + if on then + storage_light:set_brightness(80) + else + storage_light:set_on(false) + end + end) + + local remote = devices.IkeaRemote.new({ + name = "Remote", + room = "Hallway", + client = mqtt_client, + topic = helper.mqtt_z2m("hallway/remote"), + callback = hallway_automation.switch_callback, + battery_callback = battery.callback, + }) + + local trash = devices.ContactSensor.new({ + name = "Trash", + room = "Hallway", + sensor_type = "Drawer", + topic = helper.mqtt_z2m("hallway/trash"), + client = mqtt_client, + callback = hallway_automation.trash_callback, + battery_callback = battery.callback, + }) + hallway_automation.set_trash(trash) + + ---@param duration number + ---@return fun(_, open: boolean) + local function frontdoor_presence(duration) + local timeout = utils.Timeout.new() + + return function(_, open) + if open then + timeout:cancel() + + if presence.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(helper.mqtt_automation("presence/contact/frontdoor"), nil) + end) + end + end + end + + local frontdoor = devices.ContactSensor.new({ + name = "Frontdoor", + room = "Hallway", + sensor_type = "Door", + topic = helper.mqtt_z2m("hallway/frontdoor"), + client = mqtt_client, + callback = { + frontdoor_presence(debug.debug_mode and 10 or 15 * 60), + hallway_automation.door_callback, + }, + battery_callback = battery.callback, + }) + windows.add(frontdoor) + hallway_automation.set_door(frontdoor) + + return { + main_light, + storage_light, + remote, + trash, + frontdoor, + } +end + +return module diff --git a/config/rooms/hallway_top.lua b/config/rooms/hallway_top.lua new file mode 100644 index 0000000..b989df9 --- /dev/null +++ b/config/rooms/hallway_top.lua @@ -0,0 +1,47 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local hue_bridge = require("config.hue_bridge") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local light = devices.HueGroup.new({ + identifier = "hallway_top_light", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 83, + scene_id = "QeufkFDICEHWeKJ7", + }) + + local top_switch = devices.HueSwitch.new({ + name = "SwitchTop", + room = "Hallway", + client = mqtt_client, + topic = helper.mqtt_z2m("hallway/switchtop"), + left_callback = function() + light:set_on(not light:on()) + end, + battery_callback = battery.callback, + }) + + local bottom_switch = devices.HueSwitch.new({ + name = "SwitchBottom", + room = "Hallway", + client = mqtt_client, + topic = helper.mqtt_z2m("hallway/switchbottom"), + left_callback = function() + light:set_on(not light:on()) + end, + battery_callback = battery.callback, + }) + + return { + light, + top_switch, + bottom_switch, + } +end + +return module diff --git a/config/rooms/kitchen.lua b/config/rooms/kitchen.lua new file mode 100644 index 0000000..ffb5fff --- /dev/null +++ b/config/rooms/kitchen.lua @@ -0,0 +1,70 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local hue_bridge = require("config.hue_bridge") +local presence = require("config.presence") + +local module = {} + +--- @type HueGroup? +local lights = nil + +--- @type SetupFunction +function module.setup(mqtt_client) + lights = devices.HueGroup.new({ + identifier = "kitchen_lights", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 7, + scene_id = "7MJLG27RzeRAEVJ", + }) + + local kettle = devices.OutletPower.new({ + outlet_type = "Kettle", + name = "Kettle", + room = "Kitchen", + topic = helper.mqtt_z2m("kitchen/kettle"), + client = mqtt_client, + callback = helper.auto_off(), + }) + presence.turn_off_when_away(kettle) + + local kettle_remote = devices.IkeaRemote.new({ + name = "Remote", + room = "Kitchen", + client = mqtt_client, + topic = helper.mqtt_z2m("kitchen/remote"), + single_button = true, + callback = function(_, on) + kettle:set_on(on) + end, + battery_callback = battery.callback, + }) + + local kettle_remote_bedroom = devices.IkeaRemote.new({ + name = "Remote", + room = "Bedroom", + client = mqtt_client, + topic = helper.mqtt_z2m("bedroom/remote"), + single_button = true, + callback = function(_, on) + kettle:set_on(on) + end, + battery_callback = battery.callback, + }) + + return { + lights, + kettle, + kettle_remote, + kettle_remote_bedroom, + } +end + +function module.toggle_lights() + if lights then + lights:set_on(not lights:on()) + end +end + +return module diff --git a/config/rooms/living_room.lua b/config/rooms/living_room.lua new file mode 100644 index 0000000..2c81794 --- /dev/null +++ b/config/rooms/living_room.lua @@ -0,0 +1,125 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local hue_bridge = require("config.hue_bridge") +local presence = require("config.presence") +local windows = require("config.windows") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local lights = devices.HueGroup.new({ + identifier = "living_lights", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 1, + scene_id = "SNZw7jUhQ3cXSjkj", + }) + + local lights_relax = devices.HueGroup.new({ + identifier = "living_lights_relax", + ip = hue_bridge.ip, + login = hue_bridge.token, + group_id = 1, + scene_id = "eRJ3fvGHCcb6yNw", + }) + + local switch = devices.HueSwitch.new({ + name = "Switch", + room = "Living", + client = mqtt_client, + topic = helper.mqtt_z2m("living/switch"), + left_callback = require("config.rooms.kitchen").toggle_lights, + right_callback = function() + lights:set_on(not lights:on()) + end, + right_hold_callback = function() + lights_relax:set_on(true) + end, + battery_callback = battery.callback, + }) + + local pc = devices.WakeOnLAN.new({ + name = "Zeus", + room = "Living Room", + 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", + }) + + local mixer = devices.OutletOnOff.new({ + name = "Mixer", + room = "Living Room", + topic = helper.mqtt_z2m("living/mixer"), + client = mqtt_client, + }) + presence.turn_off_when_away(mixer) + + local speakers = devices.OutletOnOff.new({ + name = "Speakers", + room = "Living Room", + topic = helper.mqtt_z2m("living/speakers"), + client = mqtt_client, + }) + presence.turn_off_when_away(speakers) + + local audio_remote = devices.IkeaRemote.new({ + name = "Remote", + room = "Living Room", + client = mqtt_client, + topic = helper.mqtt_z2m("living/remote"), + single_button = true, + callback = function(_, on) + if on then + if mixer:on() then + mixer:set_on(false) + speakers:set_on(false) + else + mixer:set_on(true) + speakers:set_on(true) + end + else + if not mixer:on() then + mixer:set_on(true) + else + speakers:set_on(not speakers:on()) + end + end + end, + battery_callback = battery.callback, + }) + + local balcony = devices.ContactSensor.new({ + name = "Balcony", + room = "Living Room", + sensor_type = "Door", + topic = helper.mqtt_z2m("living/balcony"), + client = mqtt_client, + battery_callback = battery.callback, + }) + windows.add(balcony) + local window = devices.ContactSensor.new({ + name = "Window", + room = "Living Room", + topic = helper.mqtt_z2m("living/window"), + client = mqtt_client, + battery_callback = battery.callback, + }) + windows.add(window) + + return { + lights, + lights_relax, + switch, + pc, + mixer, + speakers, + audio_remote, + balcony, + window, + } +end + +return module diff --git a/config/rooms/storage.lua b/config/rooms/storage.lua new file mode 100644 index 0000000..f3ca968 --- /dev/null +++ b/config/rooms/storage.lua @@ -0,0 +1,40 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local presence = require("config.presence") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local light = devices.LightBrightness.new({ + name = "Light", + room = "Storage", + topic = helper.mqtt_z2m("storage/light"), + client = mqtt_client, + }) + presence.turn_off_when_away(light) + + local door = devices.ContactSensor.new({ + name = "Door", + room = "Storage", + sensor_type = "Door", + topic = helper.mqtt_z2m("storage/door"), + client = mqtt_client, + callback = function(_, open) + if open then + light:set_brightness(100) + else + light:set_on(false) + end + end, + battery_callback = battery.callback, + }) + + return { + light, + door, + } +end + +return module diff --git a/config/rooms/workbench.lua b/config/rooms/workbench.lua new file mode 100644 index 0000000..0a1b01e --- /dev/null +++ b/config/rooms/workbench.lua @@ -0,0 +1,68 @@ +local battery = require("config.battery") +local debug = require("config.debug") +local devices = require("automation:devices") +local helper = require("config.helper") +local presence = require("config.presence") +local utils = require("automation:utils") + +local module = {} + +--- @type SetupFunction +function module.setup(mqtt_client) + local charger = devices.OutletOnOff.new({ + name = "Charger", + room = "Workbench", + topic = helper.mqtt_z2m("workbench/charger"), + client = mqtt_client, + callback = helper.off_timeout(debug.debug_mode and 5 or 20 * 3600), + }) + + local outlets = devices.OutletOnOff.new({ + name = "Outlets", + room = "Workbench", + topic = helper.mqtt_z2m("workbench/outlet"), + client = mqtt_client, + }) + presence.turn_off_when_away(outlets) + + local light = devices.LightColorTemperature.new({ + name = "Light", + room = "Workbench", + topic = helper.mqtt_z2m("workbench/light"), + client = mqtt_client, + }) + presence.turn_off_when_away(light) + + local delay_color_temp = utils.Timeout.new() + local remote = devices.IkeaRemote.new({ + name = "Remote", + room = "Workbench", + client = mqtt_client, + topic = helper.mqtt_z2m("workbench/remote"), + callback = function(_, on) + delay_color_temp:cancel() + if on then + light:set_brightness(82) + -- NOTE: This light does NOT support changing both the brightness and color + -- temperature at the same time, so we first change the brightness and once + -- that is complete we change the color temperature, as that is less likely + -- to have to actually change. + delay_color_temp:start(0.5, function() + light:set_color_temperature(3333) + end) + else + light:set_on(false) + end + end, + battery_callback = battery.callback, + }) + + return { + charger, + outlets, + light, + remote, + } +end + +return module diff --git a/config/windows.lua b/config/windows.lua new file mode 100644 index 0000000..28207d5 --- /dev/null +++ b/config/windows.lua @@ -0,0 +1,42 @@ +local ntfy = require("config.ntfy") +local presence = require("config.presence") + +local module = {} + +--- @class OnPresence +--- @field [integer] OpenCloseInterface +local sensors = {} + +--- @param sensor OpenCloseInterface +function module.add(sensor) + table.insert(sensors, sensor) +end + +--- @type SetupFunction +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.send_notification({ + title = "Windows are open", + message = message, + tags = { "window" }, + priority = "high", + }) + end + end + end) +end + +return module