Compare commits

5 Commits

Author SHA1 Message Date
e0d2e846ff refactor!: Rewrote device implementation macro once again
All checks were successful
Build and deploy / build (push) Successful in 12m34s
Build and deploy / Deploy container (push) Successful in 30s
This time with a bit more though put into the design of the code, as a
result the macro should be a lot more robust.

This did result in the macro getting renamed from LuaDevice to Device as
this should be _the_ Device macro.
The attribute also got renamed from traits to device_traits and the
syntax got overhauled to allow for a bit more expression.
2025-09-09 02:48:44 +02:00
e642562ddb chore: Removed old leftover contact sensor presence config
All checks were successful
Build and deploy / build (push) Successful in 11m3s
Build and deploy / Deploy container (push) Successful in 4m8s
2025-09-08 04:29:30 +02:00
a2e65c2d1a refactor: Remove unneeded wrapper functions when specifying callbacks
All checks were successful
Build and deploy / build (push) Successful in 10m32s
Build and deploy / Deploy container (push) Successful in 45s
These wrappers can be moved up to where the callback itself is defined
instead of having to wrap the call manually. This also works a lot nicer
now that it is possible to provide multiple callback functions.
2025-09-08 04:13:01 +02:00
e26fd9f132 fix: Front door presence does not get cleared properly 2025-09-08 04:06:02 +02:00
eb0c80c4ce feat(callback)!: ActionCallback can now receive any amount of arguments
ActionCallback now only has one generics argument that has to implement
IntoLuaMulti, this makes ActionCallback much more flexible as it no
longer always requires two arguments.
2025-09-08 04:06:01 +02:00
70 changed files with 1256 additions and 3150 deletions

View File

@@ -9,10 +9,10 @@ on:
jobs: jobs:
build: build:
uses: dreaded_x/workflows/.gitea/workflows/docker-kubernetes.yaml@ef78704b98c72e4a6b8340f9bff7b085a7bdd95c uses: dreaded_x/workflows/.gitea/workflows/rust-kubernetes.yaml@22ee0c1788a8d2157db87d6a6f8dbe520fe48592
secrets: inherit secrets: inherit
with: with:
push_manifests: false upload_manifests: false
deploy: deploy:
name: Deploy container name: Deploy container
@@ -37,7 +37,7 @@ jobs:
-e AUTOMATION__SECRETS__MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \ -e AUTOMATION__SECRETS__MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
-e AUTOMATION__SECRETS__HUE_TOKEN=${{ secrets.HUE_TOKEN }} \ -e AUTOMATION__SECRETS__HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
-e AUTOMATION__SECRETS__NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \ -e AUTOMATION__SECRETS__NTFY_TOPIC=${{ secrets.NTFY_TOPIC }} \
$(echo ${{ toJSON(needs.build.outputs.images) }} | jq .automation -r) git.huizinga.dev/dreaded_x/automation_rs@${{ needs.build.outputs.digest }}
docker network connect web automation_rs docker network connect web automation_rs

View File

@@ -57,12 +57,24 @@ repos:
files: (\.rs|Cargo.lock)$ files: (\.rs|Cargo.lock)$
pass_filenames: false pass_filenames: false
- id: generate_definitions - id: audit
name: generate definitions name: audit
description: Generate lua definitions description: Audit packages
entry: cargo run --bin generate_definitions entry: cargo audit
args: ["--deny", "warnings"]
language: system language: system
types: [rust] pass_filenames: false
verbose: true
always_run: true
- id: udeps
name: unused
description: Check for unused crates
entry: cargo udeps
args: ["--workspace"]
language: system
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false pass_filenames: false
- id: test - id: test
@@ -75,26 +87,6 @@ repos:
files: (\.rs|Cargo.lock)$ files: (\.rs|Cargo.lock)$
pass_filenames: false pass_filenames: false
- id: udeps
name: unused
description: Check for unused crates
entry: cargo udeps
args: ["--workspace"]
language: system
types: [file]
files: (\.rs|Cargo.lock)$
pass_filenames: false
- id: audit
name: audit
description: Audit packages
entry: cargo audit
args: ["--deny", "warnings"]
language: system
pass_filenames: false
verbose: true
always_run: true
- repo: https://github.com/hadolint/hadolint - repo: https://github.com/hadolint/hadolint
rev: v2.13.1 rev: v2.13.1
hooks: hooks:

285
Cargo.lock generated
View File

@@ -36,6 +36,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "android-tzdata"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -88,13 +94,12 @@ dependencies = [
"async-trait", "async-trait",
"automation_devices", "automation_devices",
"automation_lib", "automation_lib",
"automation_macro",
"axum", "axum",
"config", "config",
"dotenvy",
"git-version", "git-version",
"google_home", "google_home",
"inventory", "hostname",
"lua_typed",
"mlua", "mlua",
"reqwest", "reqwest",
"rumqttc", "rumqttc",
@@ -102,7 +107,6 @@ dependencies = [
"serde_json", "serde_json",
"thiserror 2.0.16", "thiserror 2.0.16",
"tokio", "tokio",
"tokio-cron-scheduler",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@@ -124,8 +128,6 @@ dependencies = [
"dyn-clone", "dyn-clone",
"eui48", "eui48",
"google_home", "google_home",
"inventory",
"lua_typed",
"mlua", "mlua",
"reqwest", "reqwest",
"rumqttc", "rumqttc",
@@ -144,21 +146,20 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"automation_cast", "automation_cast",
"automation_macro",
"bytes", "bytes",
"dyn-clone", "dyn-clone",
"futures", "futures",
"google_home", "google_home",
"hostname", "indexmap",
"inventory",
"lua_typed",
"mlua", "mlua",
"rumqttc", "rumqttc",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.16", "thiserror 2.0.16",
"tokio", "tokio",
"tokio-cron-scheduler",
"tracing", "tracing",
"uuid",
] ]
[[package]] [[package]]
@@ -166,7 +167,6 @@ name = "automation_macro"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"itertools", "itertools",
"mlua",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.106", "syn 2.0.106",
@@ -317,25 +317,16 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.42" version = "0.4.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
dependencies = [ dependencies = [
"android-tzdata",
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"wasm-bindgen", "wasm-bindgen",
"windows-link 0.2.1", "windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
] ]
[[package]] [[package]]
@@ -351,15 +342,6 @@ dependencies = [
"winnow", "winnow",
] ]
[[package]]
name = "convert_case"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -378,48 +360,11 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "croner" name = "croner"
version = "3.0.0" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c007081651a19b42931f86f7d4f74ee1c2a7d0cd2c6636a81695b5ffd4e9990" checksum = "c344b0690c1ad1c7176fe18eb173e0c927008fdaaa256e40dfd43ddd149c0843"
dependencies = [ dependencies = [
"chrono", "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]] [[package]]
@@ -463,37 +408,6 @@ dependencies = [
"thiserror 2.0.16", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -505,6 +419,12 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "dyn-clone" name = "dyn-clone"
version = "1.0.20" version = "1.0.20"
@@ -538,6 +458,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]] [[package]]
name = "erased-serde" name = "erased-serde"
version = "0.4.6" version = "0.4.6"
@@ -555,7 +481,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -767,10 +693,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "heck" name = "hashbrown"
version = "0.5.0" version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[package]] [[package]]
name = "hex" name = "hex"
@@ -786,7 +712,7 @@ checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"windows-link 0.1.3", "windows-link",
] ]
[[package]] [[package]]
@@ -900,9 +826,9 @@ dependencies = [
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.64" version = "0.1.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
dependencies = [ dependencies = [
"android_system_properties", "android_system_properties",
"core-foundation-sys", "core-foundation-sys",
@@ -1008,12 +934,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -1036,12 +956,14 @@ dependencies = [
] ]
[[package]] [[package]]
name = "inventory" name = "indexmap"
version = "0.3.21" version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc61209c082fbeb19919bee74b176221b27223e27b65d781eb91af24eb1fb46e" checksum = "f2481980430f9f78649238835720ddccc57e52df14ffce1c6f37391d61b563e9"
dependencies = [ dependencies = [
"rustversion", "equivalent",
"hashbrown",
"serde",
] ]
[[package]] [[package]]
@@ -1160,27 +1082,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "lua_typed"
version = "0.1.0"
source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#3d29c9dd143737c8bffe4bacae8e701de3c6ee10"
dependencies = [
"eui48",
"lua_typed_macro",
]
[[package]]
name = "lua_typed_macro"
version = "0.1.0"
source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#3d29c9dd143737c8bffe4bacae8e701de3c6ee10"
dependencies = [
"convert_case",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]] [[package]]
name = "luajit-src" name = "luajit-src"
version = "210.6.1+f9140a6" version = "210.6.1+f9140a6"
@@ -1268,9 +1169,9 @@ dependencies = [
[[package]] [[package]]
name = "mlua" name = "mlua"
version = "0.11.4" version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9be1c2bfc684b8a228fbaebf954af7a47a98ec27721986654a4cc2c40a20cc7e" checksum = "5b3dd94c3c4dea0049b22296397040840a8f6b5b5229f438434ba82df402b42d"
dependencies = [ dependencies = [
"bstr", "bstr",
"either", "either",
@@ -1408,24 +1309,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.16" version = "0.2.16"
@@ -1567,7 +1450,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1744,7 +1627,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1978,12 +1861,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.11" version = "0.4.11"
@@ -2021,33 +1898,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 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]] [[package]]
name = "subtle" name = "subtle"
version = "2.6.1" version = "2.6.1"
@@ -2190,12 +2040,11 @@ dependencies = [
[[package]] [[package]]
name = "tokio-cron-scheduler" name = "tokio-cron-scheduler"
version = "0.15.0" version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb73c4033ddcbbf81fd828293fd41a0145cde2cbc30dd782227c5081a523214d" checksum = "5c71ce8f810abc9fabebccc30302a952f9e89c6cf246fafaf170fef164063141"
dependencies = [ dependencies = [
"chrono", "chrono",
"chrono-tz",
"croner", "croner",
"num-derive", "num-derive",
"num-traits", "num-traits",
@@ -2395,12 +2244,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -2590,22 +2433,22 @@ dependencies = [
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link 0.2.1", "windows-link",
"windows-result", "windows-result",
"windows-strings", "windows-strings",
] ]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2614,9 +2457,9 @@ dependencies = [
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -2629,28 +2472,22 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
dependencies = [ dependencies = [
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.5.1" version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
dependencies = [ dependencies = [
"windows-link 0.2.1", "windows-link",
] ]
[[package]] [[package]]

View File

@@ -2,7 +2,6 @@
name = "automation" name = "automation"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "automation"
[workspace] [workspace]
members = [ members = [
@@ -24,6 +23,7 @@ automation_lib = { path = "./automation_lib" }
automation_macro = { path = "./automation_macro" } automation_macro = { path = "./automation_macro" }
axum = "0.8.4" axum = "0.8.4"
bytes = "1.10.1" bytes = "1.10.1"
dotenvy = "0.15.7"
dyn-clone = "1.0.20" dyn-clone = "1.0.20"
eui48 = { version = "1.1.0", features = [ eui48 = { version = "1.1.0", features = [
"disp_hexstring", "disp_hexstring",
@@ -33,10 +33,9 @@ futures = "0.3.31"
google_home = { path = "./google_home/google_home" } google_home = { path = "./google_home/google_home" }
google_home_macro = { path = "./google_home/google_home_macro" } google_home_macro = { path = "./google_home/google_home_macro" }
hostname = "0.4.1" hostname = "0.4.1"
inventory = "0.3.21" indexmap = { version = "2.11.0", features = ["serde"] }
itertools = "0.14.0" itertools = "0.14.0"
json_value_merge = "2.0.1" json_value_merge = "2.0.1"
lua_typed = { git = "https://git.huizinga.dev/Dreaded_X/lua_typed" }
mlua = { version = "0.11.3", features = [ mlua = { version = "0.11.3", features = [
"lua54", "lua54",
"vendored", "vendored",
@@ -58,9 +57,10 @@ serde_repr = "0.1.20"
syn = { version = "2.0.106" } syn = { version = "2.0.106" }
thiserror = "2.0.16" thiserror = "2.0.16"
tokio = { version = "1", features = ["rt-multi-thread"] } tokio = { version = "1", features = ["rt-multi-thread"] }
tokio-cron-scheduler = "0.15.0" tokio-cron-scheduler = "0.14.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.20" tracing-subscriber = "0.3.20"
uuid = "1.18.1"
wakey = "0.3.0" wakey = "0.3.0"
[dependencies] [dependencies]
@@ -68,16 +68,15 @@ anyhow = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
automation_devices = { workspace = true } automation_devices = { workspace = true }
automation_lib = { workspace = true } automation_lib = { workspace = true }
automation_macro = { path = "./automation_macro" }
axum = { workspace = true } axum = { workspace = true }
config = { version = "0.15.15", default-features = false, features = [ config = { version = "0.15.15", default-features = false, features = [
"async", "async",
"toml", "toml",
] } ] }
dotenvy = { workspace = true }
git-version = "0.3.9" git-version = "0.3.9"
google_home = { workspace = true } google_home = { workspace = true }
lua_typed = { workspace = true } hostname = { workspace = true }
inventory = { workspace = true }
mlua = { workspace = true } mlua = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
rumqttc = { workspace = true } rumqttc = { workspace = true }
@@ -85,7 +84,6 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-cron-scheduler = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }

View File

@@ -3,8 +3,6 @@ ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
RUN cargo install cargo-chef --locked --version 0.1.71 && \ RUN cargo install cargo-chef --locked --version 0.1.71 && \
cargo install cargo-auditable --locked --version 0.6.6 cargo install cargo-auditable --locked --version 0.6.6
WORKDIR /app WORKDIR /app
COPY ./rust-toolchain.toml .
RUN rustup toolchain install
FROM base AS planner FROM base AS planner
COPY . . COPY . .
@@ -23,7 +21,6 @@ RUN cargo auditable build --release
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/automation /app/automation COPY --from=builder /app/target/release/automation /app/automation
ENV AUTOMATION__ENTRYPOINT=/app/config/config.lua ENV AUTOMATION__ENTRYPOINT=/app/config.lua
ENV LUA_PATH="/app/?.lua;;" COPY ./config.lua /app/config.lua
COPY ./config /app/config
CMD [ "/app/automation" ] CMD [ "/app/automation" ]

View File

@@ -13,8 +13,6 @@ bytes = { workspace = true }
dyn-clone = { workspace = true } dyn-clone = { workspace = true }
eui48 = { workspace = true } eui48 = { workspace = true }
google_home = { workspace = true } google_home = { workspace = true }
inventory = { workspace = true }
lua_typed = { workspace = true }
mlua = { workspace = true } mlua = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
rumqttc = { workspace = true } rumqttc = { workspace = true }

View File

@@ -9,26 +9,21 @@ use google_home::traits::{
TemperatureUnit, TemperatureUnit,
}; };
use google_home::types::Type; use google_home::types::Type;
use lua_typed::Typed;
use thiserror::Error; use thiserror::Error;
use tracing::{debug, trace}; use tracing::{debug, trace};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "AirFilterConfig")]
pub struct Config { pub struct Config {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
pub url: String, pub url: String,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(traits(OnOff))] #[device_traits(OnOff)]
pub struct AirFilter { pub struct AirFilter {
config: Config, config: Config,
} }
crate::register_device!(AirFilter);
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum Error { pub enum Error {

View File

@@ -13,45 +13,35 @@ use google_home::device;
use google_home::errors::{DeviceError, ErrorCode}; use google_home::errors::{DeviceError, ErrorCode};
use google_home::traits::OpenClose; use google_home::traits::OpenClose;
use google_home::types::Type; use google_home::types::Type;
use lua_typed::Typed;
use serde::Deserialize; use serde::Deserialize;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy, Typed)] #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
pub enum SensorType { pub enum SensorType {
Door, Door,
Drawer, Drawer,
Window, Window,
} }
crate::register_type!(SensorType);
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "ContactSensorConfig")]
pub struct Config { pub struct Config {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
#[device_config(default(SensorType::Window))] #[device_config(default(SensorType::Window))]
#[typed(default)]
pub sensor_type: SensorType, pub sensor_type: SensorType,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(ContactSensor, bool)>, pub callback: ActionCallback<(ContactSensor, bool)>,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub battery_callback: ActionCallback<(ContactSensor, f32)>, pub battery_callback: ActionCallback<(ContactSensor, f32)>,
#[device_config(from_lua)] #[device_config(from_lua)]
#[typed(default)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
#[derive(Debug)] #[derive(Debug)]
struct State { struct State {
@@ -59,12 +49,11 @@ struct State {
} }
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(traits(OpenClose))] #[device_traits(OpenClose)]
pub struct ContactSensor { pub struct ContactSensor {
config: Config, config: Config,
state: Arc<RwLock<State>>, state: Arc<RwLock<State>>,
} }
crate::register_device!(ContactSensor);
impl ContactSensor { impl ContactSensor {
async fn state(&self) -> RwLockReadGuard<'_, State> { async fn state(&self) -> RwLockReadGuard<'_, State> {

View File

@@ -3,71 +3,39 @@ use std::net::SocketAddr;
use async_trait::async_trait; use async_trait::async_trait;
use automation_lib::device::{Device, LuaDeviceCreate}; use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::lua::traits::PartialUserData; use automation_lib::lua::traits::AddAdditionalMethods;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use mlua::LuaSerdeExt; use mlua::LuaSerdeExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tracing::{error, trace, warn}; use tracing::{error, trace, warn};
#[derive(Debug, Deserialize, Typed)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[typed(rename_all = "snake_case")]
pub enum Flag { pub enum Flag {
Presence, Presence,
Darkness, Darkness,
} }
crate::register_type!(Flag);
#[derive(Debug, Clone, Deserialize, Typed)] #[derive(Debug, Clone, Deserialize)]
pub struct FlagIDs { pub struct FlagIDs {
presence: isize, presence: isize,
darkness: isize, darkness: isize,
} }
crate::register_type!(FlagIDs);
#[derive(Debug, LuaDeviceConfig, Clone, Typed)] #[derive(Debug, LuaDeviceConfig, Clone)]
#[typed(as = "HueBridgeConfig")]
pub struct Config { pub struct Config {
pub identifier: String, pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))] #[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
#[typed(as = "ip")]
pub addr: SocketAddr, pub addr: SocketAddr,
pub login: String, pub login: String,
pub flags: FlagIDs, pub flags: FlagIDs,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(extra_user_data = SetFlag)] #[device_traits(AddAdditionalMethods)]
pub struct HueBridge { pub struct HueBridge {
config: Config, config: Config,
} }
crate::register_device!(HueBridge);
struct SetFlag;
impl PartialUserData<HueBridge> for SetFlag {
fn add_methods<M: mlua::UserDataMethods<HueBridge>>(methods: &mut M) {
methods.add_async_method(
"set_flag",
async |lua, this, (flag, value): (mlua::Value, bool)| {
let flag: Flag = lua.from_value(flag)?;
this.set_flag(flag, value).await;
Ok(())
},
);
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@param flag {}\n---@param value boolean\nfunction {}:set_flag(flag, value) end\n",
<Flag as Typed>::type_name(),
<HueBridge as Typed>::type_name(),
))
}
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct FlagMessage { struct FlagMessage {
@@ -123,3 +91,21 @@ impl Device for HueBridge {
self.config.identifier.clone() self.config.identifier.clone()
} }
} }
impl AddAdditionalMethods for HueBridge {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
where
Self: Sized + 'static,
{
methods.add_async_method(
"set_flag",
async |lua, this, (flag, value): (mlua::Value, bool)| {
let flag: Flag = lua.from_value(flag)?;
this.set_flag(flag, value).await;
Ok(())
},
);
}
}

View File

@@ -5,30 +5,25 @@ use async_trait::async_trait;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use google_home::errors::ErrorCode; use google_home::errors::ErrorCode;
use google_home::traits::OnOff; use google_home::traits::OnOff;
use lua_typed::Typed;
use tracing::{error, trace, warn}; use tracing::{error, trace, warn};
use super::{Device, LuaDeviceCreate}; use super::{Device, LuaDeviceCreate};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "HueGroupConfig")]
pub struct Config { pub struct Config {
pub identifier: String, pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))] #[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 80)))]
#[typed(as = "ip")]
pub addr: SocketAddr, pub addr: SocketAddr,
pub login: String, pub login: String,
pub group_id: isize, pub group_id: isize,
pub scene_id: String, pub scene_id: String,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(traits(OnOff))] #[device_traits(OnOff)]
pub struct HueGroup { pub struct HueGroup {
config: Config, config: Config,
} }
crate::register_device!(HueGroup);
// Couple of helper function to get the correct urls // Couple of helper function to get the correct urls
#[async_trait] #[async_trait]

View File

@@ -5,46 +5,36 @@ use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt; use automation_lib::event::OnMqtt;
use automation_lib::mqtt::WrappedAsyncClient; use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::{Publish, matches}; use rumqttc::{Publish, matches};
use serde::Deserialize; use serde::Deserialize;
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "HueSwitchConfig")]
pub struct Config { pub struct Config {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub left_callback: ActionCallback<HueSwitch>, pub left_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub right_callback: ActionCallback<HueSwitch>, pub right_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub left_hold_callback: ActionCallback<HueSwitch>, pub left_hold_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub right_hold_callback: ActionCallback<HueSwitch>, pub right_hold_callback: ActionCallback<HueSwitch>,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub battery_callback: ActionCallback<(HueSwitch, f32)>, pub battery_callback: ActionCallback<(HueSwitch, f32)>,
} }
crate::register_type!(Config);
#[derive(Debug, Copy, Clone, Deserialize)] #[derive(Debug, Copy, Clone, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -69,7 +59,6 @@ struct State {
pub struct HueSwitch { pub struct HueSwitch {
config: Config, config: Config,
} }
crate::register_device!(HueSwitch);
impl Device for HueSwitch { impl Device for HueSwitch {
fn get_id(&self) -> String { fn get_id(&self) -> String {

View File

@@ -6,42 +6,33 @@ use automation_lib::event::OnMqtt;
use automation_lib::messages::{RemoteAction, RemoteMessage}; use automation_lib::messages::{RemoteAction, RemoteMessage};
use automation_lib::mqtt::WrappedAsyncClient; use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::{Publish, matches}; use rumqttc::{Publish, matches};
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "IkeaRemoteConfig")]
pub struct Config { pub struct Config {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(default)] #[device_config(default)]
#[typed(default)]
pub single_button: bool, pub single_button: bool,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(IkeaRemote, bool)>, pub callback: ActionCallback<(IkeaRemote, bool)>,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub battery_callback: ActionCallback<(IkeaRemote, f32)>, pub battery_callback: ActionCallback<(IkeaRemote, f32)>,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
pub struct IkeaRemote { pub struct IkeaRemote {
config: Config, config: Config,
} }
crate::register_device!(IkeaRemote);
impl Device for IkeaRemote { impl Device for IkeaRemote {
fn get_id(&self) -> String { fn get_id(&self) -> String {

View File

@@ -8,29 +8,24 @@ use automation_macro::{Device, LuaDeviceConfig};
use bytes::{Buf, BufMut}; use bytes::{Buf, BufMut};
use google_home::errors::{self, DeviceError}; use google_home::errors::{self, DeviceError};
use google_home::traits::OnOff; use google_home::traits::OnOff;
use lua_typed::Typed;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tracing::trace; use tracing::trace;
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "KasaOutletConfig")]
pub struct Config { pub struct Config {
pub identifier: String, pub identifier: String,
#[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))] #[device_config(rename("ip"), with(|ip| SocketAddr::new(ip, 9999)))]
#[typed(as = "ip")]
pub addr: SocketAddr, pub addr: SocketAddr,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(traits(OnOff))] #[device_traits(OnOff)]
pub struct KasaOutlet { pub struct KasaOutlet {
config: Config, config: Config,
} }
crate::register_device!(KasaOutlet);
#[async_trait] #[async_trait]
impl LuaDeviceCreate for KasaOutlet { impl LuaDeviceCreate for KasaOutlet {

View File

@@ -1,4 +1,3 @@
#![feature(iter_intersperse)]
mod air_filter; mod air_filter;
mod contact_sensor; mod contact_sensor;
mod hue_bridge; mod hue_bridge;
@@ -13,118 +12,48 @@ mod wake_on_lan;
mod washer; mod washer;
mod zigbee; mod zigbee;
use automation_lib::Module;
use automation_lib::device::{Device, LuaDeviceCreate}; use automation_lib::device::{Device, LuaDeviceCreate};
use tracing::{debug, warn}; use zigbee::light::{LightBrightness, LightColorTemperature, LightOnOff};
use zigbee::outlet::{OutletOnOff, OutletPower};
type DeviceNameFn = fn() -> String; pub use self::air_filter::AirFilter;
type RegisterDeviceFn = fn(lua: &mlua::Lua) -> mlua::Result<mlua::AnyUserData>; pub use self::contact_sensor::ContactSensor;
pub use self::hue_bridge::HueBridge;
pub struct RegisteredDevice { pub use self::hue_group::HueGroup;
name_fn: DeviceNameFn, pub use self::hue_switch::HueSwitch;
register_fn: RegisterDeviceFn, pub use self::ikea_remote::IkeaRemote;
} pub use self::kasa_outlet::KasaOutlet;
pub use self::light_sensor::LightSensor;
impl RegisteredDevice { pub use self::ntfy::*;
pub const fn new(name_fn: DeviceNameFn, register_fn: RegisterDeviceFn) -> Self { pub use self::presence::Presence;
Self { pub use self::wake_on_lan::WakeOnLAN;
name_fn, pub use self::washer::Washer;
register_fn,
}
}
pub fn get_name(&self) -> String {
(self.name_fn)()
}
pub fn register(&self, lua: &mlua::Lua) -> mlua::Result<mlua::AnyUserData> {
(self.register_fn)(lua)
}
}
macro_rules! register_device { macro_rules! register_device {
($device:ty) => { ($lua:expr, $device:ty) => {
::inventory::submit!(crate::RegisteredDevice::new( $lua.globals()
<$device as ::lua_typed::Typed>::type_name, .set(stringify!($device), $lua.create_proxy::<$device>()?)?;
::mlua::Lua::create_proxy::<$device>
));
crate::register_type!($device);
}; };
} }
pub(crate) use register_device;
inventory::collect!(RegisteredDevice); pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
register_device!(lua, AirFilter);
register_device!(lua, ContactSensor);
register_device!(lua, HueBridge);
register_device!(lua, HueGroup);
register_device!(lua, HueSwitch);
register_device!(lua, IkeaRemote);
register_device!(lua, KasaOutlet);
register_device!(lua, LightBrightness);
register_device!(lua, LightColorTemperature);
register_device!(lua, LightOnOff);
register_device!(lua, LightSensor);
register_device!(lua, Ntfy);
register_device!(lua, OutletOnOff);
register_device!(lua, OutletPower);
register_device!(lua, Presence);
register_device!(lua, WakeOnLAN);
register_device!(lua, Washer);
pub fn create_module(lua: &mlua::Lua) -> mlua::Result<mlua::Table> { Ok(())
let devices = lua.create_table()?;
debug!("Loading devices...");
for device in inventory::iter::<RegisteredDevice> {
let name = device.get_name();
debug!(name, "Registering device");
let proxy = device.register(lua)?;
devices.set(name, proxy)?;
}
Ok(devices)
} }
type TypeNameFn = fn() -> String;
type TypeDefinitionFn = fn() -> Option<String>;
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<String> {
(self.definition_fn)()
}
}
macro_rules! register_type {
($ty:ty) => {
::inventory::submit!(crate::RegisteredType::new(
<$ty as ::lua_typed::Typed>::type_name,
<$ty as ::lua_typed::Typed>::generate_full
));
};
}
pub(crate) use register_type;
inventory::collect!(RegisteredType);
fn generate_definitions() -> String {
let mut output = String::new();
let mut types: Vec<_> = inventory::iter::<RegisteredType>.into_iter().collect();
types.sort_by_key(|ty| ty.get_name());
output += "---@meta\n\nlocal devices\n\n";
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
warn!("Registered type is missing generate_full function");
}
}
output += "return devices";
output
}
inventory::submit! {Module::new("automation:devices", create_module, Some(generate_definitions))}

View File

@@ -8,29 +8,24 @@ use automation_lib::event::OnMqtt;
use automation_lib::messages::BrightnessMessage; use automation_lib::messages::BrightnessMessage;
use automation_lib::mqtt::WrappedAsyncClient; use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::Publish; use rumqttc::Publish;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "LightSensorConfig")]
pub struct Config { pub struct Config {
pub identifier: String, pub identifier: String,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
pub min: isize, pub min: isize,
pub max: isize, pub max: isize,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(LightSensor, bool)>, pub callback: ActionCallback<(LightSensor, bool)>,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
const DEFAULT: bool = false; const DEFAULT: bool = false;
@@ -44,7 +39,6 @@ pub struct LightSensor {
config: Config, config: Config,
state: Arc<RwLock<State>>, state: Arc<RwLock<State>>,
} }
crate::register_device!(LightSensor);
impl LightSensor { impl LightSensor {
async fn state(&self) -> RwLockReadGuard<'_, State> { async fn state(&self) -> RwLockReadGuard<'_, State> {

View File

@@ -3,18 +3,16 @@ use std::convert::Infallible;
use async_trait::async_trait; use async_trait::async_trait;
use automation_lib::device::{Device, LuaDeviceCreate}; use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::lua::traits::PartialUserData; use automation_lib::lua::traits::AddAdditionalMethods;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use mlua::LuaSerdeExt; use mlua::LuaSerdeExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_repr::*; use serde_repr::*;
use tracing::{error, trace, warn}; use tracing::{error, trace, warn};
#[derive(Debug, Serialize_repr, Deserialize, Clone, Copy, Typed)] #[derive(Debug, Serialize_repr, Deserialize, Clone, Copy)]
#[repr(u8)] #[repr(u8)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
#[typed(rename_all = "snake_case")]
pub enum Priority { pub enum Priority {
Min = 1, Min = 1,
Low, Low,
@@ -22,56 +20,83 @@ pub enum Priority {
High, High,
Max, Max,
} }
crate::register_type!(Priority);
#[derive(Debug, Serialize, Deserialize, Clone, Typed)] #[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "snake_case", tag = "action")] #[serde(rename_all = "snake_case", tag = "action")]
#[typed(rename_all = "snake_case", tag = "action")]
pub enum ActionType { pub enum ActionType {
Broadcast { Broadcast {
#[serde(skip_serializing_if = "HashMap::is_empty")] #[serde(skip_serializing_if = "HashMap::is_empty")]
#[serde(default)]
#[typed(default)]
extras: HashMap<String, String>, extras: HashMap<String, String>,
}, },
// View, // View,
// Http // Http
} }
#[derive(Debug, Serialize, Deserialize, Clone, Typed)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Action { pub struct Action {
#[serde(flatten)] #[serde(flatten)]
#[typed(flatten)]
pub action: ActionType, pub action: ActionType,
pub label: String, pub label: String,
pub clear: Option<bool>, pub clear: Option<bool>,
} }
crate::register_type!(Action);
#[derive(Serialize, Deserialize, Typed)] #[derive(Serialize, Deserialize)]
struct NotificationFinal { struct NotificationFinal {
topic: String, topic: String,
#[serde(flatten)] #[serde(flatten)]
#[typed(flatten)]
inner: Notification, inner: Notification,
} }
#[derive(Debug, Serialize, Clone, Deserialize, Typed)] #[derive(Debug, Serialize, Clone, Deserialize)]
pub struct Notification { pub struct Notification {
title: String, #[serde(skip_serializing_if = "Option::is_none")]
title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>, message: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default = "Default::default")] #[serde(skip_serializing_if = "Vec::is_empty", default = "Default::default")]
#[typed(default)]
tags: Vec<String>, tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
priority: Option<Priority>, priority: Option<Priority>,
#[serde(skip_serializing_if = "Vec::is_empty", default = "Default::default")] #[serde(skip_serializing_if = "Vec::is_empty", default = "Default::default")]
#[typed(default)]
actions: Vec<Action>, actions: Vec<Action>,
} }
crate::register_type!(Notification);
impl Notification { impl Notification {
pub fn new() -> Self {
Self {
title: None,
message: None,
tags: Vec::new(),
priority: None,
actions: Vec::new(),
}
}
pub fn set_title(mut self, title: &str) -> Self {
self.title = Some(title.into());
self
}
pub fn set_message(mut self, message: &str) -> Self {
self.message = Some(message.into());
self
}
pub fn add_tag(mut self, tag: &str) -> Self {
self.tags.push(tag.into());
self
}
pub fn set_priority(mut self, priority: Priority) -> Self {
self.priority = Some(priority);
self
}
pub fn add_action(mut self, action: Action) -> Self {
self.actions.push(action);
self
}
fn finalize(self, topic: &str) -> NotificationFinal { fn finalize(self, topic: &str) -> NotificationFinal {
NotificationFinal { NotificationFinal {
topic: topic.into(), topic: topic.into(),
@@ -80,46 +105,24 @@ impl Notification {
} }
} }
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] impl Default for Notification {
#[typed(as = "NtfyConfig")] fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, LuaDeviceConfig)]
pub struct Config { pub struct Config {
#[device_config(default("https://ntfy.sh".into()))] #[device_config(default("https://ntfy.sh".into()))]
#[typed(default)]
pub url: String, pub url: String,
pub topic: String, pub topic: String,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(extra_user_data = SendNotification)] #[device_traits(AddAdditionalMethods)]
pub struct Ntfy { pub struct Ntfy {
config: Config, config: Config,
} }
crate::register_device!(Ntfy);
struct SendNotification;
impl PartialUserData<Ntfy> for SendNotification {
fn add_methods<M: mlua::UserDataMethods<Ntfy>>(methods: &mut M) {
methods.add_async_method(
"send_notification",
async |lua, this, notification: mlua::Value| {
let notification: Notification = lua.from_value(notification)?;
this.send(notification).await;
Ok(())
},
);
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@param notification {}\nfunction {}:send_notification(notification) end\n",
<Notification as Typed>::type_name(),
<Ntfy as Typed>::type_name(),
))
}
}
#[async_trait] #[async_trait]
impl LuaDeviceCreate for Ntfy { impl LuaDeviceCreate for Ntfy {
@@ -159,3 +162,21 @@ impl Ntfy {
} }
} }
} }
impl AddAdditionalMethods for Ntfy {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
where
Self: Sized + 'static,
{
methods.add_async_method(
"send_notification",
async |lua, this, notification: mlua::Value| {
let notification: Notification = lua.from_value(notification)?;
this.send(notification).await;
Ok(())
},
);
}
}

View File

@@ -6,30 +6,25 @@ use automation_lib::action_callback::ActionCallback;
use automation_lib::config::MqttDeviceConfig; use automation_lib::config::MqttDeviceConfig;
use automation_lib::device::{Device, LuaDeviceCreate}; use automation_lib::device::{Device, LuaDeviceCreate};
use automation_lib::event::OnMqtt; use automation_lib::event::OnMqtt;
use automation_lib::lua::traits::PartialUserData; use automation_lib::lua::traits::AddAdditionalMethods;
use automation_lib::messages::PresenceMessage; use automation_lib::messages::PresenceMessage;
use automation_lib::mqtt::WrappedAsyncClient; use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::Publish; use rumqttc::Publish;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "PresenceConfig")]
pub struct Config { pub struct Config {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(Presence, bool)>, pub callback: ActionCallback<(Presence, bool)>,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
pub const DEFAULT_PRESENCE: bool = false; pub const DEFAULT_PRESENCE: bool = false;
@@ -40,28 +35,11 @@ pub struct State {
} }
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(extra_user_data = OverallPresence)] #[device_traits(AddAdditionalMethods)]
pub struct Presence { pub struct Presence {
config: Config, config: Config,
state: Arc<RwLock<State>>, state: Arc<RwLock<State>>,
} }
crate::register_device!(Presence);
struct OverallPresence;
impl PartialUserData<Presence> for OverallPresence {
fn add_methods<M: mlua::UserDataMethods<Presence>>(methods: &mut M) {
methods.add_async_method("overall_presence", async |_lua, this, ()| {
Ok(this.state().await.current_overall_presence)
});
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@return boolean\nfunction {}:overall_presence() end\n",
<Presence as Typed>::type_name(),
))
}
}
impl Presence { impl Presence {
async fn state(&self) -> RwLockReadGuard<'_, State> { async fn state(&self) -> RwLockReadGuard<'_, State> {
@@ -147,3 +125,14 @@ impl OnMqtt for Presence {
} }
} }
} }
impl AddAdditionalMethods for Presence {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
where
Self: Sized + 'static,
{
methods.add_async_method("overall_presence", async |_lua, this, ()| {
Ok(this.state().await.current_overall_presence)
});
}
}

View File

@@ -12,33 +12,26 @@ use google_home::device;
use google_home::errors::ErrorCode; use google_home::errors::ErrorCode;
use google_home::traits::{self, Scene}; use google_home::traits::{self, Scene};
use google_home::types::Type; use google_home::types::Type;
use lua_typed::Typed;
use rumqttc::Publish; use rumqttc::Publish;
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "WolConfig")]
pub struct Config { pub struct Config {
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
pub mac_address: MacAddress, pub mac_address: MacAddress,
#[device_config(default(Ipv4Addr::new(255, 255, 255, 255)))] #[device_config(default(Ipv4Addr::new(255, 255, 255, 255)))]
#[typed(default)]
pub broadcast_ip: Ipv4Addr, pub broadcast_ip: Ipv4Addr,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
pub struct WakeOnLAN { pub struct WakeOnLAN {
config: Config, config: Config,
} }
crate::register_device!(WakeOnLAN);
#[async_trait] #[async_trait]
impl LuaDeviceCreate for WakeOnLAN { impl LuaDeviceCreate for WakeOnLAN {

View File

@@ -8,29 +8,24 @@ use automation_lib::event::OnMqtt;
use automation_lib::messages::PowerMessage; use automation_lib::messages::PowerMessage;
use automation_lib::mqtt::WrappedAsyncClient; use automation_lib::mqtt::WrappedAsyncClient;
use automation_macro::{Device, LuaDeviceConfig}; use automation_macro::{Device, LuaDeviceConfig};
use lua_typed::Typed;
use rumqttc::Publish; use rumqttc::Publish;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, error, trace}; use tracing::{debug, error, trace};
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "WasherConfig")]
pub struct Config { pub struct Config {
pub identifier: String, pub identifier: String,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
// Power in Watt // Power in Watt
pub threshold: f32, pub threshold: f32,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub done_callback: ActionCallback<Washer>, pub done_callback: ActionCallback<Washer>,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config);
#[derive(Debug)] #[derive(Debug)]
pub struct State { pub struct State {
@@ -43,7 +38,6 @@ pub struct Washer {
config: Config, config: Config,
state: Arc<RwLock<State>>, state: Arc<RwLock<State>>,
} }
crate::register_device!(Washer);
impl Washer { impl Washer {
async fn state(&self) -> RwLockReadGuard<'_, State> { async fn state(&self) -> RwLockReadGuard<'_, State> {

View File

@@ -15,7 +15,6 @@ use google_home::device;
use google_home::errors::ErrorCode; use google_home::errors::ErrorCode;
use google_home::traits::{Brightness, Color, ColorSetting, ColorTemperatureRange, OnOff}; use google_home::traits::{Brightness, Color, ColorSetting, ColorTemperatureRange, OnOff};
use google_home::types::Type; use google_home::types::Type;
use lua_typed::Typed;
use rumqttc::{Publish, matches}; use rumqttc::{Publish, matches};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -23,47 +22,33 @@ use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
pub trait LightState: pub trait LightState:
Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + Typed + 'static Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + 'static
{ {
} }
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "ConfigLight")] pub struct Config<T: LightState> {
pub struct Config<T: LightState>
where
Light<T>: Typed,
{
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(Light<T>, T)>, pub callback: ActionCallback<(Light<T>, T)>,
#[device_config(from_lua)] #[device_config(from_lua)]
#[typed(default)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config<StateOnOff>);
crate::register_type!(Config<StateBrightness>);
crate::register_type!(Config<StateColorTemperature>);
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)] #[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize)]
#[typed(as = "LightStateOnOff")]
pub struct StateOnOff { pub struct StateOnOff {
#[serde(deserialize_with = "state_deserializer")] #[serde(deserialize_with = "state_deserializer")]
state: bool, state: bool,
} }
impl LightState for StateOnOff {} impl LightState for StateOnOff {}
crate::register_type!(StateOnOff);
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)] #[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize)]
#[typed(as = "LightStateBrightness")]
pub struct StateBrightness { pub struct StateBrightness {
#[serde(deserialize_with = "state_deserializer")] #[serde(deserialize_with = "state_deserializer")]
state: bool, state: bool,
@@ -71,7 +56,6 @@ pub struct StateBrightness {
} }
impl LightState for StateBrightness {} impl LightState for StateBrightness {}
crate::register_type!(StateBrightness);
impl From<StateBrightness> for StateOnOff { impl From<StateBrightness> for StateOnOff {
fn from(state: StateBrightness) -> Self { fn from(state: StateBrightness) -> Self {
@@ -79,15 +63,13 @@ impl From<StateBrightness> for StateOnOff {
} }
} }
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)] #[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize)]
#[typed(as = "LightStateColorTemperature")]
pub struct StateColorTemperature { pub struct StateColorTemperature {
#[serde(deserialize_with = "state_deserializer")] #[serde(deserialize_with = "state_deserializer")]
state: bool, state: bool,
brightness: f32, brightness: f32,
color_temp: u32, color_temp: u32,
} }
crate::register_type!(StateColorTemperature);
impl LightState for StateColorTemperature {} impl LightState for StateColorTemperature {}
@@ -107,31 +89,20 @@ impl From<StateColorTemperature> for StateBrightness {
} }
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(traits(OnOff for LightOnOff, LightBrightness, LightColorTemperature))] #[device_traits(OnOff for <StateOnOff>, <StateBrightness>, <StateColorTemperature>)]
#[device(traits(Brightness for LightBrightness, LightColorTemperature))] #[device_traits(Brightness for <StateBrightness>, <StateColorTemperature>)]
#[device(traits(ColorSetting for LightColorTemperature))] #[device_traits(ColorSetting for <StateColorTemperature>)]
pub struct Light<T: LightState> pub struct Light<T: LightState> {
where
Light<T>: Typed,
{
config: Config<T>, config: Config<T>,
state: Arc<RwLock<T>>, state: Arc<RwLock<T>>,
} }
pub type LightOnOff = Light<StateOnOff>; pub type LightOnOff = Light<StateOnOff>;
crate::register_device!(LightOnOff);
pub type LightBrightness = Light<StateBrightness>; pub type LightBrightness = Light<StateBrightness>;
crate::register_device!(LightBrightness);
pub type LightColorTemperature = Light<StateColorTemperature>; pub type LightColorTemperature = Light<StateColorTemperature>;
crate::register_device!(LightColorTemperature);
impl<T: LightState> Light<T> impl<T: LightState> Light<T> {
where
Light<T>: Typed,
{
async fn state(&self) -> RwLockReadGuard<'_, T> { async fn state(&self) -> RwLockReadGuard<'_, T> {
self.state.read().await self.state.read().await
} }
@@ -142,10 +113,7 @@ where
} }
#[async_trait] #[async_trait]
impl<T: LightState> LuaDeviceCreate for Light<T> impl<T: LightState> LuaDeviceCreate for Light<T> {
where
Light<T>: Typed,
{
type Config = Config<T>; type Config = Config<T>;
type Error = rumqttc::ClientError; type Error = rumqttc::ClientError;
@@ -164,17 +132,14 @@ where
} }
} }
impl<T: LightState> Device for Light<T> impl<T: LightState> Device for Light<T> {
where
Light<T>: Typed,
{
fn get_id(&self) -> String { fn get_id(&self) -> String {
self.config.info.identifier() self.config.info.identifier()
} }
} }
#[async_trait] #[async_trait]
impl OnMqtt for LightOnOff { impl OnMqtt for Light<StateOnOff> {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the device itself or from a remote // Check if the message is from the device itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
@@ -207,7 +172,7 @@ impl OnMqtt for LightOnOff {
} }
#[async_trait] #[async_trait]
impl OnMqtt for LightBrightness { impl OnMqtt for Light<StateBrightness> {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote // Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
@@ -246,7 +211,7 @@ impl OnMqtt for LightBrightness {
} }
#[async_trait] #[async_trait]
impl OnMqtt for LightColorTemperature { impl OnMqtt for Light<StateColorTemperature> {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote // Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
@@ -287,10 +252,7 @@ impl OnMqtt for LightColorTemperature {
} }
#[async_trait] #[async_trait]
impl<T: LightState> google_home::Device for Light<T> impl<T: LightState> google_home::Device for Light<T> {
where
Light<T>: Typed,
{
fn get_device_type(&self) -> Type { fn get_device_type(&self) -> Type {
Type::Light Type::Light
} }
@@ -321,7 +283,6 @@ where
impl<T> OnOff for Light<T> impl<T> OnOff for Light<T>
where where
T: LightState, T: LightState,
Light<T>: Typed,
{ {
async fn on(&self) -> Result<bool, ErrorCode> { async fn on(&self) -> Result<bool, ErrorCode> {
let state = self.state().await; let state = self.state().await;
@@ -361,7 +322,6 @@ impl<T> Brightness for Light<T>
where where
T: LightState, T: LightState,
T: Into<StateBrightness>, T: Into<StateBrightness>,
Light<T>: Typed,
{ {
async fn brightness(&self) -> Result<u8, ErrorCode> { async fn brightness(&self) -> Result<u8, ErrorCode> {
let state = self.state().await; let state = self.state().await;
@@ -403,7 +363,6 @@ impl<T> ColorSetting for Light<T>
where where
T: LightState, T: LightState,
T: Into<StateColorTemperature>, T: Into<StateColorTemperature>,
Light<T>: Typed,
{ {
fn color_temperature_range(&self) -> ColorTemperatureRange { fn color_temperature_range(&self) -> ColorTemperatureRange {
ColorTemperatureRange { ColorTemperatureRange {

View File

@@ -15,7 +15,6 @@ use google_home::device;
use google_home::errors::ErrorCode; use google_home::errors::ErrorCode;
use google_home::traits::OnOff; use google_home::traits::OnOff;
use google_home::types::Type; use google_home::types::Type;
use lua_typed::Typed;
use rumqttc::{Publish, matches}; use rumqttc::{Publish, matches};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
@@ -23,16 +22,15 @@ use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
pub trait OutletState: pub trait OutletState:
Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + Typed + 'static Debug + Clone + Default + Sync + Send + Serialize + Into<StateOnOff> + 'static
{ {
} }
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy, Typed)] #[derive(Debug, Clone, Deserialize, PartialEq, Eq, Copy)]
pub enum OutletType { pub enum OutletType {
Outlet, Outlet,
Kettle, Kettle,
} }
crate::register_type!(OutletType);
impl From<OutletType> for Type { impl From<OutletType> for Type {
fn from(outlet: OutletType) -> Self { fn from(outlet: OutletType) -> Self {
@@ -43,50 +41,36 @@ impl From<OutletType> for Type {
} }
} }
#[derive(Debug, Clone, LuaDeviceConfig, Typed)] #[derive(Debug, Clone, LuaDeviceConfig)]
#[typed(as = "ConfigOutlet")] pub struct Config<T: OutletState> {
pub struct Config<T: OutletState>
where
Outlet<T>: Typed,
{
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub info: InfoConfig, pub info: InfoConfig,
#[device_config(flatten)] #[device_config(flatten)]
#[typed(flatten)]
pub mqtt: MqttDeviceConfig, pub mqtt: MqttDeviceConfig,
#[device_config(default(OutletType::Outlet))] #[device_config(default(OutletType::Outlet))]
#[typed(default)]
pub outlet_type: OutletType, pub outlet_type: OutletType,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)]
pub callback: ActionCallback<(Outlet<T>, T)>, pub callback: ActionCallback<(Outlet<T>, T)>,
#[device_config(from_lua)] #[device_config(from_lua)]
pub client: WrappedAsyncClient, pub client: WrappedAsyncClient,
} }
crate::register_type!(Config<StateOnOff>);
crate::register_type!(Config<StatePower>);
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)] #[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize)]
#[typed(as = "OutletStateOnOff")]
pub struct StateOnOff { pub struct StateOnOff {
#[serde(deserialize_with = "state_deserializer")] #[serde(deserialize_with = "state_deserializer")]
state: bool, state: bool,
} }
crate::register_type!(StateOnOff);
impl OutletState for StateOnOff {} impl OutletState for StateOnOff {}
#[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize, Typed)] #[derive(Debug, Clone, Default, Serialize, Deserialize, LuaSerialize)]
#[typed(as = "OutletStatePower")]
pub struct StatePower { pub struct StatePower {
#[serde(deserialize_with = "state_deserializer")] #[serde(deserialize_with = "state_deserializer")]
state: bool, state: bool,
power: f64, power: f64,
} }
crate::register_type!(StatePower);
impl OutletState for StatePower {} impl OutletState for StatePower {}
@@ -97,26 +81,17 @@ impl From<StatePower> for StateOnOff {
} }
#[derive(Debug, Clone, Device)] #[derive(Debug, Clone, Device)]
#[device(traits(OnOff for OutletOnOff, OutletPower))] #[device_traits(OnOff for <StateOnOff>, <StatePower>)]
pub struct Outlet<T: OutletState> pub struct Outlet<T: OutletState> {
where
Outlet<T>: Typed,
{
config: Config<T>, config: Config<T>,
state: Arc<RwLock<T>>, state: Arc<RwLock<T>>,
} }
pub type OutletOnOff = Outlet<StateOnOff>; pub type OutletOnOff = Outlet<StateOnOff>;
crate::register_device!(OutletOnOff);
pub type OutletPower = Outlet<StatePower>; pub type OutletPower = Outlet<StatePower>;
crate::register_device!(OutletPower);
impl<T: OutletState> Outlet<T> impl<T: OutletState> Outlet<T> {
where
Outlet<T>: Typed,
{
async fn state(&self) -> RwLockReadGuard<'_, T> { async fn state(&self) -> RwLockReadGuard<'_, T> {
self.state.read().await self.state.read().await
} }
@@ -127,10 +102,7 @@ where
} }
#[async_trait] #[async_trait]
impl<T: OutletState> LuaDeviceCreate for Outlet<T> impl<T: OutletState> LuaDeviceCreate for Outlet<T> {
where
Outlet<T>: Typed,
{
type Config = Config<T>; type Config = Config<T>;
type Error = rumqttc::ClientError; type Error = rumqttc::ClientError;
@@ -149,17 +121,14 @@ where
} }
} }
impl<T: OutletState> Device for Outlet<T> impl<T: OutletState> Device for Outlet<T> {
where
Outlet<T>: Typed,
{
fn get_id(&self) -> String { fn get_id(&self) -> String {
self.config.info.identifier() self.config.info.identifier()
} }
} }
#[async_trait] #[async_trait]
impl OnMqtt for OutletOnOff { impl OnMqtt for Outlet<StateOnOff> {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the device itself or from a remote // Check if the message is from the device itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
@@ -192,7 +161,7 @@ impl OnMqtt for OutletOnOff {
} }
#[async_trait] #[async_trait]
impl OnMqtt for OutletPower { impl OnMqtt for Outlet<StatePower> {
async fn on_mqtt(&self, message: Publish) { async fn on_mqtt(&self, message: Publish) {
// Check if the message is from the deviec itself or from a remote // Check if the message is from the deviec itself or from a remote
if matches(&message.topic, &self.config.mqtt.topic) { if matches(&message.topic, &self.config.mqtt.topic) {
@@ -229,10 +198,7 @@ impl OnMqtt for OutletPower {
} }
#[async_trait] #[async_trait]
impl<T: OutletState> google_home::Device for Outlet<T> impl<T: OutletState> google_home::Device for Outlet<T> {
where
Outlet<T>: Typed,
{
fn get_device_type(&self) -> Type { fn get_device_type(&self) -> Type {
self.config.outlet_type.into() self.config.outlet_type.into()
} }
@@ -263,7 +229,6 @@ where
impl<T> OnOff for Outlet<T> impl<T> OnOff for Outlet<T>
where where
T: OutletState, T: OutletState,
Outlet<T>: Typed,
{ {
async fn on(&self) -> Result<bool, ErrorCode> { async fn on(&self) -> Result<bool, ErrorCode> {
let state = self.state().await; let state = self.state().await;

View File

@@ -4,20 +4,19 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
automation_macro = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
automation_cast = { workspace = true } automation_cast = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
dyn-clone = { workspace = true } dyn-clone = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
google_home = { workspace = true } google_home = { workspace = true }
hostname = { workspace = true } indexmap = { workspace = true }
inventory = { workspace = true }
lua_typed = { workspace = true }
mlua = { workspace = true } mlua = { workspace = true }
rumqttc = { workspace = true } rumqttc = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tokio-cron-scheduler = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
uuid = { workspace = true }

View File

@@ -1,7 +1,6 @@
use std::marker::PhantomData; use std::marker::PhantomData;
use futures::future::try_join_all; use futures::future::try_join_all;
use lua_typed::Typed;
use mlua::{FromLua, IntoLuaMulti}; use mlua::{FromLua, IntoLuaMulti};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -10,29 +9,6 @@ pub struct ActionCallback<P> {
_parameters: PhantomData<P>, _parameters: PhantomData<P>,
} }
impl Typed for ActionCallback<()> {
fn type_name() -> String {
"fun() | fun()[]".into()
}
}
impl<A: Typed> Typed for ActionCallback<A> {
fn type_name() -> String {
let type_name = A::type_name();
format!("fun(_: {type_name}) | fun(_: {type_name})[]")
}
}
impl<A: Typed, B: Typed> Typed for ActionCallback<(A, B)> {
fn type_name() -> String {
let type_name_a = A::type_name();
let type_name_b = B::type_name();
format!(
"fun(_: {type_name_a}, _: {type_name_b}) | fun(_: {type_name_a}, _: {type_name_b})[]"
)
}
}
// NOTE: For some reason the derive macro combined with PhantomData leads to issues where it // NOTE: For some reason the derive macro combined with PhantomData leads to issues where it
// requires all types part of P to implement default, even if they never actually get constructed. // requires all types part of P to implement default, even if they never actually get constructed.
// By manually implemented Default it works fine. // By manually implemented Default it works fine.

View File

@@ -1,7 +1,58 @@
use lua_typed::Typed; use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use rumqttc::{MqttOptions, Transport};
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Typed)] #[derive(Debug, Clone, Deserialize)]
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<MqttConfig> 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<FulfillmentConfig> 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)]
pub struct InfoConfig { pub struct InfoConfig {
pub name: String, pub name: String,
pub room: Option<String>, pub room: Option<String>,
@@ -17,7 +68,7 @@ impl InfoConfig {
} }
} }
#[derive(Debug, Clone, Deserialize, Typed)] #[derive(Debug, Clone, Deserialize)]
pub struct MqttDeviceConfig { pub struct MqttDeviceConfig {
pub topic: String, pub topic: String,
} }

View File

@@ -2,7 +2,6 @@ use std::fmt::Debug;
use automation_cast::Cast; use automation_cast::Cast;
use dyn_clone::DynClone; use dyn_clone::DynClone;
use lua_typed::Typed;
use mlua::ObjectLike; use mlua::ObjectLike;
use crate::event::OnMqtt; use crate::event::OnMqtt;
@@ -27,7 +26,7 @@ impl mlua::FromLua for Box<dyn Device> {
fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> { fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> {
match value { match value {
mlua::Value::UserData(ud) => { mlua::Value::UserData(ud) => {
let ud = if ud.is::<Self>() { let ud = if ud.is::<Box<dyn Device>>() {
ud ud
} else { } else {
ud.call_method::<_>("__box", ())? ud.call_method::<_>("__box", ())?
@@ -36,19 +35,10 @@ impl mlua::FromLua for Box<dyn Device> {
let b = ud.borrow::<Self>()?.clone(); let b = ud.borrow::<Self>()?.clone();
Ok(b) Ok(b)
} }
_ => Err(mlua::Error::runtime(format!( _ => Err(mlua::Error::RuntimeError("Expected user data".into())),
"Expected user data, instead found: {}",
value.type_name()
))),
} }
} }
} }
impl mlua::UserData for Box<dyn Device> {} impl mlua::UserData for Box<dyn Device> {}
impl Typed for Box<dyn Device> {
fn type_name() -> String {
"DeviceInterface".into()
}
}
dyn_clone::clone_trait_object!(Device); dyn_clone::clone_trait_object!(Device);

View File

@@ -1,8 +1,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use futures::Future;
use futures::future::join_all; use futures::future::join_all;
use tokio::sync::{RwLock, RwLockReadGuard}; use tokio::sync::{RwLock, RwLockReadGuard};
use tokio_cron_scheduler::{Job, JobScheduler};
use tracing::{debug, instrument, trace}; use tracing::{debug, instrument, trace};
use crate::device::Device; use crate::device::Device;
@@ -14,6 +17,7 @@ pub type DeviceMap = HashMap<String, Box<dyn Device>>;
pub struct DeviceManager { pub struct DeviceManager {
devices: Arc<RwLock<DeviceMap>>, devices: Arc<RwLock<DeviceMap>>,
event_channel: EventChannel, event_channel: EventChannel,
scheduler: JobScheduler,
} }
impl DeviceManager { impl DeviceManager {
@@ -23,6 +27,7 @@ impl DeviceManager {
let device_manager = Self { let device_manager = Self {
devices: Arc::new(RwLock::new(HashMap::new())), devices: Arc::new(RwLock::new(HashMap::new())),
event_channel, event_channel,
scheduler: JobScheduler::new().await.unwrap(),
}; };
tokio::spawn({ tokio::spawn({
@@ -38,6 +43,8 @@ impl DeviceManager {
} }
}); });
device_manager.scheduler.start().await.unwrap();
device_manager device_manager
} }
@@ -87,3 +94,51 @@ impl DeviceManager {
} }
} }
} }
impl mlua::UserData for DeviceManager {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_async_method("add", async |_lua, this, device: Box<dyn Device>| {
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<Box<dyn Future<Output = ()> + 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()))
}
}

View File

@@ -1 +1,11 @@
pub mod serialization; pub mod serialization;
mod timeout;
pub use timeout::Timeout;
pub fn register_with_lua(lua: &mlua::Lua) -> mlua::Result<()> {
lua.globals()
.set("Timeout", lua.create_proxy::<Timeout>()?)?;
Ok(())
}

View File

@@ -1,7 +1,6 @@
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use lua_typed::Typed;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::debug; use tracing::debug;
@@ -75,44 +74,3 @@ impl mlua::UserData for Timeout {
}); });
} }
} }
impl Typed for Timeout {
fn type_name() -> String {
"Timeout".into()
}
fn generate_header() -> Option<String> {
let type_name = Self::type_name();
Some(format!("---@class {type_name}\nlocal {type_name}\n"))
}
fn generate_members() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!(
"---@async\n---@param timeout number\n---@param callback {}\nfunction {type_name}:start(timeout, callback) end\n",
ActionCallback::<()>::type_name()
);
output += &format!("---@async\nfunction {type_name}:cancel() end\n",);
output +=
&format!("---@async\n---@return boolean\nfunction {type_name}:is_waiting() end\n",);
Some(output)
}
fn generate_footer() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!("utils.{type_name} = {{}}\n");
output += &format!("---@return {type_name}\n");
output += &format!("function utils.{type_name}.new() end\n");
Some(output)
}
}

View File

@@ -1,7 +1,5 @@
#![allow(incomplete_features)]
#![feature(iterator_try_collect)] #![feature(iterator_try_collect)]
#![feature(with_negative_coherence)]
use tracing::debug;
pub mod action_callback; pub mod action_callback;
pub mod config; pub mod config;
@@ -13,50 +11,4 @@ pub mod helpers;
pub mod lua; pub mod lua;
pub mod messages; pub mod messages;
pub mod mqtt; pub mod mqtt;
pub mod schedule;
type RegisterFn = fn(lua: &mlua::Lua) -> mlua::Result<mlua::Table>;
type DefinitionsFn = fn() -> String;
pub struct Module {
name: &'static str,
register_fn: RegisterFn,
definitions_fn: Option<DefinitionsFn>,
}
impl Module {
pub const fn new(
name: &'static str,
register_fn: RegisterFn,
definitions_fn: Option<DefinitionsFn>,
) -> Self {
Self {
name,
register_fn,
definitions_fn,
}
}
pub const fn get_name(&self) -> &'static str {
self.name
}
pub fn register(&self, lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
(self.register_fn)(lua)
}
pub fn definitions(&self) -> Option<String> {
self.definitions_fn.map(|f| f())
}
}
pub fn load_modules(lua: &mlua::Lua) -> mlua::Result<()> {
for module in inventory::iter::<Module> {
debug!(name = module.get_name(), "Loading module");
let table = module.register(lua)?;
lua.register_module(module.get_name(), table)?;
}
Ok(())
}
inventory::collect!(Module);

View File

@@ -1,3 +1 @@
pub mod traits; pub mod traits;
mod utils;

View File

@@ -2,40 +2,11 @@ use std::ops::Deref;
// TODO: Enable and disable functions based on query_only and command_only // TODO: Enable and disable functions based on query_only and command_only
pub trait PartialUserData<T> { pub trait OnOff {
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M); fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
where
fn interface_name() -> Option<&'static str> { Self: Sized + google_home::traits::OnOff + 'static,
None {
}
fn definitions() -> Option<String> {
None
}
}
pub struct Device;
impl<T> PartialUserData<T> for Device
where
T: crate::device::Device + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("get_id", async |_lua, this, _: ()| Ok(this.get_id()));
}
fn interface_name() -> Option<&'static str> {
Some("DeviceInterface")
}
}
pub struct OnOff;
impl<T> PartialUserData<T> for OnOff
where
T: google_home::traits::OnOff + 'static,
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("set_on", async |_lua, this, on: bool| { methods.add_async_method("set_on", async |_lua, this, on: bool| {
this.deref().set_on(on).await.unwrap(); this.deref().set_on(on).await.unwrap();
@@ -46,19 +17,14 @@ where
Ok(this.deref().on().await.unwrap()) Ok(this.deref().on().await.unwrap())
}); });
} }
fn interface_name() -> Option<&'static str> {
Some("OnOffInterface")
}
} }
impl<T> OnOff for T where T: google_home::traits::OnOff {}
pub struct Brightness; pub trait Brightness {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
impl<T> PartialUserData<T> for Brightness where
where Self: Sized + google_home::traits::Brightness + 'static,
T: google_home::traits::Brightness + 'static, {
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("set_brightness", async |_lua, this, brightness: u8| { methods.add_async_method("set_brightness", async |_lua, this, brightness: u8| {
this.set_brightness(brightness).await.unwrap(); this.set_brightness(brightness).await.unwrap();
@@ -69,19 +35,14 @@ where
Ok(this.brightness().await.unwrap()) Ok(this.brightness().await.unwrap())
}); });
} }
fn interface_name() -> Option<&'static str> {
Some("BrightnessInterface")
}
} }
impl<T> Brightness for T where T: google_home::traits::Brightness {}
pub struct ColorSetting; pub trait ColorSetting {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
impl<T> PartialUserData<T> for ColorSetting where
where Self: Sized + google_home::traits::ColorSetting + 'static,
T: google_home::traits::ColorSetting + 'static, {
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method( methods.add_async_method(
"set_color_temperature", "set_color_temperature",
async |_lua, this, temperature: u32| { async |_lua, this, temperature: u32| {
@@ -97,19 +58,14 @@ where
Ok(this.color().await.temperature) Ok(this.color().await.temperature)
}); });
} }
fn interface_name() -> Option<&'static str> {
Some("ColorSettingInterface")
}
} }
impl<T> ColorSetting for T where T: google_home::traits::ColorSetting {}
pub struct OpenClose; pub trait OpenClose {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
impl<T> PartialUserData<T> for OpenClose where
where Self: Sized + google_home::traits::OpenClose + 'static,
T: google_home::traits::OpenClose + 'static, {
{
fn add_methods<M: mlua::UserDataMethods<T>>(methods: &mut M) {
methods.add_async_method("set_open_percent", async |_lua, this, open_percent: u8| { methods.add_async_method("set_open_percent", async |_lua, this, open_percent: u8| {
this.set_open_percent(open_percent).await.unwrap(); this.set_open_percent(open_percent).await.unwrap();
@@ -120,8 +76,11 @@ where
Ok(this.open_percent().await.unwrap()) Ok(this.open_percent().await.unwrap())
}); });
} }
}
fn interface_name() -> Option<&'static str> { impl<T> OpenClose for T where T: google_home::traits::OpenClose {}
Some("OpenCloseInterface")
} pub trait AddAdditionalMethods {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M)
where
Self: Sized + 'static;
} }

View File

@@ -1,48 +0,0 @@
mod timeout;
use std::time::{SystemTime, UNIX_EPOCH};
use lua_typed::Typed;
pub use timeout::Timeout;
use crate::Module;
fn create_module(lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
let utils = lua.create_table()?;
utils.set(Timeout::type_name(), lua.create_proxy::<Timeout>()?)?;
let get_hostname = lua.create_function(|_lua, ()| {
hostname::get()
.map(|name| name.to_str().unwrap_or("unknown").to_owned())
.map_err(mlua::ExternalError::into_lua_err)
})?;
utils.set("get_hostname", get_hostname)?;
let get_epoch = lua.create_function(|_lua, ()| {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time is after UNIX EPOCH")
.as_millis())
})?;
utils.set("get_epoch", get_epoch)?;
Ok(utils)
}
fn generate_definitions() -> String {
let mut output = String::new();
output += "---@meta\n\nlocal utils\n\n";
output += &Timeout::generate_full().expect("Timeout should have generate_full");
output += "\n";
output += "---@return string\nfunction utils.get_hostname() end\n\n";
output += "---@return integer\nfunction utils.get_epoch() end\n\n";
output += "return utils";
output
}
inventory::submit! {Module::new("automation:utils", create_module, Some(generate_definitions))}

View File

@@ -1,67 +1,14 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::time::Duration;
use automation_macro::LuaDeviceConfig;
use lua_typed::Typed;
use mlua::FromLua; use mlua::FromLua;
use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, Transport}; use rumqttc::{AsyncClient, Event, EventLoop, Incoming};
use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::event::{self, EventChannel}; 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<MqttConfig> 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)] #[derive(Debug, Clone, FromLua)]
pub struct WrappedAsyncClient(pub AsyncClient); pub struct WrappedAsyncClient(pub AsyncClient);
impl Typed for WrappedAsyncClient {
fn type_name() -> String {
"AsyncClient".into()
}
fn generate_header() -> Option<String> {
let type_name = Self::type_name();
Some(format!("---@class {type_name}\nlocal {type_name}\n"))
}
fn generate_members() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!(
"---@async\n---@param topic string\n---@param message table?\nfunction {type_name}:send_message(topic, message) end\n"
);
Some(output)
}
}
impl Deref for WrappedAsyncClient { impl Deref for WrappedAsyncClient {
type Target = AsyncClient; type Target = AsyncClient;
@@ -102,9 +49,8 @@ impl mlua::UserData for WrappedAsyncClient {
} }
} }
pub fn start(config: MqttConfig, event_channel: &EventChannel) -> WrappedAsyncClient { pub fn start(mut eventloop: EventLoop, event_channel: &EventChannel) {
let tx = event_channel.get_tx(); let tx = event_channel.get_tx();
let (client, mut eventloop) = AsyncClient::new(config.into(), 100);
tokio::spawn(async move { tokio::spawn(async move {
debug!("Listening for MQTT events"); debug!("Listening for MQTT events");
@@ -123,6 +69,4 @@ pub fn start(config: MqttConfig, event_channel: &EventChannel) -> WrappedAsyncCl
} }
} }
}); });
WrappedAsyncClient(client)
} }

View File

@@ -0,0 +1,17 @@
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<String, IndexMap<Action, Vec<String>>>;
// #[derive(Debug, Deserialize)]
// pub struct Schedule {
// pub when: String,
// pub actions: IndexMap<Action, Vec<String>>,
// }

View File

@@ -11,6 +11,3 @@ itertools = { workspace = true }
proc-macro2 = { workspace = true } proc-macro2 = { workspace = true }
quote = { workspace = true } quote = { workspace = true }
syn = { workspace = true } syn = { workspace = true }
[dev-dependencies]
mlua = { workspace = true }

View File

@@ -1,49 +1,21 @@
use std::collections::HashMap; use std::collections::HashMap;
use proc_macro2::TokenStream as TokenStream2; use proc_macro2::TokenStream as TokenStream2;
use quote::quote; use quote::{ToTokens, quote};
use syn::parse::{Parse, ParseStream}; use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated; use syn::punctuated::Punctuated;
use syn::spanned::Spanned; use syn::{Attribute, DeriveInput, Token};
use syn::{Attribute, DeriveInput, Token, parenthesized};
enum Attr { struct DeviceTraitAttribute {
Trait(TraitAttr),
ExtraUserData(ExtraUserDataAttr),
}
impl Attr {
fn parse(attr: &Attribute) -> syn::Result<Self> {
let mut parsed = None;
attr.parse_nested_meta(|meta| {
if meta.path.is_ident("traits") {
let input;
_ = parenthesized!(input in meta.input);
parsed = Some(Attr::Trait(input.parse()?));
} else if meta.path.is_ident("extra_user_data") {
let value = meta.value()?;
parsed = Some(Attr::ExtraUserData(value.parse()?));
} else {
return Err(syn::Error::new(meta.path.span(), "Unknown attribute"));
}
Ok(())
})?;
Ok(parsed.expect("Parsed should be set"))
}
}
struct TraitAttr {
traits: Traits, traits: Traits,
aliases: Aliases, generics: Generics,
} }
impl Parse for TraitAttr { impl Parse for DeviceTraitAttribute {
fn parse(input: ParseStream) -> syn::Result<Self> { fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Self { Ok(Self {
traits: input.parse()?, traits: input.parse()?,
aliases: input.parse()?, generics: input.parse()?,
}) })
} }
} }
@@ -66,61 +38,62 @@ impl Parse for Traits {
} }
} }
#[derive(Default)] impl ToTokens for Traits {
struct Aliases(Vec<syn::Ident>); fn to_tokens(&self, tokens: &mut TokenStream2) {
let Self(traits) = &self;
impl Aliases { tokens.extend(quote! {
fn has_aliases(&self) -> bool { #(
::automation_lib::lua::traits::#traits::add_methods(methods);
)*
});
}
}
#[derive(Default)]
struct Generics(Vec<syn::AngleBracketedGenericArguments>);
impl Generics {
fn has_generics(&self) -> bool {
!self.0.is_empty() !self.0.is_empty()
} }
} }
impl Parse for Aliases { impl Parse for Generics {
fn parse(input: ParseStream) -> syn::Result<Self> { fn parse(input: ParseStream) -> syn::Result<Self> {
if !input.peek(Token![for]) { if !input.peek(Token![for]) {
if input.is_empty() { return Ok(Default::default());
return Ok(Default::default());
} else {
return Err(input.error("Expected ')' or 'for'"));
}
} }
_ = input.parse::<syn::Token![for]>()?; _ = input.parse::<syn::Token![for]>()?;
input input
.call(Punctuated::<_, Token![,]>::parse_separated_nonempty) .call(Punctuated::<_, Token![,]>::parse_separated_nonempty)
.map(|aliases| aliases.into_iter().collect()) .map(|generics| generics.into_iter().collect())
.map(Self) .map(Self)
} }
} }
#[derive(Clone)] struct Implementation {
struct ExtraUserDataAttr(syn::Ident); generics: Option<syn::AngleBracketedGenericArguments>,
traits: Traits,
impl Parse for ExtraUserDataAttr {
fn parse(input: ParseStream) -> syn::Result<Self> {
Ok(Self(input.parse()?))
}
} }
struct Implementation { impl From<(Option<syn::AngleBracketedGenericArguments>, Traits)> for Implementation {
name: syn::Ident, fn from(value: (Option<syn::AngleBracketedGenericArguments>, Traits)) -> Self {
traits: Traits, Self {
extra_user_data: Vec<ExtraUserDataAttr>, generics: value.0,
traits: value.1,
}
}
} }
impl quote::ToTokens for Implementation { impl quote::ToTokens for Implementation {
fn to_tokens(&self, tokens: &mut TokenStream2) { fn to_tokens(&self, tokens: &mut TokenStream2) {
let Self { let Self { generics, traits } = &self;
name,
traits,
extra_user_data,
} = &self;
let Traits(traits) = traits;
let extra_user_data: Vec<_> = extra_user_data.iter().map(|tr| tr.0.clone()).collect();
tokens.extend(quote! { tokens.extend(quote! {
impl mlua::UserData for #name { #generics {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) { fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_async_function("new", async |_lua, config| { methods.add_async_function("new", async |_lua, config| {
let device: Self = LuaDeviceCreate::create(config) let device: Self = LuaDeviceCreate::create(config)
@@ -135,64 +108,9 @@ impl quote::ToTokens for Implementation {
Ok(b) Ok(b)
}); });
<::automation_lib::lua::traits::Device as ::automation_lib::lua::traits::PartialUserData<#name>>::add_methods(methods); methods.add_async_method("get_id", async |_lua, this, _: ()| { Ok(this.get_id()) });
#( #traits
<::automation_lib::lua::traits::#traits as ::automation_lib::lua::traits::PartialUserData<#name>>::add_methods(methods);
)*
#(
<#extra_user_data as ::automation_lib::lua::traits::PartialUserData<#name>>::add_methods(methods);
)*
}
}
impl ::lua_typed::Typed for #name {
fn type_name() -> String {
stringify!(#name).into()
}
fn generate_header() -> std::option::Option<::std::string::String> {
let type_name = <Self as ::lua_typed::Typed>::type_name();
let mut output = String::new();
let interfaces: String = [
<::automation_lib::lua::traits::Device as ::automation_lib::lua::traits::PartialUserData<#name>>::interface_name(),
#(
<::automation_lib::lua::traits::#traits as ::automation_lib::lua::traits::PartialUserData<#name>>::interface_name(),
)*
].into_iter().flatten().intersperse(", ").collect();
let interfaces = if interfaces.is_empty() {
"".into()
} else {
format!(": {interfaces}")
};
Some(format!("---@class {type_name}{interfaces}\nlocal {type_name}\n"))
}
fn generate_members() -> Option<String> {
let mut output = String::new();
let type_name = <Self as ::lua_typed::Typed>::type_name();
output += &format!("devices.{type_name} = {{}}\n");
let config_name = <<Self as ::automation_lib::device::LuaDeviceCreate>::Config as ::lua_typed::Typed>::type_name();
output += &format!("---@param config {config_name}\n");
output += &format!("---@return {type_name}\n");
output += &format!("function devices.{type_name}.new(config) end\n");
output += &<::automation_lib::lua::traits::Device as ::automation_lib::lua::traits::PartialUserData<#name>>::definitions().unwrap_or("".into());
#(
output += &<::automation_lib::lua::traits::#traits as ::automation_lib::lua::traits::PartialUserData<#name>>::definitions().unwrap_or("".into());
)*
#(
output += &<#extra_user_data as ::automation_lib::lua::traits::PartialUserData<#name>>::definitions().unwrap_or("".into());
)*
Some(output)
} }
} }
}); });
@@ -201,26 +119,20 @@ impl quote::ToTokens for Implementation {
struct Implementations(Vec<Implementation>); struct Implementations(Vec<Implementation>);
impl Implementations { impl From<Vec<DeviceTraitAttribute>> for Implementations {
fn from_attr(attributes: Vec<Attr>, name: syn::Ident) -> Self { fn from(attributes: Vec<DeviceTraitAttribute>) -> Self {
let mut add_methods = Vec::new();
let mut all = Traits::default(); let mut all = Traits::default();
let mut implementations: HashMap<_, Traits> = HashMap::new(); let mut implementations: HashMap<_, Traits> = HashMap::new();
for attribute in attributes { for attribute in attributes {
match attribute { if attribute.generics.has_generics() {
Attr::Trait(attribute) => { for generic in &attribute.generics.0 {
if attribute.aliases.has_aliases() { implementations
for alias in &attribute.aliases.0 { .entry(Some(generic.clone()))
implementations .or_default()
.entry(Some(alias.clone())) .extend(&attribute.traits);
.or_default()
.extend(&attribute.traits);
}
} else {
all.extend(&attribute.traits);
}
} }
Attr::ExtraUserData(attribute) => add_methods.push(attribute), } else {
all.extend(&attribute.traits);
} }
} }
@@ -232,34 +144,27 @@ impl Implementations {
} }
} }
Self( Self(implementations.into_iter().map(Into::into).collect())
implementations
.into_iter()
.map(|(alias, traits)| Implementation {
name: alias.unwrap_or(name.clone()),
traits,
extra_user_data: add_methods.clone(),
})
.collect(),
)
} }
} }
pub fn device(input: DeriveInput) -> TokenStream2 { pub fn device(input: &DeriveInput) -> TokenStream2 {
let name = &input.ident;
let Implementations(imp) = match input let Implementations(imp) = match input
.attrs .attrs
.iter() .iter()
.filter(|attr| attr.path().is_ident("device")) .filter(|attr| attr.path().is_ident("device_traits"))
.map(Attr::parse) .map(Attribute::parse_args)
.try_collect::<Vec<_>>() .try_collect::<Vec<_>>()
{ {
Ok(attr) => Implementations::from_attr(attr, input.ident), Ok(result) => result.into(),
Err(err) => return err.into_compile_error(), Err(err) => return err.into_compile_error(),
}; };
quote! { quote! {
#( #(
#imp impl mlua::UserData for #name #imp
)* )*
} }
} }

View File

@@ -32,42 +32,26 @@ pub fn lua_serialize(input: proc_macro::TokenStream) -> proc_macro::TokenStream
/// Derive macro generating an impl for the trait `::mlua::UserData` /// Derive macro generating an impl for the trait `::mlua::UserData`
/// ///
/// # Device traits /// The `device_traits` attribute can be used to tell the macro what traits are implemented so that
/// The `device(traits)` attribute can be used to tell the macro what traits are implemented so that
/// the appropriate methods can automatically be registered. /// the appropriate methods can automatically be registered.
/// If the struct does not have any type parameters the syntax is very simple: /// If the struct does not have any type parameters the syntax is very simple:
/// ```rust /// ```
/// #[device(traits(TraitA, TraitB))] /// #[device_traits(TraitA, TraitB)]
/// ``` /// ```
/// ///
/// If the type does have type parameters you will have to manually specify all variations that /// If the type does have type parameters you will have to manually specify all variations that
/// have the trait available: /// have the trait available:
/// ```rust /// ```
/// #[device(traits(TraitA, TraitB for <StateA>, <StateB>))] /// #[device_traits(TraitA, TraitB for <StateA>, <StateB>)]
/// ``` /// ```
/// If multiple of these attributes are specified they will all combined appropriately. /// If multiple of these attributes are specified they will all combined appropriately.
/// ///
/// ///
/// ## NOTE /// # NOTE
/// If your type _has_ type parameters any instance of the traits attribute that does not specify /// If your type has type parameters any instance of the attribute will have the traits applied to
/// any type parameters will have the traits applied to _all_ other type parameter variations /// _all_ other type parameter variations listed in the other attributes.
/// listed in the other trait attributes. This behavior only applies if there is at least one #[proc_macro_derive(Device, attributes(device_traits))]
/// instance with type parameters specified.
///
/// # Additional methods
/// Additional methods can be added by using the `device(add_methods)` attribute. This attribute
/// takes the path to a function with the following signature that can register the additional methods:
///
/// ```rust
/// # struct D;
/// fn top_secret<M: mlua::UserDataMethods<D>>(methods: &mut M) {}
/// ```
/// It can then be registered with:
/// ```rust
/// #[device(add_methods = top_secret)]
/// ```
#[proc_macro_derive(Device, attributes(device))]
pub fn device(input: proc_macro::TokenStream) -> proc_macro::TokenStream { pub fn device(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let ast = parse_macro_input!(input as DeriveInput); let ast = parse_macro_input!(input as DeriveInput);
device::device(ast).into() device::device(&ast).into()
} }

678
config.lua Normal file
View File

@@ -0,0 +1,678 @@
local device_manager = require("device_manager")
local utils = require("utils")
local secrets = require("secrets")
local debug = require("variables").debug or false
print(_VERSION)
local host = utils.get_hostname()
print("Running @" .. host)
local function mqtt_z2m(topic)
return "zigbee2mqtt/" .. topic
end
local function mqtt_automation(topic)
return "automation/" .. topic
end
local fulfillment = {
openid_url = "https://login.huizinga.dev/api/oidc",
}
local mqtt_client = require("mqtt").new({
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 = Ntfy.new({
topic = secrets.ntfy_topic,
})
device_manager:add(ntfy)
local low_battery = {}
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)
local on_presence = {
add = function(self, f)
self[#self + 1] = f
end,
}
local presence_system = 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)
local function turn_off_when_away(device)
on_presence:add(function(presence)
if not presence then
device:set_on(false)
end
end)
end
local on_light = {
add = function(self, f)
self[#self + 1] = f
end,
}
device_manager:add(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
local hue_bridge = 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 = 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 = 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 = 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(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(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 = 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 = 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(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,
}))
local function kettle_timeout()
local timeout = 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
local kettle = 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)
local function set_kettle(_, on)
kettle:set_on(on)
end
device_manager:add(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(IkeaRemote.new({
name = "Remote",
room = "Kitchen",
client = mqtt_client,
topic = mqtt_z2m("kitchen/remote"),
single_button = true,
callback = set_kettle,
battery_callback = check_battery,
}))
local function off_timeout(duration)
local timeout = 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 = 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(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(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 = 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 = 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 = Timeout.new()
device_manager:add(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 = HueGroup.new({
identifier = "hallway_top_light",
ip = hue_ip,
login = hue_token,
group_id = 83,
scene_id = "QeufkFDICEHWeKJ7",
})
device_manager:add(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(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 = Timeout.new(),
forced = false,
switch_callback = function(self)
return function(_, on)
self.timeout:cancel()
self.group.set_on(on)
self.forced = on
end
end,
door_callback = function(self)
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,
trash_callback = function(self)
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,
light_callback = function(self)
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 = 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 = 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,
}
local function presence(duration)
local timeout = 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(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 = 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)
hallway_light_automation.door = hallway_frontdoor
local hallway_trash = 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 = 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 = AirFilter.new({
name = "Air Filter",
room = "Bedroom",
url = "http://10.0.0.103",
})
device_manager:add(bedroom_air_filter)
local bedroom_lights = 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 = 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(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,
}))
device_manager:add(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(ContactSensor.new({
name = "Window",
room = "Living Room",
topic = mqtt_z2m("living/window"),
client = mqtt_client,
battery_callback = check_battery,
}))
device_manager:add(ContactSensor.new({
name = "Window",
room = "Bedroom",
topic = mqtt_z2m("bedroom/window"),
client = mqtt_client,
battery_callback = check_battery,
}))
device_manager:add(ContactSensor.new({
name = "Window",
room = "Guest Room",
topic = mqtt_z2m("guest/window"),
client = mqtt_client,
battery_callback = check_battery,
}))
local storage_light = 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(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

View File

@@ -1,47 +0,0 @@
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

View File

@@ -1,32 +0,0 @@
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"),
},
}

View File

@@ -1,35 +0,0 @@
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

View File

@@ -1,85 +0,0 @@
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

View File

@@ -1,49 +0,0 @@
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

View File

@@ -1,41 +0,0 @@
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

View File

@@ -1,44 +0,0 @@
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

View File

@@ -1,34 +0,0 @@
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

View File

@@ -1,80 +0,0 @@
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

View File

@@ -1,12 +0,0 @@
--- @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"),
}

View File

@@ -1,40 +0,0 @@
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

View File

@@ -1,78 +0,0 @@
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

View File

@@ -1,43 +0,0 @@
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)
local printer = devices.OutletOnOff.new({
name = "3D Printer",
room = "Guest Room",
topic = helper.mqtt_z2m("guest/printer"),
client = mqtt_client,
})
--- @type Module
return {
light,
window,
printer,
}
end
return module

View File

@@ -1,105 +0,0 @@
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)
local timeout = utils.Timeout.new()
local function frontdoor_presence(_, open)
if open then
timeout:cancel()
if not presence.overall_presence() then
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), {
state = true,
updated = utils.get_epoch(),
})
end
else
timeout:start(debug.debug_mode and 10 or 15 * 60, function()
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), nil)
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,
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

View File

@@ -1,48 +0,0 @@
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

View File

@@ -1,71 +0,0 @@
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

View File

@@ -1,126 +0,0 @@
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

View File

@@ -1,41 +0,0 @@
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

View File

@@ -1,69 +0,0 @@
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

View File

@@ -1,43 +0,0 @@
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

View File

@@ -1,337 +0,0 @@
-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED
---@meta
local devices
---@class Action
---@field action
---| "broadcast"
---@field extras (table<string, string>)?
---@field label string
---@field clear (boolean)?
local Action
---@class AirFilter: DeviceInterface, OnOffInterface
local AirFilter
devices.AirFilter = {}
---@param config AirFilterConfig
---@return AirFilter
function devices.AirFilter.new(config) end
---@class AirFilterConfig
---@field name string
---@field room (string)?
---@field url string
local AirFilterConfig
---@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 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 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
devices.HueBridge = {}
---@param config HueBridgeConfig
---@return HueBridge
function devices.HueBridge.new(config) end
---@async
---@param flag Flag
---@param value boolean
function HueBridge:set_flag(flag, value) end
---@class HueBridgeConfig
---@field identifier string
---@field ip string
---@field login string
---@field flags FlagIDs
local HueBridgeConfig
---@class HueGroup: DeviceInterface, OnOffInterface
local HueGroup
devices.HueGroup = {}
---@param config HueGroupConfig
---@return HueGroup
function devices.HueGroup.new(config) end
---@class HueGroupConfig
---@field identifier string
---@field ip string
---@field login string
---@field group_id integer
---@field scene_id string
local HueGroupConfig
---@class HueSwitch: DeviceInterface
local HueSwitch
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
devices.IkeaRemote = {}
---@param config IkeaRemoteConfig
---@return IkeaRemote
function devices.IkeaRemote.new(config) end
---@class IkeaRemoteConfig
---@field name string
---@field room (string)?
---@field single_button (boolean)?
---@field topic string
---@field client AsyncClient
---@field callback (fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[])?
---@field battery_callback (fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[])?
local IkeaRemoteConfig
---@class KasaOutlet: DeviceInterface, OnOffInterface
local KasaOutlet
devices.KasaOutlet = {}
---@param config KasaOutletConfig
---@return KasaOutlet
function devices.KasaOutlet.new(config) end
---@class KasaOutletConfig
---@field identifier string
---@field ip string
local KasaOutletConfig
---@class LightBrightness: DeviceInterface, OnOffInterface, BrightnessInterface
local LightBrightness
devices.LightBrightness = {}
---@param config ConfigLightLightStateBrightness
---@return LightBrightness
function devices.LightBrightness.new(config) end
---@class LightColorTemperature: DeviceInterface, OnOffInterface, BrightnessInterface, ColorSettingInterface
local LightColorTemperature
devices.LightColorTemperature = {}
---@param config ConfigLightLightStateColorTemperature
---@return LightColorTemperature
function devices.LightColorTemperature.new(config) end
---@class LightOnOff: DeviceInterface, OnOffInterface
local LightOnOff
devices.LightOnOff = {}
---@param config ConfigLightLightStateOnOff
---@return LightOnOff
function devices.LightOnOff.new(config) end
---@class LightSensor: DeviceInterface
local LightSensor
devices.LightSensor = {}
---@param config LightSensorConfig
---@return LightSensor
function devices.LightSensor.new(config) end
---@class LightSensorConfig
---@field identifier string
---@field topic string
---@field min integer
---@field max integer
---@field callback (fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[])?
---@field client AsyncClient
local LightSensorConfig
---@class LightStateBrightness
---@field state boolean
---@field brightness number
local LightStateBrightness
---@class LightStateColorTemperature
---@field state boolean
---@field brightness number
---@field color_temp integer
local LightStateColorTemperature
---@class LightStateOnOff
---@field state boolean
local LightStateOnOff
---@class Notification
---@field title string
---@field message (string)?
---@field tags ((string)[])?
---@field priority (Priority)?
---@field actions ((Action)[])?
local Notification
---@class Ntfy: DeviceInterface
local Ntfy
devices.Ntfy = {}
---@param config NtfyConfig
---@return Ntfy
function devices.Ntfy.new(config) end
---@async
---@param notification Notification
function Ntfy:send_notification(notification) end
---@class NtfyConfig
---@field url (string)?
---@field topic string
local NtfyConfig
---@class OutletOnOff: DeviceInterface, OnOffInterface
local OutletOnOff
devices.OutletOnOff = {}
---@param config ConfigOutletOutletStateOnOff
---@return OutletOnOff
function devices.OutletOnOff.new(config) end
---@class OutletPower: DeviceInterface, OnOffInterface
local OutletPower
devices.OutletPower = {}
---@param config ConfigOutletOutletStatePower
---@return OutletPower
function devices.OutletPower.new(config) end
---@class OutletStateOnOff
---@field state boolean
local OutletStateOnOff
---@class OutletStatePower
---@field state boolean
---@field power number
local OutletStatePower
---@alias OutletType
---| "Outlet"
---| "Kettle"
---@class Presence: DeviceInterface
local Presence
devices.Presence = {}
---@param config PresenceConfig
---@return Presence
function devices.Presence.new(config) end
---@async
---@return boolean
function Presence:overall_presence() end
---@class PresenceConfig
---@field topic string
---@field callback (fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[])?
---@field client AsyncClient
local PresenceConfig
---@alias Priority
---| "min"
---| "low"
---| "default"
---| "high"
---| "max"
---@alias SensorType
---| "Door"
---| "Drawer"
---| "Window"
---@class WakeOnLAN: DeviceInterface
local WakeOnLAN
devices.WakeOnLAN = {}
---@param config WolConfig
---@return WakeOnLAN
function devices.WakeOnLAN.new(config) end
---@class Washer: DeviceInterface
local Washer
devices.Washer = {}
---@param config WasherConfig
---@return Washer
function devices.Washer.new(config) end
---@class WasherConfig
---@field identifier string
---@field topic string
---@field threshold number
---@field done_callback (fun(_: Washer) | fun(_: Washer)[])?
---@field client AsyncClient
local WasherConfig
---@class WolConfig
---@field name string
---@field room (string)?
---@field topic string
---@field mac_address string
---@field broadcast_ip (string)?
---@field client AsyncClient
local WolConfig
return devices

View File

@@ -1,6 +0,0 @@
---@meta
---@type table<string, string?>
local secrets
return secrets

View File

@@ -1,27 +0,0 @@
-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED
---@meta
local utils
---@class Timeout
local Timeout
---@async
---@param timeout number
---@param callback fun() | fun()[]
function Timeout:start(timeout, callback) end
---@async
function Timeout:cancel() end
---@async
---@return boolean
function Timeout:is_waiting() end
utils.Timeout = {}
---@return Timeout
function utils.Timeout.new() end
---@return string
function utils.get_hostname() end
---@return integer
function utils.get_epoch() end
return utils

View File

@@ -1,6 +0,0 @@
---@meta
---@type table<string, string?>
local variables
return variables

View File

@@ -1,41 +0,0 @@
-- 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<string, fun() | fun()[]>
---@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

View File

@@ -1,42 +0,0 @@
--- @meta
---@class DeviceInterface
local DeviceInterface
---@return string
function DeviceInterface:get_id() end
---@class OnOffInterface: DeviceInterface
local OnOffInterface
---@async
---@param on boolean
function OnOffInterface:set_on(on) end
---@async
---@return boolean
function OnOffInterface:on() end
---@class BrightnessInterface: DeviceInterface
local BrightnessInterface
---@async
---@param brightness integer
function BrightnessInterface:set_brightness(brightness) end
---@async
---@return integer
function BrightnessInterface:brightness() end
---@class ColorSettingInterface: DeviceInterface
local ColorSettingInterface
---@async
---@param temperature integer
function ColorSettingInterface:set_color_temperature(temperature) end
---@async
---@return integer
function ColorSettingInterface:color_temperature() end
---@class OpenCloseInterface: DeviceInterface
local OpenCloseInterface
---@async
---@param open_percent integer
function OpenCloseInterface:set_open_percent(open_percent) end
---@async
---@return integer
function OpenCloseInterface:open_percent() end

View File

@@ -1,18 +0,0 @@
variable "TAG_BASE" {}
variable "RELEASE_VERSION" {}
group "default" {
targets = ["automation"]
}
target "docker-metadata-action" {}
target "automation" {
inherits = ["docker-metadata-action"]
context = "./"
dockerfile = "Dockerfile"
tags = [for tag in target.docker-metadata-action.tags : "${TAG_BASE}:${tag}"]
args = {
RELEASE_VERSION="${RELEASE_VERSION}"
}
}

View File

@@ -1,45 +0,0 @@
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 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::<Module> {
if let Some(definitions) = module.definitions() {
info!(name = module.get_name(), "Generating definitions");
let filename = format!("{}.lua", module.get_name());
write_definitions(&filename, &definitions)?;
} else {
warn!(name = module.get_name(), "No definitions");
}
}
write_definitions("config.lua", &generate_definitions())?;
Ok(())
}

View File

@@ -1,19 +1,9 @@
use std::collections::{HashMap, VecDeque}; use std::collections::HashMap;
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 serde::Deserialize;
use crate::schedule::Scheduler;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct Setup { pub struct Config {
#[serde(default = "default_entrypoint")] #[serde(default = "default_entrypoint")]
pub entrypoint: String, pub entrypoint: String,
#[serde(default)] #[serde(default)]
@@ -23,266 +13,5 @@ pub struct Setup {
} }
fn default_entrypoint() -> String { fn default_entrypoint() -> String {
"./config/config.lua".into() "./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<String> {
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<Self> {
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<String, ActionCallback<()>>);
impl Typed for Schedule {
fn type_name() -> String {
"Schedule".into()
}
fn generate_header() -> Option<String> {
Some(format!(
"---@alias {} {}\n",
Self::type_name(),
HashMap::<String, ActionCallback<()>>::type_name(),
))
}
}
impl FromLua for Schedule {
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
Ok(Self(FromLua::from_lua(value, lua)?))
}
}
impl IntoIterator for Schedule {
type Item = <HashMap<String, ActionCallback<()>> as IntoIterator>::Item;
type IntoIter = <HashMap<String, ActionCallback<()>> as IntoIterator>::IntoIter;
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
#[derive(Debug, Default)]
struct Module {
setup: Option<SetupFunction>,
devices: Vec<Box<dyn Device>>,
schedule: Schedule,
modules: Vec<Module>,
}
// TODO: Add option to typed to rename field
impl Typed for Module {
fn type_name() -> String {
"Module".into()
}
fn generate_header() -> Option<String> {
Some(format!("---@class {}\n", Self::type_name()))
}
fn generate_members() -> Option<String> {
Some(format!(
r#"---@field setup {}
---@field devices {}?
---@field schedule {}?
---@field [number] {}?
"#,
Option::<SetupFunction>::type_name(),
Vec::<Box<dyn Device>>::type_name(),
Schedule::type_name(),
Vec::<Module>::type_name(),
))
}
fn generate_footer() -> Option<String> {
let type_name = <Self as Typed>::type_name();
Some(format!("local {type_name}\n"))
}
}
impl FromLua for Module {
fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> {
// 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::<Module>() {
modules.push(module?);
}
Ok(Module {
setup,
devices,
schedule,
modules,
})
}
}
#[derive(Debug, Default)]
pub struct Modules(Vec<Module>);
impl Typed for Modules {
fn type_name() -> String {
Vec::<Module>::type_name()
}
}
impl FromLua for Modules {
fn from_lua(value: mlua::Value, lua: &mlua::Lua) -> mlua::Result<Self> {
Ok(Self(FromLua::from_lua(value, lua)?))
}
}
impl Modules {
pub async fn resolve(
self,
lua: &mlua::Lua,
client: &WrappedAsyncClient,
) -> mlua::Result<Resolved> {
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) = <Vec<_> 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<Box<dyn Device>>,
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<FulfillmentConfig> 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
} }

View File

@@ -1,7 +0,0 @@
#![feature(if_let_guard)]
pub mod config;
pub mod schedule;
pub mod secret;
pub mod version;
pub mod web;

View File

@@ -1,27 +1,34 @@
#![feature(iter_intersperse)] #![feature(iter_intersperse)]
mod config;
mod secret;
mod version;
mod web;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::Path;
use std::process; use std::process;
use std::time::{SystemTime, UNIX_EPOCH};
use ::config::{Environment, File}; use ::config::{Environment, File};
use automation::config::{Config, Setup}; use automation_lib::config::{FulfillmentConfig, MqttConfig};
use automation::secret::EnvironmentSecretFile;
use automation::version::VERSION;
use automation::web::{ApiError, User};
use automation_lib::device_manager::DeviceManager; use automation_lib::device_manager::DeviceManager;
use automation_lib::mqtt; use automation_lib::helpers;
use automation_lib::mqtt::{self, WrappedAsyncClient};
use axum::extract::{FromRef, State}; use axum::extract::{FromRef, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::routing::post; use axum::routing::post;
use axum::{Json, Router}; use axum::{Json, Router};
use config::Config;
use dotenvy::dotenv;
use google_home::{GoogleHome, Request, Response}; use google_home::{GoogleHome, Request, Response};
use mlua::LuaSerdeExt; use mlua::LuaSerdeExt;
use rumqttc::AsyncClient;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use web::{ApiError, User};
// Force automation_devices to link so that it gets registered as a module use crate::secret::EnvironmentSecretFile;
extern crate automation_devices; use crate::version::VERSION;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
@@ -67,11 +74,13 @@ async fn fulfillment(
} }
async fn app() -> anyhow::Result<()> { async fn app() -> anyhow::Result<()> {
dotenv().ok();
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
info!(version = VERSION, "automation_rs"); info!(version = VERSION, "automation_rs");
let setup: Setup = ::config::Config::builder() let config: Config = ::config::Config::builder()
.add_source( .add_source(
File::with_name(&format!("{}.toml", std::env!("CARGO_PKG_NAME"))).required(false), File::with_name(&format!("{}.toml", std::env!("CARGO_PKG_NAME"))).required(false),
) )
@@ -129,22 +138,48 @@ async fn app() -> anyhow::Result<()> {
})?; })?;
lua.globals().set("print", print)?; lua.globals().set("print", print)?;
automation_lib::load_modules(&lua)?; let mqtt = lua.create_table()?;
let event_channel = device_manager.event_channel();
let mqtt_new = lua.create_function(move |lua, config: mlua::Value| {
let config: MqttConfig = lua.from_value(config)?;
lua.register_module("automation:variables", lua.to_value(&setup.variables)?)?; // Create a mqtt client
lua.register_module("automation:secrets", lua.to_value(&setup.secrets)?)?; // 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);
mqtt::start(eventloop, &event_channel);
let entrypoint = Path::new(&setup.entrypoint); Ok(WrappedAsyncClient(client))
let config: Config = lua.load(entrypoint).eval_async().await?; })?;
mqtt.set("new", mqtt_new)?;
lua.register_module("mqtt", mqtt)?;
let mqtt_client = mqtt::start(config.mqtt, &device_manager.event_channel()); lua.register_module("device_manager", device_manager.clone())?;
let resolved = config.modules.resolve(&lua, &mqtt_client).await?; lua.register_module("variables", lua.to_value(&config.variables)?)?;
for device in resolved.devices { lua.register_module("secrets", lua.to_value(&config.secrets)?)?;
device_manager.add(device).await;
}
resolved.scheduler.start().await?; let utils = lua.create_table()?;
let get_hostname = lua.create_function(|_lua, ()| {
hostname::get()
.map(|name| name.to_str().unwrap_or("unknown").to_owned())
.map_err(mlua::ExternalError::into_lua_err)
})?;
utils.set("get_hostname", get_hostname)?;
let get_epoch = lua.create_function(|_lua, ()| {
Ok(SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time is after UNIX EPOCH")
.as_millis())
})?;
utils.set("get_epoch", get_epoch)?;
lua.register_module("utils", utils)?;
automation_devices::register_with_lua(&lua)?;
helpers::register_with_lua(&lua)?;
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)?;
// Create google home fulfillment route // Create google home fulfillment route
let fulfillment = Router::new().route("/google_home", post(fulfillment)); let fulfillment = Router::new().route("/google_home", post(fulfillment));
@@ -153,12 +188,12 @@ async fn app() -> anyhow::Result<()> {
let app = Router::new() let app = Router::new()
.nest("/fulfillment", fulfillment) .nest("/fulfillment", fulfillment)
.with_state(AppState { .with_state(AppState {
openid_url: config.fulfillment.openid_url.clone(), openid_url: fulfillment_config.openid_url.clone(),
device_manager, device_manager,
}); });
// Start the web server // Start the web server
let addr: SocketAddr = config.fulfillment.into(); let addr: SocketAddr = fulfillment_config.into();
info!("Server started on http://{addr}"); info!("Server started on http://{addr}");
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
axum::serve(listener, app).await?; axum::serve(listener, app).await?;

View File

@@ -1,37 +0,0 @@
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<Box<dyn Future<Output = ()> + 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
}
}