Compare commits

...

14 Commits

Author SHA1 Message Date
Dreaded_X b30988f869 feat: Sync printer light with room light
Build and deploy / build (push) Successful in 18m25s
Build and deploy / Deploy container (push) Successful in 37s
2026-06-20 00:44:21 +02:00
Dreaded_X aacb11aba3 feat: Add basic support for bambu printer 2026-06-20 00:44:20 +02:00
Dreaded_X 0ef3f33746 feat: Build fully static binary 2026-06-19 21:36:30 +02:00
Dreaded_X d353fa9759 fix: MQTT broke after internal domain change
Build and deploy / build (push) Successful in 14m8s
Build and deploy / Deploy container (push) Successful in 5m38s
2026-06-19 03:46:26 +02:00
Dreaded_X 9979ab3446 fix: Deploy requires login now
Build and deploy / build (push) Successful in 15m9s
Build and deploy / Deploy container (push) Successful in 1m20s
2026-05-12 05:52:39 +02:00
Dreaded_X b66357749b chore: Update dependencies 2026-05-12 05:52:38 +02:00
Dreaded_X 5a2c1b0a13 feat: Added automation for wardrobe light 2026-05-12 04:27:46 +02:00
Dreaded_X ccd9460e76 chore: Update README to use prek 2026-05-12 04:27:16 +02:00
Dreaded_X 00c32e8993 feat: Added all_on function to HueGroup 2026-05-12 04:26:42 +02:00
Dreaded_X 8c6adae3ae fix: Chef cook uses wrong toolchain
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
Dreaded_X 2158bde1c2 chore: Upgraded to new workflow 2025-11-20 04:44:25 +01:00
Dreaded_X b547f66d86 feat: Added 3d printer to guest room
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
Dreaded_X f3de8e36ea fix: Frontdoor presence is the wrong way around
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
Dreaded_X 44f2c57819 fix: Set entrypoint lua path correctly in Dockerfile
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
21 changed files with 1434 additions and 639 deletions
+3
View File
@@ -1,2 +1,5 @@
[env]
RUST_LOG = "automation=debug"
[target.x86_64-unknown-linux-musl]
rustflags = ["-C", "link-arg=-lc"]
+2
View File
@@ -2,3 +2,5 @@
.env
# Use the rust environment provided by the container
rust-toolchain.toml
Dockerfile
docker-bake.hcl
+9 -3
View File
@@ -9,10 +9,10 @@ on:
jobs:
build:
uses: dreaded_x/workflows/.gitea/workflows/rust-kubernetes.yaml@22ee0c1788a8d2157db87d6a6f8dbe520fe48592
uses: dreaded_x/workflows/.gitea/workflows/docker-kubernetes.yaml@ef78704b98c72e4a6b8340f9bff7b085a7bdd95c
secrets: inherit
with:
upload_manifests: false
push_manifests: false
deploy:
name: Deploy container
@@ -26,6 +26,10 @@ 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 \
@@ -37,7 +41,9 @@ 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 }} \
git.huizinga.dev/dreaded_x/automation_rs@${{ needs.build.outputs.digest }}
-e AUTOMATION__SECRETS__PRINTER_DEVICE_ID=${{ secrets.PRINTER_DEVICE_ID }} \
-e AUTOMATION__SECRETS__PRINTER_ACCESS_CODE=${{ secrets.PRINTER_ACCESS_CODE }} \
$(echo ${{ toJSON(needs.build.outputs.images) }} | jq .automation -r)
docker network connect web automation_rs
Generated
+1045 -575
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.99"
anyhow = "1.0.102"
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.4"
bytes = "1.10.1"
axum = "0.8.9"
bytes = "1.11.1"
dyn-clone = "1.0.20"
eui48 = { version = "1.1.0", features = [
"disp_hexstring",
"serde",
], default-features = false }
futures = "0.3.31"
futures = "0.3.32"
google_home = { path = "./google_home/google_home" }
google_home_macro = { path = "./google_home/google_home_macro" }
hostname = "0.4.1"
inventory = "0.3.21"
hostname = "0.4.2"
inventory = "0.3.24"
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.3", features = [
mlua = { version = "0.11.6", features = [
"lua54",
"vendored",
"macros",
@@ -45,23 +45,23 @@ mlua = { version = "0.11.3", features = [
"async",
"send",
] }
proc-macro2 = "1.0.101"
quote = "1.0.40"
reqwest = { version = "0.12.23", features = [
proc-macro2 = "1.0.106"
quote = "1.0.45"
reqwest = { version = "0.13.3", features = [
"json",
"rustls-tls",
"rustls",
], default-features = false } # Use rustls, since the other packages also use rustls
rumqttc = "0.24.0"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
rumqttc = "0.25.1"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
serde_repr = "0.1.20"
syn = { version = "2.0.106" }
thiserror = "2.0.16"
syn = { version = "2.0.117" }
thiserror = "2.0.18"
tokio = { version = "1", features = ["rt-multi-thread"] }
tokio-cron-scheduler = "0.15.0"
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
wakey = "0.3.0"
tokio-cron-scheduler = "0.15.1"
tracing = "0.1.44"
tracing-subscriber = "0.3.23"
wakey = "0.4.1"
[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.15", default-features = false, features = [
config = { version = "0.15.22", default-features = false, features = [
"async",
"toml",
] }
+7 -3
View File
@@ -1,14 +1,15 @@
FROM rust:1.89 AS base
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
FROM rust:1.95-alpine AS base
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 . .
RUN cargo chef prepare --recipe-path recipe.json
FROM base AS builder
RUN apk add --no-cache g++=15.2.0-r2 cmake=4.1.3-r0 make=4.4.1-r3 openssl-dev=3.5.7-r0 openssl-libs-static=3.5.7-r0
# HACK: Now we can use unstable feature while on stable rust!
ENV RUSTC_BOOTSTRAP=1
COPY --from=planner /app/recipe.json recipe.json
@@ -19,7 +20,10 @@ ARG RELEASE_VERSION
ENV RELEASE_VERSION=${RELEASE_VERSION}
RUN cargo auditable build --release
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
FROM scratch 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 [pre-commit](https://pre-commit.com) to make sure everything is ready to go when committing.
This repository uses [prek](https://prek.j178.dev/) to make sure everything is ready to go when committing.
Install the pre-commit hooks by running the following command:
```bash
pre-commit install
prek install
```
+1
View File
@@ -25,3 +25,4 @@ thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
wakey = { workspace = true }
bambulab = { version = "0.4.30", default-features = false }
+142
View File
@@ -0,0 +1,142 @@
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use async_trait::async_trait;
use automation_lib::action_callback::ActionCallback;
use automation_lib::device::Device;
use automation_macro::{Device, LuaDeviceConfig};
use bambulab::client::Client;
use bambulab::{Command, Message};
use google_home::errors::{self};
use google_home::traits::OnOff;
use lua_typed::Typed;
use tracing::{debug, trace};
use crate::{DebugWrap, LuaDeviceCreate};
#[derive(Debug, Clone, LuaDeviceConfig, Typed, Default)]
#[typed(as = "BambuCallbacks")]
pub struct Callbacks {
#[device_config(from_lua, default)]
#[typed(default)]
pub state: ActionCallback<Bambu>,
#[device_config(from_lua, default)]
#[typed(default)]
pub connected: ActionCallback<Bambu>,
}
crate::register_type!(Callbacks);
#[derive(Debug, Clone, LuaDeviceConfig, Typed)]
#[typed(as = "BambuConfig")]
pub struct Config {
pub host: String,
pub device_id: String,
pub access_code: String,
#[device_config(from_lua, default)]
pub callbacks: Callbacks,
}
crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff))]
pub struct Bambu {
config: Config,
client: DebugWrap<Client>,
state: Arc<AtomicBool>,
}
crate::register_device!(Bambu);
#[async_trait]
impl LuaDeviceCreate for Bambu {
type Config = Config;
type Error = Infallible;
async fn create(config: Self::Config) -> Result<Self, Infallible> {
trace!(id = config.device_id, "Setting up bambu");
let (tx, mut rx) = tokio::sync::broadcast::channel(25);
let client = Client::new(&config.host, &config.access_code, &config.device_id, tx);
let state = Arc::new(AtomicBool::new(false));
let bambu = Self {
config,
client: DebugWrap(client.clone()),
state: state.clone(),
};
tokio::spawn({
let mut bambu = bambu.clone();
async move {
// The printer might be offline so periodically try to reconnecct
loop {
bambu.client.run().await.ok();
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
});
tokio::spawn({
let bambu = bambu.clone();
async move {
loop {
let message = rx.recv().await.unwrap();
match message {
Message::Print(data) => 'print: {
// Extract the state of the chamber light
let Some(light_report) = data.print.lights_report else {
break 'print;
};
let on = light_report
.iter()
.find(|report| report.node == "chamber_light")
.map(|report| report.mode == "on")
.unwrap_or(false);
state.store(on, std::sync::atomic::Ordering::Relaxed);
bambu.config.callbacks.state.call(bambu.clone()).await;
}
Message::Connected => {
debug!(id = bambu.config.device_id, "Connected");
client.publish(Command::PushAll).await.unwrap();
bambu.config.callbacks.connected.call(bambu.clone()).await;
}
// Ignore everything else
_ => {}
}
}
}
});
Ok(bambu)
}
}
impl Device for Bambu {
fn get_id(&self) -> String {
self.config.device_id.clone()
}
}
#[async_trait]
impl OnOff for Bambu {
async fn on(&self) -> Result<bool, errors::ErrorCode> {
Ok(self.state.load(std::sync::atomic::Ordering::Relaxed))
}
async fn set_on(&self, on: bool) -> Result<(), errors::ErrorCode> {
// NOTE: This will error in case the printer is offline, but we don't really care in that
// case so we just ignore the error
self.client.publish(Command::SetChamberLight(on)).await.ok();
Ok(())
}
}
+47
View File
@@ -2,6 +2,7 @@ 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;
@@ -25,6 +26,7 @@ crate::register_type!(Config);
#[derive(Debug, Clone, Device)]
#[device(traits(OnOff))]
#[device(extra_user_data = AllOn)]
pub struct HueGroup {
config: Config,
}
@@ -122,6 +124,47 @@ 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};
@@ -164,5 +207,9 @@ mod message {
pub fn any_on(&self) -> bool {
self.state.any_on
}
pub fn all_on(&self) -> bool {
self.state.all_on
}
}
}
+4 -8
View File
@@ -122,15 +122,11 @@ 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
}
_ => {}
}
+30
View File
@@ -1,5 +1,7 @@
#![feature(debug_closure_helpers)]
#![feature(iter_intersperse)]
mod air_filter;
mod bambu;
mod contact_sensor;
mod hue_bridge;
mod hue_group;
@@ -13,6 +15,9 @@ mod wake_on_lan;
mod washer;
mod zigbee;
use std::fmt;
use std::ops::{Deref, DerefMut};
use automation_lib::Module;
use automation_lib::device::{Device, LuaDeviceCreate};
use tracing::{debug, warn};
@@ -20,6 +25,31 @@ use tracing::{debug, warn};
type DeviceNameFn = fn() -> String;
type RegisterDeviceFn = fn(lua: &mlua::Lua) -> mlua::Result<mlua::AnyUserData>;
#[derive(Clone)]
struct DebugWrap<T: Clone>(T);
impl<T: Clone> DerefMut for DebugWrap<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<T: Clone> Deref for DebugWrap<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<T: Clone> fmt::Debug for DebugWrap<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("DebugWrap")
.field_with(|f| f.write_str(stringify!(T)))
.finish()
}
}
pub struct RegisteredDevice {
name_fn: DeviceNameFn,
register_fn: RegisterDeviceFn,
-1
View File
@@ -1,4 +1,3 @@
#![feature(iter_intersperse)]
#![feature(iterator_try_collect)]
mod device;
mod lua_device_config;
+2 -2
View File
@@ -10,12 +10,12 @@ return {
openid_url = "https://login.huizinga.dev/api/oidc",
},
mqtt = {
host = ((host == "zeus" or host == "hephaestus") and "olympus.lan.huizinga.dev") or "mosquitto",
host = ((host == "zeus" or host == "hephaestus") and "olympus.huizinga.lan") or "mosquitto",
port = 8883,
client_name = "automation-" .. host,
username = "mqtt",
password = secrets.mqtt_password,
tls = host == "zeus" or host == "hephaestus",
tls = false,
},
modules = {
require("config.battery"),
+32 -1
View File
@@ -25,6 +25,13 @@ 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",
@@ -32,13 +39,36 @@ 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()
lights:set_on(not lights:on())
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
end,
left_hold_callback = function()
lights_relax:set_on(true)
@@ -61,6 +91,7 @@ function module.setup(mqtt_client)
lights,
lights_relax,
air_filter,
wardrobe_door,
switch,
window,
},
+30 -1
View File
@@ -4,15 +4,35 @@ local helper = require("config.helper")
local presence = require("config.presence")
local windows = require("config.windows")
local secrets = require("automation:secrets")
--- @type Module
local module = {}
function module.setup(mqtt_client)
local light = devices.LightOnOff.new({
local light = nil
local bambu = devices.Bambu.new({
host = "thalia.huizinga.lan",
device_id = secrets.printer_device_id,
access_code = secrets.printer_access_code,
callbacks = {
connected = function(self)
if light ~= nil then
self:set_on(light:on())
end
end,
},
})
light = devices.LightOnOff.new({
name = "Light",
room = "Guest Room",
topic = helper.mqtt_z2m("guest/light"),
client = mqtt_client,
callback = function(_, state)
bambu:set_on(state.state)
end,
})
presence.turn_off_when_away(light)
@@ -25,10 +45,19 @@ 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,
bambu,
}
end
+14 -19
View File
@@ -59,26 +59,21 @@ function module.setup(mqtt_client)
})
hallway_automation.set_trash(trash)
---@param duration number
---@return fun(_, open: boolean)
local function frontdoor_presence(duration)
local timeout = utils.Timeout.new()
local timeout = utils.Timeout.new()
local function frontdoor_presence(_, open)
if open then
timeout:cancel()
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)
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
@@ -89,7 +84,7 @@ function module.setup(mqtt_client)
topic = helper.mqtt_z2m("hallway/frontdoor"),
client = mqtt_client,
callback = {
frontdoor_presence(debug.debug_mode and 10 or 15 * 60),
frontdoor_presence,
hallway_automation.door_callback,
},
battery_callback = battery.callback,
+22
View File
@@ -24,6 +24,25 @@ function devices.AirFilter.new(config) end
---@field url string
local AirFilterConfig
---@class Bambu: DeviceInterface, OnOffInterface
local Bambu
devices.Bambu = {}
---@param config BambuConfig
---@return Bambu
function devices.Bambu.new(config) end
---@class BambuCallbacks
---@field state (fun(_: Bambu) | fun(_: Bambu)[])?
---@field connected (fun(_: Bambu) | fun(_: Bambu)[])?
local BambuCallbacks
---@class BambuConfig
---@field host string
---@field device_id string
---@field access_code string
---@field callbacks BambuCallbacks
local BambuConfig
---@class ConfigLightLightStateBrightness
---@field name string
---@field room (string)?
@@ -116,6 +135,9 @@ 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
+20
View File
@@ -0,0 +1,20 @@
variable "TAG_BASE" {}
variable "RELEASE_VERSION" {}
group "default" {
targets = ["automation"]
}
target "docker-metadata-action" {
tags = []
}
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-2025-08-20"
channel = "nightly-2026-05-12"
components = ["rustfmt", "clippy", "rust-analyzer"]
profile = "minimal"
-2
View File
@@ -1,5 +1,3 @@
#![feature(if_let_guard)]
pub mod config;
pub mod schedule;
pub mod secret;