Kconfig / menuconfig
Happy Hare's machine configuration is a Kconfig tree (Linux-kernel style)
that make menuconfig turns into a plain CONFIG_* value file
(.mmu_config, gitignored), which the build then turns into the rendered
Klipper .cfg files. Kconfiglib is vendored and forked at
installer/lib/kconfiglib/ — the fork adds HH-specific language features
(references/kconfiglib-extensions.md is
the full catalog). The block comment at the top of installer/Kconfig is the
authoritative in-repo documentation of those extensions — keep it updated when
you change the fork.
The value flow (one pass, top to bottom)
env vars ──(expanded at PARSE time)──▶ installer/Kconfig tree
│ make menuconfig / make olddefconfig (stale only)
▼
.mmu_config (+ .mmu_config_<unit> per unit in multi-unit)
│ python -m installer.build --pre-parse-kconfig
▼ (KConfig.as_dict() → values + choices)
out/.mmu_config.pickle (regenerated only when .mmu_config is newer)
│ build_config_file(): render_template (Jinja, [[ ]] / [% %])
▼ + HHConfig merge of the
out/mmu/*.cfg user's existing .cfg values
│ install
▼
$(KLIPPER_CONFIG_HOME)/mmu/*.cfg
Load-bearing facts about the flow:
- Env is consumed at parse time, not load time. kconfiglib expands
$(VAR)and$(shell, ...)whileKconfig()is being constructed. The vars (UNIT_NAME,MCU_NAME,UNIT_INDEX,F_MULTI_UNIT,F_MULTI_UNIT_ENTRY_POINT,F_PER_GATE_MCU,HH_VERSION,KLIPPER_HOME, ...) must be in the environment before the parse — and in multi-unit they differ per parse, changing the tree's shape (whole symbol sets appear/disappear behindif MULTI_UNIT_ENTRY_POINT).test/hh/cfg.py::_env()is the reference implementation: assign (neversetdefault) and restore around each parse. - Multi-unit = three parses, driven by
install.sh: one entry-point parse (F_MULTI_UNIT_ENTRY_POINT=y F_MULTI_UNIT=y,UNIT_NAME="u0,u1,..."becomes theMMU_UNITSlist) plus one per unit (F_MULTI_UNIT=y UNIT_NAME=uN MCU_NAME=uN UNIT_INDEX=Nplus the printer-levelHAS_SENSOR_TOOLHEAD/EXTRUDER/TOOLHEAD_CUTTERread back out of the top-level file). Seeinstall.shrun_kconfig_top/run_kconfig_units/run_kconfig_one. The per-unit config file and the installed.cfgfiles both gain a_<unit>suffix. makeitself reads the value file: the Makefile does-include $(KCONFIG_CONFIG)(Makefile:47), so anyCONFIG_*symbol (CONFIG_MULTI_UNIT,CONFIG_MMU_UNITS,CONFIG_KLIPPER_HOME, ...) steers the build (e.g.unit_names). Renaming such a symbol is a Makefile change too.- Staleness:
kconfig_sources(Makefile:244) = everyinstaller/**/Kconfig*pluskconfigfunctions.py, compared by mtime against the value file; when stale,olddefconfig(never menuconfig) refreshes the file with new defaults. NewKconfig*files are picked up automatically by the wildcard; files with any other name are invisible to this mechanism. - User values survive by design:
olddefconfigonly fills in new symbols' defaults; explicit user assignments in an existing.mmu_configare preserved. This is why changing the default or meaning of an existing symbol is a breaking change for installed machines (see CONTRIBUTING: such changes "will probably be rejected").
The symbol-naming contract
installer/Kconfig's header comment is the spec; the machinery lives in the
kconfiglib fork. Three prefix lists are hard-coded in different places and
must be kept in sync when you introduce a new special prefix:
| Prefix | Meaning |
|---|---|
PARAM_* |
Parameters of various types that flow into template rendering (the main "real" options) |
VAR_* |
Gcode macro variables (land in gcode_macro sections via VAR_SECTION_MAP) |
PIN_* |
String symbols representing pins |
BOOL_* |
Booleans, often driving a promptless int PARAM (historical pair; BOOLINT now replaces it) |
MMU_HAS_* |
Hardware capability flags (encoder, leds, heaters, sensors, ...) |
CHOICE_X / CHOICE_X_* |
Named choice "X" and its members |
UNSELECT_* |
Force-off switch: a type/board does select UNSELECT_X to hide/disable feature X's prompt (if !UNSELECT_X in the Kconfig) |
The special-default behaviour: symbols (and CHOICE_-named choices) whose
name matches the list are written into .mmu_config with a trailing
#~DEFAULT~# magic token whenever they were not user-set (i.e. the saved
value is just the computed default). On the next menuconfig/olddefconfig load
(load_config(..., filter_defaults=True)) that assignment is parsed then
cleared and marked _was_default — so the symbol stays a modifiable
default: the user can change it, menuconfig shows
(NOT DEFAULT) and the r key resets it back to the default. Any other
name is a normal Kconfig variable whose saved value is always an explicit
assignment that never falls back to the default.
The three lists (verified anchors, will drift — re-grep):
- write side — which values get the
#~DEFAULT~#token:kconfiglib.py:~1762→('PARAM_', 'VAR_', 'PIN_', 'BOOL_', 'MMU_HAS_', 'CHOICE_', 'UNSELECT_')plus unnamed-check: choices must be namedCHOICE_*. - display side — which get the
(NOT DEFAULT)marker (i.e. arer-resettable in the UI):menuconfig.py:~3509→('PARAM_', 'VAR_', 'PIN_', 'BOOL_', 'MMU_HAS_')for prompted symbols; choices by nameCHOICE_*. - reset side — what
rmay actually clear:menuconfig._reset_node→ the 7-prefix tuple plus choice-member/sibling clearing forCHOICE_choices.
Note the three lists are deliberately not identical (e.g. UNSELECT_ gets
a default token but no (NOT DEFAULT) marker — it's a hidden switch the user
isn't meant to fiddle with). Don't "simplify" them into one without deciding
what the UI behaviour should be.
VAR_* (macro variables) and PIN_* get the token but their cfg
landing spot differs: VAR_* values are copied into gcode_macro sections
per VAR_SECTION_MAP in installer/build.py (variable_<name> options),
not into hardware files.
Naming trap — array grouping. KConfig.as_dict() collapses any symbol
matching ^(.+_)(\d+)$ with index ≤ 12 into a list in the render dict
(PIN_EJECT_BUTTON_0..11 → PIN_EJECT_BUTTON: [..]) so Jinja can index it.
A single surviving match is ungrouped again (it's just a name that happens
to end in digits). Choice members are exempt (checked before grouping) —
which is exactly what keeps version-numbered names like
MMU_TYPE_ERCF_1_1 from corrupting the dict. Consequences:
- Don't name two unrelated real symbols
FOO_1,FOO_2unless you want them rendered as one indexed list. - A non-choice symbol whose name ends in a small integer is only safe if no sibling with the same prefix exists.
- The
> 12index guard is a warning, not an error —FOO_13passes through verbatim.
Tree layout
installer/
Kconfig # ROOT. Header = extension docs. Env plumbing,
# multi-unit branching (two different menu trees),
# source order, osource /tmp/.Kconfig.generated
Kconfig.<topic> # one file per feature: name, num_gates,
# selector_type, endstops, options, pins, heater,
# fans, leds, encoder, espooler, nfc_reader, ...
# Convention: "# Sets/Defines parameter tokens:" header
mmu_types/ (+ starters/) # rsource "Kconfig.*" — one file per machine type
# (each inlines Kconfig.mmu_additions; see the
# cross-file section below)
boards/ (+ custom/, per_gate/) # MCU/board selection; pin defaults
connection/ # MCU serial/CAN auto-discovery ($(shell) heavy)
servos/ sensors/ toolheads/ macro_vars/
lib/kconfiglib/ # VENDORED FORK: kconfiglib.py, menuconfig.py,
# olddefconfig.py, kconfigfunctions.py,
# test_kconfig_pickle_consistency.py
build.py # KConfig(as_dict/get/getint/is_enabled/is_selected),
# ParsedKConfig pickle, render_template, HHConfig merge,
# supplemental_params/hidden_params, VAR_SECTION_MAP
parser.py # layout-preserving .cfg parser (ConfigBuilder)
upgrades.py # version-upgrade transforms (Upgrades)
The root Kconfig builds two different menus: if MULTI_UNIT_ENTRY_POINT
(shared/printer-level options: MMU_UNITS list, toolhead, options, purging,
speeds, macro vars, shared pins, paths) vs if !MULTI_UNIT_ENTRY_POINT
(per-unit: MMU type, board, connection, pins, endstops, ...). Per-unit files
are excluded from the entry-point tree via if !MULTI_UNIT guards (e.g.
toolheads/Kconfig appears in both — standalone machines keep it per-unit).
Printer-level capabilities are handed down to unit parses as env
(HAS_SENSOR_TOOLHEAD, ... → $(shell) macros at root Kconfig:~116,
feeding promptless MMU_HAS_* defaults at Kconfig:~203).
Checklist: adding / changing a symbol
- Name it per the contract above. A symbol that should be a modifiable
default (user-tweakable,
r-resettable) needs a special prefix and a prompt. Promptless symbols are invisible in the UI, can't get(NOT DEFAULT)/r, and — critically (pitfall 2) — kconfiglib only honoursuser_valuefor visible symbols. - Pick the tree: shared (entry point, guarded so it's absent from units)
or per-unit. New topic file → name it
Kconfig.*so the Makefilekconfig_sourceswildcard sees it (that's what triggersolddefconfigrefresh on existing installs). - Wire it into the menu:
sourceit frominstaller/Kconfig(or a parent Kconfig file) at the right position. Usemenu "..."withhelpfor grouping,comment "_"/comment "_Heading"for separators,if !UNSELECT_Xfor disable-able features,@repeatfor per-gate expansions (sparingly — pitfall 10),prompt "..." if PARAM_NUM_GATES > $(i)to hide surplus gates. - Land it in a .cfg (if it should): update the Jinja template(s) under
config/— the.cfgoption name is chosen by the template author (num_gates : [[PARAM_NUM_GATES]]), the Kconfig symbol only supplies the value.VAR_*→variable_<name>in agcode_macrosection. A parameter that must survive upgrade but isn't in any template → add it tosupplemental_params(documented-but-commented) orhidden_params(legal-but-unexposed) inbuild.py. - Defaults: new symbols get their default filled into existing
(stale)
.mmu_configfiles automatically byolddefconfigon the nextinstall.sh/makerun. Changing an existing symbol's default or semantics changes what gets rendered for every installed machine — additive options over reinterpretation (CONTRIBUTING). - Test it (all of these run on a laptop, no printer):
- Add/extend a profile in
test/hh/profiles.py(a profile is a dict of Kconfig symbols) and runmake test UT=test_mmu_profiles.py(config breadth) and/orUT=test_mmu_config.py(rendering + a few direct-kconfiglib tests). The harness (test/hh/cfg.py) renders the real templates, so a renamed param or broken[% if %]guard shows up as a boot failure. make console ARGS='--profile boxturtle'— boots the rendered config in the fake-Klipper harness.- Pinning a default takes a profile that does NOT set the symbol.
Profile
symsare explicit user values; an unset symbol is what exercises the computed default. Register render-only profiles in the trailingPROFILEStuple (notCONSOLE_PROFILES) and assert on the rendered text fromcfg.render(profile)(parse the section; seeTestBoxTurtleRender.test_led_effect_defaults_are_preserved). - The fake Klipper never validates an i2c bus name against a chipdef
(
bus.pyjust records it). A made-up bus likei2c3_PC0_PC1(stock STM32F446 i2c3 is PB3/PB4 or PC8/PC9 — the historical MMB 2.0 default; the name no longer appears in the tree) passes the whole harness and dies at real-machine boot — check bus names against the target Klipper's chipdef by eye (a real one:i2c2_PB10_PB11inboards/Kconfig.tzb_1_0). make verify_pickle— regression check that every explicitCONFIG_*assignment survivesas_dict()(see pitfall 2).make menuconfiginteractively (needs the real env context; normally reached via./install.sh -i).
- Add/extend a profile in
- If you touched the fork itself (
as_dict, load/write, menuconfig): the tests for it aremake verify_pickle(pickle consistency),make test UT=test_menuconfig.py(menuconfig cursor behaviour), and profile tests. The vendored base is kconfiglib v14.1 — the HH patches are marked# Happy Hare:inline; if you re-sync upstream, that grep is your change list.
Machine- and board-specific defaults (cross-file)
Machine- and board-specific defaults MUST live in the matching machine type's mmu_types/Kconfig.<mmu> file (inside its if MMU_TYPE_X block, or as BOARD_TYPE choice default) or the matching board's boards/Kconfig.<board> file — never in a feature Kconfig.<topic>. A feature file stays machine- and board-agnostic; a default ... if MMU_TYPE_X / if BOARD_TYPE_X line in a feature file is a rule violation even when it happens to work (earliest-default-wins, below).
Machines and boards get their own defaults by re-declaring the same config in
mmu_types/Kconfig.<mmu> (inside the if MMU_TYPE_X block — no if on the
default needed) or boards/Kconfig.<board> with just an added default —
no prompt, no repeated help. The nodes merge into one Symbol (upstream Kconfig: same-name
config definitions merge; prompts add, defaults add). Gotchas, all bitten
in this repo:
- A default's condition is evaluated as an expression against the
current values of the symbols it references. The parser stores the
explicit
default <val> if <cond>condition (kconfiglib.py:3608-3610);_propagate_deps(kconfiglib.py:4166) then ANDs in the node's owndepends onand every enclosingif/menudependency — so a plaindefault <val>inside anif BOARD_TYPE_Xblock is correctly scoped with no repeated condition (this is how all board/machine files are written). What surprises people is the evaluation: conditions are checked against the symbols' computed values, and for a promptless (invisible) symbol the user value is discarded (pitfall 2) — so a condition can silently fail to match for a reason that has nothing to do with scoping. - The earliest-parsed default wins for strings (
_node_ordered_string_default(), kconfiglib.py:5613 — also used by the value-computation path at :5010, so it decides both the computed value and the min-config write).boards/Kconfigis sourced at Kconfig:275, beforeKconfig.mmu_additions(:277), so a satisfied machine-type (mmu_types/Kconfig, :273) or board default beats a feature file's laterdefault "". choicemembers cannot come from another file. A machine/board file may steer an existing choice withdefault <CHOICE_MEMBER> if <cond>only; the members themselves must be declared inside thechoice ... endchoiceblock (per-gate variants in its@repeatblock). Selection is first-satisfied over the mergedchoice.defaultslist, in parse order (Choice._selection_from_defaults, kconfiglib.py:~6130) — put the new board-specific default line above the older, more general ones in that order (for choices declared in a feature file, "above" is not the same as "in an earlier-sourced file" — see the inlining bullet below).- Feature files are inlined once per machine type. Every
mmu_types/Kconfig.*(all 19, incl.starters/) sourcesKconfig.mmu_additionsinside its ownif MMU_TYPE_Xblock, wrapped inif SHOW_MMU_ADDITIONS_WITH_TYPE(adef_bool n, no prompt, nothing selects it — dormant UI option, rootKconfig:~217); the root also has a fallback source atKconfig:~277underif !SHOW_MMU_ADDITIONS_WITH_TYPE. So a feature-file symbol has20 definition sites that all merge into ONE object (for a choice: one shared Choice,273choice.defaultsaccumulating in parse order — introspectkc.choices, evaluate conditions with module-levelkconfiglib.expr_value; fork expr nodes are tuples, the tree root iskc.top_node, line numbers arelinenr)._propagate_depsANDs each copy's guards into its defaults, so while the dormant guard is n the ~19 inlined copies' defaults are dead and the live general defaults are the untyped root fallback's. Consequences: steering a feature-file choice from anywhere inmmu_types/(sourced `Kconfig:, before:~277) beats the live general default — "above the older, more general ones" means before the fallback copy in parse order, not "in an earlier-sourced file". Keep the steering line above the type file's own additions source anyway (it still wins if the dormant guard is ever enabled). This is also why adefault ... if MMU_TYPE_X` left in the feature file happens to work: the fallback copy carries it, first-satisfied. - Board type and per-gate MCU are mutually exclusive in the tree.
boards/Kconfigsourcesboards/per_gate/(EBB Gen1 / SLB) instead of the normal board set whenMMU_HAS_PER_GATE_MCUis set — soBOARD_TYPE_MMB_2_0(and the other normal boards) cannot be selected in a per-gate-MCU config. A board file's defaults only ever apply to devices on that board's MCU: defaulting a per-gate device's bus from the main board's Kconfig is either dead code (per-gate-MCU configs) or a hardware collision (per-gate devices on one shared MCU can't share a fixed-address i2c bus — see the per-gate NFC help inKconfig.nfc_reader).
Pitfalls (each one has bitten a past session — they're in code comments)
- Env at parse time. Get env wrong and pins silently render as
:PD5(no chip) instead ofunit0:PD5— wrong output, no error.cfg.py'sassert_sane()catches the chip-less form; only the per-parse assign/restore discipline catches the wrong-chip form. - The visibility trap. kconfiglib discards
Symbol.user_value(andChoice.user_selection) when the symbol is not currently visible, falling back to the computed default.build.py'sKConfig.get/getint/ is_enabled/is_selected/as_dictall deliberately read the raw user value first — many HH symbols are promptless-by-design and rely on that. If you add a new way of reading values, replicate the fallback and runmake verify_pickle;test_kconfig_pickle_consistency.pyexists because this exact class of silent value-dropping has happened several times. Related: BOOLuser_valueis an int tristate (0/1/2), not"n"/"y"— normalize withTRI_TO_STR. #~DEFAULT~#is live syntax inside.mmu_config.saved-config-value(a kconfiglib preprocessor function,kconfigfunctions.py, cached by mtime+size) and the load path both parse it. Tooling that rewrites value files must preserve the token.$(shell, ...)is expensive. The root Kconfig's serial/CAN discovery macros are parameterized Make functions, so kconfiglib re-forks a shell for every reference (~370 in one multi-unit parse; 22s of a 25s boot before the harness's per-parse cache incfg.py::_install_shell_cache). Adding shell-heavy macros costs every menuconfig run — measure first, and note the cache must stay scoped to one parse (env changes between parses).- Makefile traps: inline
#comments pad values with leading spaces (Makefile:3 — keep comments on their own line); any new interactive make goal must be added to theMAKECMDGOALSexclusion list (Makefile:39) or--output-syncbuffers its prompt away; the value files are.PRECIOUS(Makefile:183).make variablesprints which interpreter each half (test vs installer) settled on. Kconfigresolves viasrctree: the Makefile exportssrctree := $(SRC)/installer, which is whymake menuconfig Kconfigworks from the repo root and whyrsource(relative-to-file) vssource(relative to srctree) matters in this tree.- Version coupling: the
happy_hare_versionsymbol renders$(HH_VERSION), whichinstall.shsed-extracts fromextras/mmu/mmu_constants.py;mmu_machine.pyrefuses to boot on a config whose major.minor is older than the code. Keep them in lockstep with the git tag. - Config parse errors are reported, not fatal:
build.py'sreport_parse_errors— unparseable lines in printer.cfg/moonraker.conf survive verbatim (marked# !! HAPPY HARE PARSE ERROR) but lines in the HH-generated files are lost because those files are regenerated from templates. Loud warnings on both. - Array grouping can clobber a same-named scalar in
as_dict().FOO_0..Nare grouped intoresult["FOO"]in a post-pass that runs after the per-symbol loop, so a multi-element list silently overwrites a non-indexed scalarFOOstored earlier (and Jinja then renders the list's repr instead of the value). If a board file re-declares per-gate indexed symbols, gate them on exactly the conditions the feature file uses (e.g.MMU_HAS_PER_GATE_NFC_READERS&&PARAM_NUM_GATES > $(i)) so they only exist when the feature file defines them anyway. (Singletons get ungrouped, so a clobber needs ≥2 — which@repeathappily provides.) @repeatis a per-parse multiplier — use sparingly. It is a line-level preprocessor (reference item 6): the body is duplicatedmax-min+1times on everyKconfig()construction, uncached (multi- unit pays it per unit), and each expanded line becomes real symbol nodes in the parse — so amin=0 max=11block costs 12× the body's node count on every pass. In this forkmin/maxare literal integers only, so a@repeatis never dynamic: when a loop's count is static (here, always), unroll it in the file instead of adding a new@repeatblock. The established per-gate blocks (Kconfig.pins,Kconfig.nfc_reader,Kconfig.environment_sensor, ...) predate this guidance — don't add to that footprint, and unroll when you end up editing one of them per-board anyway.
Quick reference
make menuconfig # interactive (usually via ./install.sh -i)
make olddefconfig # fill new defaults into .mmu_config (stale-only in install.sh)
make verify_pickle # every explicit CONFIG_* assignment survives as_dict()
# (needs an existing .mmu_config)
make variables # interpreters + file sets, printed
make test UT=test_mmu_config.py | test_mmu_profiles.py | test_menuconfig.py
# or: make test ALL=1 — whole suite, non-interactive
make console ARGS='--profile boxturtle'
make diff # installed vs freshly built configs