diff --git a/.cargo/config.toml b/.cargo/config.toml index 69d2c54..61e35b2 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [env] RUST_LOG = "automation=debug" + +[target.x86_64-unknown-linux-musl] +rustflags = ["-C", "link-arg=-lc"] diff --git a/.dockerignore b/.dockerignore index ac9867e..56919ad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ .env # Use the rust environment provided by the container rust-toolchain.toml +Dockerfile +docker-bake.hcl diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 8b9c27e..83d3ed1 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -41,6 +41,8 @@ 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 }} \ + -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 diff --git a/Cargo.lock b/Cargo.lock index 43ac2f7..2707f33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -42,6 +42,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -105,6 +117,7 @@ dependencies = [ "async-trait", "automation_lib", "automation_macro", + "bambulab", "bytes", "dyn-clone", "eui48", @@ -231,6 +244,20 @@ dependencies = [ "tracing", ] +[[package]] +name = "bambulab" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25534ead4a23a9ecee1530f1169246eabc2602ab704ffc57d41b59811167d83" +dependencies = [ + "futures", + "nanoid", + "paho-mqtt", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "base64" version = "0.22.1" @@ -349,6 +376,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "config" version = "0.15.22" @@ -398,6 +434,21 @@ dependencies = [ "strum", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "darling" version = "0.20.11" @@ -576,6 +627,27 @@ dependencies = [ "serde", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -697,6 +769,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -1402,6 +1480,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "nanoid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628de41fe064cc3f0cf07f3d299ee3e73521adaff72278731d5c8cae3797873" +dependencies = [ + "rand", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1443,6 +1530,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "ordered-float" version = "2.10.1" @@ -1452,6 +1551,38 @@ dependencies = [ "num-traits", ] +[[package]] +name = "paho-mqtt" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23c6e899549be260b8c8d7fb6908d72eacb9a1e10229c7e294a7a8ff1c768620" +dependencies = [ + "async-channel", + "crossbeam-channel", + "futures", + "futures-timer", + "libc", + "log", + "paho-mqtt-sys", + "thiserror", +] + +[[package]] +name = "paho-mqtt-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e508bdadfa0d58d67792060fa447ec20729db5dc76e3dc7de9f4d29e521a43" +dependencies = [ + "cmake", + "openssl-sys", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2577,6 +2708,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "wakey" version = "0.4.1" diff --git a/Dockerfile b/Dockerfile index 9572b91..7f5c614 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ -FROM rust:1.95 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 @@ -10,6 +9,7 @@ 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 @@ -20,7 +20,8 @@ ARG RELEASE_VERSION ENV RELEASE_VERSION=${RELEASE_VERSION} RUN cargo auditable build --release -FROM gcr.io/distroless/cc-debian13: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;;" diff --git a/automation_devices/Cargo.toml b/automation_devices/Cargo.toml index 5aba41f..3491da8 100644 --- a/automation_devices/Cargo.toml +++ b/automation_devices/Cargo.toml @@ -25,3 +25,4 @@ thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } wakey = { workspace = true } +bambulab = { version = "0.4.30", default-features = false } diff --git a/automation_devices/src/bambu.rs b/automation_devices/src/bambu.rs new file mode 100644 index 0000000..03baf74 --- /dev/null +++ b/automation_devices/src/bambu.rs @@ -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, + #[device_config(from_lua, default)] + #[typed(default)] + pub connected: ActionCallback, +} +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, + + state: Arc, +} +crate::register_device!(Bambu); + +#[async_trait] +impl LuaDeviceCreate for Bambu { + type Config = Config; + type Error = Infallible; + + async fn create(config: Self::Config) -> Result { + 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 { + 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(()) + } +} diff --git a/automation_devices/src/lib.rs b/automation_devices/src/lib.rs index 0407c6a..ed5a667 100644 --- a/automation_devices/src/lib.rs +++ b/automation_devices/src/lib.rs @@ -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; +#[derive(Clone)] +struct DebugWrap(T); + +impl DerefMut for DebugWrap { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Deref for DebugWrap { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Debug for DebugWrap { + 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, diff --git a/config/rooms/guest_bedroom.lua b/config/rooms/guest_bedroom.lua index 55f313c..d364655 100644 --- a/config/rooms/guest_bedroom.lua +++ b/config/rooms/guest_bedroom.lua @@ -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) @@ -37,6 +57,7 @@ function module.setup(mqtt_client) light, window, printer, + bambu, } end diff --git a/definitions/automation:devices.lua b/definitions/automation:devices.lua index a182df4..6cb8ab1 100644 --- a/definitions/automation:devices.lua +++ b/definitions/automation:devices.lua @@ -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)? diff --git a/docker-bake.hcl b/docker-bake.hcl index ff12c92..b87b391 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -5,7 +5,9 @@ group "default" { targets = ["automation"] } -target "docker-metadata-action" {} +target "docker-metadata-action" { + tags = [] +} target "automation" { inherits = ["docker-metadata-action"]