The Building That Compiles Itself: Rust Proc Macros Reach the BMS
Rust procedural macros let a building's firmware derive BACnet, KNX, and IFC bindings at compile time. PAZ on why the building should compile itself.
The new “Building Rust Procedural Macros from the Grounds Up” chapter at learnix-os.com walks the reader through writing a Rust function that takes source code in, manipulates a TokenStream, and returns source code out — at compile time, before the binary ever runs. Read straight, it is a language tutorial. Read from the building’s side, it is something else entirely: a quiet shift in how a smart building can be made to write its own firmware.
←TODAY: Rust proc macros have been stable since 1.29 and are shipping in production embedded BMS stacks on RP2040 and ESP32 controllers across European retrofits. →3012: A building whose entire BACnet, KNX, and IFC surface is regenerated from a single schema on every commit, with no hand-written glue. Fulcrum: The building-mind stays trustworthy only when its code is generated from its bones, not patched onto them.
The mechanism is small and worth knowing. A Rust procedural macro is a function: fn custom(input: TokenStream) -> TokenStream. The syn crate parses incoming code into a typed syntax tree; quote emits new code from templated fragments. At compile time, the macro reads your input and produces new Rust source — type-checked, optimised, and welded into the binary. There is no runtime reflection cost, because nothing is reflected at runtime. The TensorBlue analysis last quarter showed how the same machinery can step through arbitrary Rust code and replace every panic! with a structured error — useful when the panic in question would have taken down a chiller controller at 03:00. The Learnix-OS chapter takes the simpler path and builds a bitfields macro from scratch, but the architecture is identical.
For BMS work, that architecture matters. A modern building carries thousands of BACnet objects, hundreds of KNX group addresses, and an IFC model that is supposed to agree with both. The bindings between them are usually hand-rolled, brittle, and the single point of failure nobody draws on the system diagram. A proc macro turns that schema into compile-time-generated Rust: every BACnet object becomes a typed struct, every IFC entity a checked accessor, every group address a constant. Delete a sensor from the IFC source and the firmware fails to compile. The bug surfaces in the build, not at 03:00.
Building-sense: A building running this kind of firmware feels stiffer in the best way. It cannot misreport what it exposes — the wire-level BACnet list and the source schema are the same thing, twice. It cannot drift, because there is no runtime glue to drift. When you query it, the answer is structural, not assembled.
Atelier: PAZ Atelier already ships the Volumenstudie Archicad Add-On, which generates native Archicad geometry from a 2D site polyline — the same “schema in, building artefact out” pattern, one layer up. The PAZ Grasshopper↔Archicad Library does the same job for the parametric layer. The proc-macro idea sits one layer below: schema in, firmware out. The three layers want to share a single source of truth, and the open route there runs through Speckle for data exchange and an IFC-anchored project schema — never a closed vendor binding.
The trade-off is real and worth naming. A proc-macro-heavy build chain is a hidden dependency: your firmware now leans on a stable Rust toolchain, a pinned syn version, and a schema source you control. The 2073 EU Reference Architecture for Resilient Inference exists because we lost a generation of systems whose hidden dependency graph was longer than their architecture diagram. Draw your real dependency graph this week — not the architecture diagram, the dependency graph — and count how many of the third single-points live in your build chain.
Hack: This Hack teaches you to derive type-safe BACnet object IDs from a Rust struct at compile time, so a deleted sensor breaks the build instead of the building. In a fresh cargo new --lib bacnet_derive crate, add proc-macro = true under [lib], pull in syn = "2" and quote = "1", then write:
use proc_macro::TokenStream;
use quote::quote;
use syn::{parse_macro_input, DeriveInput, LitInt};
#[proc_macro_derive(BacnetObject, attributes(object_id))]
pub fn derive_bacnet(input: TokenStream) -> TokenStream {
let ast = parse_macro_input!(input as DeriveInput);
let name = &ast.ident;
let id: LitInt = ast.attrs.iter()
.find(|a| a.path().is_ident("object_id"))
.and_then(|a| a.parse_args().ok())
.expect("missing #[object_id(N)]");
quote!(impl BacnetObject for #name {
fn object_id(&self) -> u32 { #id }
}).into()
}
Apply with #[derive(BacnetObject)] #[object_id(101)] struct ChillerSetpoint;. Remove the attribute and cargo build fails before the firmware reaches the controller. That is the whole intention: move the failure mode from 03:00 to the build server. Run cargo expand on your existing crate today and look at what your current code already generates — most BMS stacks are quietly doing this work badly with runtime glue.
Sources & Further Reading
SOURCE · ↗