Compare commits

27 Commits

Author SHA1 Message Date
8c6adae3ae fix: Chef cook uses wrong toolchain
All checks were successful
Build and deploy / build (push) Successful in 10m28s
Build and deploy / Deploy container (push) Successful in 40s
This adds a toolchain setup step to the base image so we do not have to
do it multiple times
2025-11-20 04:44:25 +01:00
2158bde1c2 chore: Upgraded to new workflow 2025-11-20 04:44:25 +01:00
b547f66d86 feat: Added 3d printer to guest room
All checks were successful
Build and deploy / build (push) Successful in 17m17s
Build and deploy / Deploy container (push) Successful in 3m19s
2025-11-16 16:37:12 +01:00
f3de8e36ea fix: Frontdoor presence is the wrong way around
All checks were successful
Build and deploy / build (push) Successful in 14m48s
Build and deploy / Deploy container (push) Successful in 1m59s
2025-10-30 20:56:49 +01:00
44f2c57819 fix: Set entrypoint lua path correctly in Dockerfile
All checks were successful
Build and deploy / build (push) Successful in 11m27s
Build and deploy / Deploy container (push) Successful in 29s
2025-10-22 05:17:02 +02:00
ad158f2c22 feat: Reduced visibility of config structs
All checks were successful
Build and deploy / build (push) Successful in 9m0s
Build and deploy / Deploy container (push) Successful in 49s
2025-10-22 04:13:54 +02:00
f36adf2f19 feat: Implement useful traits to simplify code 2025-10-22 04:09:01 +02:00
5947098bfb chore: Fix config type annotations
All checks were successful
Build and deploy / build (push) Successful in 12m30s
Build and deploy / Deploy container (push) Has been skipped
2025-10-22 03:59:59 +02:00
8a3143a3ea feat: Added type alias for setup and schedule types 2025-10-22 03:59:40 +02:00
9546585440 feat(config)!: Made schedule part of new modules
All checks were successful
Build and deploy / build (push) Successful in 11m57s
Build and deploy / Deploy container (push) Has been skipped
2025-10-22 03:24:34 +02:00
a938f3d71b feat(config)!: Improve config module resolution
All checks were successful
Build and deploy / build (push) Successful in 11m31s
Build and deploy / Deploy container (push) Has been skipped
The new system is slightly less flexible, but the code and lua
definitions is now a lot simpler and easier to understand.
In fact the old lua definition was not actually correct.

It is likely that existing configs require not/minimal tweaks to work
again.
2025-10-22 03:09:15 +02:00
a6c19eb9b4 fix: Fix issues with inner type definitions 2025-10-22 02:59:21 +02:00
7db628709a refactor: Split config
All checks were successful
Build and deploy / build (push) Successful in 10m56s
Build and deploy / Deploy container (push) Has been skipped
2025-10-20 05:02:19 +02:00
bc75f7005c 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 05:02:04 +02:00
2056c6c70d feat(config)!: Changed default config location 2025-10-20 04:48:33 +02:00
2fe9fbadfb feat(config)!: Remove device manager lua code
With the recent changes the device manager no longer needs to be
available in lua.
2025-10-20 04:48:33 +02:00
2db4af7427 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-20 04:48:32 +02:00
7b7279017f refactor: Restructured config to not rely on mqtt client being available
In preparation of changes to the mqtt client the config is rewritten to
use a device creation function for devices that need the mqtt client.

This also fixes a but where hallway_top_light was not actually added to
the device manager.
2025-10-20 04:48:29 +02:00
f05856cd0c feat(config)!: In config devices can now also be a (table of) function(s)
This function receives the mqtt client as an argument. In the future
this will be the only way to create devices that require the mqtt client.
2025-10-20 04:48:28 +02:00
02b6cf12a1 feat: Improved device conversion error message 2025-10-20 04:48:28 +02:00
02b87126e1 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-20 04:48:28 +02:00
1ffccd955c 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-20 04:48:28 +02:00
948380ea9b feat: Receive devices through config return 2025-10-20 04:48:28 +02:00
0c80cef5a1 feat: Ensure consistent ordering device definitions 2025-10-20 04:48:28 +02:00
84e8942fc9 feat: Generate definitions for config 2025-10-20 04:48:27 +02:00
b557afe2fc refactor: Move definition writing into separate function 2025-10-20 04:48:27 +02:00
5801421378 refactor: Move main.rs to bin/automation.rs 2025-10-20 04:48:22 +02:00
39 changed files with 1547 additions and 1008 deletions

View File

@@ -9,10 +9,10 @@ on:
jobs: jobs:
build: build:
uses: dreaded_x/workflows/.gitea/workflows/rust-kubernetes.yaml@22ee0c1788a8d2157db87d6a6f8dbe520fe48592 uses: dreaded_x/workflows/.gitea/workflows/docker-kubernetes.yaml@ef78704b98c72e4a6b8340f9bff7b085a7bdd95c
secrets: inherit secrets: inherit
with: with:
upload_manifests: false push_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 }} \
git.huizinga.dev/dreaded_x/automation_rs@${{ needs.build.outputs.digest }} $(echo ${{ toJSON(needs.build.outputs.images) }} | jq .automation -r)
docker network connect web automation_rs docker network connect web automation_rs

12
Cargo.lock generated
View File

@@ -144,6 +144,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"automation_cast", "automation_cast",
"automation_macro",
"bytes", "bytes",
"dyn-clone", "dyn-clone",
"futures", "futures",
@@ -554,7 +555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1162,17 +1163,16 @@ dependencies = [
[[package]] [[package]]
name = "lua_typed" name = "lua_typed"
version = "0.1.0" version = "0.1.0"
source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#f6a684291432aae2ef7109712882e7e3ed758d08" source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#3d29c9dd143737c8bffe4bacae8e701de3c6ee10"
dependencies = [ dependencies = [
"eui48", "eui48",
"lua_typed_macro", "lua_typed_macro",
"mlua",
] ]
[[package]] [[package]]
name = "lua_typed_macro" name = "lua_typed_macro"
version = "0.1.0" version = "0.1.0"
source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#f6a684291432aae2ef7109712882e7e3ed758d08" source = "git+https://git.huizinga.dev/Dreaded_X/lua_typed#3d29c9dd143737c8bffe4bacae8e701de3c6ee10"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"itertools", "itertools",
@@ -1567,7 +1567,7 @@ dependencies = [
"once_cell", "once_cell",
"socket2", "socket2",
"tracing", "tracing",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -1744,7 +1744,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]

View File

@@ -3,6 +3,8 @@ 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 . .
@@ -21,6 +23,7 @@ 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.lua ENV AUTOMATION__ENTRYPOINT=/app/config/config.lua
COPY ./config.lua /app/config.lua ENV LUA_PATH="/app/?.lua;;"
COPY ./config /app/config
CMD [ "/app/automation" ] CMD [ "/app/automation" ]

View File

@@ -4,6 +4,7 @@ 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 }

View File

@@ -1,34 +1,6 @@
use std::time::Duration;
use lua_typed::Typed; use lua_typed::Typed;
use rumqttc::{MqttOptions, Transport};
use serde::Deserialize; use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Typed)]
pub struct MqttConfig {
pub host: String,
pub port: u16,
pub client_name: String,
pub username: String,
pub password: String,
#[serde(default)]
pub tls: bool,
}
impl From<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, Deserialize, Typed)] #[derive(Debug, Clone, Deserialize, Typed)]
pub struct InfoConfig { pub struct InfoConfig {
pub name: String, pub name: String,

View File

@@ -27,7 +27,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::<Box<dyn Device>>() { let ud = if ud.is::<Self>() {
ud ud
} else { } else {
ud.call_method::<_>("__box", ())? ud.call_method::<_>("__box", ())?
@@ -36,7 +36,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::RuntimeError("Expected user data".into())), _ => Err(mlua::Error::runtime(format!(
"Expected user data, instead found: {}",
value.type_name()
))),
} }
} }
} }

View File

@@ -2,8 +2,6 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use futures::future::join_all; use futures::future::join_all;
use lua_typed::Typed;
use mlua::FromLua;
use tokio::sync::{RwLock, RwLockReadGuard}; use tokio::sync::{RwLock, RwLockReadGuard};
use tracing::{debug, instrument, trace}; use tracing::{debug, instrument, trace};
@@ -12,7 +10,7 @@ use crate::event::{Event, EventChannel, OnMqtt};
pub type DeviceMap = HashMap<String, Box<dyn Device>>; pub type DeviceMap = HashMap<String, Box<dyn Device>>;
#[derive(Clone, FromLua)] #[derive(Clone)]
pub struct DeviceManager { pub struct DeviceManager {
devices: Arc<RwLock<DeviceMap>>, devices: Arc<RwLock<DeviceMap>>,
event_channel: EventChannel, event_channel: EventChannel,
@@ -89,21 +87,3 @@ 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_method("event_channel", |_lua, this, ()| Ok(this.event_channel()))
}
}
impl Typed for DeviceManager {
fn type_name() -> String {
"DeviceManager".into()
}
}

View File

@@ -1,15 +1,41 @@
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::time::Duration;
use automation_macro::LuaDeviceConfig;
use lua_typed::Typed; use lua_typed::Typed;
use mlua::{FromLua, LuaSerdeExt}; use mlua::FromLua;
use rumqttc::{AsyncClient, Event, EventLoop, Incoming}; use rumqttc::{AsyncClient, Event, Incoming, MqttOptions, Transport};
use serde::Deserialize;
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::Module;
use crate::config::MqttConfig;
use crate::device_manager::DeviceManager;
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);
@@ -34,20 +60,6 @@ impl Typed for WrappedAsyncClient {
Some(output) Some(output)
} }
fn generate_footer() -> Option<String> {
let mut output = String::new();
let type_name = Self::type_name();
output += &format!("mqtt.{type_name} = {{}}\n");
output += &format!("---@param device_manager {}\n", DeviceManager::type_name());
output += &format!("---@param config {}\n", MqttConfig::type_name());
output += &format!("---@return {type_name}\n");
output += "function mqtt.new(device_manager, config) end\n";
Some(output)
}
} }
impl Deref for WrappedAsyncClient { impl Deref for WrappedAsyncClient {
@@ -90,8 +102,9 @@ impl mlua::UserData for WrappedAsyncClient {
} }
} }
pub fn start(mut eventloop: EventLoop, event_channel: &EventChannel) { pub fn start(config: MqttConfig, event_channel: &EventChannel) -> WrappedAsyncClient {
let tx = event_channel.get_tx(); let 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");
@@ -110,42 +123,6 @@ pub fn start(mut eventloop: EventLoop, event_channel: &EventChannel) {
} }
} }
}); });
WrappedAsyncClient(client)
} }
fn create_module(lua: &mlua::Lua) -> mlua::Result<mlua::Table> {
let mqtt = lua.create_table()?;
let mqtt_new = lua.create_function(
move |lua, (device_manager, config): (DeviceManager, mlua::Value)| {
let event_channel = device_manager.event_channel();
let config: MqttConfig = lua.from_value(config)?;
// Create a mqtt client
// TODO: When starting up, the devices are not yet created, this could lead to a device being out of sync
let (client, eventloop) = AsyncClient::new(config.into(), 100);
start(eventloop, &event_channel);
Ok(WrappedAsyncClient(client))
},
)?;
mqtt.set("new", mqtt_new)?;
Ok(mqtt)
}
fn generate_definitions() -> String {
let mut output = String::new();
output += "---@meta\n\nlocal mqtt\n\n";
output += &MqttConfig::generate_full().expect("WrappedAsyncClient should have generate_full");
output += "\n";
output +=
&WrappedAsyncClient::generate_full().expect("WrappedAsyncClient should have generate_full");
output += "\n";
output += "return mqtt";
output
}
inventory::submit! {Module::new("automation:mqtt", create_module, Some(generate_definitions))}

View File

@@ -1,760 +0,0 @@
local devices = require("automation:devices")
local device_manager = require("automation:device_manager")
local utils = require("automation:utils")
local secrets = require("automation:secrets")
local debug = require("automation:variables").debug and true or false
print(_VERSION)
local host = utils.get_hostname()
print("Running @" .. host)
--- @param topic string
--- @return string
local function mqtt_z2m(topic)
return "zigbee2mqtt/" .. topic
end
--- @param topic string
--- @return string
local function mqtt_automation(topic)
return "automation/" .. topic
end
local mqtt_client = require("automation:mqtt").new(device_manager, {
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
port = 8883,
client_name = "automation-" .. host,
username = "mqtt",
password = secrets.mqtt_password,
tls = host == "zeus" or host == "hephaestus",
})
local devs = {}
function devs:add(device)
table.insert(self, device)
end
local ntfy_topic = secrets.ntfy_topic
if ntfy_topic == nil then
error("Ntfy topic is not specified")
end
local ntfy = devices.Ntfy.new({
topic = ntfy_topic,
})
devs:add(ntfy)
--- @type {[string]: number}
local low_battery = {}
--- @param device DeviceInterface
--- @param battery number
local function check_battery(device, battery)
local id = device:get_id()
if battery < 15 then
print("Device '" .. id .. "' has low battery: " .. tostring(battery))
low_battery[id] = battery
else
low_battery[id] = nil
end
end
local function notify_low_battery()
-- Don't send notifications if there are now devices with low battery
if next(low_battery) == nil then
print("No devices with low battery")
return
end
local lines = {}
for name, battery in pairs(low_battery) do
table.insert(lines, name .. ": " .. tostring(battery) .. "%")
end
local message = table.concat(lines, "\n")
ntfy:send_notification({
title = "Low battery",
message = message,
tags = { "battery" },
priority = "default",
})
end
--- @class OnPresence
--- @field [integer] fun(presence: boolean)
local on_presence = {}
--- @param f fun(presence: boolean)
function on_presence:add(f)
self[#self + 1] = f
end
local presence_system = devices.Presence.new({
topic = mqtt_automation("presence/+/#"),
client = mqtt_client,
callback = function(_, presence)
for _, f in ipairs(on_presence) do
if type(f) == "function" then
f(presence)
end
end
end,
})
devs:add(presence_system)
on_presence:add(function(presence)
ntfy:send_notification({
title = "Presence",
message = presence and "Home" or "Away",
tags = { "house" },
priority = "low",
actions = {
{
action = "broadcast",
extras = {
cmd = "presence",
state = presence and "0" or "1",
},
label = presence and "Set away" or "Set home",
clear = true,
},
},
})
end)
on_presence:add(function(presence)
mqtt_client:send_message(mqtt_automation("debug") .. "/presence", {
state = presence,
updated = utils.get_epoch(),
})
end)
--- @class WindowSensor
--- @field [integer] OpenCloseInterface
local window_sensors = {}
--- @param sensor OpenCloseInterface
function window_sensors:add(sensor)
self[#self + 1] = sensor
end
on_presence:add(function(presence)
if not presence then
local open = {}
for _, sensor in ipairs(window_sensors) do
if sensor:open_percent() > 0 then
local id = sensor:get_id()
print("Open window detected: " .. id)
table.insert(open, id)
end
end
if #open > 0 then
local message = table.concat(open, "\n")
ntfy:send_notification({
title = "Windows are open",
message = message,
tags = { "window" },
priority = "high",
})
end
end
end)
--- @param device OnOffInterface
local function turn_off_when_away(device)
on_presence:add(function(presence)
if not presence then
device:set_on(false)
end
end)
end
--- @class OnLight
--- @field [integer] fun(light: boolean)
local on_light = {}
--- @param f fun(light: boolean)
function on_light:add(f)
self[#self + 1] = f
end
devs:add(devices.LightSensor.new({
identifier = "living_light_sensor",
topic = mqtt_z2m("living/light"),
client = mqtt_client,
min = 22000,
max = 23500,
callback = function(_, light)
for _, f in ipairs(on_light) do
if type(f) == "function" then
f(light)
end
end
end,
}))
on_light:add(function(light)
mqtt_client:send_message(mqtt_automation("debug") .. "/darkness", {
state = not light,
updated = utils.get_epoch(),
})
end)
local hue_ip = "10.0.0.102"
local hue_token = secrets.hue_token
if hue_token == nil then
error("Hue token is not specified")
end
local hue_bridge = devices.HueBridge.new({
identifier = "hue_bridge",
ip = hue_ip,
login = hue_token,
flags = {
presence = 41,
darkness = 43,
},
})
devs:add(hue_bridge)
on_light:add(function(light)
hue_bridge:set_flag("darkness", not light)
end)
on_presence:add(function(presence)
hue_bridge:set_flag("presence", presence)
end)
local kitchen_lights = devices.HueGroup.new({
identifier = "kitchen_lights",
ip = hue_ip,
login = hue_token,
group_id = 7,
scene_id = "7MJLG27RzeRAEVJ",
})
devs:add(kitchen_lights)
local living_lights = devices.HueGroup.new({
identifier = "living_lights",
ip = hue_ip,
login = hue_token,
group_id = 1,
scene_id = "SNZw7jUhQ3cXSjkj",
})
devs:add(living_lights)
local living_lights_relax = devices.HueGroup.new({
identifier = "living_lights",
ip = hue_ip,
login = hue_token,
group_id = 1,
scene_id = "eRJ3fvGHCcb6yNw",
})
devs:add(living_lights_relax)
devs:add(devices.HueSwitch.new({
name = "Switch",
room = "Living",
client = mqtt_client,
topic = mqtt_z2m("living/switch"),
left_callback = function()
kitchen_lights:set_on(not kitchen_lights:on())
end,
right_callback = function()
living_lights:set_on(not living_lights:on())
end,
right_hold_callback = function()
living_lights_relax:set_on(true)
end,
battery_callback = check_battery,
}))
devs:add(devices.WakeOnLAN.new({
name = "Zeus",
room = "Living Room",
topic = mqtt_automation("appliance/living_room/zeus"),
client = mqtt_client,
mac_address = "30:9c:23:60:9c:13",
broadcast_ip = "10.0.3.255",
}))
local living_mixer = devices.OutletOnOff.new({
name = "Mixer",
room = "Living Room",
topic = mqtt_z2m("living/mixer"),
client = mqtt_client,
})
turn_off_when_away(living_mixer)
devs:add(living_mixer)
local living_speakers = devices.OutletOnOff.new({
name = "Speakers",
room = "Living Room",
topic = mqtt_z2m("living/speakers"),
client = mqtt_client,
})
turn_off_when_away(living_speakers)
devs:add(living_speakers)
devs:add(devices.IkeaRemote.new({
name = "Remote",
room = "Living Room",
client = mqtt_client,
topic = mqtt_z2m("living/remote"),
single_button = true,
callback = function(_, on)
if on then
if living_mixer:on() then
living_mixer:set_on(false)
living_speakers:set_on(false)
else
living_mixer:set_on(true)
living_speakers:set_on(true)
end
else
if not living_mixer:on() then
living_mixer:set_on(true)
else
living_speakers:set_on(not living_speakers:on())
end
end
end,
battery_callback = check_battery,
}))
--- @return fun(self: OnOffInterface, state: {state: boolean, power: number})
local function kettle_timeout()
local timeout = utils.Timeout.new()
return function(self, state)
if state.state and state.power < 100 then
timeout:start(3, function()
self:set_on(false)
end)
else
timeout:cancel()
end
end
end
--- @type OutletPower
local kettle = devices.OutletPower.new({
outlet_type = "Kettle",
name = "Kettle",
room = "Kitchen",
topic = mqtt_z2m("kitchen/kettle"),
client = mqtt_client,
callback = kettle_timeout(),
})
turn_off_when_away(kettle)
devs:add(kettle)
--- @param on boolean
local function set_kettle(_, on)
kettle:set_on(on)
end
devs:add(devices.IkeaRemote.new({
name = "Remote",
room = "Bedroom",
client = mqtt_client,
topic = mqtt_z2m("bedroom/remote"),
single_button = true,
callback = set_kettle,
battery_callback = check_battery,
}))
devs:add(devices.IkeaRemote.new({
name = "Remote",
room = "Kitchen",
client = mqtt_client,
topic = mqtt_z2m("kitchen/remote"),
single_button = true,
callback = set_kettle,
battery_callback = check_battery,
}))
--- @param duration number
--- @return fun(self: OnOffInterface, state: {state: boolean})
local function off_timeout(duration)
local timeout = utils.Timeout.new()
return function(self, state)
if state.state then
timeout:start(duration, function()
self:set_on(false)
end)
else
timeout:cancel()
end
end
end
local bathroom_light = devices.LightOnOff.new({
name = "Light",
room = "Bathroom",
topic = mqtt_z2m("bathroom/light"),
client = mqtt_client,
callback = off_timeout(debug and 60 or 45 * 60),
})
devs:add(bathroom_light)
devs:add(devices.Washer.new({
identifier = "bathroom_washer",
topic = mqtt_z2m("bathroom/washer"),
client = mqtt_client,
threshold = 1,
done_callback = function()
ntfy:send_notification({
title = "Laundy is done",
message = "Don't forget to hang it!",
tags = { "womans_clothes" },
priority = "high",
})
end,
}))
devs:add(devices.OutletOnOff.new({
name = "Charger",
room = "Workbench",
topic = mqtt_z2m("workbench/charger"),
client = mqtt_client,
callback = off_timeout(debug and 5 or 20 * 3600),
}))
local workbench_outlet = devices.OutletOnOff.new({
name = "Outlet",
room = "Workbench",
topic = mqtt_z2m("workbench/outlet"),
client = mqtt_client,
})
turn_off_when_away(workbench_outlet)
devs:add(workbench_outlet)
local workbench_light = devices.LightColorTemperature.new({
name = "Light",
room = "Workbench",
topic = mqtt_z2m("workbench/light"),
client = mqtt_client,
})
turn_off_when_away(workbench_light)
devs:add(workbench_light)
local delay_color_temp = utils.Timeout.new()
devs:add(devices.IkeaRemote.new({
name = "Remote",
room = "Workbench",
client = mqtt_client,
topic = mqtt_z2m("workbench/remote"),
callback = function(_, on)
delay_color_temp:cancel()
if on then
workbench_light:set_brightness(82)
-- NOTE: This light does NOT support changing both the brightness and color
-- temperature at the same time, so we first change the brightness and once
-- that is complete we change the color temperature, as that is less likely
-- to have to actually change.
delay_color_temp:start(0.5, function()
workbench_light:set_color_temperature(3333)
end)
else
workbench_light:set_on(false)
end
end,
battery_callback = check_battery,
}))
local hallway_top_light = devices.HueGroup.new({
identifier = "hallway_top_light",
ip = hue_ip,
login = hue_token,
group_id = 83,
scene_id = "QeufkFDICEHWeKJ7",
})
devs:add(devices.HueSwitch.new({
name = "SwitchBottom",
room = "Hallway",
client = mqtt_client,
topic = mqtt_z2m("hallway/switchbottom"),
left_callback = function()
hallway_top_light:set_on(not hallway_top_light:on())
end,
battery_callback = check_battery,
}))
devs:add(devices.HueSwitch.new({
name = "SwitchTop",
room = "Hallway",
client = mqtt_client,
topic = mqtt_z2m("hallway/switchtop"),
left_callback = function()
hallway_top_light:set_on(not hallway_top_light:on())
end,
battery_callback = check_battery,
}))
local hallway_light_automation = {
timeout = utils.Timeout.new(),
forced = false,
trash = nil,
door = nil,
}
---@return fun(_, on: boolean)
function hallway_light_automation:switch_callback()
return function(_, on)
self.timeout:cancel()
self.group.set_on(on)
self.forced = on
end
end
---@return fun(_, open: boolean)
function hallway_light_automation:door_callback()
return function(_, open)
if open then
self.timeout:cancel()
self.group.set_on(true)
elseif not self.forced then
self.timeout:start(debug and 10 or 2 * 60, function()
if self.trash == nil or self.trash:open_percent() == 0 then
self.group.set_on(false)
end
end)
end
end
end
---@return fun(_, open: boolean)
function hallway_light_automation:trash_callback()
return function(_, open)
if open then
self.group.set_on(true)
else
if
not self.timeout:is_waiting()
and (self.door == nil or self.door:open_percent() == 0)
and not self.forced
then
self.group.set_on(false)
end
end
end
end
---@return fun(_, state: { on: boolean })
function hallway_light_automation:light_callback()
return function(_, state)
if
state.on
and (self.trash == nil or self.trash:open_percent()) == 0
and (self.door == nil or self.door:open_percent() == 0)
then
-- If the door and trash are not open, that means the light got turned on manually
self.timeout:cancel()
self.forced = true
elseif not state.on then
-- The light is never forced when it is off
self.forced = false
end
end
end
local hallway_storage = devices.LightBrightness.new({
name = "Storage",
room = "Hallway",
topic = mqtt_z2m("hallway/storage"),
client = mqtt_client,
callback = hallway_light_automation:light_callback(),
})
turn_off_when_away(hallway_storage)
devs:add(hallway_storage)
local hallway_bottom_lights = devices.HueGroup.new({
identifier = "hallway_bottom_lights",
ip = hue_ip,
login = hue_token,
group_id = 81,
scene_id = "3qWKxGVadXFFG4o",
})
devs:add(hallway_bottom_lights)
hallway_light_automation.group = {
set_on = function(on)
if on then
hallway_storage:set_brightness(80)
else
hallway_storage:set_on(false)
end
hallway_bottom_lights:set_on(on)
end,
}
---@param duration number
---@return fun(_, open: boolean)
local function presence(duration)
local timeout = utils.Timeout.new()
return function(_, open)
if open then
timeout:cancel()
if not presence_system:overall_presence() then
mqtt_client:send_message(mqtt_automation("presence/contact/frontdoor"), {
state = true,
updated = utils.get_epoch(),
})
end
else
timeout:start(duration, function()
mqtt_client:send_message(mqtt_automation("presence/contact/frontdoor"), nil)
end)
end
end
end
devs:add(devices.IkeaRemote.new({
name = "Remote",
room = "Hallway",
client = mqtt_client,
topic = mqtt_z2m("hallway/remote"),
callback = hallway_light_automation:switch_callback(),
battery_callback = check_battery,
}))
local hallway_frontdoor = devices.ContactSensor.new({
name = "Frontdoor",
room = "Hallway",
sensor_type = "Door",
topic = mqtt_z2m("hallway/frontdoor"),
client = mqtt_client,
callback = {
presence(debug and 10 or 15 * 60),
hallway_light_automation:door_callback(),
},
battery_callback = check_battery,
})
devs:add(hallway_frontdoor)
window_sensors:add(hallway_frontdoor)
hallway_light_automation.door = hallway_frontdoor
local hallway_trash = devices.ContactSensor.new({
name = "Trash",
room = "Hallway",
sensor_type = "Drawer",
topic = mqtt_z2m("hallway/trash"),
client = mqtt_client,
callback = hallway_light_automation:trash_callback(),
battery_callback = check_battery,
})
devs:add(hallway_trash)
hallway_light_automation.trash = hallway_trash
local guest_light = devices.LightOnOff.new({
name = "Light",
room = "Guest Room",
topic = mqtt_z2m("guest/light"),
client = mqtt_client,
})
turn_off_when_away(guest_light)
devs:add(guest_light)
local bedroom_air_filter = devices.AirFilter.new({
name = "Air Filter",
room = "Bedroom",
url = "http://10.0.0.103",
})
devs:add(bedroom_air_filter)
local bedroom_lights = devices.HueGroup.new({
identifier = "bedroom_lights",
ip = hue_ip,
login = hue_token,
group_id = 3,
scene_id = "PvRs-lGD4VRytL9",
})
devs:add(bedroom_lights)
local bedroom_lights_relax = devices.HueGroup.new({
identifier = "bedroom_lights",
ip = hue_ip,
login = hue_token,
group_id = 3,
scene_id = "60tfTyR168v2csz",
})
devs:add(bedroom_lights_relax)
devs:add(devices.HueSwitch.new({
name = "Switch",
room = "Bedroom",
client = mqtt_client,
topic = mqtt_z2m("bedroom/switch"),
left_callback = function()
bedroom_lights:set_on(not bedroom_lights:on())
end,
left_hold_callback = function()
bedroom_lights_relax:set_on(true)
end,
battery_callback = check_battery,
}))
local balcony = devices.ContactSensor.new({
name = "Balcony",
room = "Living Room",
sensor_type = "Door",
topic = mqtt_z2m("living/balcony"),
client = mqtt_client,
battery_callback = check_battery,
})
devs:add(balcony)
window_sensors:add(balcony)
local living_window = devices.ContactSensor.new({
name = "Window",
room = "Living Room",
topic = mqtt_z2m("living/window"),
client = mqtt_client,
battery_callback = check_battery,
})
devs:add(living_window)
window_sensors:add(living_window)
local bedroom_window = devices.ContactSensor.new({
name = "Window",
room = "Bedroom",
topic = mqtt_z2m("bedroom/window"),
client = mqtt_client,
battery_callback = check_battery,
})
devs:add(bedroom_window)
window_sensors:add(bedroom_window)
local guest_window = devices.ContactSensor.new({
name = "Window",
room = "Guest Room",
topic = mqtt_z2m("guest/window"),
client = mqtt_client,
battery_callback = check_battery,
})
devs:add(guest_window)
window_sensors:add(guest_window)
local storage_light = devices.LightBrightness.new({
name = "Light",
room = "Storage",
topic = mqtt_z2m("storage/light"),
client = mqtt_client,
})
turn_off_when_away(storage_light)
devs:add(storage_light)
devs:add(devices.ContactSensor.new({
name = "Door",
room = "Storage",
sensor_type = "Door",
topic = mqtt_z2m("storage/door"),
client = mqtt_client,
callback = function(_, open)
if open then
storage_light:set_brightness(100)
else
storage_light:set_on(false)
end
end,
battery_callback = check_battery,
}))
---@type Config
return {
fulfillment = {
openid_url = "https://login.huizinga.dev/api/oidc",
},
devices = devs,
schedule = {
["0 0 19 * * *"] = function()
bedroom_air_filter:set_on(true)
end,
["0 0 20 * * *"] = function()
bedroom_air_filter:set_on(false)
end,
["0 0 21 */1 * *"] = notify_low_battery,
},
}

47
config/battery.lua Normal file
View File

@@ -0,0 +1,47 @@
local ntfy = require("config.ntfy")
--- @class BatteryModule: Module
local module = {}
--- @type {[string]: number}
local low_battery = {}
--- @param device DeviceInterface
--- @param battery number
function module.callback(device, battery)
local id = device:get_id()
if battery < 15 then
print("Device '" .. id .. "' has low battery: " .. tostring(battery))
low_battery[id] = battery
else
low_battery[id] = nil
end
end
local function notify_low_battery()
-- Don't send notifications if there are now devices with low battery
if next(low_battery) == nil then
print("No devices with low battery")
return
end
local lines = {}
for name, battery in pairs(low_battery) do
table.insert(lines, name .. ": " .. tostring(battery) .. "%")
end
local message = table.concat(lines, "\n")
ntfy.send_notification({
title = "Low battery",
message = message,
tags = { "battery" },
priority = "default",
})
end
--- @type Schedule
module.schedule = {
["0 0 21 */1 * *"] = notify_low_battery,
}
return module

32
config/config.lua Normal file
View File

@@ -0,0 +1,32 @@
local utils = require("automation:utils")
local secrets = require("automation:secrets")
local host = utils.get_hostname()
print("Lua " .. _VERSION .. " running on " .. utils.get_hostname())
---@type Config
return {
fulfillment = {
openid_url = "https://login.huizinga.dev/api/oidc",
},
mqtt = {
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
port = 8883,
client_name = "automation-" .. host,
username = "mqtt",
password = secrets.mqtt_password,
tls = host == "zeus" or host == "hephaestus",
},
modules = {
require("config.battery"),
require("config.debug"),
require("config.hallway_automation"),
require("config.helper"),
require("config.hue_bridge"),
require("config.light"),
require("config.ntfy"),
require("config.presence"),
require("config.rooms"),
require("config.windows"),
},
}

35
config/debug.lua Normal file
View File

@@ -0,0 +1,35 @@
local helper = require("config.helper")
local light = require("config.light")
local presence = require("config.presence")
local utils = require("automation:utils")
local variables = require("automation:variables")
--- @class DebugModule: Module
local module = {}
if variables.debug == "true" then
module.debug_mode = true
elseif not variables.debug or variables.debug == "false" then
module.debug_mode = false
else
error("Variable debug has invalid value '" .. variables.debug .. "', expected 'true' or 'false'")
end
--- @type SetupFunction
function module.setup(mqtt_client)
presence.add_callback(function(p)
mqtt_client:send_message(helper.mqtt_automation("debug") .. "/presence", {
state = p,
updated = utils.get_epoch(),
})
end)
light.add_callback(function(l)
mqtt_client:send_message(helper.mqtt_automation("debug") .. "/darkness", {
state = not l,
updated = utils.get_epoch(),
})
end)
end
return module

View File

@@ -0,0 +1,85 @@
local debug = require("config.debug")
local utils = require("automation:utils")
--- @class HallwayAutomationModule: Module
local module = {}
local timeout = utils.Timeout.new()
local forced = false
--- @type OpenCloseInterface?
local trash = nil
--- @type OpenCloseInterface?
local door = nil
--- @type fun(on: boolean)[]
local callbacks = {}
--- @param on boolean
local function callback(on)
for _, f in ipairs(callbacks) do
f(on)
end
end
---@type fun(device: DeviceInterface, on: boolean)
function module.switch_callback(_, on)
timeout:cancel()
callback(on)
forced = on
end
---@type fun(device: DeviceInterface, open: boolean)
function module.door_callback(_, open)
if open then
timeout:cancel()
callback(true)
elseif not forced then
timeout:start(debug.debug_mode and 10 or 2 * 60, function()
if trash == nil or trash:open_percent() == 0 then
callback(false)
end
end)
end
end
---@type fun(device: DeviceInterface, open: boolean)
function module.trash_callback(_, open)
if open then
callback(true)
else
if not forced and not timeout:is_waiting() and (door == nil or door:open_percent() == 0) then
callback(false)
end
end
end
---@type fun(device: DeviceInterface, state: { state: boolean })
function module.light_callback(_, state)
print("LIGHT = " .. tostring(state.state))
if state.state and (trash == nil or trash:open_percent()) == 0 and (door == nil or door:open_percent() == 0) then
-- If the door and trash are not open, that means the light got turned on manually
timeout:cancel()
forced = true
elseif not state.state then
-- The light is never forced when it is off
forced = false
end
end
--- @param t OpenCloseInterface
function module.set_trash(t)
trash = t
end
--- @param d OpenCloseInterface
function module.set_door(d)
door = d
end
--- @param c fun(on: boolean)
function module.add_callback(c)
table.insert(callbacks, c)
end
return module

49
config/helper.lua Normal file
View File

@@ -0,0 +1,49 @@
local utils = require("automation:utils")
--- @class HelperModule: Module
local module = {}
--- @param topic string
--- @return string
function module.mqtt_z2m(topic)
return "zigbee2mqtt/" .. topic
end
--- @param topic string
--- @return string
function module.mqtt_automation(topic)
return "automation/" .. topic
end
--- @return fun(self: OnOffInterface, state: {state: boolean, power: number})
function module.auto_off()
local timeout = utils.Timeout.new()
return function(self, state)
if state.state and state.power < 100 then
timeout:start(3, function()
self:set_on(false)
end)
else
timeout:cancel()
end
end
end
--- @param duration number
--- @return fun(self: OnOffInterface, state: {state: boolean})
function module.off_timeout(duration)
local timeout = utils.Timeout.new()
return function(self, state)
if state.state then
timeout:start(duration, function()
self:set_on(false)
end)
else
timeout:cancel()
end
end
end
return module

41
config/hue_bridge.lua Normal file
View File

@@ -0,0 +1,41 @@
local devices = require("automation:devices")
local light = require("config.light")
local presence = require("config.presence")
local secrets = require("automation:secrets")
--- @class HueBridgeModule: Module
local module = {}
module.ip = "10.0.0.102"
module.token = secrets.hue_token
if module.token == nil then
error("Hue token is not specified")
end
--- @type SetupFunction
function module.setup()
local bridge = devices.HueBridge.new({
identifier = "hue_bridge",
ip = module.ip,
login = module.token,
flags = {
presence = 41,
darkness = 43,
},
})
light.add_callback(function(l)
bridge:set_flag("darkness", not l)
end)
presence.add_callback(function(p)
bridge:set_flag("presence", p)
end)
return {
bridge,
}
end
return module

44
config/light.lua Normal file
View File

@@ -0,0 +1,44 @@
local devices = require("automation:devices")
local helper = require("config.helper")
--- @class LightModule: Module
local module = {}
--- @class OnPresence
--- @field [integer] fun(light: boolean)
local callbacks = {}
--- @param callback fun(light: boolean)
function module.add_callback(callback)
table.insert(callbacks, callback)
end
--- @param _ DeviceInterface
--- @param light boolean
local function callback(_, light)
for _, f in ipairs(callbacks) do
f(light)
end
end
--- @type LightSensor?
module.device = nil
--- @type SetupFunction
function module.setup(mqtt_client)
module.device = devices.LightSensor.new({
identifier = "living_light_sensor",
topic = helper.mqtt_z2m("living/light"),
client = mqtt_client,
min = 22000,
max = 23500,
callback = callback,
})
--- @type Module
return {
module.device,
}
end
return module

34
config/ntfy.lua Normal file
View File

@@ -0,0 +1,34 @@
local devices = require("automation:devices")
local secrets = require("automation:secrets")
--- @class NtfyModule: Module
local module = {}
local ntfy_topic = secrets.ntfy_topic
if ntfy_topic == nil then
error("Ntfy topic is not specified")
end
--- @type Ntfy?
local ntfy = nil
--- @param notification Notification
function module.send_notification(notification)
if ntfy then
ntfy:send_notification(notification)
end
end
--- @type SetupFunction
function module.setup()
ntfy = devices.Ntfy.new({
topic = ntfy_topic,
})
--- @type Module
return {
ntfy,
}
end
return module

80
config/presence.lua Normal file
View File

@@ -0,0 +1,80 @@
local devices = require("automation:devices")
local helper = require("config.helper")
local ntfy = require("config.ntfy")
--- @class PresenceModule: Module
local module = {}
--- @class OnPresence
--- @field [integer] fun(presence: boolean)
local callbacks = {}
--- @param callback fun(presence: boolean)
function module.add_callback(callback)
table.insert(callbacks, callback)
end
--- @param device OnOffInterface
function module.turn_off_when_away(device)
module.add_callback(function(presence)
if not presence then
device:set_on(false)
end
end)
end
--- @param _ DeviceInterface
--- @param presence boolean
local function callback(_, presence)
for _, f in ipairs(callbacks) do
f(presence)
end
end
--- @type Presence?
local presence = nil
--- @type SetupFunction
function module.setup(mqtt_client)
presence = devices.Presence.new({
topic = helper.mqtt_automation("presence/+/#"),
client = mqtt_client,
callback = callback,
})
module.add_callback(function(p)
ntfy.send_notification({
title = "Presence",
message = p and "Home" or "Away",
tags = { "house" },
priority = "low",
actions = {
{
action = "broadcast",
extras = {
cmd = "presence",
state = p and "0" or "1",
},
label = p and "Set away" or "Set home",
clear = true,
},
},
})
end)
--- @type Module
return {
presence,
}
end
function module.overall_presence()
-- Default to no presence when the device has not been created yet
if not presence then
return false
end
return presence:overall_presence()
end
return module

12
config/rooms.lua Normal file
View File

@@ -0,0 +1,12 @@
--- @type Module
return {
require("config.rooms.bathroom"),
require("config.rooms.bedroom"),
require("config.rooms.guest_bedroom"),
require("config.rooms.hallway_bottom"),
require("config.rooms.hallway_top"),
require("config.rooms.kitchen"),
require("config.rooms.living_room"),
require("config.rooms.storage"),
require("config.rooms.workbench"),
}

40
config/rooms/bathroom.lua Normal file
View File

@@ -0,0 +1,40 @@
local debug = require("config.debug")
local devices = require("automation:devices")
local helper = require("config.helper")
local ntfy = require("config.ntfy")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.LightOnOff.new({
name = "Light",
room = "Bathroom",
topic = helper.mqtt_z2m("bathroom/light"),
client = mqtt_client,
callback = helper.off_timeout(debug.debug_mode and 60 or 45 * 60),
})
local washer = devices.Washer.new({
identifier = "bathroom_washer",
topic = helper.mqtt_z2m("bathroom/washer"),
client = mqtt_client,
threshold = 1,
done_callback = function()
ntfy.send_notification({
title = "Laundy is done",
message = "Don't forget to hang it!",
tags = { "womans_clothes" },
priority = "high",
})
end,
})
--- @type Module
return {
light,
washer,
}
end
return module

78
config/rooms/bedroom.lua Normal file
View File

@@ -0,0 +1,78 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local windows = require("config.windows")
--- @type Module
local module = {}
--- @type AirFilter?
local air_filter = nil
function module.setup(mqtt_client)
local lights = devices.HueGroup.new({
identifier = "bedroom_lights",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 3,
scene_id = "PvRs-lGD4VRytL9",
})
local lights_relax = devices.HueGroup.new({
identifier = "bedroom_lights_relax",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 3,
scene_id = "60tfTyR168v2csz",
})
air_filter = devices.AirFilter.new({
name = "Air Filter",
room = "Bedroom",
url = "http://10.0.0.103",
})
local switch = devices.HueSwitch.new({
name = "Switch",
room = "Bedroom",
client = mqtt_client,
topic = helper.mqtt_z2m("bedroom/switch"),
left_callback = function()
lights:set_on(not lights:on())
end,
left_hold_callback = function()
lights_relax:set_on(true)
end,
battery_callback = battery.callback,
})
local window = devices.ContactSensor.new({
name = "Window",
room = "Bedroom",
topic = helper.mqtt_z2m("bedroom/window"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(window)
--- @type Module
return {
devices = {
lights,
lights_relax,
air_filter,
switch,
window,
},
schedule = {
["0 0 19 * * *"] = function()
air_filter:set_on(true)
end,
["0 0 20 * * *"] = function()
air_filter:set_on(false)
end,
},
}
end
return module

View File

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

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

@@ -0,0 +1,48 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.HueGroup.new({
identifier = "hallway_top_light",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 83,
scene_id = "QeufkFDICEHWeKJ7",
})
local top_switch = devices.HueSwitch.new({
name = "SwitchTop",
room = "Hallway",
client = mqtt_client,
topic = helper.mqtt_z2m("hallway/switchtop"),
left_callback = function()
light:set_on(not light:on())
end,
battery_callback = battery.callback,
})
local bottom_switch = devices.HueSwitch.new({
name = "SwitchBottom",
room = "Hallway",
client = mqtt_client,
topic = helper.mqtt_z2m("hallway/switchbottom"),
left_callback = function()
light:set_on(not light:on())
end,
battery_callback = battery.callback,
})
--- @type Module
return {
light,
top_switch,
bottom_switch,
}
end
return module

71
config/rooms/kitchen.lua Normal file
View File

@@ -0,0 +1,71 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
--- @class KitchenModule: Module
local module = {}
--- @type HueGroup?
local lights = nil
--- @type SetupFunction
function module.setup(mqtt_client)
lights = devices.HueGroup.new({
identifier = "kitchen_lights",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 7,
scene_id = "7MJLG27RzeRAEVJ",
})
local kettle = devices.OutletPower.new({
outlet_type = "Kettle",
name = "Kettle",
room = "Kitchen",
topic = helper.mqtt_z2m("kitchen/kettle"),
client = mqtt_client,
callback = helper.auto_off(),
})
presence.turn_off_when_away(kettle)
local kettle_remote = devices.IkeaRemote.new({
name = "Remote",
room = "Kitchen",
client = mqtt_client,
topic = helper.mqtt_z2m("kitchen/remote"),
single_button = true,
callback = function(_, on)
kettle:set_on(on)
end,
battery_callback = battery.callback,
})
local kettle_remote_bedroom = devices.IkeaRemote.new({
name = "Remote",
room = "Bedroom",
client = mqtt_client,
topic = helper.mqtt_z2m("bedroom/remote"),
single_button = true,
callback = function(_, on)
kettle:set_on(on)
end,
battery_callback = battery.callback,
})
return {
lights,
kettle,
kettle_remote,
kettle_remote_bedroom,
}
end
function module.toggle_lights()
if lights then
lights:set_on(not lights:on())
end
end
return module

View File

@@ -0,0 +1,126 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local hue_bridge = require("config.hue_bridge")
local presence = require("config.presence")
local windows = require("config.windows")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local lights = devices.HueGroup.new({
identifier = "living_lights",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 1,
scene_id = "SNZw7jUhQ3cXSjkj",
})
local lights_relax = devices.HueGroup.new({
identifier = "living_lights_relax",
ip = hue_bridge.ip,
login = hue_bridge.token,
group_id = 1,
scene_id = "eRJ3fvGHCcb6yNw",
})
local switch = devices.HueSwitch.new({
name = "Switch",
room = "Living",
client = mqtt_client,
topic = helper.mqtt_z2m("living/switch"),
left_callback = require("config.rooms.kitchen").toggle_lights,
right_callback = function()
lights:set_on(not lights:on())
end,
right_hold_callback = function()
lights_relax:set_on(true)
end,
battery_callback = battery.callback,
})
local pc = devices.WakeOnLAN.new({
name = "Zeus",
room = "Living Room",
topic = helper.mqtt_automation("appliance/living_room/zeus"),
client = mqtt_client,
mac_address = "30:9c:23:60:9c:13",
broadcast_ip = "10.0.3.255",
})
local mixer = devices.OutletOnOff.new({
name = "Mixer",
room = "Living Room",
topic = helper.mqtt_z2m("living/mixer"),
client = mqtt_client,
})
presence.turn_off_when_away(mixer)
local speakers = devices.OutletOnOff.new({
name = "Speakers",
room = "Living Room",
topic = helper.mqtt_z2m("living/speakers"),
client = mqtt_client,
})
presence.turn_off_when_away(speakers)
local audio_remote = devices.IkeaRemote.new({
name = "Remote",
room = "Living Room",
client = mqtt_client,
topic = helper.mqtt_z2m("living/remote"),
single_button = true,
callback = function(_, on)
if on then
if mixer:on() then
mixer:set_on(false)
speakers:set_on(false)
else
mixer:set_on(true)
speakers:set_on(true)
end
else
if not mixer:on() then
mixer:set_on(true)
else
speakers:set_on(not speakers:on())
end
end
end,
battery_callback = battery.callback,
})
local balcony = devices.ContactSensor.new({
name = "Balcony",
room = "Living Room",
sensor_type = "Door",
topic = helper.mqtt_z2m("living/balcony"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(balcony)
local window = devices.ContactSensor.new({
name = "Window",
room = "Living Room",
topic = helper.mqtt_z2m("living/window"),
client = mqtt_client,
battery_callback = battery.callback,
})
windows.add(window)
--- @type Module
return {
lights,
lights_relax,
switch,
pc,
mixer,
speakers,
audio_remote,
balcony,
window,
}
end
return module

41
config/rooms/storage.lua Normal file
View File

@@ -0,0 +1,41 @@
local battery = require("config.battery")
local devices = require("automation:devices")
local helper = require("config.helper")
local presence = require("config.presence")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.LightBrightness.new({
name = "Light",
room = "Storage",
topic = helper.mqtt_z2m("storage/light"),
client = mqtt_client,
})
presence.turn_off_when_away(light)
local door = devices.ContactSensor.new({
name = "Door",
room = "Storage",
sensor_type = "Door",
topic = helper.mqtt_z2m("storage/door"),
client = mqtt_client,
callback = function(_, open)
if open then
light:set_brightness(100)
else
light:set_on(false)
end
end,
battery_callback = battery.callback,
})
--- @type Module
return {
light,
door,
}
end
return module

View File

@@ -0,0 +1,69 @@
local battery = require("config.battery")
local debug = require("config.debug")
local devices = require("automation:devices")
local helper = require("config.helper")
local presence = require("config.presence")
local utils = require("automation:utils")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local charger = devices.OutletOnOff.new({
name = "Charger",
room = "Workbench",
topic = helper.mqtt_z2m("workbench/charger"),
client = mqtt_client,
callback = helper.off_timeout(debug.debug_mode and 5 or 20 * 3600),
})
local outlets = devices.OutletOnOff.new({
name = "Outlets",
room = "Workbench",
topic = helper.mqtt_z2m("workbench/outlet"),
client = mqtt_client,
})
presence.turn_off_when_away(outlets)
local light = devices.LightColorTemperature.new({
name = "Light",
room = "Workbench",
topic = helper.mqtt_z2m("workbench/light"),
client = mqtt_client,
})
presence.turn_off_when_away(light)
local delay_color_temp = utils.Timeout.new()
local remote = devices.IkeaRemote.new({
name = "Remote",
room = "Workbench",
client = mqtt_client,
topic = helper.mqtt_z2m("workbench/remote"),
callback = function(_, on)
delay_color_temp:cancel()
if on then
light:set_brightness(82)
-- NOTE: This light does NOT support changing both the brightness and color
-- temperature at the same time, so we first change the brightness and once
-- that is complete we change the color temperature, as that is less likely
-- to have to actually change.
delay_color_temp:start(0.5, function()
light:set_color_temperature(3333)
end)
else
light:set_on(false)
end
end,
battery_callback = battery.callback,
})
--- @type Module
return {
charger,
outlets,
light,
remote,
}
end
return module

43
config/windows.lua Normal file
View File

@@ -0,0 +1,43 @@
local ntfy = require("config.ntfy")
local presence = require("config.presence")
--- @class WindowsModule: Module
local module = {}
--- @class OnPresence
--- @field [integer] OpenCloseInterface
local sensors = {}
--- @param sensor OpenCloseInterface
function module.add(sensor)
table.insert(sensors, sensor)
end
--- @type SetupFunction
function module.setup()
presence.add_callback(function(p)
if not p then
local open = {}
for _, sensor in ipairs(sensors) do
if sensor:open_percent() > 0 then
local id = sensor:get_id()
print("Open window detected: " .. id)
table.insert(open, id)
end
end
if #open > 0 then
local message = table.concat(open, "\n")
ntfy.send_notification({
title = "Windows are open",
message = message,
tags = { "window" },
priority = "high",
})
end
end
end)
end
return module

View File

@@ -1,8 +0,0 @@
---@meta
---@class DeviceManager
local DeviceManager
---@param device DeviceInterface
function DeviceManager:add(device) end
return DeviceManager

View File

@@ -6,9 +6,9 @@ local devices
---@class Action ---@class Action
---@field action ---@field action
---| "broadcast" ---| "broadcast"
---@field extras table<string, string>? ---@field extras (table<string, string>)?
---@field label string ---@field label string
---@field clear boolean? ---@field clear (boolean)?
local Action local Action
---@class AirFilter: DeviceInterface, OnOffInterface ---@class AirFilter: DeviceInterface, OnOffInterface
@@ -20,49 +20,49 @@ function devices.AirFilter.new(config) end
---@class AirFilterConfig ---@class AirFilterConfig
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field url string ---@field url string
local AirFilterConfig local AirFilterConfig
---@class ConfigLightLightStateBrightness ---@class ConfigLightLightStateBrightness
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field callback fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[]? ---@field callback (fun(_: LightBrightness, _: LightStateBrightness) | fun(_: LightBrightness, _: LightStateBrightness)[])?
---@field client AsyncClient? ---@field client (AsyncClient)?
local ConfigLightLightStateBrightness local ConfigLightLightStateBrightness
---@class ConfigLightLightStateColorTemperature ---@class ConfigLightLightStateColorTemperature
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field callback fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[]? ---@field callback (fun(_: LightColorTemperature, _: LightStateColorTemperature) | fun(_: LightColorTemperature, _: LightStateColorTemperature)[])?
---@field client AsyncClient? ---@field client (AsyncClient)?
local ConfigLightLightStateColorTemperature local ConfigLightLightStateColorTemperature
---@class ConfigLightLightStateOnOff ---@class ConfigLightLightStateOnOff
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field callback fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[]? ---@field callback (fun(_: LightOnOff, _: LightStateOnOff) | fun(_: LightOnOff, _: LightStateOnOff)[])?
---@field client AsyncClient? ---@field client (AsyncClient)?
local ConfigLightLightStateOnOff local ConfigLightLightStateOnOff
---@class ConfigOutletOutletStateOnOff ---@class ConfigOutletOutletStateOnOff
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field outlet_type OutletType? ---@field outlet_type (OutletType)?
---@field callback fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[]? ---@field callback (fun(_: OutletOnOff, _: OutletStateOnOff) | fun(_: OutletOnOff, _: OutletStateOnOff)[])?
---@field client AsyncClient ---@field client AsyncClient
local ConfigOutletOutletStateOnOff local ConfigOutletOutletStateOnOff
---@class ConfigOutletOutletStatePower ---@class ConfigOutletOutletStatePower
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field outlet_type OutletType? ---@field outlet_type (OutletType)?
---@field callback fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[]? ---@field callback (fun(_: OutletPower, _: OutletStatePower) | fun(_: OutletPower, _: OutletStatePower)[])?
---@field client AsyncClient ---@field client AsyncClient
local ConfigOutletOutletStatePower local ConfigOutletOutletStatePower
@@ -75,12 +75,12 @@ function devices.ContactSensor.new(config) end
---@class ContactSensorConfig ---@class ContactSensorConfig
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field sensor_type SensorType? ---@field sensor_type (SensorType)?
---@field callback fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[]? ---@field callback (fun(_: ContactSensor, _: boolean) | fun(_: ContactSensor, _: boolean)[])?
---@field battery_callback fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[]? ---@field battery_callback (fun(_: ContactSensor, _: number) | fun(_: ContactSensor, _: number)[])?
---@field client AsyncClient? ---@field client (AsyncClient)?
local ContactSensorConfig local ContactSensorConfig
---@alias Flag ---@alias Flag
@@ -134,14 +134,14 @@ function devices.HueSwitch.new(config) end
---@class HueSwitchConfig ---@class HueSwitchConfig
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field client AsyncClient ---@field client AsyncClient
---@field left_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ---@field left_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field right_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ---@field right_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field left_hold_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ---@field left_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field right_hold_callback fun(_: HueSwitch) | fun(_: HueSwitch)[]? ---@field right_hold_callback (fun(_: HueSwitch) | fun(_: HueSwitch)[])?
---@field battery_callback fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[]? ---@field battery_callback (fun(_: HueSwitch, _: number) | fun(_: HueSwitch, _: number)[])?
local HueSwitchConfig local HueSwitchConfig
---@class IkeaRemote: DeviceInterface ---@class IkeaRemote: DeviceInterface
@@ -153,12 +153,12 @@ function devices.IkeaRemote.new(config) end
---@class IkeaRemoteConfig ---@class IkeaRemoteConfig
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field single_button boolean? ---@field single_button (boolean)?
---@field topic string ---@field topic string
---@field client AsyncClient ---@field client AsyncClient
---@field callback fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[]? ---@field callback (fun(_: IkeaRemote, _: boolean) | fun(_: IkeaRemote, _: boolean)[])?
---@field battery_callback fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[]? ---@field battery_callback (fun(_: IkeaRemote, _: number) | fun(_: IkeaRemote, _: number)[])?
local IkeaRemoteConfig local IkeaRemoteConfig
---@class KasaOutlet: DeviceInterface, OnOffInterface ---@class KasaOutlet: DeviceInterface, OnOffInterface
@@ -206,7 +206,7 @@ function devices.LightSensor.new(config) end
---@field topic string ---@field topic string
---@field min integer ---@field min integer
---@field max integer ---@field max integer
---@field callback fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[]? ---@field callback (fun(_: LightSensor, _: boolean) | fun(_: LightSensor, _: boolean)[])?
---@field client AsyncClient ---@field client AsyncClient
local LightSensorConfig local LightSensorConfig
@@ -227,10 +227,10 @@ local LightStateOnOff
---@class Notification ---@class Notification
---@field title string ---@field title string
---@field message string? ---@field message (string)?
---@field tags string[]? ---@field tags ((string)[])?
---@field priority Priority? ---@field priority (Priority)?
---@field actions Action[]? ---@field actions ((Action)[])?
local Notification local Notification
---@class Ntfy: DeviceInterface ---@class Ntfy: DeviceInterface
@@ -244,7 +244,7 @@ function devices.Ntfy.new(config) end
function Ntfy:send_notification(notification) end function Ntfy:send_notification(notification) end
---@class NtfyConfig ---@class NtfyConfig
---@field url string? ---@field url (string)?
---@field topic string ---@field topic string
local NtfyConfig local NtfyConfig
@@ -287,7 +287,7 @@ function Presence:overall_presence() end
---@class PresenceConfig ---@class PresenceConfig
---@field topic string ---@field topic string
---@field callback fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[]? ---@field callback (fun(_: Presence, _: boolean) | fun(_: Presence, _: boolean)[])?
---@field client AsyncClient ---@field client AsyncClient
local PresenceConfig local PresenceConfig
@@ -321,16 +321,16 @@ function devices.Washer.new(config) end
---@field identifier string ---@field identifier string
---@field topic string ---@field topic string
---@field threshold number ---@field threshold number
---@field done_callback fun(_: Washer) | fun(_: Washer)[]? ---@field done_callback (fun(_: Washer) | fun(_: Washer)[])?
---@field client AsyncClient ---@field client AsyncClient
local WasherConfig local WasherConfig
---@class WolConfig ---@class WolConfig
---@field name string ---@field name string
---@field room string? ---@field room (string)?
---@field topic string ---@field topic string
---@field mac_address string ---@field mac_address string
---@field broadcast_ip string? ---@field broadcast_ip (string)?
---@field client AsyncClient ---@field client AsyncClient
local WolConfig local WolConfig

View File

@@ -1,27 +0,0 @@
-- DO NOT MODIFY, FILE IS AUTOMATICALLY GENERATED
---@meta
local mqtt
---@class MqttConfig
---@field host string
---@field port integer
---@field client_name string
---@field username string
---@field password string
---@field tls boolean
local MqttConfig
---@class AsyncClient
local AsyncClient
---@async
---@param topic string
---@param message table?
function AsyncClient:send_message(topic, message) end
mqtt.AsyncClient = {}
---@param device_manager DeviceManager
---@param config MqttConfig
---@return AsyncClient
function mqtt.new(device_manager, config) end
return mqtt

View File

@@ -3,12 +3,39 @@
---@class FulfillmentConfig ---@class FulfillmentConfig
---@field openid_url string ---@field openid_url string
---@field ip string? ---@field ip (string)?
---@field port integer? ---@field port (integer)?
local FulfillmentConfig local FulfillmentConfig
---@class Config ---@class Config
---@field fulfillment FulfillmentConfig ---@field fulfillment FulfillmentConfig
---@field devices DeviceInterface[]? ---@field modules (Module)[]
---@field schedule table<string, fun() | fun()[]>? ---@field mqtt MqttConfig
local Config 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

18
docker-bake.hcl Normal file
View File

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

@@ -6,11 +6,11 @@ use std::process;
use ::config::{Environment, File}; use ::config::{Environment, File};
use automation::config::{Config, Setup}; use automation::config::{Config, Setup};
use automation::schedule::start_scheduler;
use automation::secret::EnvironmentSecretFile; use automation::secret::EnvironmentSecretFile;
use automation::version::VERSION; use automation::version::VERSION;
use automation::web::{ApiError, User}; use automation::web::{ApiError, User};
use automation_lib::device_manager::DeviceManager; use automation_lib::device_manager::DeviceManager;
use automation_lib::mqtt;
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;
@@ -131,19 +131,20 @@ async fn app() -> anyhow::Result<()> {
automation_lib::load_modules(&lua)?; automation_lib::load_modules(&lua)?;
lua.register_module("automation:device_manager", device_manager.clone())?;
lua.register_module("automation:variables", lua.to_value(&setup.variables)?)?; lua.register_module("automation:variables", lua.to_value(&setup.variables)?)?;
lua.register_module("automation:secrets", lua.to_value(&setup.secrets)?)?; lua.register_module("automation:secrets", lua.to_value(&setup.secrets)?)?;
let entrypoint = Path::new(&setup.entrypoint); let entrypoint = Path::new(&setup.entrypoint);
let config: Config = lua.load(entrypoint).eval_async().await?; let config: Config = lua.load(entrypoint).eval_async().await?;
for device in config.devices { 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; device_manager.add(device).await;
} }
start_scheduler(config.schedule).await?; resolved.scheduler.start().await?;
// 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));

View File

@@ -1,9 +1,8 @@
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::Write; use std::io::Write;
use automation::config::{Config, FulfillmentConfig}; use automation::config::generate_definitions;
use automation_lib::Module; use automation_lib::Module;
use lua_typed::Typed;
use tracing::{info, warn}; use tracing::{info, warn};
extern crate automation_devices; extern crate automation_devices;
@@ -26,17 +25,6 @@ fn write_definitions(filename: &str, definitions: &str) -> std::io::Result<()> {
Ok(()) 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("FulfillmentConfig should have a definition");
output
}
fn main() -> std::io::Result<()> { fn main() -> std::io::Result<()> {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@@ -51,7 +39,7 @@ fn main() -> std::io::Result<()> {
} }
} }
write_definitions("config.lua", &config_definitions())?; write_definitions("config.lua", &generate_definitions())?;
Ok(()) Ok(())
} }

View File

@@ -1,12 +1,17 @@
use std::collections::HashMap; use std::collections::{HashMap, VecDeque};
use std::net::{Ipv4Addr, SocketAddr}; use std::net::{Ipv4Addr, SocketAddr};
use std::ops::Deref;
use automation_lib::action_callback::ActionCallback; use automation_lib::action_callback::ActionCallback;
use automation_lib::device::Device; use automation_lib::device::Device;
use automation_lib::mqtt::{MqttConfig, WrappedAsyncClient};
use automation_macro::LuaDeviceConfig; use automation_macro::LuaDeviceConfig;
use lua_typed::Typed; 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 Setup {
#[serde(default = "default_entrypoint")] #[serde(default = "default_entrypoint")]
@@ -18,7 +23,7 @@ pub struct Setup {
} }
fn default_entrypoint() -> String { fn default_entrypoint() -> String {
"./config.lua".into() "./config/config.lua".into()
} }
#[derive(Debug, Deserialize, Typed)] #[derive(Debug, Deserialize, Typed)]
@@ -32,15 +37,219 @@ pub struct FulfillmentConfig {
pub port: u16, 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)] #[derive(Debug, LuaDeviceConfig, Typed)]
pub struct Config { pub struct Config {
pub fulfillment: FulfillmentConfig, pub fulfillment: FulfillmentConfig,
#[device_config(from_lua, default)] #[device_config(from_lua, default)]
#[typed(default)] pub modules: Modules,
pub devices: Vec<Box<dyn Device>>, #[device_config(from_lua)]
#[device_config(from_lua, default)] pub mqtt: MqttConfig,
#[typed(default)]
pub schedule: HashMap<String, ActionCallback<()>>,
} }
impl From<FulfillmentConfig> for SocketAddr { impl From<FulfillmentConfig> for SocketAddr {
@@ -55,3 +264,25 @@ fn default_fulfillment_ip() -> Ipv4Addr {
fn default_fulfillment_port() -> u16 { fn default_fulfillment_port() -> u16 {
7878 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,3 +1,5 @@
#![feature(if_let_guard)]
pub mod config; pub mod config;
pub mod schedule; pub mod schedule;
pub mod secret; pub mod secret;

View File

@@ -1,15 +1,22 @@
use std::collections::HashMap;
use std::pin::Pin; use std::pin::Pin;
use automation_lib::action_callback::ActionCallback; use automation_lib::action_callback::ActionCallback;
use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError}; use tokio_cron_scheduler::{Job, JobScheduler, JobSchedulerError};
pub async fn start_scheduler( #[derive(Debug, Default)]
schedule: HashMap<String, ActionCallback<()>>, pub struct Scheduler {
) -> Result<(), JobSchedulerError> { 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?; let scheduler = JobScheduler::new().await?;
for (s, f) in schedule { for (s, f) in self.jobs {
let job = { let job = {
move |_uuid, _lock| -> Pin<Box<dyn Future<Output = ()> + Send>> { move |_uuid, _lock| -> Pin<Box<dyn Future<Output = ()> + Send>> {
let f = f.clone(); let f = f.clone();
@@ -27,3 +34,4 @@ pub async fn start_scheduler(
scheduler.start().await scheduler.start().await
} }
}