diff --git a/Cargo.lock b/Cargo.lock index 142c38c..09d5e63 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" @@ -94,11 +88,13 @@ dependencies = [ "async-trait", "automation_devices", "automation_lib", + "automation_macro", "axum", "config", "git-version", "google_home", "inventory", + "lua_typed", "mlua", "reqwest", "rumqttc", @@ -106,6 +102,7 @@ dependencies = [ "serde_json", "thiserror 2.0.16", "tokio", + "tokio-cron-scheduler", "tracing", "tracing-subscriber", ] @@ -147,12 +144,12 @@ version = "0.1.0" dependencies = [ "async-trait", "automation_cast", + "automation_macro", "bytes", "dyn-clone", "futures", "google_home", "hostname", - "indexmap", "inventory", "lua_typed", "mlua", @@ -161,9 +158,7 @@ dependencies = [ "serde_json", "thiserror 2.0.16", "tokio", - "tokio-cron-scheduler", "tracing", - "uuid", ] [[package]] @@ -322,16 +317,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]] @@ -374,11 +378,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]] @@ -422,6 +463,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" @@ -466,12 +538,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" @@ -489,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]] @@ -701,10 +767,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" @@ -720,7 +786,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" dependencies = [ "cfg-if", "libc", - "windows-link", + "windows-link 0.1.3", ] [[package]] @@ -834,9 +900,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", @@ -942,6 +1008,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" @@ -963,17 +1035,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" @@ -1102,7 +1163,7 @@ 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#3d29c9dd143737c8bffe4bacae8e701de3c6ee10" dependencies = [ "eui48", "lua_typed_macro", @@ -1111,7 +1172,7 @@ dependencies = [ [[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#3d29c9dd143737c8bffe4bacae8e701de3c6ee10" dependencies = [ "convert_case", "itertools", @@ -1207,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", @@ -1347,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" @@ -1488,7 +1567,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1665,7 +1744,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1899,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" @@ -1936,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" @@ -2078,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", @@ -2477,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", @@ -2501,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", @@ -2517,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 384f87f..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] @@ -70,6 +68,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", @@ -77,6 +76,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 } @@ -85,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/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/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/automation_lib/Cargo.toml b/automation_lib/Cargo.toml index 15b28aa..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 } @@ -11,7 +12,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 +20,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/config.rs b/automation_lib/src/config.rs index e0c9e0e..995ef52 100644 --- a/automation_lib/src/config.rs +++ b/automation_lib/src/config.rs @@ -1,58 +1,6 @@ -use std::net::{Ipv4Addr, SocketAddr}; -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, 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/automation_lib/src/device.rs b/automation_lib/src/device.rs index 8e2c334..3c0b82b 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; @@ -26,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", ())? @@ -35,10 +36,19 @@ 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() + ))), } } } impl mlua::UserData for Box {} +impl Typed for Box { + fn type_name() -> String { + "DeviceInterface".into() + } +} + dyn_clone::clone_trait_object!(Device); diff --git a/automation_lib/src/device_manager.rs b/automation_lib/src/device_manager.rs index fe755f2..08f8fbf 100644 --- a/automation_lib/src/device_manager.rs +++ b/automation_lib/src/device_manager.rs @@ -1,13 +1,8 @@ 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; @@ -15,11 +10,10 @@ use crate::event::{Event, EventChannel, OnMqtt}; pub type DeviceMap = HashMap>; -#[derive(Clone, FromLua)] +#[derive(Clone)] pub struct DeviceManager { devices: Arc>, event_channel: EventChannel, - scheduler: JobScheduler, } impl DeviceManager { @@ -29,7 +23,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 +38,6 @@ impl DeviceManager { } }); - device_manager.scheduler.start().await.unwrap(); - device_manager } @@ -96,57 +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_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())) - } -} - -impl Typed for DeviceManager { - fn type_name() -> String { - "DeviceManager".into() - } -} 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/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/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 deleted file mode 100644 index f39654f..0000000 --- a/config.lua +++ /dev/null @@ -1,751 +0,0 @@ -local devices = require("automation:devices") -local device_manager = require("automation:device_manager") -local utils = require("automation:utils") -local secrets = require("automation:secrets") -local debug = require("automation:variables").debug and true or false - -print(_VERSION) - -local host = utils.get_hostname() -print("Running @" .. host) - ---- @param topic string ---- @return string -local function mqtt_z2m(topic) - return "zigbee2mqtt/" .. topic -end - ---- @param topic string ---- @return string -local function mqtt_automation(topic) - return "automation/" .. topic -end - -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, - client_name = "automation-" .. host, - username = "mqtt", - password = secrets.mqtt_password, - tls = host == "zeus" or host == "hephaestus", -}) - -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, -}) -device_manager: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 -device_manager:schedule("0 0 21 */1 * *", function() - -- Don't send notifications if there are now devices with low battery - if next(low_battery) == nil then - print("No devices with low battery") - return - end - - local lines = {} - for name, battery in pairs(low_battery) do - table.insert(lines, name .. ": " .. tostring(battery) .. "%") - end - local message = table.concat(lines, "\n") - - ntfy:send_notification({ - title = "Low battery", - message = message, - tags = { "battery" }, - priority = "default", - }) -end) - ---- @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, -}) -device_manager: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 -device_manager: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, - }, -}) -device_manager: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", -}) -device_manager:add(kitchen_lights) -local living_lights = devices.HueGroup.new({ - identifier = "living_lights", - ip = hue_ip, - login = hue_token, - group_id = 1, - scene_id = "SNZw7jUhQ3cXSjkj", -}) -device_manager: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", -}) -device_manager:add(living_lights_relax) - -device_manager: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, -})) - -device_manager: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) -device_manager: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) -device_manager:add(living_speakers) - -device_manager: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 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 - ---- @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) -device_manager:add(kettle) - ---- @param on boolean -local function set_kettle(_, on) - kettle:set_on(on) -end - -device_manager: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, -})) - -device_manager: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) - 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 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), -}) -device_manager:add(bathroom_light) - -device_manager: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, -})) - -device_manager: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) -device_manager: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) -device_manager:add(workbench_light) - -local delay_color_temp = utils.Timeout.new() -device_manager: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", -}) -device_manager: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, -})) -device_manager: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, - 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 - -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) -device_manager:add(hallway_storage) - -local hallway_bottom_lights = devices.HueGroup.new({ - identifier = "hallway_bottom_lights", - ip = hue_ip, - login = hue_token, - group_id = 81, - scene_id = "3qWKxGVadXFFG4o", -}) -device_manager: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 - -device_manager: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, -}) -device_manager: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, -}) -device_manager: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) -device_manager: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) - -local bedroom_lights = devices.HueGroup.new({ - identifier = "bedroom_lights", - ip = hue_ip, - login = hue_token, - group_id = 3, - scene_id = "PvRs-lGD4VRytL9", -}) -device_manager:add(bedroom_lights) -local bedroom_lights_relax = devices.HueGroup.new({ - identifier = "bedroom_lights", - ip = hue_ip, - login = hue_token, - group_id = 3, - scene_id = "60tfTyR168v2csz", -}) -device_manager:add(bedroom_lights_relax) - -device_manager: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, -}) -device_manager: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, -}) -device_manager: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, -}) -device_manager: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, -}) -device_manager: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) -device_manager:add(storage_light) - -device_manager: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, -})) - -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) - -return fulfillment diff --git a/config/battery.lua b/config/battery.lua new file mode 100644 index 0000000..521331a --- /dev/null +++ b/config/battery.lua @@ -0,0 +1,47 @@ +local ntfy = require("config.ntfy") + +--- @class BatteryModule: Module +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 + +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 + +--- @type Schedule +module.schedule = { + ["0 0 21 */1 * *"] = notify_low_battery, +} + +return module diff --git a/config/config.lua b/config/config.lua new file mode 100644 index 0000000..289a573 --- /dev/null +++ b/config/config.lua @@ -0,0 +1,32 @@ +local utils = require("automation:utils") +local secrets = require("automation:secrets") + +local host = utils.get_hostname() +print("Lua " .. _VERSION .. " running on " .. utils.get_hostname()) + +---@type Config +return { + fulfillment = { + openid_url = "https://login.huizinga.dev/api/oidc", + }, + 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"), + }, +} diff --git a/config/debug.lua b/config/debug.lua new file mode 100644 index 0000000..98f459a --- /dev/null +++ b/config/debug.lua @@ -0,0 +1,35 @@ +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") + +--- @class DebugModule: Module +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..ad0f3d9 --- /dev/null +++ b/config/hallway_automation.lua @@ -0,0 +1,85 @@ +local debug = require("config.debug") +local utils = require("automation:utils") + +--- @class HallwayAutomationModule: Module +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..fd421e2 --- /dev/null +++ b/config/helper.lua @@ -0,0 +1,49 @@ +local utils = require("automation:utils") + +--- @class HelperModule: Module +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..f2b2d9b --- /dev/null +++ b/config/hue_bridge.lua @@ -0,0 +1,41 @@ +local devices = require("automation:devices") +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" +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..8fb9abe --- /dev/null +++ b/config/light.lua @@ -0,0 +1,44 @@ +local devices = require("automation:devices") +local helper = require("config.helper") + +--- @class LightModule: Module +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, + }) + + --- @type Module + return { + module.device, + } +end + +return module diff --git a/config/ntfy.lua b/config/ntfy.lua new file mode 100644 index 0000000..d6d617e --- /dev/null +++ b/config/ntfy.lua @@ -0,0 +1,34 @@ +local devices = require("automation:devices") +local secrets = require("automation:secrets") + +--- @class NtfyModule: Module +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, + }) + + --- @type Module + return { + ntfy, + } +end + +return module diff --git a/config/presence.lua b/config/presence.lua new file mode 100644 index 0000000..1eaec55 --- /dev/null +++ b/config/presence.lua @@ -0,0 +1,80 @@ +local devices = require("automation:devices") +local helper = require("config.helper") +local ntfy = require("config.ntfy") + +--- @class PresenceModule: Module +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) + + --- @type Module + 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..98fe996 --- /dev/null +++ b/config/rooms.lua @@ -0,0 +1,12 @@ +--- @type Module +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..da98874 --- /dev/null +++ b/config/rooms/bathroom.lua @@ -0,0 +1,40 @@ +local debug = require("config.debug") +local devices = require("automation:devices") +local helper = require("config.helper") +local ntfy = require("config.ntfy") + +--- @type Module +local module = {} + +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, + }) + + --- @type Module + return { + light, + washer, + } +end + +return module diff --git a/config/rooms/bedroom.lua b/config/rooms/bedroom.lua new file mode 100644 index 0000000..42bf2a5 --- /dev/null +++ b/config/rooms/bedroom.lua @@ -0,0 +1,78 @@ +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") + +--- @type Module +local module = {} + +--- @type AirFilter? +local air_filter = nil + +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) + + --- @type Module + return { + 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 + +return module diff --git a/config/rooms/guest_bedroom.lua b/config/rooms/guest_bedroom.lua new file mode 100644 index 0000000..e2ae089 --- /dev/null +++ b/config/rooms/guest_bedroom.lua @@ -0,0 +1,35 @@ +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") + +--- @type Module +local module = {} + +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) + + --- @type Module + 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..e208ef8 --- /dev/null +++ b/config/rooms/hallway_bottom.lua @@ -0,0 +1,110 @@ +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") + +--- @type Module +local module = {} + +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) + + --- @type Module + 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..3fdfe27 --- /dev/null +++ b/config/rooms/hallway_top.lua @@ -0,0 +1,48 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local hue_bridge = require("config.hue_bridge") + +--- @type Module +local module = {} + +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, + }) + + --- @type Module + 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..9835520 --- /dev/null +++ b/config/rooms/kitchen.lua @@ -0,0 +1,71 @@ +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") + +--- @class KitchenModule: Module +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..15a2e31 --- /dev/null +++ b/config/rooms/living_room.lua @@ -0,0 +1,126 @@ +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") + +--- @type Module +local module = {} + +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) + + --- @type Module + 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..aacb87d --- /dev/null +++ b/config/rooms/storage.lua @@ -0,0 +1,41 @@ +local battery = require("config.battery") +local devices = require("automation:devices") +local helper = require("config.helper") +local presence = require("config.presence") + +--- @type Module +local module = {} + +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, + }) + + --- @type Module + return { + light, + door, + } +end + +return module diff --git a/config/rooms/workbench.lua b/config/rooms/workbench.lua new file mode 100644 index 0000000..c0e00c0 --- /dev/null +++ b/config/rooms/workbench.lua @@ -0,0 +1,69 @@ +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") + +--- @type Module +local module = {} + +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, + }) + + --- @type Module + return { + charger, + outlets, + light, + remote, + } +end + +return module diff --git a/config/windows.lua b/config/windows.lua new file mode 100644 index 0000000..7214789 --- /dev/null +++ b/config/windows.lua @@ -0,0 +1,43 @@ +local ntfy = require("config.ntfy") +local presence = require("config.presence") + +--- @class WindowsModule: Module +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 diff --git a/definitions/automation:device_manager.lua b/definitions/automation:device_manager.lua deleted file mode 100644 index 9fa74fc..0000000 --- a/definitions/automation:device_manager.lua +++ /dev/null @@ -1,12 +0,0 @@ ----@meta - ----@class DeviceManager -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/automation:devices.lua b/definitions/automation:devices.lua index aa3336a..06b0667 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 @@ -24,25 +20,77 @@ function devices.AirFilter.new(config) end ---@class AirFilterConfig ---@field name string ----@field room string? +---@field room (string)? ---@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,40 +110,12 @@ 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 HueGroup: DeviceInterface, OnOffInterface +local HueGroup +devices.HueGroup = {} +---@param config HueGroupConfig +---@return HueGroup +function devices.HueGroup.new(config) end ---@class HueGroupConfig ---@field identifier string @@ -110,12 +125,24 @@ local LightSensorConfig ---@field scene_id string local HueGroupConfig ----@class HueGroup: DeviceInterface, OnOffInterface -local HueGroup -devices.HueGroup = {} ----@param config HueGroupConfig ----@return HueGroup -function devices.HueGroup.new(config) end +---@class HueSwitch: DeviceInterface +local HueSwitch +devices.HueSwitch = {} +---@param config HueSwitchConfig +---@return HueSwitch +function devices.HueSwitch.new(config) end + +---@class HueSwitchConfig +---@field name 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)[])? +local HueSwitchConfig ---@class IkeaRemote: DeviceInterface local IkeaRemote @@ -126,119 +153,25 @@ 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 WolConfig ----@field name string ----@field room string? ----@field topic string ----@field mac_address string ----@field broadcast_ip string? ----@field client AsyncClient -local WolConfig +---@class KasaOutlet: DeviceInterface, OnOffInterface +local KasaOutlet +devices.KasaOutlet = {} +---@param config KasaOutletConfig +---@return KasaOutlet +function devices.KasaOutlet.new(config) end ----@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 KasaOutletConfig +---@field identifier string +---@field ip string +local KasaOutletConfig ---@class LightBrightness: DeviceInterface, OnOffInterface, BrightnessInterface local LightBrightness @@ -254,22 +187,6 @@ devices.LightColorTemperature = {} ---@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 = {} @@ -277,34 +194,45 @@ devices.LightOnOff = {} ---@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 LightSensor: DeviceInterface +local LightSensor +devices.LightSensor = {} +---@param config LightSensorConfig +---@return LightSensor +function devices.LightSensor.new(config) end ----@class NtfyConfig ----@field url string? +---@class LightSensorConfig +---@field identifier string ---@field topic string -local NtfyConfig +---@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[]? +---@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 = {} @@ -315,23 +243,95 @@ function devices.Ntfy.new(config) end ---@param notification Notification function Ntfy:send_notification(notification) end ----@class HueSwitch: DeviceInterface -local HueSwitch -devices.HueSwitch = {} ----@param config HueSwitchConfig ----@return HueSwitch -function devices.HueSwitch.new(config) end - ----@class HueSwitchConfig ----@field name string ----@field room string? +---@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 ----@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 +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 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 new file mode 100644 index 0000000..9bd274c --- /dev/null +++ b/definitions/config.lua @@ -0,0 +1,41 @@ +-- 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 +---@field modules (Module)[] +---@field mqtt MqttConfig +local Config + +---@alias SetupFunction fun(mqtt_client: AsyncClient): Module | DeviceInterface[] | nil + +---@alias Schedule table + +---@class Module +---@field setup (SetupFunction)? +---@field devices (DeviceInterface)[]? +---@field schedule Schedule? +---@field [number] (Module)[]? +local Module + +---@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/main.rs b/src/bin/automation.rs similarity index 82% rename from src/main.rs rename to src/bin/automation.rs index 34424e2..8dea38e 100644 --- a/src/main.rs +++ b/src/bin/automation.rs @@ -1,29 +1,24 @@ #![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_lib::config::FulfillmentConfig; +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 automation_lib::mqtt; 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::secret::EnvironmentSecretFile; -use crate::version::VERSION; // Force automation_devices to link so that it gets registered as a module extern crate automation_devices; @@ -76,7 +71,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), ) @@ -136,14 +131,20 @@ 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)?)?; - lua.register_module("automation:variables", lua.to_value(&config.variables)?)?; - lua.register_module("automation:secrets", lua.to_value(&config.secrets)?)?; + let entrypoint = Path::new(&setup.entrypoint); + let config: Config = lua.load(entrypoint).eval_async().await?; - 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 mqtt_client = mqtt::start(config.mqtt, &device_manager.event_channel()); + + let resolved = config.modules.resolve(&lua, &mqtt_client).await?; + for device in resolved.devices { + device_manager.add(device).await; + } + + resolved.scheduler.start().await?; // Create google home fulfillment route let fulfillment = Router::new().route("/google_home", post(fulfillment)); @@ -152,12 +153,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?; diff --git a/src/bin/generate_definitions.rs b/src/bin/generate_definitions.rs index 789cd2f..7518f2b 100644 --- a/src/bin/generate_definitions.rs +++ b/src/bin/generate_definitions.rs @@ -1,32 +1,45 @@ use std::fs::{self, File}; use std::io::Write; +use automation::config::generate_definitions; use automation_lib::Module; 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"); } } + write_definitions("config.lua", &generate_definitions())?; + Ok(()) } diff --git a/src/config.rs b/src/config.rs index 39e0931..60408d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,19 @@ -use std::collections::HashMap; +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; +use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient}; +use automation_macro::LuaDeviceConfig; +use lua_typed::Typed; +use mlua::FromLua; use serde::Deserialize; +use crate::schedule::Scheduler; + #[derive(Debug, Deserialize)] -pub struct Config { +pub struct Setup { #[serde(default = "default_entrypoint")] pub entrypoint: String, #[serde(default)] @@ -13,5 +23,266 @@ pub struct Config { } fn default_entrypoint() -> String { - "./config.lua".into() + "./config/config.lua".into() +} + +#[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)] +struct SetupFunction(mlua::Function); + +impl Typed for SetupFunction { + fn type_name() -> String { + "SetupFunction".into() + } + + fn generate_header() -> Option { + Some(format!( + "---@alias {} fun(mqtt_client: {}): {} | DeviceInterface[] | nil\n", + Self::type_name(), + 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)?)) + } +} + +impl Deref for SetupFunction { + type Target = mlua::Function; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Default)] +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)?)) + } +} + +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)] +struct Module { + setup: Option, + devices: Vec>, + schedule: Schedule, + modules: Vec, +} + +// TODO: Add option to typed to rename field +impl Typed for Module { + fn type_name() -> String { + "Module".into() + } + + fn generate_header() -> Option { + Some(format!("---@class {}\n", Self::type_name())) + } + + fn generate_members() -> Option { + Some(format!( + r#"---@field setup {} +---@field devices {}? +---@field schedule {}? +---@field [number] {}? +"#, + Option::::type_name(), + Vec::>::type_name(), + Schedule::type_name(), + Vec::::type_name(), + )) + } + + 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()); + } + + 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 schedule = table.get("schedule").unwrap_or_default(); + + let mut modules = Vec::new(); + + for module in table.sequence_values::() { + modules.push(module?); + } + + Ok(Module { + setup, + devices, + schedule, + 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(Self(FromLua::from_lua(value, lua)?)) + } +} + +impl Modules { + pub async fn resolve( + self, + lua: &mlua::Lua, + client: &WrappedAsyncClient, + ) -> mlua::Result { + 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; + }; + + modules.extend(module.modules); + + if let Some(setup) = module.setup { + let result: mlua::Value = setup.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); + for (cron, f) in module.schedule { + scheduler.add_job(cron, f); + } + } + + Ok(Resolved { devices, scheduler }) + } +} + +#[derive(Debug, Default)] +pub struct Resolved { + pub devices: Vec>, + pub scheduler: Scheduler, +} + +#[derive(Debug, LuaDeviceConfig, Typed)] +pub struct Config { + pub fulfillment: FulfillmentConfig, + #[device_config(from_lua, default)] + pub modules: Modules, + #[device_config(from_lua)] + pub mqtt: MqttConfig, +} + +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 +} + +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 } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..dbfca74 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +#![feature(if_let_guard)] + +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..3615fed --- /dev/null +++ b/src/schedule.rs @@ -0,0 +1,37 @@ +use std::pin::Pin; + +use automation_lib::action_callback::ActionCallback; +use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError}; + +#[derive(Debug, Default)] +pub struct Scheduler { + jobs: Vec<(String, ActionCallback<()>)>, +} + +impl Scheduler { + pub fn add_job(&mut self, cron: String, f: ActionCallback<()>) { + self.jobs.push((cron, f)); + } + + 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 + } +}