From ba818c6b602137123d9f2858043530be91667ef7 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 02:41:03 +0200 Subject: [PATCH 01/23] refactor(config)!: Setup for expanding lua config return Moves the application config out of automation_lib and sets up the config return type for further expansion. --- automation_lib/src/config.rs | 24 ------------------------ config.lua | 10 +++++----- src/config.rs | 30 +++++++++++++++++++++++++++++- src/main.rs | 19 +++++++++---------- 4 files changed, 43 insertions(+), 40 deletions(-) diff --git a/automation_lib/src/config.rs b/automation_lib/src/config.rs index e0c9e0e..0848ee0 100644 --- a/automation_lib/src/config.rs +++ b/automation_lib/src/config.rs @@ -1,4 +1,3 @@ -use std::net::{Ipv4Addr, SocketAddr}; use std::time::Duration; use lua_typed::Typed; @@ -30,29 +29,6 @@ impl From for MqttOptions { } } -#[derive(Debug, Deserialize)] -pub struct FulfillmentConfig { - pub openid_url: String, - #[serde(default = "default_fulfillment_ip")] - pub ip: Ipv4Addr, - #[serde(default = "default_fulfillment_port")] - pub port: u16, -} - -impl From for SocketAddr { - fn from(fulfillment: FulfillmentConfig) -> Self { - (fulfillment.ip, fulfillment.port).into() - } -} - -fn default_fulfillment_ip() -> Ipv4Addr { - [0, 0, 0, 0].into() -} - -fn default_fulfillment_port() -> u16 { - 7878 -} - #[derive(Debug, Clone, Deserialize, Typed)] pub struct InfoConfig { pub name: String, diff --git a/config.lua b/config.lua index f39654f..8b3897d 100644 --- a/config.lua +++ b/config.lua @@ -21,10 +21,6 @@ local function mqtt_automation(topic) return "automation/" .. topic end -local fulfillment = { - openid_url = "https://login.huizinga.dev/api/oidc", -} - local mqtt_client = require("automation:mqtt").new(device_manager, { host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto", port = 8883, @@ -748,4 +744,8 @@ device_manager:schedule("0 0 20 * * *", function() bedroom_air_filter:set_on(false) end) -return fulfillment +return { + fulfillment = { + openid_url = "https://login.huizinga.dev/api/oidc", + }, +} diff --git a/src/config.rs b/src/config.rs index 39e0931..190b2f2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,10 @@ use std::collections::HashMap; +use std::net::{Ipv4Addr, SocketAddr}; use serde::Deserialize; #[derive(Debug, Deserialize)] -pub struct Config { +pub struct Setup { #[serde(default = "default_entrypoint")] pub entrypoint: String, #[serde(default)] @@ -15,3 +16,30 @@ pub struct Config { fn default_entrypoint() -> String { "./config.lua".into() } + +#[derive(Debug, Deserialize)] +pub struct FulfillmentConfig { + pub openid_url: String, + #[serde(default = "default_fulfillment_ip")] + pub ip: Ipv4Addr, + #[serde(default = "default_fulfillment_port")] + pub port: u16, +} + +#[derive(Debug, Deserialize)] +pub struct Config { + pub fulfillment: FulfillmentConfig, +} + +impl From for SocketAddr { + fn from(fulfillment: FulfillmentConfig) -> Self { + (fulfillment.ip, fulfillment.port).into() + } +} +fn default_fulfillment_ip() -> Ipv4Addr { + [0, 0, 0, 0].into() +} + +fn default_fulfillment_port() -> u16 { + 7878 +} diff --git a/src/main.rs b/src/main.rs index 34424e2..fb43ff2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,19 +9,18 @@ use std::path::Path; use std::process; use ::config::{Environment, File}; -use automation_lib::config::FulfillmentConfig; use automation_lib::device_manager::DeviceManager; use axum::extract::{FromRef, State}; use axum::http::StatusCode; use axum::routing::post; use axum::{Json, Router}; -use config::Config; use google_home::{GoogleHome, Request, Response}; use mlua::LuaSerdeExt; use tokio::net::TcpListener; use tracing::{debug, error, info, warn}; use web::{ApiError, User}; +use crate::config::{Config, Setup}; use crate::secret::EnvironmentSecretFile; use crate::version::VERSION; @@ -76,7 +75,7 @@ async fn app() -> anyhow::Result<()> { info!(version = VERSION, "automation_rs"); - let config: Config = ::config::Config::builder() + let setup: Setup = ::config::Config::builder() .add_source( File::with_name(&format!("{}.toml", std::env!("CARGO_PKG_NAME"))).required(false), ) @@ -138,12 +137,12 @@ async fn app() -> anyhow::Result<()> { lua.register_module("automation:device_manager", device_manager.clone())?; - lua.register_module("automation:variables", lua.to_value(&config.variables)?)?; - lua.register_module("automation:secrets", lua.to_value(&config.secrets)?)?; + lua.register_module("automation:variables", lua.to_value(&setup.variables)?)?; + lua.register_module("automation:secrets", lua.to_value(&setup.secrets)?)?; - let entrypoint = Path::new(&config.entrypoint); - let fulfillment_config: mlua::Value = lua.load(entrypoint).eval_async().await?; - let fulfillment_config: FulfillmentConfig = lua.from_value(fulfillment_config)?; + let entrypoint = Path::new(&setup.entrypoint); + let config: mlua::Value = lua.load(entrypoint).eval_async().await?; + let config: Config = lua.from_value(config)?; // Create google home fulfillment route let fulfillment = Router::new().route("/google_home", post(fulfillment)); @@ -152,12 +151,12 @@ async fn app() -> anyhow::Result<()> { let app = Router::new() .nest("/fulfillment", fulfillment) .with_state(AppState { - openid_url: fulfillment_config.openid_url.clone(), + openid_url: config.fulfillment.openid_url.clone(), device_manager, }); // Start the web server - let addr: SocketAddr = fulfillment_config.into(); + let addr: SocketAddr = config.fulfillment.into(); info!("Server started on http://{addr}"); let listener = TcpListener::bind(addr).await?; axum::serve(listener, app).await?; -- 2.49.1 From 5801421378784a4cecc15f167c69ae29b467285d Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 02:57:18 +0200 Subject: [PATCH 02/23] refactor: Move main.rs to bin/automation.rs --- src/{main.rs => bin/automation.rs} | 13 ++++--------- src/lib.rs | 4 ++++ 2 files changed, 8 insertions(+), 9 deletions(-) rename src/{main.rs => bin/automation.rs} (96%) create mode 100644 src/lib.rs diff --git a/src/main.rs b/src/bin/automation.rs similarity index 96% rename from src/main.rs rename to src/bin/automation.rs index fb43ff2..155947d 100644 --- a/src/main.rs +++ b/src/bin/automation.rs @@ -1,14 +1,14 @@ #![feature(iter_intersperse)] -mod config; -mod secret; -mod version; -mod web; use std::net::SocketAddr; use std::path::Path; use std::process; use ::config::{Environment, File}; +use automation::config::{Config, Setup}; +use automation::secret::EnvironmentSecretFile; +use automation::version::VERSION; +use automation::web::{ApiError, User}; use automation_lib::device_manager::DeviceManager; use axum::extract::{FromRef, State}; use axum::http::StatusCode; @@ -18,11 +18,6 @@ use google_home::{GoogleHome, Request, Response}; use mlua::LuaSerdeExt; use tokio::net::TcpListener; use tracing::{debug, error, info, warn}; -use web::{ApiError, User}; - -use crate::config::{Config, Setup}; -use crate::secret::EnvironmentSecretFile; -use crate::version::VERSION; // Force automation_devices to link so that it gets registered as a module extern crate automation_devices; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..49e787e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod secret; +pub mod version; +pub mod web; -- 2.49.1 From b557afe2fc474746c2e3989270ebc134962d6241 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 03:12:40 +0200 Subject: [PATCH 03/23] refactor: Move definition writing into separate function --- config.lua | 1 + src/bin/generate_definitions.rs | 26 ++++++++++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/config.lua b/config.lua index 8b3897d..5b95428 100644 --- a/config.lua +++ b/config.lua @@ -744,6 +744,7 @@ device_manager:schedule("0 0 20 * * *", function() bedroom_air_filter:set_on(false) end) +---@type Config return { fulfillment = { openid_url = "https://login.huizinga.dev/api/oidc", diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index 789cd2f..be95d04 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -6,23 +6,33 @@ use tracing::{info, warn}; extern crate automation_devices; -fn main() -> std::io::Result<()> { - tracing_subscriber::fmt::init(); - +fn write_definitions(filename: &str, definitions: &str) -> std::io::Result<()> { let definitions_directory = std::path::Path::new(std::env!("CARGO_MANIFEST_DIR")).join("definitions"); fs::create_dir_all(&definitions_directory)?; + let mut file = File::create(definitions_directory.join(filename))?; + + file.write_all(b"-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED\n")?; + file.write_all(definitions.as_bytes())?; + + // Make sure we have a trailing new line + if !definitions.ends_with("\n") { + file.write_all(b"\n")?; + } + + Ok(()) +} + +fn main() -> std::io::Result<()> { + tracing_subscriber::fmt::init(); + for module in inventory::iter:: { if let Some(definitions) = module.definitions() { info!(name = module.get_name(), "Generating definitions"); let filename = format!("{}.lua", module.get_name()); - let mut file = File::create(definitions_directory.join(filename))?; - - file.write_all(b"-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED\n")?; - file.write_all(definitions.as_bytes())?; - file.write_all(b"\n")?; + write_definitions(&filename, &definitions)?; } else { warn!(name = module.get_name(), "No definitions"); } -- 2.49.1 From 84e8942fc91f6f179016d6208b10da3b6581e93f Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 03:15:27 +0200 Subject: [PATCH 04/23] feat: Generate definitions for config --- Cargo.lock | 1 + Cargo.toml | 1 + definitions/config.lua | 12 ++++++++++++ src/bin/generate_definitions.rs | 15 +++++++++++++++ src/config.rs | 7 +++++-- 5 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 definitions/config.lua diff --git a/Cargo.lock b/Cargo.lock index 142c38c..03a313f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,7 @@ dependencies = [ "git-version", "google_home", "inventory", + "lua_typed", "mlua", "reqwest", "rumqttc", diff --git a/Cargo.toml b/Cargo.toml index 384f87f..bd2a927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ config = { version = "0.15.15", default-features = false, features = [ ] } git-version = "0.3.9" google_home = { workspace = true } +lua_typed = { workspace = true } inventory = { workspace = true } mlua = { workspace = true } reqwest = { workspace = true } diff --git a/definitions/config.lua b/definitions/config.lua new file mode 100644 index 0000000..dc070a5 --- /dev/null +++ b/definitions/config.lua @@ -0,0 +1,12 @@ +-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED +---@meta + +---@class FulfillmentConfig +---@field openid_url string +---@field ip string? +---@field port integer? +local FulfillmentConfig + +---@class Config +---@field fulfillment FulfillmentConfig +local Config diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index be95d04..aab0e20 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -1,7 +1,9 @@ use std::fs::{self, File}; use std::io::Write; +use automation::config::{Config, FulfillmentConfig}; use automation_lib::Module; +use lua_typed::Typed; use tracing::{info, warn}; extern crate automation_devices; @@ -24,6 +26,17 @@ fn write_definitions(filename: &str, definitions: &str) -> std::io::Result<()> { Ok(()) } +fn config_definitions() -> String { + let mut output = "---@meta\n\n".to_string(); + + output += + &FulfillmentConfig::generate_full().expect("FulfillmentConfig should have a definition"); + output += "\n"; + output += &Config::generate_full().expect("Config should have a definition"); + + output +} + fn main() -> std::io::Result<()> { tracing_subscriber::fmt::init(); @@ -38,5 +51,7 @@ fn main() -> std::io::Result<()> { } } + write_definitions("config.lua", &config_definitions())?; + Ok(()) } diff --git a/src/config.rs b/src/config.rs index 190b2f2..34851b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; +use lua_typed::Typed; use serde::Deserialize; #[derive(Debug, Deserialize)] @@ -17,16 +18,18 @@ fn default_entrypoint() -> String { "./config.lua".into() } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Typed)] pub struct FulfillmentConfig { pub openid_url: String, #[serde(default = "default_fulfillment_ip")] + #[typed(default)] pub ip: Ipv4Addr, #[serde(default = "default_fulfillment_port")] + #[typed(default)] pub port: u16, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Typed)] pub struct Config { pub fulfillment: FulfillmentConfig, } -- 2.49.1 From 0c80cef5a14f847e01d5fc7df146cd34399b952e Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 03:56:17 +0200 Subject: [PATCH 05/23] feat: Ensure consistent ordering device definitions --- automation_devices/src/lib.rs | 35 +- definitions/automation:devices.lua | 538 ++++++++++++++--------------- 2 files changed, 299 insertions(+), 274 deletions(-) diff --git a/automation_devices/src/lib.rs b/automation_devices/src/lib.rs index a560398..0407c6a 100644 --- a/automation_devices/src/lib.rs +++ b/automation_devices/src/lib.rs @@ -70,13 +70,35 @@ pub fn create_module(lua: &mlua::Lua) -> mlua::Result { Ok(devices) } -type RegisterTypeFn = fn() -> Option; +type TypeNameFn = fn() -> String; +type TypeDefinitionFn = fn() -> Option; -pub struct RegisteredType(RegisterTypeFn); +pub struct RegisteredType { + name_fn: TypeNameFn, + definition_fn: TypeDefinitionFn, +} + +impl RegisteredType { + pub const fn new(name_fn: TypeNameFn, definition_fn: TypeDefinitionFn) -> Self { + Self { + name_fn, + definition_fn, + } + } + + pub fn get_name(&self) -> String { + (self.name_fn)() + } + + pub fn register(&self) -> Option { + (self.definition_fn)() + } +} macro_rules! register_type { ($ty:ty) => { - ::inventory::submit!(crate::RegisteredType( + ::inventory::submit!(crate::RegisteredType::new( + <$ty as ::lua_typed::Typed>::type_name, <$ty as ::lua_typed::Typed>::generate_full )); }; @@ -88,9 +110,12 @@ inventory::collect!(RegisteredType); fn generate_definitions() -> String { let mut output = String::new(); + let mut types: Vec<_> = inventory::iter::.into_iter().collect(); + types.sort_by_key(|ty| ty.get_name()); + output += "---@meta\n\nlocal devices\n\n"; - for ty in inventory::iter:: { - if let Some(def) = ty.0() { + for ty in types { + if let Some(def) = (ty.definition_fn)() { output += &(def + "\n"); } else { // NOTE: Due to how this works the typed is erased, so we don't know the cause diff --git a/definitions/automation:devices.lua b/definitions/automation:devices.lua index aa3336a..ef7c3c0 100644 --- a/definitions/automation:devices.lua +++ b/definitions/automation:devices.lua @@ -3,17 +3,13 @@ local devices ----@class KasaOutletConfig ----@field identifier string ----@field ip string -local KasaOutletConfig - ----@class KasaOutlet: DeviceInterface, OnOffInterface -local KasaOutlet -devices.KasaOutlet = {} ----@param config KasaOutletConfig ----@return KasaOutlet -function devices.KasaOutlet.new(config) end +---@class Action +---@field action +---| "broadcast" +---@field extras table? +---@field label string +---@field clear boolean? +local Action ---@class AirFilter: DeviceInterface, OnOffInterface local AirFilter @@ -28,21 +24,73 @@ function devices.AirFilter.new(config) end ---@field url string local AirFilterConfig ----@class Presence: DeviceInterface -local Presence -devices.Presence = {} ----@param config PresenceConfig ----@return Presence -function devices.Presence.new(config) end ----@async ----@return boolean -function Presence:overall_presence() end - ----@class PresenceConfig +---@class ConfigLightLightStateBrightness +---@field name string +---@field room string? ---@field topic string ----@field callback fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[]? +---@field callback fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[]? +---@field client AsyncClient? +local ConfigLightLightStateBrightness + +---@class ConfigLightLightStateColorTemperature +---@field name string +---@field room string? +---@field topic string +---@field callback fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[]? +---@field client AsyncClient? +local ConfigLightLightStateColorTemperature + +---@class ConfigLightLightStateOnOff +---@field name string +---@field room string? +---@field topic string +---@field callback fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[]? +---@field client AsyncClient? +local ConfigLightLightStateOnOff + +---@class ConfigOutletOutletStateOnOff +---@field name string +---@field room string? +---@field topic string +---@field outlet_type OutletType? +---@field callback fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[]? ---@field client AsyncClient -local PresenceConfig +local ConfigOutletOutletStateOnOff + +---@class ConfigOutletOutletStatePower +---@field name string +---@field room string? +---@field topic string +---@field outlet_type OutletType? +---@field callback fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[]? +---@field client AsyncClient +local ConfigOutletOutletStatePower + +---@class ContactSensor: DeviceInterface, OpenCloseInterface +local ContactSensor +devices.ContactSensor = {} +---@param config ContactSensorConfig +---@return ContactSensor +function devices.ContactSensor.new(config) end + +---@class ContactSensorConfig +---@field name string +---@field room string? +---@field topic string +---@field sensor_type SensorType? +---@field callback fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[]? +---@field battery_callback fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[]? +---@field client AsyncClient? +local ContactSensorConfig + +---@alias Flag +---| "presence" +---| "darkness" + +---@class FlagIDs +---@field presence integer +---@field darkness integer +local FlagIDs ---@class HueBridge: DeviceInterface local HueBridge @@ -55,11 +103,6 @@ function devices.HueBridge.new(config) end ---@param value boolean function HueBridge:set_flag(flag, value) end ----@class FlagIDs ----@field presence integer ----@field darkness integer -local FlagIDs - ---@class HueBridgeConfig ---@field identifier string ---@field ip string @@ -67,49 +110,6 @@ local FlagIDs ---@field flags FlagIDs local HueBridgeConfig ----@alias Flag ----| "presence" ----| "darkness" - ----@class WasherConfig ----@field identifier string ----@field topic string ----@field threshold number ----@field done_callback fun(_: Washer) | fun(_: Washer)[]? ----@field client AsyncClient -local WasherConfig - ----@class Washer: DeviceInterface -local Washer -devices.Washer = {} ----@param config WasherConfig ----@return Washer -function devices.Washer.new(config) end - ----@class LightSensor: DeviceInterface -local LightSensor -devices.LightSensor = {} ----@param config LightSensorConfig ----@return LightSensor -function devices.LightSensor.new(config) end - ----@class LightSensorConfig ----@field identifier string ----@field topic string ----@field min integer ----@field max integer ----@field callback fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[]? ----@field client AsyncClient -local LightSensorConfig - ----@class HueGroupConfig ----@field identifier string ----@field ip string ----@field login string ----@field group_id integer ----@field scene_id string -local HueGroupConfig - ---@class HueGroup: DeviceInterface, OnOffInterface local HueGroup devices.HueGroup = {} @@ -117,203 +117,13 @@ devices.HueGroup = {} ---@return HueGroup function devices.HueGroup.new(config) end ----@class IkeaRemote: DeviceInterface -local IkeaRemote -devices.IkeaRemote = {} ----@param config IkeaRemoteConfig ----@return IkeaRemote -function devices.IkeaRemote.new(config) end - ----@class IkeaRemoteConfig ----@field name string ----@field room string? ----@field single_button boolean? ----@field topic string ----@field client AsyncClient ----@field callback fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[]? ----@field battery_callback fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[]? -local IkeaRemoteConfig - ----@class WolConfig ----@field name string ----@field room string? ----@field topic string ----@field mac_address string ----@field broadcast_ip string? ----@field client AsyncClient -local WolConfig - ----@class WakeOnLAN: DeviceInterface -local WakeOnLAN -devices.WakeOnLAN = {} ----@param config WolConfig ----@return WakeOnLAN -function devices.WakeOnLAN.new(config) end - ----@class ConfigOutletOutletStateOnOff ----@field name string ----@field room string? ----@field topic string ----@field outlet_type OutletType? ----@field callback fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[]? ----@field client AsyncClient -local ConfigOutletOutletStateOnOff - ----@class OutletStatePower ----@field state boolean ----@field power number -local OutletStatePower - ----@class ConfigOutletOutletStatePower ----@field name string ----@field room string? ----@field topic string ----@field outlet_type OutletType? ----@field callback fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[]? ----@field client AsyncClient -local ConfigOutletOutletStatePower - ----@alias OutletType ----| "Outlet" ----| "Kettle" - ----@class OutletStateOnOff ----@field state boolean -local OutletStateOnOff - ----@class OutletOnOff: DeviceInterface, OnOffInterface -local OutletOnOff -devices.OutletOnOff = {} ----@param config ConfigOutletOutletStateOnOff ----@return OutletOnOff -function devices.OutletOnOff.new(config) end - ----@class OutletPower: DeviceInterface, OnOffInterface -local OutletPower -devices.OutletPower = {} ----@param config ConfigOutletOutletStatePower ----@return OutletPower -function devices.OutletPower.new(config) end - ----@class ContactSensorConfig ----@field name string ----@field room string? ----@field topic string ----@field sensor_type SensorType? ----@field callback fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[]? ----@field battery_callback fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[]? ----@field client AsyncClient? -local ContactSensorConfig - ----@alias SensorType ----| "Door" ----| "Drawer" ----| "Window" - ----@class ContactSensor: DeviceInterface, OpenCloseInterface -local ContactSensor -devices.ContactSensor = {} ----@param config ContactSensorConfig ----@return ContactSensor -function devices.ContactSensor.new(config) end - ----@class LightStateOnOff ----@field state boolean -local LightStateOnOff - ----@class ConfigLightLightStateColorTemperature ----@field name string ----@field room string? ----@field topic string ----@field callback fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[]? ----@field client AsyncClient? -local ConfigLightLightStateColorTemperature - ----@class LightStateBrightness ----@field state boolean ----@field brightness number -local LightStateBrightness - ----@class LightStateColorTemperature ----@field state boolean ----@field brightness number ----@field color_temp integer -local LightStateColorTemperature - ----@class LightBrightness: DeviceInterface, OnOffInterface, BrightnessInterface -local LightBrightness -devices.LightBrightness = {} ----@param config ConfigLightLightStateBrightness ----@return LightBrightness -function devices.LightBrightness.new(config) end - ----@class LightColorTemperature: DeviceInterface, OnOffInterface, BrightnessInterface, ColorSettingInterface -local LightColorTemperature -devices.LightColorTemperature = {} ----@param config ConfigLightLightStateColorTemperature ----@return LightColorTemperature -function devices.LightColorTemperature.new(config) end - ----@class ConfigLightLightStateOnOff ----@field name string ----@field room string? ----@field topic string ----@field callback fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[]? ----@field client AsyncClient? -local ConfigLightLightStateOnOff - ----@class ConfigLightLightStateBrightness ----@field name string ----@field room string? ----@field topic string ----@field callback fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[]? ----@field client AsyncClient? -local ConfigLightLightStateBrightness - ----@class LightOnOff: DeviceInterface, OnOffInterface -local LightOnOff -devices.LightOnOff = {} ----@param config ConfigLightLightStateOnOff ----@return LightOnOff -function devices.LightOnOff.new(config) end - ----@class Action ----@field action ----| "broadcast" ----@field extras table? ----@field label string ----@field clear boolean? -local Action - ----@class NtfyConfig ----@field url string? ----@field topic string -local NtfyConfig - ----@class Notification ----@field title string ----@field message string? ----@field tags string[]? ----@field priority Priority? ----@field actions Action[]? -local Notification - ----@alias Priority ----| "min" ----| "low" ----| "default" ----| "high" ----| "max" - ----@class Ntfy: DeviceInterface -local Ntfy -devices.Ntfy = {} ----@param config NtfyConfig ----@return Ntfy -function devices.Ntfy.new(config) end ----@async ----@param notification Notification -function Ntfy:send_notification(notification) end +---@class HueGroupConfig +---@field identifier string +---@field ip string +---@field login string +---@field group_id integer +---@field scene_id string +local HueGroupConfig ---@class HueSwitch: DeviceInterface local HueSwitch @@ -334,4 +144,194 @@ function devices.HueSwitch.new(config) end ---@field battery_callback fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[]? local HueSwitchConfig +---@class IkeaRemote: DeviceInterface +local IkeaRemote +devices.IkeaRemote = {} +---@param config IkeaRemoteConfig +---@return IkeaRemote +function devices.IkeaRemote.new(config) end + +---@class IkeaRemoteConfig +---@field name string +---@field room string? +---@field single_button boolean? +---@field topic string +---@field client AsyncClient +---@field callback fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[]? +---@field battery_callback fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[]? +local IkeaRemoteConfig + +---@class KasaOutlet: DeviceInterface, OnOffInterface +local KasaOutlet +devices.KasaOutlet = {} +---@param config KasaOutletConfig +---@return KasaOutlet +function devices.KasaOutlet.new(config) end + +---@class KasaOutletConfig +---@field identifier string +---@field ip string +local KasaOutletConfig + +---@class LightBrightness: DeviceInterface, OnOffInterface, BrightnessInterface +local LightBrightness +devices.LightBrightness = {} +---@param config ConfigLightLightStateBrightness +---@return LightBrightness +function devices.LightBrightness.new(config) end + +---@class LightColorTemperature: DeviceInterface, OnOffInterface, BrightnessInterface, ColorSettingInterface +local LightColorTemperature +devices.LightColorTemperature = {} +---@param config ConfigLightLightStateColorTemperature +---@return LightColorTemperature +function devices.LightColorTemperature.new(config) end + +---@class LightOnOff: DeviceInterface, OnOffInterface +local LightOnOff +devices.LightOnOff = {} +---@param config ConfigLightLightStateOnOff +---@return LightOnOff +function devices.LightOnOff.new(config) end + +---@class LightSensor: DeviceInterface +local LightSensor +devices.LightSensor = {} +---@param config LightSensorConfig +---@return LightSensor +function devices.LightSensor.new(config) end + +---@class LightSensorConfig +---@field identifier string +---@field topic string +---@field min integer +---@field max integer +---@field callback fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[]? +---@field client AsyncClient +local LightSensorConfig + +---@class LightStateBrightness +---@field state boolean +---@field brightness number +local LightStateBrightness + +---@class LightStateColorTemperature +---@field state boolean +---@field brightness number +---@field color_temp integer +local LightStateColorTemperature + +---@class LightStateOnOff +---@field state boolean +local LightStateOnOff + +---@class Notification +---@field title string +---@field message string? +---@field tags string[]? +---@field priority Priority? +---@field actions Action[]? +local Notification + +---@class Ntfy: DeviceInterface +local Ntfy +devices.Ntfy = {} +---@param config NtfyConfig +---@return Ntfy +function devices.Ntfy.new(config) end +---@async +---@param notification Notification +function Ntfy:send_notification(notification) end + +---@class NtfyConfig +---@field url string? +---@field topic string +local NtfyConfig + +---@class OutletOnOff: DeviceInterface, OnOffInterface +local OutletOnOff +devices.OutletOnOff = {} +---@param config ConfigOutletOutletStateOnOff +---@return OutletOnOff +function devices.OutletOnOff.new(config) end + +---@class OutletPower: DeviceInterface, OnOffInterface +local OutletPower +devices.OutletPower = {} +---@param config ConfigOutletOutletStatePower +---@return OutletPower +function devices.OutletPower.new(config) end + +---@class OutletStateOnOff +---@field state boolean +local OutletStateOnOff + +---@class OutletStatePower +---@field state boolean +---@field power number +local OutletStatePower + +---@alias OutletType +---| "Outlet" +---| "Kettle" + +---@class Presence: DeviceInterface +local Presence +devices.Presence = {} +---@param config PresenceConfig +---@return Presence +function devices.Presence.new(config) end +---@async +---@return boolean +function Presence:overall_presence() end + +---@class PresenceConfig +---@field topic string +---@field callback fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[]? +---@field client AsyncClient +local PresenceConfig + +---@alias Priority +---| "min" +---| "low" +---| "default" +---| "high" +---| "max" + +---@alias SensorType +---| "Door" +---| "Drawer" +---| "Window" + +---@class WakeOnLAN: DeviceInterface +local WakeOnLAN +devices.WakeOnLAN = {} +---@param config WolConfig +---@return WakeOnLAN +function devices.WakeOnLAN.new(config) end + +---@class Washer: DeviceInterface +local Washer +devices.Washer = {} +---@param config WasherConfig +---@return Washer +function devices.Washer.new(config) end + +---@class WasherConfig +---@field identifier string +---@field topic string +---@field threshold number +---@field done_callback fun(_: Washer) | fun(_: Washer)[]? +---@field client AsyncClient +local WasherConfig + +---@class WolConfig +---@field name string +---@field room string? +---@field topic string +---@field mac_address string +---@field broadcast_ip string? +---@field client AsyncClient +local WolConfig + return devices -- 2.49.1 From 948380ea9b130f8d843718749cdb81f27e820bb7 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 03:45:11 +0200 Subject: [PATCH 06/23] feat: Receive devices through config return --- Cargo.lock | 1 + Cargo.toml | 1 + automation_lib/src/device.rs | 7 +++ config.lua | 84 +++++++++++++++++++----------------- definitions/config.lua | 1 + src/bin/automation.rs | 7 ++- src/config.rs | 7 ++- 7 files changed, 66 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03a313f..a71f1da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,7 @@ dependencies = [ "async-trait", "automation_devices", "automation_lib", + "automation_macro", "axum", "config", "git-version", diff --git a/Cargo.toml b/Cargo.toml index bd2a927..fa7f3e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } automation_devices = { workspace = true } automation_lib = { workspace = true } +automation_macro = { path = "./automation_macro" } axum = { workspace = true } config = { version = "0.15.15", default-features = false, features = [ "async", diff --git a/automation_lib/src/device.rs b/automation_lib/src/device.rs index 8e2c334..6adacb5 100644 --- a/automation_lib/src/device.rs +++ b/automation_lib/src/device.rs @@ -2,6 +2,7 @@ use std::fmt::Debug; use automation_cast::Cast; use dyn_clone::DynClone; +use lua_typed::Typed; use mlua::ObjectLike; use crate::event::OnMqtt; @@ -41,4 +42,10 @@ impl mlua::FromLua for Box { } impl mlua::UserData for Box {} +impl Typed for Box { + fn type_name() -> String { + "DeviceInterface".into() + } +} + dyn_clone::clone_trait_object!(Device); diff --git a/config.lua b/config.lua index 5b95428..b67a3d9 100644 --- a/config.lua +++ b/config.lua @@ -30,6 +30,11 @@ local mqtt_client = require("automation:mqtt").new(device_manager, { tls = host == "zeus" or host == "hephaestus", }) +local devs = {} +function devs:add(device) + table.insert(self, device) +end + local ntfy_topic = secrets.ntfy_topic if ntfy_topic == nil then error("Ntfy topic is not specified") @@ -37,7 +42,7 @@ end local ntfy = devices.Ntfy.new({ topic = ntfy_topic, }) -device_manager:add(ntfy) +devs:add(ntfy) --- @type {[string]: number} local low_battery = {} @@ -92,7 +97,7 @@ local presence_system = devices.Presence.new({ end end, }) -device_manager:add(presence_system) +devs:add(presence_system) on_presence:add(function(presence) ntfy:send_notification({ title = "Presence", @@ -166,7 +171,7 @@ local on_light = {} function on_light:add(f) self[#self + 1] = f end -device_manager:add(devices.LightSensor.new({ +devs:add(devices.LightSensor.new({ identifier = "living_light_sensor", topic = mqtt_z2m("living/light"), client = mqtt_client, @@ -202,7 +207,7 @@ local hue_bridge = devices.HueBridge.new({ darkness = 43, }, }) -device_manager:add(hue_bridge) +devs:add(hue_bridge) on_light:add(function(light) hue_bridge:set_flag("darkness", not light) end) @@ -217,7 +222,7 @@ local kitchen_lights = devices.HueGroup.new({ group_id = 7, scene_id = "7MJLG27RzeRAEVJ", }) -device_manager:add(kitchen_lights) +devs:add(kitchen_lights) local living_lights = devices.HueGroup.new({ identifier = "living_lights", ip = hue_ip, @@ -225,7 +230,7 @@ local living_lights = devices.HueGroup.new({ group_id = 1, scene_id = "SNZw7jUhQ3cXSjkj", }) -device_manager:add(living_lights) +devs:add(living_lights) local living_lights_relax = devices.HueGroup.new({ identifier = "living_lights", ip = hue_ip, @@ -233,9 +238,9 @@ local living_lights_relax = devices.HueGroup.new({ group_id = 1, scene_id = "eRJ3fvGHCcb6yNw", }) -device_manager:add(living_lights_relax) +devs:add(living_lights_relax) -device_manager:add(devices.HueSwitch.new({ +devs:add(devices.HueSwitch.new({ name = "Switch", room = "Living", client = mqtt_client, @@ -252,7 +257,7 @@ device_manager:add(devices.HueSwitch.new({ battery_callback = check_battery, })) -device_manager:add(devices.WakeOnLAN.new({ +devs:add(devices.WakeOnLAN.new({ name = "Zeus", room = "Living Room", topic = mqtt_automation("appliance/living_room/zeus"), @@ -268,7 +273,7 @@ local living_mixer = devices.OutletOnOff.new({ client = mqtt_client, }) turn_off_when_away(living_mixer) -device_manager:add(living_mixer) +devs:add(living_mixer) local living_speakers = devices.OutletOnOff.new({ name = "Speakers", room = "Living Room", @@ -276,9 +281,9 @@ local living_speakers = devices.OutletOnOff.new({ client = mqtt_client, }) turn_off_when_away(living_speakers) -device_manager:add(living_speakers) +devs:add(living_speakers) -device_manager:add(devices.IkeaRemote.new({ +devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Living Room", client = mqtt_client, @@ -329,14 +334,14 @@ local kettle = devices.OutletPower.new({ callback = kettle_timeout(), }) turn_off_when_away(kettle) -device_manager:add(kettle) +devs:add(kettle) --- @param on boolean local function set_kettle(_, on) kettle:set_on(on) end -device_manager:add(devices.IkeaRemote.new({ +devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Bedroom", client = mqtt_client, @@ -346,7 +351,7 @@ device_manager:add(devices.IkeaRemote.new({ battery_callback = check_battery, })) -device_manager:add(devices.IkeaRemote.new({ +devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Kitchen", client = mqtt_client, @@ -379,9 +384,9 @@ local bathroom_light = devices.LightOnOff.new({ client = mqtt_client, callback = off_timeout(debug and 60 or 45 * 60), }) -device_manager:add(bathroom_light) +devs:add(bathroom_light) -device_manager:add(devices.Washer.new({ +devs:add(devices.Washer.new({ identifier = "bathroom_washer", topic = mqtt_z2m("bathroom/washer"), client = mqtt_client, @@ -396,7 +401,7 @@ device_manager:add(devices.Washer.new({ end, })) -device_manager:add(devices.OutletOnOff.new({ +devs:add(devices.OutletOnOff.new({ name = "Charger", room = "Workbench", topic = mqtt_z2m("workbench/charger"), @@ -411,7 +416,7 @@ local workbench_outlet = devices.OutletOnOff.new({ client = mqtt_client, }) turn_off_when_away(workbench_outlet) -device_manager:add(workbench_outlet) +devs:add(workbench_outlet) local workbench_light = devices.LightColorTemperature.new({ name = "Light", @@ -420,10 +425,10 @@ local workbench_light = devices.LightColorTemperature.new({ client = mqtt_client, }) turn_off_when_away(workbench_light) -device_manager:add(workbench_light) +devs:add(workbench_light) local delay_color_temp = utils.Timeout.new() -device_manager:add(devices.IkeaRemote.new({ +devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Workbench", client = mqtt_client, @@ -453,7 +458,7 @@ local hallway_top_light = devices.HueGroup.new({ group_id = 83, scene_id = "QeufkFDICEHWeKJ7", }) -device_manager:add(devices.HueSwitch.new({ +devs:add(devices.HueSwitch.new({ name = "SwitchBottom", room = "Hallway", client = mqtt_client, @@ -463,7 +468,7 @@ device_manager:add(devices.HueSwitch.new({ end, battery_callback = check_battery, })) -device_manager:add(devices.HueSwitch.new({ +devs:add(devices.HueSwitch.new({ name = "SwitchTop", room = "Hallway", client = mqtt_client, @@ -546,7 +551,7 @@ local hallway_storage = devices.LightBrightness.new({ callback = hallway_light_automation:light_callback(), }) turn_off_when_away(hallway_storage) -device_manager:add(hallway_storage) +devs:add(hallway_storage) local hallway_bottom_lights = devices.HueGroup.new({ identifier = "hallway_bottom_lights", @@ -555,7 +560,7 @@ local hallway_bottom_lights = devices.HueGroup.new({ group_id = 81, scene_id = "3qWKxGVadXFFG4o", }) -device_manager:add(hallway_bottom_lights) +devs:add(hallway_bottom_lights) hallway_light_automation.group = { set_on = function(on) @@ -591,7 +596,7 @@ local function presence(duration) end end -device_manager:add(devices.IkeaRemote.new({ +devs:add(devices.IkeaRemote.new({ name = "Remote", room = "Hallway", client = mqtt_client, @@ -611,7 +616,7 @@ local hallway_frontdoor = devices.ContactSensor.new({ }, battery_callback = check_battery, }) -device_manager:add(hallway_frontdoor) +devs:add(hallway_frontdoor) window_sensors:add(hallway_frontdoor) hallway_light_automation.door = hallway_frontdoor @@ -624,7 +629,7 @@ local hallway_trash = devices.ContactSensor.new({ callback = hallway_light_automation:trash_callback(), battery_callback = check_battery, }) -device_manager:add(hallway_trash) +devs:add(hallway_trash) hallway_light_automation.trash = hallway_trash local guest_light = devices.LightOnOff.new({ @@ -634,14 +639,14 @@ local guest_light = devices.LightOnOff.new({ client = mqtt_client, }) turn_off_when_away(guest_light) -device_manager:add(guest_light) +devs:add(guest_light) local bedroom_air_filter = devices.AirFilter.new({ name = "Air Filter", room = "Bedroom", url = "http://10.0.0.103", }) -device_manager:add(bedroom_air_filter) +devs:add(bedroom_air_filter) local bedroom_lights = devices.HueGroup.new({ identifier = "bedroom_lights", @@ -650,7 +655,7 @@ local bedroom_lights = devices.HueGroup.new({ group_id = 3, scene_id = "PvRs-lGD4VRytL9", }) -device_manager:add(bedroom_lights) +devs:add(bedroom_lights) local bedroom_lights_relax = devices.HueGroup.new({ identifier = "bedroom_lights", ip = hue_ip, @@ -658,9 +663,9 @@ local bedroom_lights_relax = devices.HueGroup.new({ group_id = 3, scene_id = "60tfTyR168v2csz", }) -device_manager:add(bedroom_lights_relax) +devs:add(bedroom_lights_relax) -device_manager:add(devices.HueSwitch.new({ +devs:add(devices.HueSwitch.new({ name = "Switch", room = "Bedroom", client = mqtt_client, @@ -682,7 +687,7 @@ local balcony = devices.ContactSensor.new({ client = mqtt_client, battery_callback = check_battery, }) -device_manager:add(balcony) +devs:add(balcony) window_sensors:add(balcony) local living_window = devices.ContactSensor.new({ name = "Window", @@ -691,7 +696,7 @@ local living_window = devices.ContactSensor.new({ client = mqtt_client, battery_callback = check_battery, }) -device_manager:add(living_window) +devs:add(living_window) window_sensors:add(living_window) local bedroom_window = devices.ContactSensor.new({ name = "Window", @@ -700,7 +705,7 @@ local bedroom_window = devices.ContactSensor.new({ client = mqtt_client, battery_callback = check_battery, }) -device_manager:add(bedroom_window) +devs:add(bedroom_window) window_sensors:add(bedroom_window) local guest_window = devices.ContactSensor.new({ name = "Window", @@ -709,7 +714,7 @@ local guest_window = devices.ContactSensor.new({ client = mqtt_client, battery_callback = check_battery, }) -device_manager:add(guest_window) +devs:add(guest_window) window_sensors:add(guest_window) local storage_light = devices.LightBrightness.new({ @@ -719,9 +724,9 @@ local storage_light = devices.LightBrightness.new({ client = mqtt_client, }) turn_off_when_away(storage_light) -device_manager:add(storage_light) +devs:add(storage_light) -device_manager:add(devices.ContactSensor.new({ +devs:add(devices.ContactSensor.new({ name = "Door", room = "Storage", sensor_type = "Door", @@ -749,4 +754,5 @@ return { fulfillment = { openid_url = "https://login.huizinga.dev/api/oidc", }, + devices = devs, } diff --git a/definitions/config.lua b/definitions/config.lua index dc070a5..346d45a 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -9,4 +9,5 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig +---@field devices DeviceInterface[]? local Config diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 155947d..a0cd914 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -136,8 +136,11 @@ async fn app() -> anyhow::Result<()> { lua.register_module("automation:secrets", lua.to_value(&setup.secrets)?)?; let entrypoint = Path::new(&setup.entrypoint); - let config: mlua::Value = lua.load(entrypoint).eval_async().await?; - let config: Config = lua.from_value(config)?; + let config: Config = lua.load(entrypoint).eval_async().await?; + + for device in config.devices { + device_manager.add(device).await; + } // Create google home fulfillment route let fulfillment = Router::new().route("/google_home", post(fulfillment)); diff --git a/src/config.rs b/src/config.rs index 34851b1..2875164 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,8 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; +use automation_lib::device::Device; +use automation_macro::LuaDeviceConfig; use lua_typed::Typed; use serde::Deserialize; @@ -29,9 +31,12 @@ pub struct FulfillmentConfig { pub port: u16, } -#[derive(Debug, Deserialize, Typed)] +#[derive(Debug, LuaDeviceConfig, Typed)] pub struct Config { pub fulfillment: FulfillmentConfig, + #[device_config(from_lua, default)] + #[typed(default)] + pub devices: Vec>, } impl From for SocketAddr { -- 2.49.1 From 1ffccd955cf38e59fc95a9380759874ec14424bb Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Fri, 17 Oct 2025 04:29:14 +0200 Subject: [PATCH 07/23] refactor(config)!: Move scheduler out of device_manager Due to changes made in mlua the new scheduler is much simpler. It also had no real business being part of the device manager, so it has now been moved to be part of the returned config. --- Cargo.lock | 233 ++++++++++++++++------ Cargo.toml | 5 +- automation_lib/Cargo.toml | 3 - automation_lib/src/device_manager.rs | 43 ---- automation_lib/src/lib.rs | 1 - automation_lib/src/schedule.rs | 17 -- config.lua | 20 +- definitions/automation:device_manager.lua | 4 - definitions/config.lua | 1 + src/bin/automation.rs | 3 + src/config.rs | 3 + src/lib.rs | 1 + src/schedule.rs | 28 +++ 13 files changed, 224 insertions(+), 138 deletions(-) delete mode 100644 automation_lib/src/schedule.rs create mode 100644 src/schedule.rs diff --git a/Cargo.lock b/Cargo.lock index a71f1da..0153116 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,12 +36,6 @@ dependencies = [ "serde", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -108,6 +102,7 @@ dependencies = [ "serde_json", "thiserror 2.0.16", "tokio", + "tokio-cron-scheduler", "tracing", "tracing-subscriber", ] @@ -154,7 +149,6 @@ dependencies = [ "futures", "google_home", "hostname", - "indexmap", "inventory", "lua_typed", "mlua", @@ -163,9 +157,7 @@ dependencies = [ "serde_json", "thiserror 2.0.16", "tokio", - "tokio-cron-scheduler", "tracing", - "uuid", ] [[package]] @@ -324,16 +316,25 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", ] [[package]] @@ -376,11 +377,48 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "croner" -version = "2.2.0" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843" +checksum = "4c007081651a19b42931f86f7d4f74ee1c2a7d0cd2c6636a81695b5ffd4e9990" dependencies = [ "chrono", + "derive_builder", + "strum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.106", ] [[package]] @@ -424,6 +462,37 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.106", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -468,12 +537,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "erased-serde" version = "0.4.6" @@ -703,10 +766,10 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" @@ -722,7 +785,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -836,9 +899,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -944,6 +1007,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -965,17 +1034,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9" -dependencies = [ - "equivalent", - "hashbrown", - "serde", -] - [[package]] name = "inventory" version = "0.3.21" @@ -1104,16 +1162,17 @@ dependencies = [ [[package]] name = "lua_typed" version = "0.1.0" -source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#08f5c4533a93131e8eda6702c062fb841d14d4e1" +source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#f6a684291432aae2ef7109712882e7e3ed758d08" dependencies = [ "eui48", "lua_typed_macro", + "mlua", ] [[package]] name = "lua_typed_macro" version = "0.1.0" -source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#08f5c4533a93131e8eda6702c062fb841d14d4e1" +source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#f6a684291432aae2ef7109712882e7e3ed758d08" dependencies = [ "convert_case", "itertools", @@ -1209,9 +1268,9 @@ dependencies = [ [[package]] name = "mlua" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dd94c3c4dea0049b22296397040840a8f6b5b5229f438434ba82df402b42d" +checksum = "9be1c2bfc684b8a228fbaebf954af7a47a98ec27721986654a4cc2c40a20cc7e" dependencies = [ "bstr", "either", @@ -1349,6 +1408,24 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1901,6 +1978,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + [[package]] name = "slab" version = "0.4.11" @@ -1938,6 +2021,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2080,11 +2190,12 @@ dependencies = [ [[package]] name = "tokio-cron-scheduler" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c71ce8f810abc9fabebccc30302a952f9e89c6cf246fafaf170fef164063141" +checksum = "bb73c4033ddcbbf81fd828293fd41a0145cde2cbc30dd782227c5081a523214d" dependencies = [ "chrono", + "chrono-tz", "croner", "num-derive", "num-traits", @@ -2479,22 +2590,22 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.1", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -2503,9 +2614,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -2519,21 +2630,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] -name = "windows-result" -version = "0.3.4" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index fa7f3e8..159d31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,6 @@ futures = "0.3.31" google_home = { path = "./google_home/google_home" } google_home_macro = { path = "./google_home/google_home_macro" } hostname = "0.4.1" -indexmap = { version = "2.11.0", features = ["serde"] } inventory = "0.3.21" itertools = "0.14.0" json_value_merge = "2.0.1" @@ -59,10 +58,9 @@ serde_repr = "0.1.20" syn = { version = "2.0.106" } thiserror = "2.0.16" tokio = { version = "1", features = ["rt-multi-thread"] } -tokio-cron-scheduler = "0.14.0" +tokio-cron-scheduler = "0.15.0" tracing = "0.1.41" tracing-subscriber = "0.3.20" -uuid = "1.18.1" wakey = "0.3.0" [dependencies] @@ -87,6 +85,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-cron-scheduler = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/automation_lib/Cargo.toml b/automation_lib/Cargo.toml index 15b28aa..04dea30 100644 --- a/automation_lib/Cargo.toml +++ b/automation_lib/Cargo.toml @@ -11,7 +11,6 @@ dyn-clone = { workspace = true } futures = { workspace = true } google_home = { workspace = true } hostname = { workspace = true } -indexmap = { workspace = true } inventory = { workspace = true } lua_typed = { workspace = true } mlua = { workspace = true } @@ -20,6 +19,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-cron-scheduler = { workspace = true } tracing = { workspace = true } -uuid = { workspace = true } diff --git a/automation_lib/src/device_manager.rs b/automation_lib/src/device_manager.rs index fe755f2..cdf302a 100644 --- a/automation_lib/src/device_manager.rs +++ b/automation_lib/src/device_manager.rs @@ -1,13 +1,10 @@ use std::collections::HashMap; -use std::pin::Pin; use std::sync::Arc; -use futures::Future; use futures::future::join_all; use lua_typed::Typed; use mlua::FromLua; use tokio::sync::{RwLock, RwLockReadGuard}; -use tokio_cron_scheduler::{Job, JobScheduler}; use tracing::{debug, instrument, trace}; use crate::device::Device; @@ -19,7 +16,6 @@ pub type DeviceMap = HashMap>; pub struct DeviceManager { devices: Arc>, event_channel: EventChannel, - scheduler: JobScheduler, } impl DeviceManager { @@ -29,7 +25,6 @@ impl DeviceManager { let device_manager = Self { devices: Arc::new(RwLock::new(HashMap::new())), event_channel, - scheduler: JobScheduler::new().await.unwrap(), }; tokio::spawn({ @@ -45,8 +40,6 @@ impl DeviceManager { } }); - device_manager.scheduler.start().await.unwrap(); - device_manager } @@ -105,42 +98,6 @@ impl mlua::UserData for DeviceManager { Ok(()) }); - methods.add_async_method( - "schedule", - async |lua, this, (schedule, f): (String, mlua::Function)| { - debug!("schedule = {schedule}"); - // This creates a function, that returns the actual job we want to run - let create_job = { - let lua = lua.clone(); - - move |uuid: uuid::Uuid, - _: tokio_cron_scheduler::JobScheduler| - -> Pin + Send>> { - let lua = lua.clone(); - - // Create the actual function we want to run on a schedule - let future = async move { - let f: mlua::Function = - lua.named_registry_value(uuid.to_string().as_str()).unwrap(); - f.call_async::<()>(()).await.unwrap(); - }; - - Box::pin(future) - } - }; - - let job = Job::new_async(schedule.as_str(), create_job).unwrap(); - - let uuid = this.scheduler.add(job).await.unwrap(); - - // Store the function in the registry - lua.set_named_registry_value(uuid.to_string().as_str(), f) - .unwrap(); - - Ok(()) - }, - ); - methods.add_method("event_channel", |_lua, this, ()| Ok(this.event_channel())) } } diff --git a/automation_lib/src/lib.rs b/automation_lib/src/lib.rs index 224971a..a50fabd 100644 --- a/automation_lib/src/lib.rs +++ b/automation_lib/src/lib.rs @@ -13,7 +13,6 @@ pub mod helpers; pub mod lua; pub mod messages; pub mod mqtt; -pub mod schedule; type RegisterFn = fn(lua: &mlua::Lua) -> mlua::Result; type DefinitionsFn = fn() -> String; diff --git a/automation_lib/src/schedule.rs b/automation_lib/src/schedule.rs deleted file mode 100644 index 3300629..0000000 --- a/automation_lib/src/schedule.rs +++ /dev/null @@ -1,17 +0,0 @@ -use indexmap::IndexMap; -use serde::Deserialize; - -#[derive(Debug, Deserialize, Hash, PartialEq, Eq, Clone, Copy)] -#[serde(rename_all = "snake_case")] -pub enum Action { - On, - Off, -} - -pub type Schedule = IndexMap>>; - -// #[derive(Debug, Deserialize)] -// pub struct Schedule { -// pub when: String, -// pub actions: IndexMap>, -// } diff --git a/config.lua b/config.lua index b67a3d9..90493b9 100644 --- a/config.lua +++ b/config.lua @@ -57,7 +57,7 @@ local function check_battery(device, battery) low_battery[id] = nil end end -device_manager:schedule("0 0 21 */1 * *", function() +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") @@ -76,7 +76,7 @@ device_manager:schedule("0 0 21 */1 * *", function() tags = { "battery" }, priority = "default", }) -end) +end --- @class OnPresence --- @field [integer] fun(presence: boolean) @@ -742,17 +742,19 @@ devs:add(devices.ContactSensor.new({ battery_callback = check_battery, })) -device_manager:schedule("0 0 19 * * *", function() - bedroom_air_filter:set_on(true) -end) -device_manager:schedule("0 0 20 * * *", function() - bedroom_air_filter:set_on(false) -end) - ---@type Config return { fulfillment = { openid_url = "https://login.huizinga.dev/api/oidc", }, devices = devs, + 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/definitions/automation:device_manager.lua b/definitions/automation:device_manager.lua index 9fa74fc..23af2c8 100644 --- a/definitions/automation:device_manager.lua +++ b/definitions/automation:device_manager.lua @@ -5,8 +5,4 @@ local DeviceManager ---@param device DeviceInterface function DeviceManager:add(device) end ----@param cron string ----@param callback fun() -function DeviceManager:schedule(cron, callback) end - return DeviceManager diff --git a/definitions/config.lua b/definitions/config.lua index 346d45a..9892431 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -10,4 +10,5 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ---@field devices DeviceInterface[]? +---@field schedule table? local Config diff --git a/src/bin/automation.rs b/src/bin/automation.rs index a0cd914..644e94a 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -6,6 +6,7 @@ use std::process; use ::config::{Environment, File}; use automation::config::{Config, Setup}; +use automation::schedule::start_scheduler; use automation::secret::EnvironmentSecretFile; use automation::version::VERSION; use automation::web::{ApiError, User}; @@ -142,6 +143,8 @@ async fn app() -> anyhow::Result<()> { device_manager.add(device).await; } + start_scheduler(config.schedule).await?; + // Create google home fulfillment route let fulfillment = Router::new().route("/google_home", post(fulfillment)); diff --git a/src/config.rs b/src/config.rs index 2875164..2360daf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -37,6 +37,9 @@ pub struct Config { #[device_config(from_lua, default)] #[typed(default)] pub devices: Vec>, + #[device_config(from_lua, default)] + #[typed(default)] + pub schedule: HashMap, } impl From for SocketAddr { diff --git a/src/lib.rs b/src/lib.rs index 49e787e..f1e947d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod schedule; pub mod secret; pub mod version; pub mod web; diff --git a/src/schedule.rs b/src/schedule.rs new file mode 100644 index 0000000..62a90ad --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; +use std::pin::Pin; + +use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError}; + +pub async fn start_scheduler( + schedule: HashMap, +) -> Result<(), JobSchedulerError> { + let scheduler = JobScheduler::new().await?; + + for (s, f) in schedule { + let job = { + move |_uuid, _lock| -> Pin + Send>> { + let f = f.clone(); + + Box::pin(async move { + f.call_async::<()>(()).await.unwrap(); + }) + } + }; + + let job = Job::new_async(s, job)?; + + scheduler.add(job).await?; + } + + scheduler.start().await +} -- 2.49.1 From 02b87126e15851f297e815f83e0940a4960d8e62 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 02:43:24 +0200 Subject: [PATCH 08/23] feat: Use ActionCallback for schedule This has two advantages: - Each schedule entry can take either a single function or table of functions. - We get a better type definition. --- definitions/config.lua | 2 +- src/config.rs | 3 ++- src/schedule.rs | 5 +++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/definitions/config.lua b/definitions/config.lua index 9892431..c6cc30f 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -10,5 +10,5 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ---@field devices DeviceInterface[]? ----@field schedule table? +---@field schedule table? local Config diff --git a/src/config.rs b/src/config.rs index 2360daf..38d732c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, SocketAddr}; +use automation_lib::action_callback::ActionCallback; use automation_lib::device::Device; use automation_macro::LuaDeviceConfig; use lua_typed::Typed; @@ -39,7 +40,7 @@ pub struct Config { pub devices: Vec>, #[device_config(from_lua, default)] #[typed(default)] - pub schedule: HashMap, + pub schedule: HashMap>, } impl From for SocketAddr { diff --git a/src/schedule.rs b/src/schedule.rs index 62a90ad..543c0fe 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use std::pin::Pin; +use automation_lib::action_callback::ActionCallback; use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError}; pub async fn start_scheduler( - schedule: HashMap, + schedule: HashMap>, ) -> Result<(), JobSchedulerError> { let scheduler = JobScheduler::new().await?; @@ -14,7 +15,7 @@ pub async fn start_scheduler( let f = f.clone(); Box::pin(async move { - f.call_async::<()>(()).await.unwrap(); + f.call(()).await; }) } }; -- 2.49.1 From 02b6cf12a1b48d156daded3ddba372b7484c5dfb Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 03:30:15 +0200 Subject: [PATCH 09/23] feat: Improved device conversion error message --- automation_lib/src/device.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/automation_lib/src/device.rs b/automation_lib/src/device.rs index 6adacb5..3c0b82b 100644 --- a/automation_lib/src/device.rs +++ b/automation_lib/src/device.rs @@ -27,7 +27,7 @@ impl mlua::FromLua for Box { fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result { match value { mlua::Value::UserData(ud) => { - let ud = if ud.is::>() { + let ud = if ud.is::() { ud } else { ud.call_method::<_>("__box", ())? @@ -36,7 +36,10 @@ impl mlua::FromLua for Box { let b = ud.borrow::()?.clone(); Ok(b) } - _ => Err(mlua::Error::RuntimeError("Expected user data".into())), + _ => Err(mlua::Error::runtime(format!( + "Expected user data, instead found: {}", + value.type_name() + ))), } } } -- 2.49.1 From f05856cd0ce99132e8c07a81c2c946b79d0301c4 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 04:18:26 +0200 Subject: [PATCH 10/23] feat(config)!: In config devices can now also be a (table of) function(s) This function receives the mqtt client as an argument. In the future this will be the only way to create devices that require the mqtt client. --- config.lua | 4 ++ definitions/config.lua | 5 ++- src/bin/automation.rs | 6 ++- src/bin/generate_definitions.rs | 4 +- src/config.rs | 74 +++++++++++++++++++++++++++++++-- 5 files changed, 86 insertions(+), 7 deletions(-) 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>, -- 2.49.1 From 7b7279017fc28baee2f75b17ea19498e148625a0 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 04:23:03 +0200 Subject: [PATCH 11/23] refactor: Restructured config to not rely on mqtt client being available In preparation of changes to the mqtt client the config is rewritten to use a device creation function for devices that need the mqtt client. This also fixes a but where hallway_top_light was not actually added to the device manager. --- config.lua | 1213 ++++++++++++++++++++++++++-------------------------- 1 file changed, 612 insertions(+), 601 deletions(-) diff --git a/config.lua b/config.lua index 2920bf6..24b9566 100644 --- a/config.lua +++ b/config.lua @@ -21,296 +21,8 @@ local function mqtt_automation(topic) return "automation/" .. topic end -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", -}) - -local devs = {} -function devs:add(device) - table.insert(self, device) -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, -}) -devs:add(ntfy) - ---- @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 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 - ---- @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 - -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) -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) - mqtt_client:send_message(mqtt_automation("debug") .. "/presence", { - state = presence, - updated = utils.get_epoch(), - }) -end) - ---- @class WindowSensor ---- @field [integer] OpenCloseInterface -local window_sensors = {} ---- @param sensor OpenCloseInterface -function window_sensors:add(sensor) - self[#self + 1] = sensor -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) - ---- @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 OnLight ---- @field [integer] fun(light: boolean) -local on_light = {} ---- @param f fun(light: boolean) -function on_light:add(f) - self[#self + 1] = f -end -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, -})) -on_light:add(function(light) - mqtt_client:send_message(mqtt_automation("debug") .. "/darkness", { - state = not light, - updated = utils.get_epoch(), - }) -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, - }, -}) -devs:add(hue_bridge) -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", -}) -devs:add(kitchen_lights) -local living_lights = devices.HueGroup.new({ - identifier = "living_lights", - ip = hue_ip, - login = hue_token, - group_id = 1, - scene_id = "SNZw7jUhQ3cXSjkj", -}) -devs:add(living_lights) -local living_lights_relax = devices.HueGroup.new({ - identifier = "living_lights", - ip = hue_ip, - login = hue_token, - group_id = 1, - scene_id = "eRJ3fvGHCcb6yNw", -}) -devs:add(living_lights_relax) - -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, -})) - --- @return fun(self: OnOffInterface, state: {state: boolean, power: number}) -local function kettle_timeout() +local function auto_off() local timeout = utils.Timeout.new() return function(self, state) @@ -324,43 +36,6 @@ local function kettle_timeout() end end ---- @type OutletPower -local kettle = devices.OutletPower.new({ - outlet_type = "Kettle", - name = "Kettle", - room = "Kitchen", - topic = mqtt_z2m("kitchen/kettle"), - client = mqtt_client, - callback = kettle_timeout(), -}) -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, -})) - --- @param duration number --- @return fun(self: OnOffInterface, state: {state: boolean}) local function off_timeout(duration) @@ -377,108 +52,6 @@ local function off_timeout(duration) end end -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, -})) - -local hallway_top_light = devices.HueGroup.new({ - identifier = "hallway_top_light", - ip = hue_ip, - login = hue_token, - group_id = 83, - scene_id = "QeufkFDICEHWeKJ7", -}) -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_light_automation = { timeout = utils.Timeout.new(), forced = false, @@ -543,16 +116,175 @@ function hallway_light_automation:light_callback() end end -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) +--- @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, @@ -560,94 +292,6 @@ local hallway_bottom_lights = devices.HueGroup.new({ group_id = 81, scene_id = "3qWKxGVadXFFG4o", }) -devs:add(hallway_bottom_lights) - -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, -} - ----@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 - -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, -})) -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) - -local bedroom_air_filter = devices.AirFilter.new({ - name = "Air Filter", - room = "Bedroom", - url = "http://10.0.0.103", -}) -devs:add(bedroom_air_filter) - local bedroom_lights = devices.HueGroup.new({ identifier = "bedroom_lights", ip = hue_ip, @@ -655,7 +299,6 @@ local bedroom_lights = devices.HueGroup.new({ group_id = 3, scene_id = "PvRs-lGD4VRytL9", }) -devs:add(bedroom_lights) local bedroom_lights_relax = devices.HueGroup.new({ identifier = "bedroom_lights", ip = hue_ip, @@ -663,87 +306,443 @@ local bedroom_lights_relax = devices.HueGroup.new({ group_id = 3, scene_id = "60tfTyR168v2csz", }) -devs:add(bedroom_lights_relax) -devs:add(devices.HueSwitch.new({ - name = "Switch", +local bedroom_air_filter = devices.AirFilter.new({ + name = "Air Filter", 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, -})) + url = "http://10.0.0.103", +}) -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 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) -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) + on_light:add(function(light) + mqtt_client:send_message(mqtt_automation("debug") .. "/darkness", { + state = not light, + updated = utils.get_epoch(), + }) + end) -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) + 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, - battery_callback = check_battery, -})) + 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 --- HACK: If the devices config contains a function it will call it so we have to remove it -devs.add = nil + 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 { @@ -751,7 +750,19 @@ return { openid_url = "https://login.huizinga.dev/api/oidc", }, mqtt = mqtt_client, - devices = devs, + devices = { + 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) -- 2.49.1 From 2db4af74270c8f64f2a52e1a2297eafb3f2d9953 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 04:58:53 +0200 Subject: [PATCH 12/23] feat(config)!: Config now returns the mqtt config instead of the client Instead the client is now created on the rust side based on the config. Devices that require the mqtt client will now instead need to be constructor using a function. This function receives the mqtt client. --- Cargo.lock | 1 + automation_lib/Cargo.toml | 1 + automation_lib/src/config.rs | 28 ---------- automation_lib/src/mqtt.rs | 93 +++++++++++++-------------------- config.lua | 9 ++-- definitions/automation:mqtt.lua | 27 ---------- definitions/config.lua | 18 ++++++- src/bin/automation.rs | 5 +- src/bin/generate_definitions.rs | 6 +++ src/config.rs | 4 +- 10 files changed, 70 insertions(+), 122 deletions(-) delete mode 100644 definitions/automation:mqtt.lua diff --git a/Cargo.lock b/Cargo.lock index 0153116..c8b3419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,7 @@ version = "0.1.0" dependencies = [ "async-trait", "automation_cast", + "automation_macro", "bytes", "dyn-clone", "futures", diff --git a/automation_lib/Cargo.toml b/automation_lib/Cargo.toml index 04dea30..b05bee5 100644 --- a/automation_lib/Cargo.toml +++ b/automation_lib/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +automation_macro = { workspace = true } async-trait = { workspace = true } automation_cast = { workspace = true } bytes = { workspace = true } diff --git a/automation_lib/src/config.rs b/automation_lib/src/config.rs index 0848ee0..995ef52 100644 --- a/automation_lib/src/config.rs +++ b/automation_lib/src/config.rs @@ -1,34 +1,6 @@ -use std::time::Duration; - use lua_typed::Typed; -use rumqttc::{MqttOptions, Transport}; use serde::Deserialize; -#[derive(Debug, Clone, Deserialize, Typed)] -pub struct MqttConfig { - pub host: String, - pub port: u16, - pub client_name: String, - pub username: String, - pub password: String, - #[serde(default)] - pub tls: bool, -} - -impl From for MqttOptions { - fn from(value: MqttConfig) -> Self { - let mut mqtt_options = MqttOptions::new(value.client_name, value.host, value.port); - mqtt_options.set_credentials(value.username, value.password); - mqtt_options.set_keep_alive(Duration::from_secs(5)); - - if value.tls { - mqtt_options.set_transport(Transport::tls_with_default_config()); - } - - mqtt_options - } -} - #[derive(Debug, Clone, Deserialize, Typed)] pub struct InfoConfig { pub name: String, diff --git a/automation_lib/src/mqtt.rs b/automation_lib/src/mqtt.rs index 038251c..bd1b48f 100644 --- a/automation_lib/src/mqtt.rs +++ b/automation_lib/src/mqtt.rs @@ -1,15 +1,41 @@ use std::ops::{Deref, DerefMut}; +use std::time::Duration; +use automation_macro::LuaDeviceConfig; use lua_typed::Typed; -use mlua::{FromLua, LuaSerdeExt}; -use rumqttc::{AsyncClient, Event, EventLoop, Incoming}; +use mlua::FromLua; +use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, Transport}; +use serde::Deserialize; use tracing::{debug, warn}; -use crate::Module; -use crate::config::MqttConfig; -use crate::device_manager::DeviceManager; use crate::event::{self, EventChannel}; +#[derive(Debug, Clone, LuaDeviceConfig, Deserialize, Typed)] +pub struct MqttConfig { + pub host: String, + pub port: u16, + pub client_name: String, + pub username: String, + pub password: String, + #[serde(default)] + #[typed(default)] + pub tls: bool, +} + +impl From for MqttOptions { + fn from(value: MqttConfig) -> Self { + let mut mqtt_options = MqttOptions::new(value.client_name, value.host, value.port); + mqtt_options.set_credentials(value.username, value.password); + mqtt_options.set_keep_alive(Duration::from_secs(5)); + + if value.tls { + mqtt_options.set_transport(Transport::tls_with_default_config()); + } + + mqtt_options + } +} + #[derive(Debug, Clone, FromLua)] pub struct WrappedAsyncClient(pub AsyncClient); @@ -34,20 +60,6 @@ impl Typed for WrappedAsyncClient { Some(output) } - - fn generate_footer() -> Option { - let mut output = String::new(); - - let type_name = Self::type_name(); - - output += &format!("mqtt.{type_name} = {{}}\n"); - output += &format!("---@param device_manager {}\n", DeviceManager::type_name()); - output += &format!("---@param config {}\n", MqttConfig::type_name()); - output += &format!("---@return {type_name}\n"); - output += "function mqtt.new(device_manager, config) end\n"; - - Some(output) - } } impl Deref for WrappedAsyncClient { @@ -90,8 +102,9 @@ impl mlua::UserData for WrappedAsyncClient { } } -pub fn start(mut eventloop: EventLoop, event_channel: &EventChannel) { +pub fn start(config: MqttConfig, event_channel: &EventChannel) -> WrappedAsyncClient { let tx = event_channel.get_tx(); + let (client, mut eventloop) = AsyncClient::new(config.into(), 100); tokio::spawn(async move { debug!("Listening for MQTT events"); @@ -110,42 +123,6 @@ pub fn start(mut eventloop: EventLoop, event_channel: &EventChannel) { } } }); + + WrappedAsyncClient(client) } - -fn create_module(lua: &mlua::Lua) -> mlua::Result { - let mqtt = lua.create_table()?; - let mqtt_new = lua.create_function( - move |lua, (device_manager, config): (DeviceManager, mlua::Value)| { - let event_channel = device_manager.event_channel(); - let config: MqttConfig = lua.from_value(config)?; - - // Create a mqtt client - // TODO: When starting up, the devices are not yet created, this could lead to a device being out of sync - let (client, eventloop) = AsyncClient::new(config.into(), 100); - start(eventloop, &event_channel); - - Ok(WrappedAsyncClient(client)) - }, - )?; - mqtt.set("new", mqtt_new)?; - - Ok(mqtt) -} - -fn generate_definitions() -> String { - let mut output = String::new(); - - output += "---@meta\n\nlocal mqtt\n\n"; - - output += &MqttConfig::generate_full().expect("WrappedAsyncClient should have generate_full"); - output += "\n"; - output += - &WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have generate_full"); - output += "\n"; - - output += "return mqtt"; - - output -} - -inventory::submit! {Module::new("automation:mqtt", create_module, Some(generate_definitions))} diff --git a/config.lua b/config.lua index 24b9566..f683ccf 100644 --- a/config.lua +++ b/config.lua @@ -1,5 +1,4 @@ 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 @@ -734,22 +733,22 @@ local function create_devs(mqtt_client) 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, { +--- @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", -}) +} ---@type Config return { fulfillment = { openid_url = "https://login.huizinga.dev/api/oidc", }, - mqtt = mqtt_client, + mqtt = mqtt_config, devices = { create_devs, ntfy, diff --git a/definitions/automation:mqtt.lua b/definitions/automation:mqtt.lua deleted file mode 100644 index 58c83ae..0000000 --- a/definitions/automation:mqtt.lua +++ /dev/null @@ -1,27 +0,0 @@ --- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED ----@meta - -local mqtt - ----@class MqttConfig ----@field host string ----@field port integer ----@field client_name string ----@field username string ----@field password string ----@field tls boolean -local MqttConfig - ----@class AsyncClient -local AsyncClient ----@async ----@param topic string ----@param message table? -function AsyncClient:send_message(topic, message) end -mqtt.AsyncClient = {} ----@param device_manager DeviceManager ----@param config MqttConfig ----@return AsyncClient -function mqtt.new(device_manager, config) end - -return mqtt diff --git a/definitions/config.lua b/definitions/config.lua index c271e12..ad89353 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -10,8 +10,24 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ---@field devices Devices? ----@field mqtt AsyncClient +---@field mqtt MqttConfig ---@field schedule table? local Config ---@alias Devices (DeviceInterface | fun(client: AsyncClient): Devices)[] + +---@class MqttConfig +---@field host string +---@field port integer +---@field client_name string +---@field username string +---@field password string +---@field tls boolean? +local MqttConfig + +---@class AsyncClient +local AsyncClient +---@async +---@param topic string +---@param message table? +function AsyncClient:send_message(topic, message) end diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 5baca23..1965a09 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -11,6 +11,7 @@ use automation::secret::EnvironmentSecretFile; use automation::version::VERSION; use automation::web::{ApiError, User}; use automation_lib::device_manager::DeviceManager; +use automation_lib::mqtt; use axum::extract::{FromRef, State}; use axum::http::StatusCode; use axum::routing::post; @@ -139,8 +140,10 @@ async fn app() -> anyhow::Result<()> { let entrypoint = Path::new(&setup.entrypoint); let config: Config = lua.load(entrypoint).eval_async().await?; + let mqtt_client = mqtt::start(config.mqtt, &device_manager.event_channel()); + if let Some(devices) = config.devices { - for device in devices.get(&lua, &config.mqtt).await? { + for device in devices.get(&lua, &mqtt_client).await? { device_manager.add(device).await; } } diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index 41262f5..ac6a3de 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -3,6 +3,7 @@ use std::io::Write; use automation::config::{Config, Devices, FulfillmentConfig}; use automation_lib::Module; +use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; use lua_typed::Typed; use tracing::{info, warn}; @@ -35,6 +36,11 @@ fn config_definitions() -> String { output += &Config::generate_full().expect("Config should have a definition"); output += "\n"; output += &Devices::generate_full().expect("Devices should have a definition"); + output += "\n"; + output += &MqttConfig::generate_full().expect("MqttConfig should have a definition"); + output += "\n"; + output += + &WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have a definition"); output } diff --git a/src/config.rs b/src/config.rs index daac095..9d692e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,7 +3,7 @@ use std::net::{Ipv4Addr, SocketAddr}; use automation_lib::action_callback::ActionCallback; use automation_lib::device::Device; -use automation_lib::mqtt::WrappedAsyncClient; +use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; use automation_macro::LuaDeviceConfig; use lua_typed::Typed; use mlua::FromLua; @@ -105,7 +105,7 @@ pub struct Config { #[device_config(from_lua, default)] pub devices: Option, #[device_config(from_lua)] - pub mqtt: WrappedAsyncClient, + pub mqtt: MqttConfig, #[device_config(from_lua, default)] #[typed(default)] pub schedule: HashMap>, -- 2.49.1 From 2fe9fbadfb84ba5df94e1473af721dd3be8f71eb Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 05:29:41 +0200 Subject: [PATCH 13/23] feat(config)!: Remove device manager lua code With the recent changes the device manager no longer needs to be available in lua. --- automation_lib/src/device_manager.rs | 22 +--------------------- definitions/automation:device_manager.lua | 8 -------- src/bin/automation.rs | 2 -- 3 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 definitions/automation:device_manager.lua diff --git a/automation_lib/src/device_manager.rs b/automation_lib/src/device_manager.rs index cdf302a..08f8fbf 100644 --- a/automation_lib/src/device_manager.rs +++ b/automation_lib/src/device_manager.rs @@ -2,8 +2,6 @@ use std::collections::HashMap; use std::sync::Arc; use futures::future::join_all; -use lua_typed::Typed; -use mlua::FromLua; use tokio::sync::{RwLock, RwLockReadGuard}; use tracing::{debug, instrument, trace}; @@ -12,7 +10,7 @@ use crate::event::{Event, EventChannel, OnMqtt}; pub type DeviceMap = HashMap>; -#[derive(Clone, FromLua)] +#[derive(Clone)] pub struct DeviceManager { devices: Arc>, event_channel: EventChannel, @@ -89,21 +87,3 @@ impl DeviceManager { } } } - -impl mlua::UserData for DeviceManager { - fn add_methods>(methods: &mut M) { - methods.add_async_method("add", async |_lua, this, device: Box| { - this.add(device).await; - - Ok(()) - }); - - methods.add_method("event_channel", |_lua, this, ()| Ok(this.event_channel())) - } -} - -impl Typed for DeviceManager { - fn type_name() -> String { - "DeviceManager".into() - } -} diff --git a/definitions/automation:device_manager.lua b/definitions/automation:device_manager.lua deleted file mode 100644 index 23af2c8..0000000 --- a/definitions/automation:device_manager.lua +++ /dev/null @@ -1,8 +0,0 @@ ----@meta - ----@class DeviceManager -local DeviceManager ----@param device DeviceInterface -function DeviceManager:add(device) end - -return DeviceManager diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 1965a09..5e1bb5f 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -132,8 +132,6 @@ async fn app() -> anyhow::Result<()> { automation_lib::load_modules(&lua)?; - lua.register_module("automation:device_manager", device_manager.clone())?; - lua.register_module("automation:variables", lua.to_value(&setup.variables)?)?; lua.register_module("automation:secrets", lua.to_value(&setup.secrets)?)?; -- 2.49.1 From 2056c6c70d8b642fa7500ee73f7ad1fd9d3d7351 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Sun, 19 Oct 2025 05:03:08 +0200 Subject: [PATCH 14/23] feat(config)!: Changed default config location --- Dockerfile | 3 +-- config.lua => config/config.lua | 0 src/config.rs | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) rename config.lua => config/config.lua (100%) diff --git a/Dockerfile b/Dockerfile index 0719f81..2af030b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,5 @@ RUN cargo auditable build --release FROM gcr.io/distroless/cc-debian12:nonroot AS runtime COPY --from=builder /app/target/release/automation /app/automation -ENV AUTOMATION__ENTRYPOINT=/app/config.lua -COPY ./config.lua /app/config.lua +COPY ./config /app/config CMD [ "/app/automation" ] diff --git a/config.lua b/config/config.lua similarity index 100% rename from config.lua rename to config/config.lua diff --git a/src/config.rs b/src/config.rs index 9d692e2..93bd2fb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,7 +20,7 @@ pub struct Setup { } fn default_entrypoint() -> String { - "./config.lua".into() + "./config/config.lua".into() } #[derive(Debug, Deserialize, Typed)] -- 2.49.1 From bc75f7005c61b9d6b3cd1a77f4c3b0d4983ab080 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Mon, 20 Oct 2025 04:08:55 +0200 Subject: [PATCH 15/23] feat(config)!: Device creation function is now named entry It now has to be called 'setup', this makes it possible to just include the table as a whole in devices and it will automatically call the correct function. --- config/config.lua | 5 ++-- definitions/config.lua | 6 +++-- src/bin/automation.rs | 4 +-- src/bin/generate_definitions.rs | 4 +-- src/config.rs | 47 ++++++++++++++++++++------------- src/lib.rs | 2 ++ 6 files changed, 41 insertions(+), 27 deletions(-) diff --git a/config/config.lua b/config/config.lua index f683ccf..ebdb722 100644 --- a/config/config.lua +++ b/config/config.lua @@ -312,6 +312,7 @@ local bedroom_air_filter = devices.AirFilter.new({ url = "http://10.0.0.103", }) +--- @type SetupFunction local function create_devs(mqtt_client) on_presence:add(function(presence) mqtt_client:send_message(mqtt_automation("debug") .. "/presence", { @@ -749,8 +750,8 @@ return { openid_url = "https://login.huizinga.dev/api/oidc", }, mqtt = mqtt_config, - devices = { - create_devs, + modules = { + setup = create_devs, ntfy, hue_bridge, kitchen_lights, diff --git a/definitions/config.lua b/definitions/config.lua index ad89353..dbe0077 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -9,12 +9,14 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ----@field devices Devices? +---@field modules Modules? ---@field mqtt MqttConfig ---@field schedule table? local Config ----@alias Devices (DeviceInterface | fun(client: AsyncClient): Devices)[] +---@alias SetupFunction fun(mqtt_client: AsyncClient): SetupTable? +---@alias SetupTable (DeviceInterface | { setup: SetupFunction? } | SetupTable)[] +---@alias Modules SetupFunction | SetupTable ---@class MqttConfig ---@field host string diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 5e1bb5f..4120120 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -140,8 +140,8 @@ async fn app() -> anyhow::Result<()> { let mqtt_client = mqtt::start(config.mqtt, &device_manager.event_channel()); - if let Some(devices) = config.devices { - for device in devices.get(&lua, &mqtt_client).await? { + if let Some(modules) = config.modules { + for device in modules.setup(&lua, &mqtt_client).await? { device_manager.add(device).await; } } diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index ac6a3de..a2348f9 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, Devices, FulfillmentConfig}; +use automation::config::{Config, FulfillmentConfig, Modules}; use automation_lib::Module; use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; use lua_typed::Typed; @@ -35,7 +35,7 @@ fn config_definitions() -> String { 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 += &Modules::generate_full().expect("Setups should have a definition"); output += "\n"; output += &MqttConfig::generate_full().expect("MqttConfig should have a definition"); output += "\n"; diff --git a/src/config.rs b/src/config.rs index 93bd2fb..0c537f8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -35,10 +35,10 @@ pub struct FulfillmentConfig { } #[derive(Debug, Default)] -pub struct Devices(mlua::Value); +pub struct Modules(mlua::Value); -impl Devices { - pub async fn get( +impl Modules { + pub async fn setup( self, lua: &mlua::Lua, client: &WrappedAsyncClient, @@ -60,17 +60,22 @@ impl Devices { }; for pair in table.pairs() { - let (_, value): (mlua::Value, _) = pair?; + let (name, value): (String, _) = 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?); + mlua::Value::Table(table) => queue.push_back(table), + mlua::Value::UserData(_) + if let Ok(device) = Box::from_lua(value.clone(), lua) => + { + devices.push(device); } - _ => Err(mlua::Error::runtime(format!( - "Expected a device, table, or function, instead found: {}", - value.type_name() - )))?, + mlua::Value::Function(f) if name == "setup" => { + let value: mlua::Value = f.call_async(client.clone()).await?; + if let Some(table) = value.as_table() { + queue.push_back(table.clone()); + } + } + _ => {} } } } @@ -79,22 +84,26 @@ impl Devices { } } -impl FromLua for Devices { +impl FromLua for Modules { fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result { - Ok(Devices(value)) + Ok(Modules(value)) } } -impl Typed for Devices { +impl Typed for Modules { fn type_name() -> String { - "Devices".into() + "Modules".into() } fn generate_header() -> Option { + let type_name = Self::type_name(); + let client_type = WrappedAsyncClient::type_name(); + Some(format!( - "---@alias {} (DeviceInterface | fun(client: {}): Devices)[]\n", - ::type_name(), - ::type_name() + r#"---@alias SetupFunction fun(mqtt_client: {client_type}): SetupTable? +---@alias SetupTable (DeviceInterface | {{ setup: SetupFunction? }} | SetupTable)[] +---@alias {type_name} SetupFunction | SetupTable +"#, )) } } @@ -103,7 +112,7 @@ impl Typed for Devices { pub struct Config { pub fulfillment: FulfillmentConfig, #[device_config(from_lua, default)] - pub devices: Option, + pub modules: Option, #[device_config(from_lua)] pub mqtt: MqttConfig, #[device_config(from_lua, default)] diff --git a/src/lib.rs b/src/lib.rs index f1e947d..dbfca74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(if_let_guard)] + pub mod config; pub mod schedule; pub mod secret; -- 2.49.1 From 7db628709a0f6973325a01dee9eb20c740f27d31 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Mon, 20 Oct 2025 04:39:12 +0200 Subject: [PATCH 16/23] refactor: Split config --- config/battery.lua | 41 ++ config/config.lua | 781 +------------------------------- config/debug.lua | 34 ++ config/hallway_automation.lua | 84 ++++ config/helper.lua | 48 ++ config/hue_bridge.lua | 40 ++ config/light.lua | 42 ++ config/ntfy.lua | 32 ++ config/presence.lua | 78 ++++ config/rooms.lua | 12 + config/rooms/bathroom.lua | 39 ++ config/rooms/bedroom.lua | 74 +++ config/rooms/guest_bedroom.lua | 34 ++ config/rooms/hallway_bottom.lua | 109 +++++ config/rooms/hallway_top.lua | 47 ++ config/rooms/kitchen.lua | 70 +++ config/rooms/living_room.lua | 125 +++++ config/rooms/storage.lua | 40 ++ config/rooms/workbench.lua | 68 +++ config/windows.lua | 42 ++ 20 files changed, 1083 insertions(+), 757 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/rooms.lua create mode 100644 config/rooms/bathroom.lua create mode 100644 config/rooms/bedroom.lua create mode 100644 config/rooms/guest_bedroom.lua create mode 100644 config/rooms/hallway_bottom.lua create mode 100644 config/rooms/hallway_top.lua create mode 100644 config/rooms/kitchen.lua create mode 100644 config/rooms/living_room.lua create mode 100644 config/rooms/storage.lua create mode 100644 config/rooms/workbench.lua create mode 100644 config/windows.lua 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 ebdb722..cb5cbdc 100644 --- a/config/config.lua +++ b/config/config.lua @@ -1,775 +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", -}) - ---- @type SetupFunction -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..729be12 --- /dev/null +++ b/config/rooms.lua @@ -0,0 +1,12 @@ +--- @type SetupTable +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 -- 2.49.1 From a6c19eb9b4ab2d927387a11fa7218debf517378b Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 02:59:21 +0200 Subject: [PATCH 17/23] fix: Fix issues with inner type definitions --- Cargo.lock | 11 ++-- definitions/automation:devices.lua | 86 +++++++++++++++--------------- definitions/config.lua | 10 ++-- 3 files changed, 53 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8b3419..09d5e63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,7 +555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1163,17 +1163,16 @@ dependencies = [ [[package]] name = "lua_typed" version = "0.1.0" -source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#f6a684291432aae2ef7109712882e7e3ed758d08" +source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#3d29c9dd143737c8bffe4bacae8e701de3c6ee10" dependencies = [ "eui48", "lua_typed_macro", - "mlua", ] [[package]] name = "lua_typed_macro" version = "0.1.0" -source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#f6a684291432aae2ef7109712882e7e3ed758d08" +source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#3d29c9dd143737c8bffe4bacae8e701de3c6ee10" dependencies = [ "convert_case", "itertools", @@ -1568,7 +1567,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1745,7 +1744,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/definitions/automation:devices.lua b/definitions/automation:devices.lua index ef7c3c0..06b0667 100644 --- a/definitions/automation:devices.lua +++ b/definitions/automation:devices.lua @@ -6,9 +6,9 @@ local devices ---@class Action ---@field action ---| "broadcast" ----@field extras table? +---@field extras (table)? ---@field label string ----@field clear boolean? +---@field clear (boolean)? local Action ---@class AirFilter: DeviceInterface, OnOffInterface @@ -20,49 +20,49 @@ function devices.AirFilter.new(config) end ---@class AirFilterConfig ---@field name string ----@field room string? +---@field room (string)? ---@field url string local AirFilterConfig ---@class ConfigLightLightStateBrightness ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ----@field callback fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[]? ----@field client AsyncClient? +---@field callback (fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[])? +---@field client (AsyncClient)? local ConfigLightLightStateBrightness ---@class ConfigLightLightStateColorTemperature ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ----@field callback fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[]? ----@field client AsyncClient? +---@field callback (fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[])? +---@field client (AsyncClient)? local ConfigLightLightStateColorTemperature ---@class ConfigLightLightStateOnOff ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ----@field callback fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[]? ----@field client AsyncClient? +---@field callback (fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[])? +---@field client (AsyncClient)? local ConfigLightLightStateOnOff ---@class ConfigOutletOutletStateOnOff ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ----@field outlet_type OutletType? ----@field callback fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[]? +---@field outlet_type (OutletType)? +---@field callback (fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[])? ---@field client AsyncClient local ConfigOutletOutletStateOnOff ---@class ConfigOutletOutletStatePower ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ----@field outlet_type OutletType? ----@field callback fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[]? +---@field outlet_type (OutletType)? +---@field callback (fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[])? ---@field client AsyncClient local ConfigOutletOutletStatePower @@ -75,12 +75,12 @@ function devices.ContactSensor.new(config) end ---@class ContactSensorConfig ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ----@field sensor_type SensorType? ----@field callback fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[]? ----@field battery_callback fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[]? ----@field client AsyncClient? +---@field sensor_type (SensorType)? +---@field callback (fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[])? +---@field battery_callback (fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[])? +---@field client (AsyncClient)? local ContactSensorConfig ---@alias Flag @@ -134,14 +134,14 @@ function devices.HueSwitch.new(config) end ---@class HueSwitchConfig ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ---@field client AsyncClient ----@field left_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ----@field right_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ----@field left_hold_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ----@field right_hold_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ----@field battery_callback fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[]? +---@field left_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])? +---@field right_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])? +---@field left_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])? +---@field right_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])? +---@field battery_callback (fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[])? local HueSwitchConfig ---@class IkeaRemote: DeviceInterface @@ -153,12 +153,12 @@ function devices.IkeaRemote.new(config) end ---@class IkeaRemoteConfig ---@field name string ----@field room string? ----@field single_button boolean? +---@field room (string)? +---@field single_button (boolean)? ---@field topic string ---@field client AsyncClient ----@field callback fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[]? ----@field battery_callback fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[]? +---@field callback (fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[])? +---@field battery_callback (fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[])? local IkeaRemoteConfig ---@class KasaOutlet: DeviceInterface, OnOffInterface @@ -206,7 +206,7 @@ function devices.LightSensor.new(config) end ---@field topic string ---@field min integer ---@field max integer ----@field callback fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[]? +---@field callback (fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[])? ---@field client AsyncClient local LightSensorConfig @@ -227,10 +227,10 @@ local LightStateOnOff ---@class Notification ---@field title string ----@field message string? ----@field tags string[]? ----@field priority Priority? ----@field actions Action[]? +---@field message (string)? +---@field tags ((string)[])? +---@field priority (Priority)? +---@field actions ((Action)[])? local Notification ---@class Ntfy: DeviceInterface @@ -244,7 +244,7 @@ function devices.Ntfy.new(config) end function Ntfy:send_notification(notification) end ---@class NtfyConfig ----@field url string? +---@field url (string)? ---@field topic string local NtfyConfig @@ -287,7 +287,7 @@ function Presence:overall_presence() end ---@class PresenceConfig ---@field topic string ----@field callback fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[]? +---@field callback (fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[])? ---@field client AsyncClient local PresenceConfig @@ -321,16 +321,16 @@ function devices.Washer.new(config) end ---@field identifier string ---@field topic string ---@field threshold number ----@field done_callback fun(_: Washer) | fun(_: Washer)[]? +---@field done_callback (fun(_: Washer) | fun(_: Washer)[])? ---@field client AsyncClient local WasherConfig ---@class WolConfig ---@field name string ----@field room string? +---@field room (string)? ---@field topic string ---@field mac_address string ----@field broadcast_ip string? +---@field broadcast_ip (string)? ---@field client AsyncClient local WolConfig diff --git a/definitions/config.lua b/definitions/config.lua index dbe0077..db2d55d 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -3,15 +3,15 @@ ---@class FulfillmentConfig ---@field openid_url string ----@field ip string? ----@field port integer? +---@field ip (string)? +---@field port (integer)? local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ----@field modules Modules? +---@field modules (Modules)? ---@field mqtt MqttConfig ----@field schedule table? +---@field schedule (table)? local Config ---@alias SetupFunction fun(mqtt_client: AsyncClient): SetupTable? @@ -24,7 +24,7 @@ local Config ---@field client_name string ---@field username string ---@field password string ----@field tls boolean? +---@field tls (boolean)? local MqttConfig ---@class AsyncClient -- 2.49.1 From a938f3d71bc3b2ba5b5b2cd6a49f8a7bed62cb6c Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 03:03:38 +0200 Subject: [PATCH 18/23] feat(config)!: Improve config module resolution The new system is slightly less flexible, but the code and lua definitions is now a lot simpler and easier to understand. In fact the old lua definition was not actually correct. It is likely that existing configs require not/minimal tweaks to work again. --- config/rooms.lua | 2 +- definitions/config.lua | 10 +- src/bin/automation.rs | 7 +- src/bin/generate_definitions.rs | 4 +- src/config.rs | 189 ++++++++++++++++++++++---------- 5 files changed, 145 insertions(+), 67 deletions(-) diff --git a/config/rooms.lua b/config/rooms.lua index 729be12..98fe996 100644 --- a/config/rooms.lua +++ b/config/rooms.lua @@ -1,4 +1,4 @@ ---- @type SetupTable +--- @type Module return { require("config.rooms.bathroom"), require("config.rooms.bedroom"), diff --git a/definitions/config.lua b/definitions/config.lua index db2d55d..32ed6aa 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -9,14 +9,16 @@ local FulfillmentConfig ---@class Config ---@field fulfillment FulfillmentConfig ----@field modules (Modules)? +---@field modules (Module)[] ---@field mqtt MqttConfig ---@field schedule (table)? local Config ----@alias SetupFunction fun(mqtt_client: AsyncClient): SetupTable? ----@alias SetupTable (DeviceInterface | { setup: SetupFunction? } | SetupTable)[] ----@alias Modules SetupFunction | SetupTable +---@class Module +---@field setup (fun(mqtt_client: AsyncClient): Module | DeviceInterface[] | nil)? +---@field devices (DeviceInterface)[]? +---@field [number] (Module)[]? +local Module ---@class MqttConfig ---@field host string diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 4120120..8b759c3 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -140,10 +140,9 @@ async fn app() -> anyhow::Result<()> { let mqtt_client = mqtt::start(config.mqtt, &device_manager.event_channel()); - if let Some(modules) = config.modules { - for device in modules.setup(&lua, &mqtt_client).await? { - device_manager.add(device).await; - } + let resolved = config.modules.resolve(&lua, &mqtt_client).await?; + for device in resolved.devices { + 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 a2348f9..fbff60a 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, Modules}; +use automation::config::{Config, FulfillmentConfig, Module as ConfigModule}; use automation_lib::Module; use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; use lua_typed::Typed; @@ -35,7 +35,7 @@ fn config_definitions() -> String { output += "\n"; output += &Config::generate_full().expect("Config should have a definition"); output += "\n"; - output += &Modules::generate_full().expect("Setups should have a definition"); + output += &ConfigModule::generate_full().expect("Module should have a definition"); output += "\n"; output += &MqttConfig::generate_full().expect("MqttConfig should have a definition"); output += "\n"; diff --git a/src/config.rs b/src/config.rs index 0c537f8..5f2331a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,85 +34,162 @@ pub struct FulfillmentConfig { pub port: u16, } +#[derive(Debug)] +pub struct SetupFunction(mlua::Function); + +impl Typed for SetupFunction { + fn type_name() -> String { + format!( + "fun(mqtt_client: {}): {} | DeviceInterface[] | nil", + WrappedAsyncClient::type_name(), + Module::type_name() + ) + } +} + +impl FromLua for SetupFunction { + fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result { + Ok(Self(FromLua::from_lua(value, lua)?)) + } +} + #[derive(Debug, Default)] -pub struct Modules(mlua::Value); +pub struct Module { + pub setup: Option, + pub devices: Vec>, + pub modules: Vec, +} -impl Modules { - pub async fn setup( - 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() - )))?, - }; +// TODO: Add option to typed to rename field +impl Typed for Module { + fn type_name() -> String { + "Module".into() + } - let mut queue: VecDeque = [initial_table].into(); - loop { - let Some(table) = queue.pop_front() else { - break; - }; + fn generate_header() -> Option { + Some(format!("---@class {}\n", Self::type_name())) + } - for pair in table.pairs() { - let (name, value): (String, _) = pair?; + fn generate_members() -> Option { + Some(format!( + r#"---@field setup {} +---@field devices {}? +---@field [number] {}? +"#, + Option::::type_name(), + Vec::>::type_name(), + Vec::::type_name(), + )) + } - match value { - mlua::Value::Table(table) => queue.push_back(table), - mlua::Value::UserData(_) - if let Ok(device) = Box::from_lua(value.clone(), lua) => - { - devices.push(device); - } - mlua::Value::Function(f) if name == "setup" => { - let value: mlua::Value = f.call_async(client.clone()).await?; - if let Some(table) = value.as_table() { - queue.push_back(table.clone()); - } - } - _ => {} - } - } + fn generate_footer() -> Option { + let type_name = ::type_name(); + Some(format!("local {type_name}\n")) + } +} + +impl FromLua for Module { + fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result { + // When calling require it might return a result from the searcher indicating how the + // module was found, we want to ignore these entries. + // TODO: Find a better solution for this + if value.is_string() { + return Ok(Default::default()); } - Ok(devices) + let mlua::Value::Table(table) = value else { + return Err(mlua::Error::runtime(format!( + "Expected module table, instead found: {}", + value.type_name() + ))); + }; + + let setup = table.get("setup")?; + + let devices = table.get("devices").unwrap_or_default(); + let mut modules = Vec::new(); + + for module in table.sequence_values::() { + modules.push(module?); + } + + Ok(Module { + setup, + devices, + modules, + }) + } +} + +#[derive(Debug, Default)] +pub struct Modules(Vec); + +impl Typed for Modules { + fn type_name() -> String { + Vec::::type_name() } } impl FromLua for Modules { - fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result { - Ok(Modules(value)) + fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result { + Ok(Self(FromLua::from_lua(value, lua)?)) } } -impl Typed for Modules { - fn type_name() -> String { - "Modules".into() - } +impl Modules { + pub async fn resolve( + self, + lua: &mlua::Lua, + client: &WrappedAsyncClient, + ) -> mlua::Result { + let mut modules: VecDeque<_> = self.0.into(); - fn generate_header() -> Option { - let type_name = Self::type_name(); - let client_type = WrappedAsyncClient::type_name(); + let mut devices = Vec::new(); - Some(format!( - r#"---@alias SetupFunction fun(mqtt_client: {client_type}): SetupTable? ----@alias SetupTable (DeviceInterface | {{ setup: SetupFunction? }} | SetupTable)[] ----@alias {type_name} SetupFunction | SetupTable -"#, - )) + loop { + let Some(module) = modules.pop_front() else { + break; + }; + + modules.extend(module.modules); + + if let Some(setup) = module.setup { + let result: mlua::Value = setup.0.call_async(client.clone()).await?; + + if result.is_nil() { + // We ignore nil results + } else if let Ok(d) = as FromLua>::from_lua(result.clone(), lua) + && !d.is_empty() + { + // This is a shortcut for the common pattern of setup functions that only + // return devices + devices.extend(d); + } else if let Ok(module) = FromLua::from_lua(result.clone(), lua) { + modules.push_back(module); + } else { + return Err(mlua::Error::runtime( + "Setup function returned data in an unexpected format", + )); + } + } + + devices.extend(module.devices); + } + + Ok(Resolved { devices }) } } +#[derive(Debug, Default)] +pub struct Resolved { + pub devices: Vec>, +} + #[derive(Debug, LuaDeviceConfig, Typed)] pub struct Config { pub fulfillment: FulfillmentConfig, #[device_config(from_lua, default)] - pub modules: Option, + pub modules: Modules, #[device_config(from_lua)] pub mqtt: MqttConfig, #[device_config(from_lua, default)] -- 2.49.1 From 95465854404dc11fa6ee8d9979b5e1a7f93121ea Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 03:24:34 +0200 Subject: [PATCH 19/23] feat(config)!: Made schedule part of new modules --- config/battery.lua | 4 ++++ config/config.lua | 10 --------- config/rooms/bedroom.lua | 27 ++++++++++++---------- definitions/config.lua | 2 +- src/bin/automation.rs | 3 +-- src/config.rs | 22 ++++++++++++------ src/schedule.rs | 48 +++++++++++++++++++++++----------------- 7 files changed, 64 insertions(+), 52 deletions(-) diff --git a/config/battery.lua b/config/battery.lua index 7eb82f2..19546bc 100644 --- a/config/battery.lua +++ b/config/battery.lua @@ -38,4 +38,8 @@ function module.notify_low_battery() }) end +module.schedule = { + ["0 0 21 */1 * *"] = module.notify_low_battery, +} + return module diff --git a/config/config.lua b/config/config.lua index cb5cbdc..289a573 100644 --- a/config/config.lua +++ b/config/config.lua @@ -29,14 +29,4 @@ return { require("config.rooms"), require("config.windows"), }, - -- TODO: Make this also part of the modules - schedule = { - ["0 0 19 * * *"] = function() - require("config.rooms.bedroom").set_airfilter_on(true) - end, - ["0 0 20 * * *"] = function() - require("config.rooms.bedroom").set_airfilter_on(false) - end, - ["0 0 21 */1 * *"] = require("config.battery").notify_low_battery, - }, } diff --git a/config/rooms/bedroom.lua b/config/rooms/bedroom.lua index ee8a561..50502f5 100644 --- a/config/rooms/bedroom.lua +++ b/config/rooms/bedroom.lua @@ -56,19 +56,22 @@ function module.setup(mqtt_client) windows.add(window) return { - lights, - lights_relax, - air_filter, - switch, - window, + devices = { + lights, + lights_relax, + air_filter, + switch, + window, + }, + schedule = { + ["0 0 19 * * *"] = function() + air_filter:set_on(true) + end, + ["0 0 20 * * *"] = function() + air_filter:set_on(false) + end, + }, } 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/definitions/config.lua b/definitions/config.lua index 32ed6aa..1cef4fd 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -11,12 +11,12 @@ local FulfillmentConfig ---@field fulfillment FulfillmentConfig ---@field modules (Module)[] ---@field mqtt MqttConfig ----@field schedule (table)? local Config ---@class Module ---@field setup (fun(mqtt_client: AsyncClient): Module | DeviceInterface[] | nil)? ---@field devices (DeviceInterface)[]? +---@field schedule table? ---@field [number] (Module)[]? local Module diff --git a/src/bin/automation.rs b/src/bin/automation.rs index 8b759c3..8dea38e 100644 --- a/src/bin/automation.rs +++ b/src/bin/automation.rs @@ -6,7 +6,6 @@ use std::process; use ::config::{Environment, File}; use automation::config::{Config, Setup}; -use automation::schedule::start_scheduler; use automation::secret::EnvironmentSecretFile; use automation::version::VERSION; use automation::web::{ApiError, User}; @@ -145,7 +144,7 @@ async fn app() -> anyhow::Result<()> { device_manager.add(device).await; } - start_scheduler(config.schedule).await?; + resolved.scheduler.start().await?; // Create google home fulfillment route let fulfillment = Router::new().route("/google_home", post(fulfillment)); diff --git a/src/config.rs b/src/config.rs index 5f2331a..fd71452 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ use lua_typed::Typed; use mlua::FromLua; use serde::Deserialize; +use crate::schedule::Scheduler; + #[derive(Debug, Deserialize)] pub struct Setup { #[serde(default = "default_entrypoint")] @@ -57,6 +59,7 @@ impl FromLua for SetupFunction { pub struct Module { pub setup: Option, pub devices: Vec>, + pub schedule: HashMap>, pub modules: Vec, } @@ -74,10 +77,12 @@ impl Typed for Module { Some(format!( r#"---@field setup {} ---@field devices {}? +---@field schedule {}? ---@field [number] {}? "#, Option::::type_name(), Vec::>::type_name(), + HashMap::>::type_name(), Vec::::type_name(), )) } @@ -105,8 +110,9 @@ impl FromLua for Module { }; let setup = table.get("setup")?; - let devices = table.get("devices").unwrap_or_default(); + let schedule = table.get("schedule").unwrap_or_default(); + let mut modules = Vec::new(); for module in table.sequence_values::() { @@ -116,6 +122,7 @@ impl FromLua for Module { Ok(Module { setup, devices, + schedule, modules, }) } @@ -142,10 +149,10 @@ impl Modules { lua: &mlua::Lua, client: &WrappedAsyncClient, ) -> mlua::Result { - let mut modules: VecDeque<_> = self.0.into(); - let mut devices = Vec::new(); + let mut scheduler = Scheduler::default(); + let mut modules: VecDeque<_> = self.0.into(); loop { let Some(module) = modules.pop_front() else { break; @@ -174,15 +181,19 @@ impl Modules { } devices.extend(module.devices); + for (cron, f) in module.schedule { + scheduler.add_job(cron, f); + } } - Ok(Resolved { devices }) + Ok(Resolved { devices, scheduler }) } } #[derive(Debug, Default)] pub struct Resolved { pub devices: Vec>, + pub scheduler: Scheduler, } #[derive(Debug, LuaDeviceConfig, Typed)] @@ -192,9 +203,6 @@ pub struct Config { pub modules: Modules, #[device_config(from_lua)] pub mqtt: MqttConfig, - #[device_config(from_lua, default)] - #[typed(default)] - pub schedule: HashMap>, } impl From for SocketAddr { diff --git a/src/schedule.rs b/src/schedule.rs index 543c0fe..3615fed 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -1,29 +1,37 @@ -use std::collections::HashMap; use std::pin::Pin; use automation_lib::action_callback::ActionCallback; use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError}; -pub async fn start_scheduler( - schedule: HashMap>, -) -> Result<(), JobSchedulerError> { - let scheduler = JobScheduler::new().await?; +#[derive(Debug, Default)] +pub struct Scheduler { + jobs: Vec<(String, ActionCallback<()>)>, +} - for (s, f) in schedule { - let job = { - move |_uuid, _lock| -> Pin + Send>> { - let f = f.clone(); - - Box::pin(async move { - f.call(()).await; - }) - } - }; - - let job = Job::new_async(s, job)?; - - scheduler.add(job).await?; +impl Scheduler { + pub fn add_job(&mut self, cron: String, f: ActionCallback<()>) { + self.jobs.push((cron, f)); } - scheduler.start().await + pub async fn start(self) -> Result<(), JobSchedulerError> { + let scheduler = JobScheduler::new().await?; + + for (s, f) in self.jobs { + let job = { + move |_uuid, _lock| -> Pin + Send>> { + let f = f.clone(); + + Box::pin(async move { + f.call(()).await; + }) + } + }; + + let job = Job::new_async(s, job)?; + + scheduler.add(job).await?; + } + + scheduler.start().await + } } -- 2.49.1 From 8a3143a3ea5a4da6e28293cdc16ffc6b3bf9f6a0 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 03:59:40 +0200 Subject: [PATCH 20/23] feat: Added type alias for setup and schedule types --- definitions/config.lua | 8 +++++-- src/bin/generate_definitions.rs | 8 ++++++- src/config.rs | 40 ++++++++++++++++++++++++++++----- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/definitions/config.lua b/definitions/config.lua index 1cef4fd..9bd274c 100644 --- a/definitions/config.lua +++ b/definitions/config.lua @@ -13,10 +13,14 @@ local FulfillmentConfig ---@field mqtt MqttConfig local Config +---@alias SetupFunction fun(mqtt_client: AsyncClient): Module | DeviceInterface[] | nil + +---@alias Schedule table + ---@class Module ----@field setup (fun(mqtt_client: AsyncClient): Module | DeviceInterface[] | nil)? +---@field setup (SetupFunction)? ---@field devices (DeviceInterface)[]? ----@field schedule table? +---@field schedule Schedule? ---@field [number] (Module)[]? local Module diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index fbff60a..495b5b0 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -1,7 +1,9 @@ use std::fs::{self, File}; use std::io::Write; -use automation::config::{Config, FulfillmentConfig, Module as ConfigModule}; +use automation::config::{ + Config, FulfillmentConfig, Module as ConfigModule, Schedule, SetupFunction, +}; use automation_lib::Module; use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; use lua_typed::Typed; @@ -35,6 +37,10 @@ fn config_definitions() -> String { output += "\n"; output += &Config::generate_full().expect("Config should have a definition"); output += "\n"; + output += &SetupFunction::generate_full().expect("SetupFunction should have a definition"); + output += "\n"; + output += &Schedule::generate_full().expect("Schedule should have a definition"); + output += "\n"; output += &ConfigModule::generate_full().expect("Module should have a definition"); output += "\n"; output += &MqttConfig::generate_full().expect("MqttConfig should have a definition"); diff --git a/src/config.rs b/src/config.rs index fd71452..eb8793e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -41,11 +41,16 @@ pub struct SetupFunction(mlua::Function); impl Typed for SetupFunction { fn type_name() -> String { - format!( - "fun(mqtt_client: {}): {} | DeviceInterface[] | nil", + "SetupFunction".into() + } + + fn generate_header() -> Option { + Some(format!( + "---@alias {} fun(mqtt_client: {}): {} | DeviceInterface[] | nil\n", + Self::type_name(), WrappedAsyncClient::type_name(), Module::type_name() - ) + )) } } @@ -55,11 +60,34 @@ impl FromLua for SetupFunction { } } +#[derive(Debug, Default)] +pub struct Schedule(HashMap>); + +impl Typed for Schedule { + fn type_name() -> String { + "Schedule".into() + } + + fn generate_header() -> Option { + Some(format!( + "---@alias {} {}\n", + Self::type_name(), + HashMap::>::type_name(), + )) + } +} + +impl FromLua for Schedule { + fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result { + Ok(Self(FromLua::from_lua(value, lua)?)) + } +} + #[derive(Debug, Default)] pub struct Module { pub setup: Option, pub devices: Vec>, - pub schedule: HashMap>, + pub schedule: Schedule, pub modules: Vec, } @@ -82,7 +110,7 @@ impl Typed for Module { "#, Option::::type_name(), Vec::>::type_name(), - HashMap::>::type_name(), + Schedule::type_name(), Vec::::type_name(), )) } @@ -181,7 +209,7 @@ impl Modules { } devices.extend(module.devices); - for (cron, f) in module.schedule { + for (cron, f) in module.schedule.0 { scheduler.add_job(cron, f); } } -- 2.49.1 From 5947098bfb52ce2555d481f7f4cff1c2c305ec11 Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 03:59:59 +0200 Subject: [PATCH 21/23] chore: Fix config type annotations --- config/battery.lua | 6 ++++-- config/debug.lua | 1 + config/hallway_automation.lua | 1 + config/helper.lua | 1 + config/hue_bridge.lua | 1 + config/light.lua | 2 ++ config/ntfy.lua | 2 ++ config/presence.lua | 2 ++ config/rooms/bathroom.lua | 3 ++- config/rooms/bedroom.lua | 3 ++- config/rooms/guest_bedroom.lua | 3 ++- config/rooms/hallway_bottom.lua | 3 ++- config/rooms/hallway_top.lua | 3 ++- config/rooms/kitchen.lua | 1 + config/rooms/living_room.lua | 3 ++- config/rooms/storage.lua | 3 ++- config/rooms/workbench.lua | 3 ++- config/windows.lua | 1 + 18 files changed, 32 insertions(+), 10 deletions(-) diff --git a/config/battery.lua b/config/battery.lua index 19546bc..521331a 100644 --- a/config/battery.lua +++ b/config/battery.lua @@ -1,5 +1,6 @@ local ntfy = require("config.ntfy") +--- @class BatteryModule: Module local module = {} --- @type {[string]: number} @@ -17,7 +18,7 @@ function module.callback(device, battery) end end -function module.notify_low_battery() +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") @@ -38,8 +39,9 @@ function module.notify_low_battery() }) end +--- @type Schedule module.schedule = { - ["0 0 21 */1 * *"] = module.notify_low_battery, + ["0 0 21 */1 * *"] = notify_low_battery, } return module diff --git a/config/debug.lua b/config/debug.lua index 639a64a..98f459a 100644 --- a/config/debug.lua +++ b/config/debug.lua @@ -4,6 +4,7 @@ local presence = require("config.presence") local utils = require("automation:utils") local variables = require("automation:variables") +--- @class DebugModule: Module local module = {} if variables.debug == "true" then diff --git a/config/hallway_automation.lua b/config/hallway_automation.lua index 7ebe7fb..ad0f3d9 100644 --- a/config/hallway_automation.lua +++ b/config/hallway_automation.lua @@ -1,6 +1,7 @@ local debug = require("config.debug") local utils = require("automation:utils") +--- @class HallwayAutomationModule: Module local module = {} local timeout = utils.Timeout.new() diff --git a/config/helper.lua b/config/helper.lua index c62c876..fd421e2 100644 --- a/config/helper.lua +++ b/config/helper.lua @@ -1,5 +1,6 @@ local utils = require("automation:utils") +--- @class HelperModule: Module local module = {} --- @param topic string diff --git a/config/hue_bridge.lua b/config/hue_bridge.lua index 54bee7a..f2b2d9b 100644 --- a/config/hue_bridge.lua +++ b/config/hue_bridge.lua @@ -3,6 +3,7 @@ local light = require("config.light") local presence = require("config.presence") local secrets = require("automation:secrets") +--- @class HueBridgeModule: Module local module = {} module.ip = "10.0.0.102" diff --git a/config/light.lua b/config/light.lua index 79ed303..8fb9abe 100644 --- a/config/light.lua +++ b/config/light.lua @@ -1,6 +1,7 @@ local devices = require("automation:devices") local helper = require("config.helper") +--- @class LightModule: Module local module = {} --- @class OnPresence @@ -34,6 +35,7 @@ function module.setup(mqtt_client) callback = callback, }) + --- @type Module return { module.device, } diff --git a/config/ntfy.lua b/config/ntfy.lua index 587b799..d6d617e 100644 --- a/config/ntfy.lua +++ b/config/ntfy.lua @@ -1,6 +1,7 @@ local devices = require("automation:devices") local secrets = require("automation:secrets") +--- @class NtfyModule: Module local module = {} local ntfy_topic = secrets.ntfy_topic @@ -24,6 +25,7 @@ function module.setup() topic = ntfy_topic, }) + --- @type Module return { ntfy, } diff --git a/config/presence.lua b/config/presence.lua index e2f18ce..1eaec55 100644 --- a/config/presence.lua +++ b/config/presence.lua @@ -2,6 +2,7 @@ local devices = require("automation:devices") local helper = require("config.helper") local ntfy = require("config.ntfy") +--- @class PresenceModule: Module local module = {} --- @class OnPresence @@ -61,6 +62,7 @@ function module.setup(mqtt_client) }) end) + --- @type Module return { presence, } diff --git a/config/rooms/bathroom.lua b/config/rooms/bathroom.lua index f2c20bb..da98874 100644 --- a/config/rooms/bathroom.lua +++ b/config/rooms/bathroom.lua @@ -3,9 +3,9 @@ local devices = require("automation:devices") local helper = require("config.helper") local ntfy = require("config.ntfy") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local light = devices.LightOnOff.new({ name = "Light", @@ -30,6 +30,7 @@ function module.setup(mqtt_client) end, }) + --- @type Module return { light, washer, diff --git a/config/rooms/bedroom.lua b/config/rooms/bedroom.lua index 50502f5..42bf2a5 100644 --- a/config/rooms/bedroom.lua +++ b/config/rooms/bedroom.lua @@ -4,12 +4,12 @@ local helper = require("config.helper") local hue_bridge = require("config.hue_bridge") local windows = require("config.windows") +--- @type Module local module = {} --- @type AirFilter? local air_filter = nil ---- @type SetupFunction function module.setup(mqtt_client) local lights = devices.HueGroup.new({ identifier = "bedroom_lights", @@ -55,6 +55,7 @@ function module.setup(mqtt_client) }) windows.add(window) + --- @type Module return { devices = { lights, diff --git a/config/rooms/guest_bedroom.lua b/config/rooms/guest_bedroom.lua index 2022cc2..e2ae089 100644 --- a/config/rooms/guest_bedroom.lua +++ b/config/rooms/guest_bedroom.lua @@ -4,9 +4,9 @@ local helper = require("config.helper") local presence = require("config.presence") local windows = require("config.windows") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local light = devices.LightOnOff.new({ name = "Light", @@ -25,6 +25,7 @@ function module.setup(mqtt_client) }) windows.add(window) + --- @type Module return { light, window, diff --git a/config/rooms/hallway_bottom.lua b/config/rooms/hallway_bottom.lua index 1e08858..e208ef8 100644 --- a/config/rooms/hallway_bottom.lua +++ b/config/rooms/hallway_bottom.lua @@ -8,9 +8,9 @@ local presence = require("config.presence") local utils = require("automation:utils") local windows = require("config.windows") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local main_light = devices.HueGroup.new({ identifier = "hallway_main_light", @@ -97,6 +97,7 @@ function module.setup(mqtt_client) windows.add(frontdoor) hallway_automation.set_door(frontdoor) + --- @type Module return { main_light, storage_light, diff --git a/config/rooms/hallway_top.lua b/config/rooms/hallway_top.lua index b989df9..3fdfe27 100644 --- a/config/rooms/hallway_top.lua +++ b/config/rooms/hallway_top.lua @@ -3,9 +3,9 @@ local devices = require("automation:devices") local helper = require("config.helper") local hue_bridge = require("config.hue_bridge") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local light = devices.HueGroup.new({ identifier = "hallway_top_light", @@ -37,6 +37,7 @@ function module.setup(mqtt_client) battery_callback = battery.callback, }) + --- @type Module return { light, top_switch, diff --git a/config/rooms/kitchen.lua b/config/rooms/kitchen.lua index ffb5fff..9835520 100644 --- a/config/rooms/kitchen.lua +++ b/config/rooms/kitchen.lua @@ -4,6 +4,7 @@ local helper = require("config.helper") local hue_bridge = require("config.hue_bridge") local presence = require("config.presence") +--- @class KitchenModule: Module local module = {} --- @type HueGroup? diff --git a/config/rooms/living_room.lua b/config/rooms/living_room.lua index 2c81794..15a2e31 100644 --- a/config/rooms/living_room.lua +++ b/config/rooms/living_room.lua @@ -5,9 +5,9 @@ local hue_bridge = require("config.hue_bridge") local presence = require("config.presence") local windows = require("config.windows") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local lights = devices.HueGroup.new({ identifier = "living_lights", @@ -109,6 +109,7 @@ function module.setup(mqtt_client) }) windows.add(window) + --- @type Module return { lights, lights_relax, diff --git a/config/rooms/storage.lua b/config/rooms/storage.lua index f3ca968..aacb87d 100644 --- a/config/rooms/storage.lua +++ b/config/rooms/storage.lua @@ -3,9 +3,9 @@ local devices = require("automation:devices") local helper = require("config.helper") local presence = require("config.presence") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local light = devices.LightBrightness.new({ name = "Light", @@ -31,6 +31,7 @@ function module.setup(mqtt_client) battery_callback = battery.callback, }) + --- @type Module return { light, door, diff --git a/config/rooms/workbench.lua b/config/rooms/workbench.lua index 0a1b01e..c0e00c0 100644 --- a/config/rooms/workbench.lua +++ b/config/rooms/workbench.lua @@ -5,9 +5,9 @@ local helper = require("config.helper") local presence = require("config.presence") local utils = require("automation:utils") +--- @type Module local module = {} ---- @type SetupFunction function module.setup(mqtt_client) local charger = devices.OutletOnOff.new({ name = "Charger", @@ -57,6 +57,7 @@ function module.setup(mqtt_client) battery_callback = battery.callback, }) + --- @type Module return { charger, outlets, diff --git a/config/windows.lua b/config/windows.lua index 28207d5..7214789 100644 --- a/config/windows.lua +++ b/config/windows.lua @@ -1,6 +1,7 @@ local ntfy = require("config.ntfy") local presence = require("config.presence") +--- @class WindowsModule: Module local module = {} --- @class OnPresence -- 2.49.1 From f36adf2f19f103ab58ee8265bc6d3427bc96bd1b Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 04:09:01 +0200 Subject: [PATCH 22/23] feat: Implement useful traits to simplify code --- src/config.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/config.rs b/src/config.rs index eb8793e..c9820b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, VecDeque}; use std::net::{Ipv4Addr, SocketAddr}; +use std::ops::Deref; use automation_lib::action_callback::ActionCallback; use automation_lib::device::Device; @@ -60,6 +61,14 @@ impl FromLua for SetupFunction { } } +impl Deref for SetupFunction { + type Target = mlua::Function; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + #[derive(Debug, Default)] pub struct Schedule(HashMap>); @@ -83,6 +92,16 @@ impl FromLua for Schedule { } } +impl IntoIterator for Schedule { + type Item = > as IntoIterator>::Item; + + type IntoIter = > as IntoIterator>::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + #[derive(Debug, Default)] pub struct Module { pub setup: Option, @@ -189,7 +208,7 @@ impl Modules { modules.extend(module.modules); if let Some(setup) = module.setup { - let result: mlua::Value = setup.0.call_async(client.clone()).await?; + let result: mlua::Value = setup.call_async(client.clone()).await?; if result.is_nil() { // We ignore nil results @@ -209,7 +228,7 @@ impl Modules { } devices.extend(module.devices); - for (cron, f) in module.schedule.0 { + for (cron, f) in module.schedule { scheduler.add_job(cron, f); } } -- 2.49.1 From ad158f2c220455fe856dde9254854fc3dcd607ef Mon Sep 17 00:00:00 2001 From: Dreaded_X Date: Wed, 22 Oct 2025 04:13:54 +0200 Subject: [PATCH 23/23] feat: Reduced visibility of config structs --- src/bin/generate_definitions.rs | 30 ++------------------------- src/config.rs | 36 ++++++++++++++++++++++++++------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index 495b5b0..7518f2b 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -1,12 +1,8 @@ use std::fs::{self, File}; use std::io::Write; -use automation::config::{ - Config, FulfillmentConfig, Module as ConfigModule, Schedule, SetupFunction, -}; +use automation::config::generate_definitions; use automation_lib::Module; -use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; -use lua_typed::Typed; use tracing::{info, warn}; extern crate automation_devices; @@ -29,28 +25,6 @@ fn write_definitions(filename: &str, definitions: &str) -> std::io::Result<()> { Ok(()) } -fn config_definitions() -> String { - let mut output = "---@meta\n\n".to_string(); - - output += - &FulfillmentConfig::generate_full().expect("FulfillmentConfig should have a definition"); - output += "\n"; - output += &Config::generate_full().expect("Config should have a definition"); - output += "\n"; - output += &SetupFunction::generate_full().expect("SetupFunction should have a definition"); - output += "\n"; - output += &Schedule::generate_full().expect("Schedule should have a definition"); - output += "\n"; - output += &ConfigModule::generate_full().expect("Module should have a definition"); - output += "\n"; - output += &MqttConfig::generate_full().expect("MqttConfig should have a definition"); - output += "\n"; - output += - &WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have a definition"); - - output -} - fn main() -> std::io::Result<()> { tracing_subscriber::fmt::init(); @@ -65,7 +39,7 @@ fn main() -> std::io::Result<()> { } } - write_definitions("config.lua", &config_definitions())?; + write_definitions("config.lua", &generate_definitions())?; Ok(()) } diff --git a/src/config.rs b/src/config.rs index c9820b0..60408d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,7 +38,7 @@ pub struct FulfillmentConfig { } #[derive(Debug)] -pub struct SetupFunction(mlua::Function); +struct SetupFunction(mlua::Function); impl Typed for SetupFunction { fn type_name() -> String { @@ -70,7 +70,7 @@ impl Deref for SetupFunction { } #[derive(Debug, Default)] -pub struct Schedule(HashMap>); +struct Schedule(HashMap>); impl Typed for Schedule { fn type_name() -> String { @@ -103,11 +103,11 @@ impl IntoIterator for Schedule { } #[derive(Debug, Default)] -pub struct Module { - pub setup: Option, - pub devices: Vec>, - pub schedule: Schedule, - pub modules: Vec, +struct Module { + setup: Option, + devices: Vec>, + schedule: Schedule, + modules: Vec, } // TODO: Add option to typed to rename field @@ -264,3 +264,25 @@ fn default_fulfillment_ip() -> Ipv4Addr { fn default_fulfillment_port() -> u16 { 7878 } + +pub fn generate_definitions() -> String { + let mut output = "---@meta\n\n".to_string(); + + output += + &FulfillmentConfig::generate_full().expect("FulfillmentConfig should have a definition"); + output += "\n"; + output += &Config::generate_full().expect("Config should have a definition"); + output += "\n"; + output += &SetupFunction::generate_full().expect("SetupFunction should have a definition"); + output += "\n"; + output += &Schedule::generate_full().expect("Schedule should have a definition"); + output += "\n"; + output += &Module::generate_full().expect("Module should have a definition"); + output += "\n"; + output += &MqttConfig::generate_full().expect("MqttConfig should have a definition"); + output += "\n"; + output += + &WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have a definition"); + + output +} -- 2.49.1