Build-time code generator that consumes InterfaceManifest.yml and
emits the C++ glue currently hand-written across MessageBus.{hpp,cpp},
DBusBackend.cpp, and src/zwave-protocol/application/<Name>.{hpp,cpp}.
The manifest is the single source of truth; the generator output is a derivative. After the rollout completes, the corresponding hand- written sections are deleted and the manifest becomes the only place to add a new MessageBus event, D-Bus method, D-Bus signal, or Command Class codec.
Picked Python (3.11+) over the alternatives:
- Why Python: YAML parsing is one line; Jinja2 is the de-facto "structured-data → text" engine; the codegen runs at build time on the developer's host so the language is invisible to the daemon's runtime; the standard library carries everything except YAML and Jinja2.
- Dependencies:
python3(≥ 3.11),python3-yaml(pyyaml),python3-jinja2(Jinja2). All three ship as stock packages on every supported distro and pull in nothing transitive. - Why not C++: Same toolchain as the daemon, but YAML parsing
needs
yaml-cpp, templating gets ugly, and the build-time tool doesn't gain anything from sharing C++ types with the runtime. - Why not pure CMake: No real string processing, no struct loop, no error handling worth the name.
- Why not bash+yq: Fine for a one-off twenty-line emitter, won't scale to four output kinds with shared schema validation.
- Why not Rust / Go: Extra toolchain for zero benefit over Python.
scripts/codegen/
├── README.md this file
├── generate.py CLI entry point — dispatches to target modules
├── schema.py dataclasses mirroring the manifest
├── loader.py yaml → schema, validation, errors
├── targets/
│ ├── __init__.py
│ ├── messagebus.py emits MessageBus event structs + instantiations
│ ├── dbus_methods.py emits DBusBackend method registrations
│ ├── dbus_signals.py emits DBusBackend signal registrations + emits
│ └── cc_codecs.py emits application/<Name>.{hpp,cpp} skeletons
└── templates/ Jinja2 templates, one per generated output
# Generate everything into cmake-build-*/generated/.
python3 scripts/codegen/generate.py \
--manifest InterfaceManifest.yml \
--out cmake-build-gnu/generated
# Generate just one target — useful while iterating on a template.
python3 scripts/codegen/generate.py \
--manifest InterfaceManifest.yml \
--out cmake-build-gnu/generated \
--target messagebusThe generator runs clang-format -i on every emitted file before
exiting so the output already matches the project's style and a
post-generation diff doesn't churn on whitespace.
A custom command in the root CMakeLists.txt invokes the generator
when InterfaceManifest.yml or any file under scripts/codegen/ changes.
Generated headers land under ${CMAKE_BINARY_DIR}/generated/ (already
on the daemon's include path for Version.hpp); generated .cpp
files are added to the daemon target via target_sources. The custom
command depends on python3 being on PATH and on the two pip
packages — the configure step probes for both and fails loudly with a
"run apt install python3-yaml python3-jinja2" hint if either is
missing.
The generated tree is not committed. The single source of truth is
the manifest; the generated C++ is a derivative artefact, the same
way cmake-build-*/generated/Version.hpp already is. Reviewers see
manifest changes in the diff; CI proves the generator is reproducible.
The codegen landed in six phases between 2026-05-02 and 2026-05-03. Each phase replaced one slice of hand-written code with generator output, lowest-risk slice first.
generate.pyargparse + dispatch.loader.py+schema.pyparseInterfaceManifest.ymlinto validated dataclasses, with concrete errors on missing fields, unknown categories, dangling event refs, and malformed type expressions.- A trivial sanity-check target (since removed in Phase 6) proved the YAML → loader → schema → Jinja2 → file → clang-format chain end to end before any real target plugged in.
- CMake
add_custom_commandwith dependency tracking on the manifest, every*.py, and every template.
MessageBus.gen.hppcarries every event struct, every nested struct (NodeInfo/EndpointMember/AcceptedDongleConfig), and theIsRetained<T>specializations.MessageBus.gen.cppcarries the explicitsubscribe<T>/publish<T>instantiations.MessageBus.hppshrunk from 393 to 55 lines (a thin wrapper over the generated header plus the public-API declarations).MessageBus.cppshrunk from 162 to 30 lines.- New
MessageBusInternal.hppholds theTopic<T>/BusState/ template bodies inMessageBus::detail::, shared by both translation units.
- 13 of 22 methods (every
publish:andpublish_constant:action) now generated inline inDBusMethods.gen.cpp. - 9 methods (flag-byte unpacking on
AddNode/RemoveNode, struct-array conversion on the Multi Channel Association Set/Remove, plus the fourread_cached:-shaped getters andGetVersion/GetNetworkStatus) stay hand-written ascustom:handlers — the codegen forwards to them via thin per-method lambdas. DBusBackendInternal.hpplifted theImplstruct, tuple aliases, andBUS_NAME/OBJECT_PATH/IFACE_NAMEconstants out of the.cpp.DBusBackend.cpplost ~166 lines (the entire flat method registration list).
- All 9 D-Bus signals generated. Per-event subscribers grouped so
one
MessageBus::subscribe<X>can drive multiple signals (theApplicationCommandevent drives both the rawApplicationCommandpassthrough and the typedSwitchBinaryReportvia the manifest'sdecode:notation, which inlines theBinarySwitch::decodeReportcall and thestd::nulloptguard). - Five hand-written cache-update subscribers stay (DongleStatus /
DongleInfo / InitData / NodeListChanged / SessionStatus) — they
feed
impl->last*for thecustom: emitGet*handlers and run before the generated signal subs so any field a D-Bus signal carries is already cached when it goes out. DBusBackend.cpplost another ~270 lines (the registerSignal block and the eight signal-emit lambdas).
- Every module gets
application/<Name>.gen.hppwith COMMAND_CLASS, per-command byte constants (<wire.prefix>_<CMD>), and any per-moduleconstants:block (VALUE_OFF/VALUE_ON/VALUE_UNKNOWN,MARKER). - CCs with fully-expressed wire shapes (BinarySwitch, Basic) also
get
application/<Name>.gen.cppwith simpleencodeSet/encodeGetbodies. - Hand-written
application/<Name>.{hpp,cpp}shrunk to the irregular parts:enum State,struct Report, decode functions, multi-frame Association encoders, MultichannelAssociation's MARKER-elision REMOVE-all wire form.
- Phase 1 sanity-check target (
Manifest.gen.hpp+targets/manifest_summary.py+ its template) deleted; its job was to prove the toolchain wires up, and Phases 2–5 prove that every rebuild. - Top-level docs (
README.md,CLAUDE.md) updated to point atInterfaceManifest.ymlas the canonical interface description. MANUAL.md/README.mdmethod-table generation deferred — see the section below. The duplication is currently low-cost (~one round of doc edits per CC; no observed drift bugs in the six-phase rollout). The infrastructure to do it cleanly (auto-update markers +--checkmode + a CI verification step) is a multi-day stretch that doesn't pay for itself yet.
These are tracked here as obvious next steps for the codegen, ordered by leverage. None are blocking; each lands when a concrete need shows up.
Three methods stay custom: today (emitGetNodes,
emitGetDongleInfo, emitGetInitData) because the codegen doesn't
yet emit the cached-state lookup + tuple-construction body. The
manifest already documents the action shape; the generator just
needs the inline-body emission and a per-event "cached-as" name
convention (NodeListChanged → lastNodes, DongleInfo → cached
whole, etc.).
AddNode / RemoveNode stay custom: because the inbound flags
byte unpacks into four bool fields on the bus event. A small
unpack_bits: annotation on the param would express this:
- { name: flags, dbus: y, cpp: u8, unpack_bits: { 7: power, 6: nwi, 5: protocolLongRange, 4: skipFlNeighbors } }The codegen would emit the bitSet(flags, 7) calls automatically.
The SetMultichannelAssociation / RemoveMultichannelAssociation
endpoint-pair param is a(yy) on the wire and
vector<MessageBus::EndpointMember> on the bus event. A simple
element_struct: EndpointMember notation (positional field
mapping from the inbound sdbus::Struct to the bus struct) would
let those become regular publish: actions.
Association's encoders take a std::span<const uint8_t> members
parameter and emit it as a sequence of bytes after the
(CC, cmd, groupId) header. A richer wire: grammar could express
this — splat operator, conditional MARKER emission for MCA's
REMOVE-all — and let the encoders generate.
BinarySwitch::decodeReport and Basic::decodeReport stay
hand-written because the manifest can't currently express the
state-byte → enum-State mapping or the v1/v2 wire-form branching
in Basic. Adding a decode: block that mirrors the existing
encode_args: / wire: pattern would close the last gap.
See the section below — design held for the first concrete "manifest can't express this CC's irregular bit" case.
A target that emits the canonical method/signal table from the
manifest into MANUAL.md and README.md via auto-update markers.
Combined with a --check mode the pre-commit hook runs, this
removes the last documented duplication. Practical when manifest
edits start outpacing manual doc updates; on the back burner until
then.
A natural follow-up — once the central manifest can't cleanly express
some per-instance customization — is to support per-directory overlay
files (e.g. src/zwave-protocol/application/MultichannelAssociation.codegen.yml)
that opt out of generation for specific commands or override field
mappings. Concrete shape we'd reach for first:
# Skip the encoder for these commands; the .cpp file hand-implements
# them because the wire shape is irregular (MARKER elision when both
# member lists are empty for REMOVE-all).
skip_commands: [Remove]Deliberately deferred until Phase 5 hits the first irregular CC. The cost of introducing it now — two sources of truth (manifest + per-dir overlays) that need to stay in sync, plus more files to walk to understand the full codegen story — outweighs the benefit at the current ~20-event / 19-method / 4-CC scale. By the time the first real overlay case lands we'll know exactly what fields it needs instead of guessing now.
The central manifest stays the source of truth for data shapes; overlays are only ever for per-instance opt-outs and overrides.
- Filename suffix
.genmarks generator output:Foo.gen.hpp,Foo.gen.cpp. Easier to spot in diffs and.gitignorerules. - One template per output file under
codegen/templates/, named to match. - Do not edit generated files. They carry an autogen banner; the
pre-commit hook refuses staged changes to any file matching
*.gen.{hpp,cpp,md}. - Schema changes are breaking. Bump
version:in the manifest, call out the breaking change in the commit message. - Validation is loud. Unknown type names, dangling event refs, duplicate names, unknown action shapes — every one of these is a hard error with the manifest line/column included.