Ship the ff-*.cfg includes in printer.base.cfg

The package installed every file the mod needs and then relied on the user
to hand-edit printer.cfg with seven [include] lines. That was the one
install step nobody could automate, because printer.cfg is the user's file
and run-append.sh may not write it -- so a freshly flashed printer had all
seven configs sitting in /usr/data/config/ and not one of them loaded, and
came up as a plain printer with no toolchanger and nothing saying so.

printer.base.cfg is ours, and the stock run.sh force-copies it to
/usr/data/config/ on every flash -- the same directory as the ff-*.cfg, so
relative includes resolve. Putting them at its end wires the mod up at
flash time and makes the undo button complete: flashing the stock
FlashForge package restores its own printer.base.cfg and the includes go
with it, leaving the ff-*.cfg as inert files rather than as a config
referencing extras that are no longer in /usr/prog.

It also retires the trap in the old instructions. "Append to printer.cfg"
is wrong for any printer that has run SAVE_CONFIG: an [include] below the
"#*#" marker makes Klipper read the whole autosave block as corruption
("modifications after header") and discard every calibrated value -- PID,
mesh, shaper, nozzle and dock positions.

Most of the documented ordering rules turn out not to exist. Nothing needs
to follow [virtual_sdcard]: virtual_sdcard registers SDCARD_PRINT_FILE and
M23 in __init__, ff_print takes them over at klippy:connect, and by then
every section is loaded. That was true of the old gcode_macro wrapper and
stayed in the docs after the move to ff_print.py. gcode_macro's
rename_existing defers to klippy:connect too, so the G28 and
SHAPER_CALIBRATE wrappers do not need their targets declared first either,
and ff-print-macros.cfg reads _FF_FILAMENT through a runtime template
lookup rather than at parse time. What survives is override order:
ff-runout.cfg after printer.filament.cfg's sensors, ff-chamber.cfg after
printer.chamber.cfg. End-of-file satisfies both.

ff_print.py's error message said "must be loaded after [virtual_sdcard]",
which pointed at the wrong problem; it can only fire when there is no
[virtual_sdcard] at all.

Both halves are checked on the replica now: case-install.sh asserts the
includes and files are present after a flash, case-recovery.sh asserts a
stock flash takes the includes away again. test_includes.py covers the
wiring in the repo -- every shipped ff-*.cfg included, every include
resolving, and the block after everything it overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-08-24 15:48:35 +03:00
1 parent f10d002cf6
commit 9b1f944293
9 files changed
+274 -39

No files matched your search

+3 -2
View File
@@ -62,8 +62,9 @@ STOCK_BASE=work/software/klipper/config/printer.base.cfg
OURS=payload/klipper/config/printer.base.cfg
if [ -f "$STOCK_BASE" ] && [ -f "$OURS" ]; then
# Compare only the section/option lines: comments and blank lines differ by
# design, and our chamber include replaces their heater block.
strip() { grep -vE '^\s*(#|$)' "$1" | grep -vE '^\[(heater_generic|verify_heater) chamber_heater\]|^\[include printer\.chamber\.cfg\]'; }
# design, our chamber include replaces their heater block, and the ff-*.cfg
# includes at the end of the file are ours (see printer.base.cfg's tail).
strip() { grep -vE '^\s*(#|$)' "$1" | grep -vE '^\[(heater_generic|verify_heater) chamber_heater\]|^\[include printer\.chamber\.cfg\]|^\[include ff-[a-z-]+\.cfg\]'; }
if strip "$STOCK_BASE" | diff -q - <(strip "$OURS") >/dev/null 2>&1; then
echo ">> printer.base.cfg matches the stock file"
else
+46 -15
View File
@@ -79,14 +79,28 @@ and stay next to the machine until you trust it.
re-run step 1 after every firmware update. The `#*#` block in
`printer.cfg` is on the data partition and survives.
## Install by hand
## Install
A package built from this repo installs all of this for you — see
[building.md](building.md). What follows is the manual route, for dropping a
single edited file onto a printer that is already modded.
A package built from this repo needs no config editing at all. It ships:
On the printer (ssh as `pwned` — this assumes a jailbroken printer; how to
get root/ssh access is covered in the community
* the `ff_*.py` extras, into the Klipper tree it flashes (`bin/patch.sh`)
* the `ff-*.cfg`, to `/usr/data/config/` (`run-append.sh`, keeping any you
edited and leaving the new one as `.mod-new`)
* the `[include]` lines for all seven, at the end of `printer.base.cfg` —
which the stock `run.sh` force-copies to `/usr/data/config/` on every flash
`printer.cfg` is never touched by any of that, by design: it is the user's
file. That is also why the includes live in `printer.base.cfg` and not there
— and it makes the undo button work, since flashing the stock FlashForge
package restores its own `printer.base.cfg` and the includes go with it.
Flash, then jump to step 5 below to import your unit's calibration.
### By hand
For dropping a single edited file onto a printer that is already modded. On
the printer (ssh as `pwned` — this assumes a jailbroken printer; how to get
root/ssh access is covered in the community
[Discord](https://discord.gg/tYs3eNEDq)):
```sh
@@ -99,22 +113,37 @@ scp payload/klipper/config/ff-*.cfg \
pwned@PRINTER:/usr/data/config/
```
3. Append to `/usr/data/config/printer.cfg`. **All of these are required** —
they are not optional drop-ins, and without them Klipper comes up as a
plain printer with no toolchanger. Order matters: they must come AFTER
`[virtual_sdcard]` is defined, and must **not** go in
`printer.override.cfg`, which is included first:
3. Make sure something includes them. A flashed printer already does, at the
end of `printer.base.cfg`. **All seven are required** — they are not
optional drop-ins, and without them Klipper comes up as a plain printer
with no toolchanger:
```ini
[include ff-toolchange.cfg]
[include ff-tool-offset.cfg]
[include ff-filament.cfg]
[include ff-print-macros.cfg] ; after ff-filament.cfg (the nozzle clean uses it)
[include ff-print-macros.cfg]
[include ff-runout.cfg] ; after printer.base.cfg (overrides its sensor sections)
[include ff-chamber.cfg] ; M141/M191; follows whatever printer.chamber.cfg declared
[include ff-legacy.cfg] ; temporary — only for step 5
[include ff-legacy.cfg] ; harmless to leave in — see step 8
```
> **If you put these in `printer.cfg` instead, they must go ABOVE the
> `#*# <---------------------- SAVE_CONFIG ---------------------->`
> marker.** Klipper treats any non-`#*#` line below that marker as
> corruption, discards the entire autosave block, and every calibrated
> value — PID, mesh, shaper, nozzle and dock positions — stops being read.
Only two ordering rules are real, and both are about one file overriding
another's values, which Klipper applies in include order: `ff-runout.cfg`
after the sensor sections in `printer.base.cfg`, and `ff-chamber.cfg`
after `printer.chamber.cfg`. Placement relative to `[virtual_sdcard]` does
**not** matter — `ff_print` takes `SDCARD_PRINT_FILE`/`M23` over at
`klippy:connect`, once every section is loaded — and neither does
placement relative to the commands the `G28` and `SHAPER_CALIBRATE`
wrappers rename, since `gcode_macro` defers its rename to `klippy:connect`
as well.
4. `RESTART` (or reboot).
5. Import the factory/touchscreen calibration once:
@@ -137,8 +166,10 @@ scp payload/klipper/config/ff-*.cfg \
nothing should say `NOT CALIBRATED`, "no dock position" or "unsaved
calibration pending".
8. Remove the `[include ff-legacy.cfg]` line and `RESTART`. The import is
one-shot; the touchscreen's JSON is not consulted again.
8. Optionally remove the `[include ff-legacy.cfg]` line and `RESTART`. Not
required: the import is one-shot, and `ff_legacy`'s startup auto-import
returns early as soon as any tool has a nozzle position, so the section is
inert once step 6 has run. A flashed printer keeps it included.
## Verify
+8 -7
View File
@@ -9,16 +9,17 @@
# Klipper fork ships no START/END/PAUSE/RESUME/CANCEL macros. These macros
# reproduce the app's sequences for prints started from Mainsail.
#
# REQUIRED -- this is not an optional drop-in; the mod does not work
# without it. Include from printer.cfg AFTER [pause_resume], [virtual_sdcard] and
# ff-filament.cfg (the nozzle clean reuses its helpers and tunables):
# [include /usr/data/config/ff-filament.cfg]
# [include /usr/data/config/ff-print-macros.cfg]
# REQUIRED -- this is not an optional drop-in; the mod does not work without
# it. printer.base.cfg includes it, along with the rest of the ff-*.cfg set,
# at its end. It needs [pause_resume], [virtual_sdcard] and ff-filament.cfg
# (the nozzle clean reuses its helpers and tunables) to EXIST, but not to come
# first: nothing here reads their config at parse time.
#
# [ff_print] (klippy-extras/ff_print.py) is declared at the end of this file:
# it wraps SDCARD_PRINT_FILE and M23 so a print prepares the machine for
# itself, with no slicer changes at all. Those are the commands it takes over,
# which is why this file has to be included after [virtual_sdcard].
# itself, with no slicer changes at all. It takes those commands over at
# klippy:connect, once every section is loaded, so it does not care whether
# [virtual_sdcard] was declared before or after this file.
#
# Deliberate divergences from firmwareExe (documented in OKF/61):
# * resume reheats all tools in parallel, not e1+e2 then e3+e4 staged
+23 -11
View File
@@ -3,8 +3,8 @@
# ff-toolchange.cfg -- configuration for the native Creator 5 Pro toolchanger.
#
# Implementation: klippy-extras/ff_tool.py + ff_toolchange.py (copy both to
# /usr/prog/klipper/klippy/extras/).
# Implementation: klippy-extras/ff_tool.py + ff_toolchange.py. A built package
# installs them; see INSTALL below for the by-hand route.
#
# It is a Python extra rather than a set of gcode_macros because firmwareExe's
# sequence is a polling state machine -- it waits for the grab sensor to read
@@ -66,18 +66,30 @@
#
# ---------------------------------------------------------------------------
# INSTALL
# 1. cp klippy-extras/ff_tool.py klippy-extras/ff_toolchange.py \
# /usr/prog/klipper/klippy/extras/
# A package built from this repo needs none of this: bin/patch.sh puts the
# ff_*.py extras into the Klipper tree it flashes, the stock run.sh copies
# the ff-*.cfg to /usr/data/config/, and printer.base.cfg ships the
# [include] lines for all of them. Flashing is the whole install.
#
# By hand, onto a printer that is already modded:
# 1. cp klippy-extras/ff_*.py /usr/prog/klipper/klippy/extras/
# (Klipper lives on the firmware partition -- klipper_pri.sh runs
# /usr/prog/klipper/klippy/klippy.py. It may be mounted read-only, and an
# OTA will overwrite it, so keep a copy and a re-apply script.)
# 2. append to /usr/data/config/printer.cfg: [include ff-toolchange.cfg]
# REQUIRED -- the whole ff-*.cfg set is mandatory, not a menu of
# optional drop-ins. Without them Klipper comes up as a plain
# printer: no toolchanger, no tool offsets, no runout handling.
# It MUST come after [virtual_sdcard] -- the SDCARD_PRINT_FILE wrapper
# below uses rename_existing. Do NOT put it in printer.override.cfg, which
# is included first, before virtual_sdcard exists.
# 2. cp the ff-*.cfg to /usr/data/config/ and make sure something includes
# them -- printer.base.cfg does, at its end. All of them: the set is
# mandatory, not a menu of optional drop-ins. Without it Klipper comes up
# as a plain printer: no toolchanger, no tool offsets, no runout handling.
# If you add includes to printer.cfg instead, they MUST go ABOVE its
# "#*# <--- SAVE_CONFIG --->" marker: Klipper reads anything below that
# marker that is not a "#*#" line as corruption and drops the entire
# autosave block, taking every calibrated value with it.
# Section ORDER does not matter for [virtual_sdcard] or for the G28 /
# SHAPER_CALIBRATE wrappers below -- ff_print and gcode_macro both do
# their command takeover at klippy:connect, after all sections load.
# It does matter where one file overrides another's values: ff-runout.cfg
# after printer.base.cfg's sensor sections, ff-chamber.cfg after
# printer.chamber.cfg.
# 3. FF_IMPORT_FIRMWARE_CONFIG + SAVE_CONFIG (see README) fills in the
# dock and nozzle data.
# 4. Run TOOLCHANGE_STATUS and confirm the sensor names AND the geometry
+39 -2
View File
@@ -361,5 +361,42 @@ z_hop: 10
z_hop_speed: 3
move_to_previous: False
# ---------------------------------------------------------------------------
# The mod's Klipper config. Shipped here, at the END of this file, rather than
# hand-appended to printer.cfg -- printer.cfg is the user's and nothing in the
# package may write it (run-append.sh), so a manual edit was the one install
# step nobody could automate and everybody could get wrong. There is a sharp
# edge there too: an [include] below printer.cfg's "#*# SAVE_CONFIG" marker
# makes Klipper discard the whole autosave block ("modifications after
# header", configfile.py), silently losing every calibrated value.
#
# This file is force-copied to /usr/data/config/ by the stock run.sh on every
# flash, into the same directory as the ff-*.cfg -- so the relative includes
# below resolve, and flashing the stock FlashForge package removes them again
# by simply restoring its own printer.base.cfg. That is the undo button.
#
# REQUIRED, all of them: the ff-*.cfg set is mandatory, not a menu of optional
# drop-ins. Without it Klipper comes up as a plain printer -- no toolchanger,
# no tool offsets, no runout handling.
#
# Why the END of the file. Only two ordering rules survive, and both are
# about overriding a config VALUE, which Klipper applies in include order:
# ff-runout.cfg overrides the filament-sensor sections above
# ff-chamber.cfg follows whatever printer.chamber.cfg (line 132) declared
# Rules that used to be here and are NOT real: nothing needs to follow
# [virtual_sdcard] (ff_print.py hooks SDCARD_PRINT_FILE/M23 at klippy:connect,
# by which point every section exists), and gcode_macro's rename_existing
# defers to klippy:connect too, so the G28 and SHAPER_CALIBRATE wrappers do
# not need their targets declared earlier either.
#
# ff-legacy.cfg stays included permanently: its auto-import returns early as
# soon as any tool has a nozzle position, so it is inert after the first
# SAVE_CONFIG and costs nothing to leave in.
# ---------------------------------------------------------------------------
[include ff-toolchange.cfg]
[include ff-tool-offset.cfg]
[include ff-filament.cfg]
[include ff-print-macros.cfg]
[include ff-runout.cfg]
[include ff-chamber.cfg]
[include ff-legacy.cfg]
+4 -2
View File
@@ -157,8 +157,10 @@ class FFPrint:
prev = self.gcode.register_command(cmd, None)
if prev is None:
raise self.printer.config_error(
"ff_print: command '%s' is not registered -- [ff_print]"
" must be loaded after [virtual_sdcard]" % (cmd,))
"ff_print: command '%s' is not registered -- is there a"
" [virtual_sdcard] section in the config? (Section ORDER"
" does not matter: this runs at klippy:connect, after"
" every section is loaded.)" % (cmd,))
self.prev_handlers[cmd] = prev
self.gcode.register_command(
cmd, self._make_handler(cmd),
+16
View File
@@ -189,6 +189,22 @@ fi
grep -q 'USER-CONFIG-MUST-SURVIVE' /usr/data/config/printer.cfg 2>/dev/null \
&& ok "user printer.cfg preserved" || bad "user printer.cfg was clobbered"
# The mod's Klipper config only does anything if something includes it, and
# printer.cfg is the user's file that the package may not write. So the
# includes ship in printer.base.cfg. Without them the printer boots as a plain
# machine: no toolchanger, no tool offsets, no runout handling -- and nothing
# says so out loud, which is why this is checked here.
MISSING=""
for c in ff-toolchange ff-tool-offset ff-filament ff-print-macros \
ff-runout ff-chamber ff-legacy; do
[ -f "/usr/data/config/$c.cfg" ] || MISSING="$MISSING $c.cfg(file)"
grep -q "^\[include $c\.cfg\]" /usr/data/config/printer.base.cfg 2>/dev/null \
|| MISSING="$MISSING $c.cfg(include)"
done
[ -z "$MISSING" ] \
&& ok "printer.base.cfg includes the whole ff-*.cfg set, and all are present" \
|| bad "the mod's Klipper config is not wired up:$MISSING"
if [ -d /usr/data/anvil/backup ]; then
n=$(find /usr/data/anvil/backup -type f | wc -l)
[ "$n" -gt 0 ] && ok "installer wrote $n backup file(s)" || bad "backup dir is empty"
+12
View File
@@ -88,6 +88,18 @@ grep -q 'USER-CONFIG-MUST-SURVIVE' /usr/data/config/printer.cfg 2>/dev/null \
&& ok "user printer.cfg preserved through both flashes" \
|| bad "user printer.cfg was clobbered"
# The mod's Klipper config is wired in by printer.base.cfg, which the stock
# run.sh force-copies over on every flash. That is what makes the revert
# complete: the ff-*.cfg are left on the data partition (inert, like the rest
# of the payload) but nothing includes them any more. If a stock reflash left
# the includes behind, klippy would come up referencing [ff_toolchange] with
# the extras gone from /usr/prog -- a printer that will not start.
if grep -q '^\[include ff-' /usr/data/config/printer.base.cfg 2>/dev/null; then
bad "printer.base.cfg still includes the mod's ff-*.cfg after a stock flash"
else
ok "printer.base.cfg restored -- nothing includes the mod's config"
fi
echo
[ "$FAIL" = 0 ] && echo " recovery clean: a stock package fully reverts the mod" \
|| echo " RECOVERY TEST FAILED"
+123
View File
@@ -0,0 +1,123 @@
"""printer.base.cfg must include the whole ff-*.cfg set, in a safe place.
The mod's config only takes effect if something includes it, and the one file
that may not be written by the package is printer.cfg -- it is the user's.
So printer.base.cfg carries the includes, and this checks the two things that
silently break if it stops:
* every ff-*.cfg we ship is included, and every include resolves to a file
we ship. A new config added to payload/klipper/config/ and not wired up
would otherwise be dead weight nobody notices.
* the includes sit after the sections they override. Klipper applies
includes in parse order, so ff-chamber.cfg has to follow
printer.chamber.cfg, and ff-runout.cfg the filament sensors that
printer.filament.cfg (included at the top) declares. Putting the block at
the end of the file satisfies both; this fails if someone moves it up.
Ordering rules that are NOT checked here because they are not real: nothing
needs to follow [virtual_sdcard], and the G28 / SHAPER_CALIBRATE wrappers do
not need their targets declared first. ff_print and gcode_macro both take
their commands over at klippy:connect, once every section is loaded.
Needs nothing proprietary: the configs are all in the repo.
"""
import os
import re
import pytest
INCLUDE = re.compile(r"^\[include\s+(\S+)\]\s*$")
# Everything the mod ships, in the order printer.base.cfg must list them.
# ff-runout and ff-chamber are last because they override earlier sections.
EXPECTED = [
"ff-toolchange.cfg",
"ff-tool-offset.cfg",
"ff-filament.cfg",
"ff-print-macros.cfg",
"ff-runout.cfg",
"ff-chamber.cfg",
"ff-legacy.cfg",
]
def base_cfg(cfgdir):
return os.path.join(cfgdir, "printer.base.cfg")
def includes(cfgdir):
"""[(lineno, spec)] for every [include] in printer.base.cfg, in order."""
out = []
with open(base_cfg(cfgdir), encoding="utf-8", errors="replace") as fh:
for n, raw in enumerate(fh, 1):
m = INCLUDE.match(raw.strip())
if m:
out.append((n, m.group(1)))
return out
def ff_includes(cfgdir):
return [(n, s) for n, s in includes(cfgdir) if s.startswith("ff-")]
def shipped(cfgdir):
return sorted(f for f in os.listdir(cfgdir)
if f.startswith("ff-") and f.endswith(".cfg"))
def test_some_includes_are_found(cfgdir):
"""Guard against the regex matching nothing and every test below passing."""
assert includes(cfgdir), "no [include] parsed from printer.base.cfg"
def test_every_shipped_ff_cfg_is_included(cfgdir):
included = {s for _, s in ff_includes(cfgdir)}
missing = [f for f in shipped(cfgdir) if f not in included]
assert not missing, (
"shipped but never included, so dead on the printer: %s" % missing)
def test_every_ff_include_resolves(cfgdir):
"""A typo'd include is not a warning -- klippy refuses to start."""
for _, spec in ff_includes(cfgdir):
assert os.path.isfile(os.path.join(cfgdir, spec)), (
"printer.base.cfg includes '%s', which we do not ship" % spec)
def test_ff_includes_are_the_expected_set(cfgdir):
assert [s for _, s in ff_includes(cfgdir)] == EXPECTED
def test_ff_includes_come_last(cfgdir):
"""After every other include, so an override in ours actually wins."""
ff_lines = [n for n, _ in ff_includes(cfgdir)]
other = [n for n, s in includes(cfgdir) if not s.startswith("ff-")]
assert other, "printer.base.cfg no longer includes any stock config"
assert min(ff_lines) > max(other), (
"an ff-*.cfg include at line %d precedes a stock include at line %d "
"-- ours must be able to override theirs" % (min(ff_lines), max(other)))
@pytest.mark.parametrize("ours,theirs", [
# ff-chamber.cfg's macros build on whatever the model's chamber declared.
("ff-chamber.cfg", "printer.chamber.cfg"),
# ff-runout.cfg redeclares the fd_ex*/fm_ex* sensors that file sets up.
("ff-runout.cfg", "printer.filament.cfg"),
])
def test_override_follows_what_it_overrides(cfgdir, ours, theirs):
by_spec = {s: n for n, s in includes(cfgdir)}
for spec in (ours, theirs):
assert spec in by_spec, "printer.base.cfg no longer includes %s" % spec
assert by_spec[ours] > by_spec[theirs], (
"%s (line %d) is included before %s (line %d), so its overrides lose"
% (ours, by_spec[ours], theirs, by_spec[theirs]))
def test_nothing_follows_the_ff_block(cfgdir):
"""The block is the tail of the file; a section added after it would
parse after our overrides and quietly win."""
text = open(base_cfg(cfgdir), encoding="utf-8", errors="replace").read()
tail = text.split("[include %s]" % EXPECTED[-1], 1)[1]
stray = [l for l in tail.splitlines()
if l.strip() and not l.lstrip().startswith("#")]
assert not stray, "config lines after the ff-*.cfg include block: %s" % stray