diff --git a/config-new.lua b/config-new.lua new file mode 100644 index 0000000..f0f678e --- /dev/null +++ b/config-new.lua @@ -0,0 +1,776 @@ +local devices = require("automation:devices") +local device_manager = require("automation:device_manager") +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 + +-- TODO: Pass the mqtt config to the output config, instead of constructing the client here +local mqtt_client = require("automation:mqtt").new(device_manager, { + 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", +}) + +---@type Config +return { + fulfillment = { + openid_url = "https://login.huizinga.dev/api/oidc", + }, + mqtt = mqtt_client, + devices = { + -- TODO: Fix definition + 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, + }, + schedule = { + ["0 0 19 * * *"] = function() + bedroom_air_filter:set_on(true) + end, + ["0 0 20 * * *"] = function() + bedroom_air_filter:set_on(false) + end, + ["0 0 21 */1 * *"] = notify_low_battery, + }, +} diff --git a/config.lua b/config.lua index 90493b9..2920bf6 100644 --- a/config.lua +++ b/config.lua @@ -742,11 +742,15 @@ devs:add(devices.ContactSensor.new({ battery_callback = check_battery, })) +-- HACK: If the devices config contains a function it will call it so we have to remove it +devs.add = nil + ---@type Config return { fulfillment = { openid_url = "https://login.huizinga.dev/api/oidc", }, + mqtt = mqtt_client, devices = devs, schedule = { ["0 0 19 * * *"] = function() diff --git a/definitions/config.lua b/definitions/config.lua index c6cc30f..c271e12 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -9,6 +9,9 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ----@field devices DeviceInterface[]? +---@field devices Devices? +---@field mqtt AsyncClient ---@field schedule table? local Config + +---@alias Devices (DeviceInterface | fun(client: AsyncClient): Devices)[] diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 644e94a..5baca23 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -139,8 +139,10 @@ async fn app() -> anyhow::Result<()> { let entrypoint = Path::new(&setup.entrypoint); let config: Config = lua.load(entrypoint).eval_async().await?; - for device in config.devices { - device_manager.add(device).await; + if let Some(devices) = config.devices { + for device in devices.get(&lua, &config.mqtt).await? { + device_manager.add(device).await; + } } start_scheduler(config.schedule).await?; diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index aab0e20..41262f5 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -1,7 +1,7 @@ use std::fs::{self, File}; use std::io::Write; -use automation::config::{Config, FulfillmentConfig}; +use automation::config::{Config, Devices, FulfillmentConfig}; use automation_lib::Module; use lua_typed::Typed; use tracing::{info, warn}; @@ -33,6 +33,8 @@ fn config_definitions() -> String { &FulfillmentConfig::generate_full().expect("FulfillmentConfig should have a definition"); output += "\n"; output += &Config::generate_full().expect("Config should have a definition"); + output += "\n"; + output += &Devices::generate_full().expect("Devices should have a definition"); output } diff --git a/src/config.rs b/src/config.rs index 38d732c..daac095 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,12 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::net::{Ipv4Addr, SocketAddr}; use automation_lib::action_callback::ActionCallback; use automation_lib::device::Device; +use automation_lib::mqtt::WrappedAsyncClient; use automation_macro::LuaDeviceConfig; use lua_typed::Typed; +use mlua::FromLua; use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -32,12 +34,78 @@ pub struct FulfillmentConfig { pub port: u16, } +#[derive(Debug, Default)] +pub struct Devices(mlua::Value); + +impl Devices { + pub async fn get( + self, + lua: &mlua::Lua, + client: &WrappedAsyncClient, + ) -> mlua::Result>> { + let mut devices = Vec::new(); + let initial_table = match self.0 { + mlua::Value::Table(table) => table, + mlua::Value::Function(f) => f.call_async(client.clone()).await?, + _ => Err(mlua::Error::runtime(format!( + "Expected table or function, instead found: {}", + self.0.type_name() + )))?, + }; + + let mut queue: VecDeque = [initial_table].into(); + loop { + let Some(table) = queue.pop_front() else { + break; + }; + + for pair in table.pairs() { + let (_, value): (mlua::Value, _) = pair?; + + match value { + mlua::Value::UserData(_) => devices.push(Box::from_lua(value, lua)?), + mlua::Value::Function(f) => { + queue.push_back(f.call_async(client.clone()).await?); + } + _ => Err(mlua::Error::runtime(format!( + "Expected a device, table, or function, instead found: {}", + value.type_name() + )))?, + } + } + } + + Ok(devices) + } +} + +impl FromLua for Devices { + fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result { + Ok(Devices(value)) + } +} + +impl Typed for Devices { + fn type_name() -> String { + "Devices".into() + } + + fn generate_header() -> Option { + Some(format!( + "---@alias {} (DeviceInterface | fun(client: {}): Devices)[]\n", + ::type_name(), + ::type_name() + )) + } +} + #[derive(Debug, LuaDeviceConfig, Typed)] pub struct Config { pub fulfillment: FulfillmentConfig, #[device_config(from_lua, default)] - #[typed(default)] - pub devices: Vec>, + pub devices: Option, + #[device_config(from_lua)] + pub mqtt: WrappedAsyncClient, #[device_config(from_lua, default)] #[typed(default)] pub schedule: HashMap>,