Compare commits

15 Commits

Author SHA1 Message Date
Dreaded_X 9bfafbff87 refactor: Split config
Build and deploy / Deploy container (push) Blocked by required conditions
Build and deploy / build (push) Has been cancelled
2025-10-20 04:46:54 +02:00
Dreaded_X 956f818a3b feat(config)!: Device creation function is now named entry
It now has to be called 'setup', this makes it possible to just
include the table as a whole in devices and it will automatically call
the correct function.
2025-10-20 04:41:27 +02:00
Dreaded_X a0c5189ada feat(config)!: Changed default config location
Build and deploy / build (push) Successful in 10m26s
Build and deploy / Deploy container (push) Has been skipped
2025-10-19 05:43:43 +02:00
Dreaded_X 3676aafa23 feat(config)!: Remove device manager lua code
With the recent changes the device manager no longer needs to be
available in lua.
2025-10-19 05:43:43 +02:00
Dreaded_X 95ec3f28ff feat(config)!: Config now returns the mqtt config instead of the client
Instead the client is now created on the rust side based on the config.
Devices that require the mqtt client will now instead need to be
constructor using a function. This function receives the mqtt client.
2025-10-19 05:43:43 +02:00
Dreaded_X 303541929c chore: Restructured config to not rely on mqtt client being available
Build and deploy / build (push) Successful in 10m27s
Build and deploy / Deploy container (push) Has been skipped
In preparation of changes to the mqtt client the config is rewritten to
use a device creation function for devices that need the mqtt client.

This also fixes a but where hallway_top_light was not actually added to
the device manager.
2025-10-19 04:31:25 +02:00
Dreaded_X a26a93550b feat(config)!: In config devices can now also be a (table of) function(s)
Build and deploy / Deploy container (push) Blocked by required conditions
Build and deploy / build (push) Has been cancelled
This function receives the mqtt client as an argument. In the future
this will be the only way to create devices that require the mqtt client.
2025-10-19 04:18:26 +02:00
Dreaded_X 88a7acd55d feat: Improved device conversion error message
Build and deploy / build (push) Successful in 10m37s
Build and deploy / Deploy container (push) Has been skipped
2025-10-19 03:40:16 +02:00
Dreaded_X 3b7579878b feat: Use ActionCallback for schedule
This has two advantages:
- Each schedule entry can take either a single function or table of
  functions.
- We get a better type definition.
2025-10-19 03:40:16 +02:00
Dreaded_X 46c32c3605 refactor(config)!: Move scheduler out of device_manager
Due to changes made in mlua the new scheduler is much simpler. It also
had no real business being part of the device manager, so it has now been
moved to be part of the returned config.
2025-10-19 03:40:16 +02:00
Dreaded_X d1e7988117 feat: Receive devices through config return 2025-10-19 03:40:15 +02:00
Dreaded_X 93b0a526b1 feat: Ensure consistent ordering device definitions 2025-10-19 03:40:15 +02:00
Dreaded_X 7bb5e65c1c feat: Generate definitions for config 2025-10-19 03:40:15 +02:00
Dreaded_X a0ed373971 refactor: Move definition writing into separate function
Build and deploy / Deploy container (push) Blocked by required conditions
Build and deploy / build (push) Has been cancelled
2025-10-17 03:12:40 +02:00
Dreaded_X 5e13dff2b5 chore: Move main.rs to bin/automation.rs 2025-10-17 03:08:37 +02:00
37 changed files with 828 additions and 1425 deletions
+3 -7
View File
@@ -9,10 +9,10 @@ on:
jobs:
build:
uses: dreaded_x/workflows/.gitea/workflows/docker-kubernetes.yaml@ef78704b98c72e4a6b8340f9bff7b085a7bdd95c
uses: dreaded_x/workflows/.gitea/workflows/rust-kubernetes.yaml@22ee0c1788a8d2157db87d6a6f8dbe520fe48592
secrets: inherit
with:
push_manifests: false
upload_manifests: false
deploy:
name: Deploy container
@@ -26,10 +26,6 @@ jobs:
docker stop automation_rs || true
docker rm automation_rs || true
- name: Login to registry
run: |
docker login git.huizinga.dev -u ${{ gitea.actor }} -p ${{ secrets.REGISTRY_TOKEN }} \
- name: Create container
run: |
docker create \
@@ -41,7 +37,7 @@ jobs:
-e AUTOMATION__SECRETS__MQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} \
-e AUTOMATION__SECRETS__HUE_TOKEN=${{ secrets.HUE_TOKEN }} \
-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
Generated
+578 -910
View File
File diff suppressed because it is too large Load Diff
+21 -21
View File
@@ -16,28 +16,28 @@ members = [
[workspace.dependencies]
air_filter_types = { git = "https://git.huizinga.dev/Dreaded_X/airfilter", tag = "v0.4.4" }
anyhow = "1.0.102"
anyhow = "1.0.99"
async-trait = "0.1.89"
automation_cast = { path = "./automation_cast" }
automation_devices = { path = "./automation_devices" }
automation_lib = { path = "./automation_lib" }
automation_macro = { path = "./automation_macro" }
axum = "0.8.9"
bytes = "1.11.1"
axum = "0.8.4"
bytes = "1.10.1"
dyn-clone = "1.0.20"
eui48 = { version = "1.1.0", features = [
"disp_hexstring",
"serde",
], default-features = false }
futures = "0.3.32"
futures = "0.3.31"
google_home = { path = "./google_home/google_home" }
google_home_macro = { path = "./google_home/google_home_macro" }
hostname = "0.4.2"
inventory = "0.3.24"
hostname = "0.4.1"
inventory = "0.3.21"
itertools = "0.14.0"
json_value_merge = "2.0.1"
lua_typed = { git = "https://git.huizinga.dev/Dreaded_X/lua_typed" }
mlua = { version = "0.11.6", features = [
mlua = { version = "0.11.3", features = [
"lua54",
"vendored",
"macros",
@@ -45,23 +45,23 @@ mlua = { version = "0.11.6", features = [
"async",
"send",
] }
proc-macro2 = "1.0.106"
quote = "1.0.45"
reqwest = { version = "0.13.3", features = [
proc-macro2 = "1.0.101"
quote = "1.0.40"
reqwest = { version = "0.12.23", features = [
"json",
"rustls",
"rustls-tls",
], default-features = false } # Use rustls, since the other packages also use rustls
rumqttc = "0.25.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
rumqttc = "0.24.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
serde_repr = "0.1.20"
syn = { version = "2.0.117" }
thiserror = "2.0.18"
syn = { version = "2.0.106" }
thiserror = "2.0.16"
tokio = { version = "1", features = ["rt-multi-thread"] }
tokio-cron-scheduler = "0.15.1"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
wakey = "0.4.1"
tokio-cron-scheduler = "0.15.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
wakey = "0.3.0"
[dependencies]
anyhow = { workspace = true }
@@ -70,7 +70,7 @@ automation_devices = { workspace = true }
automation_lib = { workspace = true }
automation_macro = { path = "./automation_macro" }
axum = { workspace = true }
config = { version = "0.15.22", default-features = false, features = [
config = { version = "0.15.15", default-features = false, features = [
"async",
"toml",
] }
+2 -5
View File
@@ -1,9 +1,8 @@
FROM rust:1.95 AS base
FROM rust:1.89 AS base
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
RUN cargo install cargo-chef --locked --version 0.1.71 && \
cargo install cargo-auditable --locked --version 0.6.6
WORKDIR /app
RUN rustup toolchain install
FROM base AS planner
COPY . .
@@ -20,9 +19,7 @@ ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION}
RUN cargo auditable build --release
FROM gcr.io/distroless/cc-debian13:nonroot AS runtime
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/automation /app/automation
ENV AUTOMATION__ENTRYPOINT=/app/config/config.lua
ENV LUA_PATH="/app/?.lua;;"
COPY ./config /app/config
CMD [ "/app/automation" ]
+2 -2
View File
@@ -4,9 +4,9 @@ Custom home automation solution with Google Home integration and lua scripting.
## Development
This repository uses [prek](https://prek.j178.dev/) to make sure everything is ready to go when committing.
This repository uses [pre-commit](https://pre-commit.com) to make sure everything is ready to go when committing.
Install the pre-commit hooks by running the following command:
```bash
prek install
pre-commit install
```
-47
View File
@@ -2,7 +2,6 @@ use std::net::SocketAddr;
use anyhow::Result;
use async_trait::async_trait;
use automation_lib::lua::traits::PartialUserData;
use automation_macro::{Device, LuaDeviceConfig};
use google_home::errors::ErrorCode;
use google_home::traits::OnOff;
@@ -26,7 +25,6 @@ crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff))]
#[device(extra_user_data = AllOn)]
pub struct HueGroup {
config: Config,
}
@@ -124,47 +122,6 @@ impl OnOff for HueGroup {
}
}
struct AllOn;
impl PartialUserData<HueGroup> for AllOn {
fn add_methods<M: mlua::UserDataMethods<HueGroup>>(methods: &mut M) {
methods.add_async_method("all_on", async |_lua, this, ()| {
let res = reqwest::Client::new()
.get(this.url_get_state())
.send()
.await;
match res {
Ok(res) => {
let status = res.status();
if !status.is_success() {
warn!(id = this.get_id(), "Status code is not success: {status}");
}
let on = match res.json::<message::Info>().await {
Ok(info) => info.all_on(),
Err(err) => {
error!(id = this.get_id(), "Failed to parse message: {err}");
return Ok(false);
}
};
return Ok(on);
}
Err(err) => error!(id = this.get_id(), "Error: {err}"),
}
Ok(false)
});
}
fn definitions() -> Option<String> {
Some(format!(
"---@async\n---@return boolean\nfunction {}:all_on() end\n",
<HueGroup as Typed>::type_name(),
))
}
}
mod message {
use serde::{Deserialize, Serialize};
@@ -207,9 +164,5 @@ mod message {
pub fn any_on(&self) -> bool {
self.state.any_on
}
pub fn all_on(&self) -> bool {
self.state.all_on
}
}
}
+8 -4
View File
@@ -122,11 +122,15 @@ impl OnMqtt for HueSwitch {
Action::LeftHold => self.config.left_hold_callback.call(self.clone()).await,
Action::RightHold => self.config.right_hold_callback.call(self.clone()).await,
// If there is no hold action, the switch will act like a normal release
Action::RightHoldRelease if self.config.right_hold_callback.is_empty() => {
self.config.right_callback.call(self.clone()).await
Action::RightHoldRelease => {
if self.config.right_hold_callback.is_empty() {
self.config.right_callback.call(self.clone()).await
}
}
Action::LeftHoldRelease if self.config.left_hold_callback.is_empty() => {
self.config.left_callback.call(self.clone()).await
Action::LeftHoldRelease => {
if self.config.left_hold_callback.is_empty() {
self.config.left_callback.call(self.clone()).await
}
}
_ => {}
}
+1
View File
@@ -1,3 +1,4 @@
#![feature(iter_intersperse)]
#![feature(iterator_try_collect)]
mod device;
mod lua_device_config;
+1 -7
View File
@@ -1,6 +1,5 @@
local ntfy = require("config.ntfy")
--- @class BatteryModule: Module
local module = {}
--- @type {[string]: number}
@@ -18,7 +17,7 @@ function module.callback(device, battery)
end
end
local function notify_low_battery()
function module.notify_low_battery()
-- Don't send notifications if there are now devices with low battery
if next(low_battery) == nil then
print("No devices with low battery")
@@ -39,9 +38,4 @@ local function notify_low_battery()
})
end
--- @type Schedule
module.schedule = {
["0 0 21 */1 * *"] = notify_low_battery,
}
return module
+10
View File
@@ -29,4 +29,14 @@ return {
require("config.rooms"),
require("config.windows"),
},
-- TODO: Make this also part of the modules
schedule = {
["0 0 19 * * *"] = function()
require("config.rooms.bedroom").set_airfilter_on(true)
end,
["0 0 20 * * *"] = function()
require("config.rooms.bedroom").set_airfilter_on(false)
end,
["0 0 21 */1 * *"] = require("config.battery").notify_low_battery,
},
}
+2 -1
View File
@@ -4,7 +4,6 @@ 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
@@ -17,6 +16,8 @@ end
--- @type SetupFunction
function module.setup(mqtt_client)
print("Lua " .. _VERSION .. " running on " .. utils.get_hostname())
presence.add_callback(function(p)
mqtt_client:send_message(helper.mqtt_automation("debug") .. "/presence", {
state = p,
-1
View File
@@ -1,7 +1,6 @@
local debug = require("config.debug")
local utils = require("automation:utils")
--- @class HallwayAutomationModule: Module
local module = {}
local timeout = utils.Timeout.new()
-1
View File
@@ -1,6 +1,5 @@
local utils = require("automation:utils")
--- @class HelperModule: Module
local module = {}
--- @param topic string
-1
View File
@@ -3,7 +3,6 @@ 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"
-2
View File
@@ -1,7 +1,6 @@
local devices = require("automation:devices")
local helper = require("config.helper")
--- @class LightModule: Module
local module = {}
--- @class OnPresence
@@ -35,7 +34,6 @@ function module.setup(mqtt_client)
callback = callback,
})
--- @type Module
return {
module.device,
}
-2
View File
@@ -1,7 +1,6 @@
local devices = require("automation:devices")
local secrets = require("automation:secrets")
--- @class NtfyModule: Module
local module = {}
local ntfy_topic = secrets.ntfy_topic
@@ -25,7 +24,6 @@ function module.setup()
topic = ntfy_topic,
})
--- @type Module
return {
ntfy,
}
-2
View File
@@ -2,7 +2,6 @@ local devices = require("automation:devices")
local helper = require("config.helper")
local ntfy = require("config.ntfy")
--- @class PresenceModule: Module
local module = {}
--- @class OnPresence
@@ -62,7 +61,6 @@ function module.setup(mqtt_client)
})
end)
--- @type Module
return {
presence,
}
+1 -1
View File
@@ -1,4 +1,4 @@
--- @type Module
--- @type SetupInner
return {
require("config.rooms.bathroom"),
require("config.rooms.bedroom"),
+1 -2
View File
@@ -3,9 +3,9 @@ local devices = require("automation:devices")
local helper = require("config.helper")
local ntfy = require("config.ntfy")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local light = devices.LightOnOff.new({
name = "Light",
@@ -30,7 +30,6 @@ function module.setup(mqtt_client)
end,
})
--- @type Module
return {
light,
washer,
+14 -49
View File
@@ -4,12 +4,12 @@ local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local windows = require("config.windows")
--- @type Module
local module = {}
--- @type AirFilter?
local air_filter = nil
--- @type SetupFunction
function module.setup(mqtt_client)
local lights = devices.HueGroup.new({
identifier = "bedroom_lights",
@@ -25,13 +25,6 @@ function module.setup(mqtt_client)
group_id = 3,
scene_id = "60tfTyR168v2csz",
})
local wardrobe_light = devices.HueGroup.new({
identifier = "bedroom_lights_wardrobe",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 3,
scene_id = "1IDvpsN2YLZsDV95",
})
air_filter = devices.AirFilter.new({
name = "Air Filter",
@@ -39,36 +32,13 @@ function module.setup(mqtt_client)
url = "http://10.0.0.103",
})
local wardrobe_door = devices.ContactSensor.new({
name = "Wardrobe Door",
room = "Bedroom",
sensor_type = "Door",
topic = helper.mqtt_z2m("bedroom/wardrobe_door"),
client = mqtt_client,
callback = function(_, open)
-- Technically this has an edge case where if one of the spots is
-- on, but that is not something I ever do
if not lights:all_on() then
wardrobe_light:set_on(open)
end
end,
battery_callback = battery.callback,
})
local switch = devices.HueSwitch.new({
name = "Switch",
room = "Bedroom",
client = mqtt_client,
topic = helper.mqtt_z2m("bedroom/switch"),
left_callback = function()
local on = not lights:all_on()
lights:set_on(on)
-- This is a bit janky as the light will start to dim before turning
-- back on, however this is really and edge case that probably won't
-- happen often, so for now it's fine
if not on and wardrobe_door:open_percent() == 100 then
wardrobe_light:set_on(true)
end
lights:set_on(not lights:on())
end,
left_hold_callback = function()
lights_relax:set_on(true)
@@ -85,25 +55,20 @@ function module.setup(mqtt_client)
})
windows.add(window)
--- @type Module
return {
devices = {
lights,
lights_relax,
air_filter,
wardrobe_door,
switch,
window,
},
schedule = {
["0 0 19 * * *"] = function()
air_filter:set_on(true)
end,
["0 0 20 * * *"] = function()
air_filter:set_on(false)
end,
},
lights,
lights_relax,
air_filter,
switch,
window,
}
end
--- @param on boolean
function module.set_airfilter_on(on)
if air_filter then
air_filter:set_on(on)
end
end
return module
+1 -10
View File
@@ -4,9 +4,9 @@ local helper = require("config.helper")
local presence = require("config.presence")
local windows = require("config.windows")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local light = devices.LightOnOff.new({
name = "Light",
@@ -25,18 +25,9 @@ function module.setup(mqtt_client)
})
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
+20 -16
View File
@@ -8,9 +8,9 @@ local presence = require("config.presence")
local utils = require("automation:utils")
local windows = require("config.windows")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local main_light = devices.HueGroup.new({
identifier = "hallway_main_light",
@@ -59,21 +59,26 @@ function module.setup(mqtt_client)
})
hallway_automation.set_trash(trash)
local timeout = utils.Timeout.new()
local function frontdoor_presence(_, open)
if open then
timeout:cancel()
---@param duration number
---@return fun(_, open: boolean)
local function frontdoor_presence(duration)
local timeout = utils.Timeout.new()
if not presence.overall_presence() then
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), {
state = true,
updated = utils.get_epoch(),
})
return function(_, open)
if open then
timeout:cancel()
if presence.overall_presence() then
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), {
state = true,
updated = utils.get_epoch(),
})
end
else
timeout:start(duration, function()
mqtt_client:send_message(helper.mqtt_automation("presence/contact/frontdoor"), nil)
end)
end
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
@@ -84,7 +89,7 @@ function module.setup(mqtt_client)
topic = helper.mqtt_z2m("hallway/frontdoor"),
client = mqtt_client,
callback = {
frontdoor_presence,
frontdoor_presence(debug.debug_mode and 10 or 15 * 60),
hallway_automation.door_callback,
},
battery_callback = battery.callback,
@@ -92,7 +97,6 @@ function module.setup(mqtt_client)
windows.add(frontdoor)
hallway_automation.set_door(frontdoor)
--- @type Module
return {
main_light,
storage_light,
+1 -2
View File
@@ -3,9 +3,9 @@ local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local light = devices.HueGroup.new({
identifier = "hallway_top_light",
@@ -37,7 +37,6 @@ function module.setup(mqtt_client)
battery_callback = battery.callback,
})
--- @type Module
return {
light,
top_switch,
-1
View File
@@ -4,7 +4,6 @@ local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
--- @class KitchenModule: Module
local module = {}
--- @type HueGroup?
+1 -2
View File
@@ -5,9 +5,9 @@ local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
local windows = require("config.windows")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local lights = devices.HueGroup.new({
identifier = "living_lights",
@@ -109,7 +109,6 @@ function module.setup(mqtt_client)
})
windows.add(window)
--- @type Module
return {
lights,
lights_relax,
+1 -2
View File
@@ -3,9 +3,9 @@ local devices = require("automation:devices")
local helper = require("config.helper")
local presence = require("config.presence")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local light = devices.LightBrightness.new({
name = "Light",
@@ -31,7 +31,6 @@ function module.setup(mqtt_client)
battery_callback = battery.callback,
})
--- @type Module
return {
light,
door,
+1 -2
View File
@@ -5,9 +5,9 @@ local helper = require("config.helper")
local presence = require("config.presence")
local utils = require("automation:utils")
--- @type Module
local module = {}
--- @type SetupFunction
function module.setup(mqtt_client)
local charger = devices.OutletOnOff.new({
name = "Charger",
@@ -57,7 +57,6 @@ function module.setup(mqtt_client)
battery_callback = battery.callback,
})
--- @type Module
return {
charger,
outlets,
-1
View File
@@ -1,7 +1,6 @@
local ntfy = require("config.ntfy")
local presence = require("config.presence")
--- @class WindowsModule: Module
local module = {}
--- @class OnPresence
+43 -46
View File
@@ -6,9 +6,9 @@ local devices
---@class Action
---@field action
---| "broadcast"
---@field extras (table<string, string>)?
---@field extras table<string, string>?
---@field label string
---@field clear (boolean)?
---@field clear boolean?
local Action
---@class AirFilter: DeviceInterface, OnOffInterface
@@ -20,49 +20,49 @@ function devices.AirFilter.new(config) end
---@class AirFilterConfig
---@field name string
---@field room (string)?
---@field room string?
---@field url string
local AirFilterConfig
---@class ConfigLightLightStateBrightness
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field callback (fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[])?
---@field client (AsyncClient)?
---@field callback fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[]?
---@field client AsyncClient?
local ConfigLightLightStateBrightness
---@class ConfigLightLightStateColorTemperature
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field callback (fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[])?
---@field client (AsyncClient)?
---@field callback fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[]?
---@field client AsyncClient?
local ConfigLightLightStateColorTemperature
---@class ConfigLightLightStateOnOff
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field callback (fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[])?
---@field client (AsyncClient)?
---@field callback fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[]?
---@field client AsyncClient?
local ConfigLightLightStateOnOff
---@class ConfigOutletOutletStateOnOff
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field outlet_type (OutletType)?
---@field callback (fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[])?
---@field outlet_type OutletType?
---@field callback fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[]?
---@field client AsyncClient
local ConfigOutletOutletStateOnOff
---@class ConfigOutletOutletStatePower
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field outlet_type (OutletType)?
---@field callback (fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[])?
---@field outlet_type OutletType?
---@field callback fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[]?
---@field client AsyncClient
local ConfigOutletOutletStatePower
@@ -75,12 +75,12 @@ function devices.ContactSensor.new(config) end
---@class ContactSensorConfig
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field sensor_type (SensorType)?
---@field callback (fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[])?
---@field battery_callback (fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[])?
---@field client (AsyncClient)?
---@field sensor_type SensorType?
---@field callback fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[]?
---@field battery_callback fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[]?
---@field client AsyncClient?
local ContactSensorConfig
---@alias Flag
@@ -116,9 +116,6 @@ devices.HueGroup = {}
---@param config HueGroupConfig
---@return HueGroup
function devices.HueGroup.new(config) end
---@async
---@return boolean
function HueGroup:all_on() end
---@class HueGroupConfig
---@field identifier string
@@ -137,14 +134,14 @@ function devices.HueSwitch.new(config) end
---@class HueSwitchConfig
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field client AsyncClient
---@field left_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field right_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field left_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field right_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field battery_callback (fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[])?
---@field left_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]?
---@field right_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]?
---@field left_hold_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]?
---@field right_hold_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]?
---@field battery_callback fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[]?
local HueSwitchConfig
---@class IkeaRemote: DeviceInterface
@@ -156,12 +153,12 @@ function devices.IkeaRemote.new(config) end
---@class IkeaRemoteConfig
---@field name string
---@field room (string)?
---@field single_button (boolean)?
---@field room string?
---@field single_button boolean?
---@field topic string
---@field client AsyncClient
---@field callback (fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[])?
---@field battery_callback (fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[])?
---@field callback fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[]?
---@field battery_callback fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[]?
local IkeaRemoteConfig
---@class KasaOutlet: DeviceInterface, OnOffInterface
@@ -209,7 +206,7 @@ function devices.LightSensor.new(config) end
---@field topic string
---@field min integer
---@field max integer
---@field callback (fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[])?
---@field callback fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[]?
---@field client AsyncClient
local LightSensorConfig
@@ -230,10 +227,10 @@ local LightStateOnOff
---@class Notification
---@field title string
---@field message (string)?
---@field tags ((string)[])?
---@field priority (Priority)?
---@field actions ((Action)[])?
---@field message string?
---@field tags string[]?
---@field priority Priority?
---@field actions Action[]?
local Notification
---@class Ntfy: DeviceInterface
@@ -247,7 +244,7 @@ function devices.Ntfy.new(config) end
function Ntfy:send_notification(notification) end
---@class NtfyConfig
---@field url (string)?
---@field url string?
---@field topic string
local NtfyConfig
@@ -290,7 +287,7 @@ function Presence:overall_presence() end
---@class PresenceConfig
---@field topic string
---@field callback (fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[])?
---@field callback fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[]?
---@field client AsyncClient
local PresenceConfig
@@ -324,16 +321,16 @@ function devices.Washer.new(config) end
---@field identifier string
---@field topic string
---@field threshold number
---@field done_callback (fun(_: Washer) | fun(_: Washer)[])?
---@field done_callback fun(_: Washer) | fun(_: Washer)[]?
---@field client AsyncClient
local WasherConfig
---@class WolConfig
---@field name string
---@field room (string)?
---@field room string?
---@field topic string
---@field mac_address string
---@field broadcast_ip (string)?
---@field broadcast_ip string?
---@field client AsyncClient
local WolConfig
+8 -14
View File
@@ -3,26 +3,20 @@
---@class FulfillmentConfig
---@field openid_url string
---@field ip (string)?
---@field port (integer)?
---@field ip string?
---@field port integer?
local FulfillmentConfig
---@class Config
---@field fulfillment FulfillmentConfig
---@field modules (Module)[]
---@field modules Setup?
---@field mqtt MqttConfig
---@field schedule table<string, fun() | fun()[]>?
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
---@alias SetupFunction fun(mqtt_client: AsyncClient): SetupInner?
---@alias SetupInner (DeviceInterface | { setup: SetupFunction } | SetupInner)[]
---@alias Setup SetupFunction | SetupInner
---@class MqttConfig
---@field host string
@@ -30,7 +24,7 @@ local Module
---@field client_name string
---@field username string
---@field password string
---@field tls (boolean)?
---@field tls boolean?
local MqttConfig
---@class AsyncClient
-18
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}"
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
[toolchain]
channel = "nightly-2026-05-12"
channel = "nightly-2025-08-20"
components = ["rustfmt", "clippy", "rust-analyzer"]
profile = "minimal"
+6 -4
View File
@@ -6,6 +6,7 @@ use std::process;
use ::config::{Environment, File};
use automation::config::{Config, Setup};
use automation::schedule::start_scheduler;
use automation::secret::EnvironmentSecretFile;
use automation::version::VERSION;
use automation::web::{ApiError, User};
@@ -139,12 +140,13 @@ async fn app() -> anyhow::Result<()> {
let mqtt_client = mqtt::start(config.mqtt, &device_manager.event_channel());
let resolved = config.modules.resolve(&lua, &mqtt_client).await?;
for device in resolved.devices {
device_manager.add(device).await;
if let Some(modules) = config.modules {
for device in modules.setup(&lua, &mqtt_client).await? {
device_manager.add(device).await;
}
}
resolved.scheduler.start().await?;
start_scheduler(config.schedule).await?;
// Create google home fulfillment route
let fulfillment = Router::new().route("/google_home", post(fulfillment));
+22 -2
View File
@@ -1,8 +1,10 @@
use std::fs::{self, File};
use std::io::Write;
use automation::config::generate_definitions;
use automation::config::{Config, FulfillmentConfig, Setups};
use automation_lib::Module;
use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient};
use lua_typed::Typed;
use tracing::{info, warn};
extern crate automation_devices;
@@ -25,6 +27,24 @@ fn write_definitions(filename: &str, definitions: &str) -> std::io::Result<()> {
Ok(())
}
fn config_definitions() -> String {
let mut output = "---@meta\n\n".to_string();
output +=
&FulfillmentConfig::generate_full().expect("FulfillmentConfig should have a definition");
output += "\n";
output += &Config::generate_full().expect("Config should have a definition");
output += "\n";
output += &Setups::generate_full().expect("Setups should have a definition");
output += "\n";
output += &MqttConfig::generate_full().expect("MqttConfig should have a definition");
output += "\n";
output +=
&WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have a definition");
output
}
fn main() -> std::io::Result<()> {
tracing_subscriber::fmt::init();
@@ -39,7 +59,7 @@ fn main() -> std::io::Result<()> {
}
}
write_definitions("config.lua", &generate_definitions())?;
write_definitions("config.lua", &config_definitions())?;
Ok(())
}
+57 -211
View File
@@ -1,6 +1,5 @@
use std::collections::{HashMap, VecDeque};
use std::net::{Ipv4Addr, SocketAddr};
use std::ops::Deref;
use automation_lib::action_callback::ActionCallback;
use automation_lib::device::Device;
@@ -10,8 +9,6 @@ use lua_typed::Typed;
use mlua::FromLua;
use serde::Deserialize;
use crate::schedule::Scheduler;
#[derive(Debug, Deserialize)]
pub struct Setup {
#[serde(default = "default_entrypoint")]
@@ -37,219 +34,90 @@ pub struct FulfillmentConfig {
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<()>>);
pub struct Setups(mlua::Value);
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(
impl Setups {
pub async fn setup(
self,
lua: &mlua::Lua,
client: &WrappedAsyncClient,
) -> mlua::Result<Resolved> {
) -> mlua::Result<Vec<Box<dyn Device>>> {
let mut devices = Vec::new();
let mut scheduler = Scheduler::default();
let initial_table = match self.0 {
mlua::Value::Table(table) => table,
mlua::Value::Function(f) => f.call_async(client.clone()).await?,
_ => Err(mlua::Error::runtime(format!(
"Expected table or function, instead found: {}",
self.0.type_name()
)))?,
};
let mut modules: VecDeque<_> = self.0.into();
let mut queue: VecDeque<mlua::Table> = [initial_table].into();
loop {
let Some(module) = modules.pop_front() else {
let Some(table) = queue.pop_front() else {
break;
};
modules.extend(module.modules);
for pair in table.pairs() {
let (name, value): (String, _) = pair?;
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",
));
match value {
mlua::Value::Table(table) => queue.push_back(table),
mlua::Value::UserData(_)
if let Ok(device) = Box::from_lua(value.clone(), lua) =>
{
devices.push(device);
}
mlua::Value::Function(f) if name == "setup" => {
let value: mlua::Value = f.call_async(client.clone()).await?;
if let Some(table) = value.as_table() {
queue.push_back(table.clone());
}
}
_ => {}
}
}
devices.extend(module.devices);
for (cron, f) in module.schedule {
scheduler.add_job(cron, f);
}
}
Ok(Resolved { devices, scheduler })
Ok(devices)
}
}
#[derive(Debug, Default)]
pub struct Resolved {
pub devices: Vec<Box<dyn Device>>,
pub scheduler: Scheduler,
impl FromLua for Setups {
fn from_lua(value: mlua::Value, _lua: &mlua::Lua) -> mlua::Result<Self> {
Ok(Setups(value))
}
}
impl Typed for Setups {
fn type_name() -> String {
"Setup".into()
}
fn generate_header() -> Option<String> {
let type_name = Self::type_name();
let client_type = WrappedAsyncClient::type_name();
Some(format!(
r#"---@alias {type_name}Function fun(mqtt_client: {client_type}): {type_name}Inner?
---@alias {type_name}Inner (DeviceInterface | {{ setup: {type_name}Function }} | {type_name}Inner)[]
---@alias {type_name} {type_name}Function | {type_name}Inner
"#,
))
}
}
#[derive(Debug, LuaDeviceConfig, Typed)]
pub struct Config {
pub fulfillment: FulfillmentConfig,
#[device_config(from_lua, default)]
pub modules: Modules,
pub modules: Option<Setups>,
#[device_config(from_lua)]
pub mqtt: MqttConfig,
#[device_config(from_lua, default)]
#[typed(default)]
pub schedule: HashMap<String, ActionCallback<()>>,
}
impl From<FulfillmentConfig> for SocketAddr {
@@ -264,25 +132,3 @@ fn default_fulfillment_ip() -> Ipv4Addr {
fn default_fulfillment_port() -> u16 {
7878
}
pub fn generate_definitions() -> String {
let mut output = "---@meta\n\n".to_string();
output +=
&FulfillmentConfig::generate_full().expect("FulfillmentConfig should have a definition");
output += "\n";
output += &Config::generate_full().expect("Config should have a definition");
output += "\n";
output += &SetupFunction::generate_full().expect("SetupFunction should have a definition");
output += "\n";
output += &Schedule::generate_full().expect("Schedule should have a definition");
output += "\n";
output += &Module::generate_full().expect("Module should have a definition");
output += "\n";
output += &MqttConfig::generate_full().expect("MqttConfig should have a definition");
output += "\n";
output +=
&WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have a definition");
output
}
+2
View File
@@ -1,3 +1,5 @@
#![feature(if_let_guard)]
pub mod config;
pub mod schedule;
pub mod secret;
+20 -28
View File
@@ -1,37 +1,29 @@
use std::collections::HashMap;
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<()>)>,
}
pub async fn start_scheduler(
schedule: HashMap<String, ActionCallback<()>>,
) -> Result<(), JobSchedulerError> {
let scheduler = JobScheduler::new().await?;
impl Scheduler {
pub fn add_job(&mut self, cron: String, f: ActionCallback<()>) {
self.jobs.push((cron, f));
for (s, f) in schedule {
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?;
}
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
}
scheduler.start().await
}