Put back the one file a stock flash cannot

A printer flashed back to stock came up with the FlashForge screen and no
Klipper behind it, and stayed that way however many times the stock package
was flashed. The .cfg files were the obvious suspect and are not the fault:
stock's run.sh does `cp $WORK_DIR/klipper/config/* /usr/data/config/ -rf`, and
the printer's busybox unlinks a dangling symlink rather than writing through
it, so printer.base.cfg and the seven beside it come back on every stock
flash. The ff-*.cfg links left behind resolve to nothing and are included by
nothing. Even the SAVE_CONFIG block our Klipper wrote into printer.cfg is
harmless -- configfile.py marks every autosave section valid through
access_tracking, so `[ff_tool 0]` there is not a config error on a stock
klippy. All three were put to the printer's own binaries in the replica.

What survives is /usr/prog/klipper/klipperDaemon. Every release links it at
the mod, whose `start` does nothing on purpose -- klippy is an s6-rc longrun
and a second unsupervised one would fight it for /dev/ttyS4 -- and the stock
package has no klipperDaemon to copy over it: not in the software component,
not in its md5sum.list, no line in run.sh. Stock's start.sh ends in
`klipperDaemon start`, so the UI starts and klippy never does.

installer/recovery.sh is the repair, packed by bin/pack-recovery.sh into a
.tgz that carries no mod at all: FlashForge's own klipperDaemon, taken off a
factory /usr/prog and gated on its md5, and their Klipper configs out of the
stock package the build reads. It sweeps FlashForge's config directory by
shape rather than by a list of names -- a symlink into $MODDIR there can only
be ours -- refuses to run while the mod is still the firmware, and never
touches printer.cfg, which no package has a copy of to put back. Output goes
to work/recovery/: bin/pack.sh wipes work/out, and the replica lane installs
the newest .tgz there as the mod under test.

qa/replica/test_recovery.py holds the whole story against a real machine: the
link the mod leaves, the busybox cp, the refusal, `klipperDaemon start`
leaving no pid file, then the same command forking a real klippy once the file
is back.

Publishing lives in Klipper4FlashForge/stock-recovery, which checks this repo
out and releases there, so a repair for owners of old releases is not buried
among firmware releases they must not flash to get it.

Two things found on the way, both in the harness rather than the mod:

seed-prog.sh appended USER-CONFIG-MUST-SURVIVE as a plain comment after the
SAVE_CONFIG header, and Klipper drops the entire autosave block on the first
line there that does not start with '#*#'. Every replica ran with klippy
seeing none of the machine's saved PID, shaper, mesh or probe values, and
failing on an unrelated-looking missing option. It is written inside the block
now, an older image's copy is repaired in place, and test_install.py asserts
the invariant.

installed_image() keyed its cache on the package md5 alone, so a rebuilt
PRINTER_IMAGE silently handed back a bake from the old base -- which made the
fix above read as ineffective twice before the cache was the suspect. The tag
mixes in the base image id now, and build-printer-image.sh takes IMAGE_TAG so
a rebuilt image can be published without moving a tag ci.yml pins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015hdzEqXuFcY4xXALGPbBFV
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-09-07 20:47:04 +03:00
1 parent cfaa7e4ff9
commit ebc3d60c58
17 files changed
+1103 -21

No files matched your search

+5 -1
View File
@@ -270,7 +270,11 @@ jobs:
`/usr/data/logs/helixscreen.log`. There is no automatic fallback --
`apk del anvil-helixscreen` to boot headless on
purpose. **To go back to stock**, put FlashForge's own package on a
USB stick and power on -- that path is tested on every release too.
USB stick and power on. Their package restores everything it
carries; the one file it has no copy of is the script that starts
Klipper, and a printer that came back with a working screen and no
Klipper is repaired by the recovery package on the `recovery-v*`
releases. Releases from September 2026 leave that file alone.
---
+28
View File
@@ -9,6 +9,34 @@ both printer models or through every real-world workflow.
Release preparation and documentation sweep. No firmware behavior is changed
by this entry.
### Added
- A recovery package, built with `make recovery`, for printers that went back
to stock and came up with a working screen and no Klipper. A stock flash
restores every file Reforge points at itself except `klipperDaemon`, which
the FlashForge package does not carry: the printer keeps Reforge's copy,
whose `start` is a deliberate no-op, and stock's `start.sh` therefore starts
no Klipper at all. The package restores FlashForge's own file, clears the
dead config links an older release left behind, and puts FlashForge's
Klipper configs back. It refuses to run on a printer still running Reforge,
and never touches `printer.cfg`. See
[Going back to stock](docs/going-back-to-stock.md). It is built and
published from
[Klipper4FlashForge/stock-recovery](https://github.com/Klipper4FlashForge/stock-recovery),
which checks this repo out and releases there, so a repair for owners of old
releases is not buried among firmware releases they must not flash to get
it.
### Fixed
- The replica's seeded `printer.cfg` no longer voids its own SAVE_CONFIG
block. `seed-prog.sh` appended the `USER-CONFIG-MUST-SURVIVE` marker as a
plain comment after the SAVE_CONFIG header, and Klipper discards the entire
autosave block on the first line there that does not start with `#*#` — so
on every replica, klippy saw none of the machine's saved PID, input shaper,
bed mesh or probe values, and stopped on an unrelated-looking missing
option. `qa/replica/test_install.py` now asserts the invariant.
## v20260827c-melitopol — 2026-08-27
### Added
+43 -3
View File
@@ -89,6 +89,7 @@ ifeq ($(LOCAL),)
-v $(DOCKER_SOCK):$(DOCKER_SOCK) \
-e TEST_ENV -e PRINTER_IMAGE -e REAL_PKG -e SIM_IMAGE \
-e SIM_VERBOSE -e PROG_MB -e DATA_MB -e REQUIRE_PRINTER_SIM \
-e IMAGE_NS -e IMAGE_NAME -e IMAGE_TAG \
$(IMAGE)
RUNTTY = $(subst --rm -i,--rm -it,$(RUNSIM))
else
@@ -105,7 +106,7 @@ RUNBLDTTY = $(subst --rm -i,--rm -it,$(RUN))
.PHONY: help image shell passwd build vendor packages \
printer-image printer-image-push \
boot-screen boot-screen-sim \
qa qa-static qa-replica \
recovery qa qa-scripts qa-static qa-replica \
release clean distclean
help:
@@ -126,10 +127,15 @@ help:
@echo 'other one. MODEL=Creator5 make build builds the non-Pro variant.'
@echo
@echo 'Recovery: keep a copy of the STOCK FlashForge .tgz on a spare stick.'
@echo 'Flashing it restores every file the mod touches.'
@echo 'Flashing it restores every file the mod touches EXCEPT one --'
@echo 'klipperDaemon, which their package does not carry, so the printer'
@echo 'comes back with a UI and no Klipper. That is what this builds:'
@echo ' make recovery the repair package -> work/recovery/'
@echo ' (flashed AFTER the stock package)'
@echo
@echo 'Test:'
@echo ' make qa both lanes'
@echo ' make qa-scripts the script checks alone: parses, bashisms, $$MODDIR guards'
@echo ' make qa-static needs nothing: parses, names, packaging, the boot graph'
@echo ' make qa-replica needs docker + the firmware: install, upgrade, boot'
@echo
@@ -249,6 +255,22 @@ release: image config.env
@echo; echo "dist/:"; ls -lh dist | awk 'NR>1{print " "$$9" "$$5}'
@echo; echo "Each file installs ONLY on the model in its name."
# The recovery package: one file, for printers that a downgrade to stock left
# with a working UI and no Klipper. It carries no mod at all -- FlashForge's
# klipperDaemon, which their own package does not restore, and their Klipper
# configs. See installer/recovery.sh for what is broken on those machines.
#
# It needs the stock package (for the configs and the model gate) and nothing
# `make build` produces, so it is fetch + unpack + pack rather than a build.
# One model per run, like `make build`: MODEL=Creator5 make recovery.
# Output is work/recovery/, deliberately not work/out: the replica lane installs
# "the newest .tgz in work/out" as the mod under test, and `make build` wipes
# that directory before it writes to it.
recovery: image config.env
@$(RUN) ./bin/fetch-assets.sh --stock
@$(RUN) ./bin/unpack.sh
@$(RUN) ./bin/pack-recovery.sh
# ===========================================================================
# TEST LANE -- never ships. Reads test.env for the replica settings; the only
# targets allowed to reach the docker daemon ($(RUNSIM)).
@@ -293,6 +315,15 @@ release: image config.env
qa: image
@$(RUNSIM) python3 -m pytest ./qa -q
# The part of the static lane that needs no package feed: every script parses,
# nothing shipped is secretly bash, and no `[ -f $MODDIR/... ]` guard names a
# path no package ships. The rest of qa/static reads .apk files and needs
# `make packages` first, which the recovery workflow has no reason to do --
# it builds a package with no mod payload in it at all.
qa-scripts: image
@$(RUN) python3 -m pytest ./qa/static/test_shell_syntax.py \
./qa/static/test_moddir_paths.py -q
qa-static: image
@$(RUN) python3 -m pytest ./qa/static -q
@@ -311,6 +342,15 @@ qa-replica: image
# make printer-image build (one image serves both models)
# make printer-image-push build and push to Docker Hub
#
# REBUILDING IT AFTER A FIX TO ONE OF THE SCRIPTS IT CARRIES needs a new tag:
# ci.yml and release.yml pin the old one, and a release is blocked on that
# image, so pushing different contents to the same name moves the substrate a
# release was proved on without a commit saying so.
#
# IMAGE_TAG=1.9.7-1.2.9-20260810-r2 make printer-image-push
#
# then bump PRINTER_IMAGE in test.env.example, ci.yml and release.yml.
#
# Point PRINTER_IMAGE in test.env at it to use it.
#
# The image contains proprietary FlashForge firmware.
@@ -329,7 +369,7 @@ boot-screen-sim: image
@$(RUNSIM) ./tools/replica/sim-boot-screen.py
clean:
@rm -rf work/stage work/out work/modpayload-root
@rm -rf work/stage work/stage-recovery work/out work/recovery work/modpayload-root
@echo cleaned
distclean:
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Build the recovery package.
# ./bin/pack-recovery.sh -> work/recovery/<Machine>-anvil-recovery-<date>.tgz
#
# It is the mod package's shape with none of the mod in it: our installer under
# the name app_startup.sh runs, the panel images, and the two things a broken
# printer needs put back --
#
# prog/klipperDaemon FlashForge's own, from installer/stock/
# config/*.cfg FlashForge's own, out of the stock package here
#
# WHY klipperDaemon IS COMMITTED AND THE CONFIGS ARE NOT. Both are FlashForge's
# files and neither is ours to author. The configs are IN the stock package, so
# they are read out of work/software at build time and the package this was
# built from is the one they came from. klipperDaemon is not in it -- not in
# the software component, not in its md5sum.list, not in run.sh -- it exists
# only on the machine's own /usr/prog, put there at the factory. A build cannot
# read it from anywhere, and the printers this package exists for are exactly
# the ones whose copy is gone. So the repo carries one, taken off a factory
# /usr/prog and checked against its md5 below.
#
# Run ./bin/unpack.sh first: this reads work/software and work/outer, and does
# not need work/modpayload-root or anything else `make build` produces.
set -euo pipefail
. "$(dirname "$0")/common.sh"
# The recorded md5 of installer/stock/klipperDaemon, off a Creator5Pro factory
# /usr/prog running 1.9.7-1.2.9. The gate is not about trusting the file -- it
# is committed, it is reviewed -- but about the one edit nobody means to make:
# a stray line ending or a "helpful" tidy-up turns the file a broken printer
# executes into something no FlashForge machine has ever run.
STOCK_DAEMON_MD5=773741f6a2df3231cd52a3f441014037
DAEMON=installer/stock/klipperDaemon
[ -f "$DAEMON" ] || { echo "no $DAEMON -- the package would repair nothing" >&2; exit 1; }
_have=$(md5sum "$DAEMON" | awk '{print $1}')
if [ "$_have" != "$STOCK_DAEMON_MD5" ]; then
echo "$DAEMON has changed:" >&2
echo " expected $STOCK_DAEMON_MD5" >&2
echo " got $_have" >&2
echo " This file is FlashForge's and is restored verbatim onto printers." >&2
echo " If the change is deliberate, update STOCK_DAEMON_MD5 here and say" >&2
echo " in the commit message which machine the new copy came off." >&2
exit 1
fi
[ -d work/outer ] || { echo "run ./bin/unpack.sh first" >&2; exit 1; }
[ -d work/software/klipper/config ] || {
echo "no work/software/klipper/config -- run ./bin/unpack.sh first" >&2; exit 1; }
STAGE=work/stage-recovery
rm -rf "$STAGE"
# NOT work/out, for two reasons that each cost a run to find. The replica lane
# installs "the newest .tgz in work/out" as the mod under test, so a recovery
# package there is installed instead -- and reports itself as a mod that never
# reached the userdata partition. And bin/pack.sh begins `rm -rf work/out`, so
# the next `make build` would delete these without mentioning them.
OUT=work/recovery
mkdir -p "$STAGE/prog" "$STAGE/config" "$OUT"
# Which model. Same resolution as bin/pack.sh, and for the same reason: the
# gate baked into the installer and the filename app_startup.sh globs for have
# to agree.
PKG_MACHINE=$(cat work/.pkg_machine 2>/dev/null || echo "")
PKG_PID=$(cat work/.pkg_pid 2>/dev/null || echo "")
[ "$PKG_MACHINE" = unknown ] && PKG_MACHINE=""
[ "$PKG_PID" = unknown ] && PKG_PID=""
if [ -n "$PKG_MACHINE" ] && [ "$PKG_MACHINE" != "${TARGET_MACHINE:-$PKG_MACHINE}" ]; then
echo "MODEL MISMATCH: stock package is for '$PKG_MACHINE', TARGET_MACHINE='$TARGET_MACHINE'" >&2
exit 1
fi
OUT_MACHINE="${PKG_MACHINE:-${TARGET_MACHINE:-Creator5Pro}}"
OUT_PID="${PKG_PID:-${TARGET_PID:-0029}}"
echo ">> generating runFirmwareExe.sh ($OUT_MACHINE/$OUT_PID)"
sed -e "s/^MACHINE=.*/MACHINE=$OUT_MACHINE/" \
-e "s/^PID=.*/PID=$OUT_PID/" \
installer/recovery.sh > "$STAGE/runFirmwareExe.sh"
chmod +x "$STAGE/runFirmwareExe.sh"
# The substitutions are not optional: a package whose gate still says
# Creator5Pro because a sed missed installs on the wrong machine.
for _want in "MACHINE=$OUT_MACHINE" "PID=$OUT_PID"; do
grep -qxF "$_want" "$STAGE/runFirmwareExe.sh" || {
echo "runFirmwareExe.sh has no '${_want%%=*}=' line as expected" >&2; exit 1; }
done
sh -n "$STAGE/runFirmwareExe.sh" || {
echo "the generated runFirmwareExe.sh does not parse" >&2; exit 1; }
cp -f "$DAEMON" "$STAGE/prog/klipperDaemon"
chmod 755 "$STAGE/prog/klipperDaemon"
# FlashForge's own Klipper configs, straight out of the package bin/unpack.sh
# opened -- the same eight files stock's run.sh copies to /usr/data/config.
# printer.cfg is not among them and never has been: it is per-unit, and the
# calibration in its SAVE_CONFIG block is the owner's.
cp -f work/software/klipper/config/*.cfg "$STAGE/config/"
echo ">> carrying $(ls -1 "$STAGE/config" | wc -l) stock .cfg files"
cp -f installer/start.img "$STAGE/start.img"
for f in end.img play; do
[ -f "work/outer/$f" ] && cp -f "work/outer/$f" "$STAGE/"
done
echo ">> package contents:"
find "$STAGE" -type f | sed "s|^$STAGE/| |"
OUTFILE="$OUT/${OUT_MACHINE}-${MOD_NAME:-anvil}-recovery-${MOD_VER:?}.tgz"
echo ">> tarring + encrypting"
# Not gzipped despite the .tgz name: /usr/prog/bin/unTar pipes the decrypted
# stream straight into `tar xvf -`. The model prefix is what app_startup.sh
# globs for.
tar -cf - -C "$STAGE" . | openssl des3 -salt -md md5 -k "$FF_KEY" > "$OUTFILE"
echo
echo "Recovery package:"
ls -lh "$OUTFILE" | awk '{print " "$9" "$5}'
echo " installs on: $OUT_MACHINE only"
echo
echo "Sanity check (decrypt + list):"
openssl des3 -d -k "$FF_KEY" -salt -md md5 -in "$OUTFILE" 2>/dev/null \
| tar -tvf - | sed 's/^/ /'
echo
echo "FLASH THE STOCK FLASHFORGE PACKAGE FIRST, then this one."
echo "Afterwards: anvil-recovery.txt on the stick, /usr/data/anvil-recovery.log"
echo "on the printer."
+42
View File
@@ -113,6 +113,48 @@ and then re-runs the end-to-end install against the exact `dist/*.tgz` files
that get attached — not against a package built from the same tree, but those
files. Releases are marked pre-release.
## The recovery package
A separate, much smaller `.tgz` that carries no mod at all: FlashForge's own
`klipperDaemon` and their Klipper configs. It exists because a stock flash
restores neither of the first — their package has no `klipperDaemon` in it —
so a printer that ran a release from before September 2026 goes back to stock
with a working screen and no Klipper. `installer/recovery.sh` carries the
diagnosis at the top; [Going back to stock](going-back-to-stock.md) is the
owner-facing version.
```sh
make recovery # -> work/recovery/Creator5Pro-...tgz
MODEL=Creator5 make recovery # the other model
```
It needs the stock package and nothing else — no feed, no cross-compiler, no
signing key — so it is fetch, unpack, pack. Output goes to `work/recovery/`
rather than `work/out/`, because `make build` wipes that directory and the
replica lane treats the newest `.tgz` in it as the mod under test.
**It is published from another repository**, not from this one:
[Klipper4FlashForge/stock-recovery](https://github.com/Klipper4FlashForge/stock-recovery).
Its workflow checks this repo out at a ref you name, builds both models with
the commands above, and releases them there. Nothing about that needs a token:
this repo is public, so the recovery repo reads it with the checkout action and
publishes to itself with its own `GITHUB_TOKEN`.
Keeping it separate is what stops a repair for owners of old releases being
buried among firmware releases they must not flash to get it. A `recovery-v*`
tag pushed there is the whole release procedure; the sources stay here, and the
release notes record the commit they were built from.
That job runs `make qa-scripts` — the parse, dialect and `$MODDIR`-guard
checks, which is what applies to a package whose only executable is one shell
script — and then proves the built packages decrypt, that the installer inside
parses, that the `klipperDaemon` they carry is FlashForge's byte for byte, and
that each model's gate matches its filename. It does **not** rebuild the
firmware to re-run the replica lane. The recovery package's own replica gate
is `qa/replica/test_recovery.py`, which reproduces a downgraded machine and
asserts the repair; it is part of `make qa` and runs in `ci.yml` and
`release.yml` on every push.
## Publishing the feed
A release attaches two `.tgz` installers and one more file:
+68
View File
@@ -0,0 +1,68 @@
# Going back to stock
Flashing the stock FlashForge package for your model puts the printer back:
stick in, power on, the same as installing Reforge. It restores everything
Reforge changed **except one file**, and on printers that ran a Reforge
release from before September 2026 that one file is the difference between a
machine that prints and one that does not.
## The symptom
The printer starts, the FlashForge screen comes up and looks completely
normal — and there is no Klipper behind it. It will not home, will not heat,
will not print. Flashing the stock package again does not help, however many
times you do it.
If that is your printer, the repair is one more flash. Nothing is damaged and
nothing has been lost.
## Why it happens
Reforge points a few of the printer's own start-up files at its own copies.
The stock package carries replacements for almost all of them, so a stock
flash overwrites them and they go back to normal.
It does not carry `klipperDaemon` — the small script the printer runs at boot
to start Klipper. That file only ever came on the machine from the factory, so
a stock flash has nothing to put back, and the printer keeps running Reforge's
version. Reforge's version deliberately does nothing, because under Reforge
Klipper is started a different way. On stock, that means Klipper is never
started at all.
Releases from September 2026 onwards do not touch that file, so a printer
installed or updated since then goes back to stock cleanly.
## The repair
Download the recovery package from
[Klipper4FlashForge/stock-recovery/releases](https://github.com/Klipper4FlashForge/stock-recovery/releases).
It is a separate download from the firmware on purpose: nothing on that page
installs Reforge.
1. Flash the **stock FlashForge package** for your model first, if you have
not already. The recovery package expects a printer that is on stock.
2. Copy `<Model>-anvil-recovery-<date>.tgz` — the one whose name starts with
your model, `Creator5` or `Creator5Pro` — to the root of a FAT32 USB stick.
3. Plug it in and power the printer on. It flashes like any other package and
ends on the "power off" screen.
4. Power-cycle. Klipper starts with the screen again.
Afterwards the stick carries `anvil-recovery.txt`, saying what was repaired.
The same text is on the printer at `/usr/data/anvil-recovery.log`.
## What it changes, and what it does not
It restores `klipperDaemon`, clears the dead links Reforge left in the config
directory, and puts back FlashForge's own Klipper config files.
**Your `printer.cfg` is not touched.** The calibration saved in it — mesh,
input shaper, PID, offsets — is yours and stays exactly as it is.
It also does nothing at all on a printer that is still running Reforge: there
is nothing to repair there, so it refuses and says so on the stick. Flash the
stock package first.
## If you are still on Reforge and just want to leave
Flash the stock package, then flash the recovery package. In that order the
printer comes back with Klipper working the first time.
+2 -1
View File
@@ -109,7 +109,8 @@ working.
**Your `printer.cfg` is never touched** — not by this flash and not by any
update. It is your file, it is where your own settings go, and leaving it
alone is also part of why flashing back to stock works cleanly.
alone is also part of why [flashing back to
stock](going-back-to-stock.md) works cleanly.
Then work through [Your first print](first-print.md), in order, with the
emergency stop within reach: it starts with the checks that say whether the
+12
View File
@@ -13,3 +13,15 @@ In OrcaSlicer, open **Printer Settings**, change the upload protocol from
Slice the model again and upload the resulting `.gcode` file through the
Moonraker upload target.
## A printer that went back to stock has no Klipper
Printers that ran a Reforge release from before September 2026 keep one
Reforge file through a stock flash: the script the printer runs at boot to
start Klipper. The FlashForge package has no copy of it to put back, so the
screen comes up looking normal and nothing behind it works, however many times
the stock package is flashed.
Releases from September 2026 onwards leave that file alone. For a printer
already in that state, [Going back to
stock](going-back-to-stock.md) has the one-flash repair.
+11 -4
View File
@@ -1,7 +1,7 @@
# Support
Two things between them cover most of what can go wrong: the logs say what
happened, and flashing the stock package undoes everything.
happened, and flashing the stock package puts the printer back.
There are people to ask on [the Discord](https://discord.gg/ggJyfgVA4v).
Bring the logs.
@@ -10,11 +10,17 @@ Bring the logs.
## The undo button
**Flash the stock FlashForge package for your model.** It restores everything
it carries and installs the same way — stick in, power on. Keep a copy
downloaded before you start; the stock packages are published at
**Flash the stock FlashForge package for your model.** It installs the same
way — stick in, power on. Keep a copy downloaded before you start; the stock
packages are published at
[ghzserg/FF](https://github.com/ghzserg/FF/releases).
It restores everything it carries, which is everything Reforge changed bar one
file. On a printer that ran a Reforge release from before September 2026, that
one file leaves the machine with a normal-looking screen and no Klipper behind
it — see [Going back to stock](going-back-to-stock.md) for the symptom and the
one-flash repair.
---
## The logs
@@ -23,6 +29,7 @@ All on the data partition, and all surviving a reboot:
```
/usr/data/anvil-install.log what the installer did
/usr/data/anvil-recovery.log what the recovery package repaired
/usr/data/logs/anvil-boot.log services + UI choice at each boot
/usr/data/logs/printer.log klipper
/usr/data/logs/helixscreen.log helixscreen
+269
View File
@@ -0,0 +1,269 @@
#!/bin/sh
# THE RECOVERY INSTALLER -- packed as runFirmwareExe.sh by bin/pack-recovery.sh.
#
# It exists for one printer: a machine that ran a release from before
# 7914f03, was flashed back to a stock FlashForge package, and came up with
# the FlashForge UI and no Klipper behind it. Nothing on it is broken that a
# stock flash could reach, which is exactly why it stays broken through as
# many stock flashes as an owner cares to do.
#
# WHAT IS ACTUALLY WRONG WITH IT. Those releases pointed three stock paths at
# the mod with symlinks -- /usr/prog/PROGRAM/software/firmwareExe,
# /usr/prog/klipper/start.sh and /usr/prog/klipper/klipperDaemon. A stock
# package restores the first two: its run.sh copies its own firmwareExe and
# start.sh over them, and busybox `cp -f` unlinks a symlink rather than
# writing through it. It restores nothing over the third, because the stock
# package HAS no klipperDaemon -- it is not in the software component, not in
# its md5sum.list, and run.sh has no line for it.
#
# So the link survives, and it points at the mod's klipperDaemon, whose
# `start` deliberately does nothing: under the mod, klippy is an s6-rc
# longrun, and a second unsupervised one fighting for /dev/ttyS4 and /tmp/uds
# is the thing that shim exists to prevent. Stock's start.sh ends in
#
# /usr/prog/klipper/klipperDaemon start
#
# which now prints a line about s6 and exits 0. The UI starts, klippy never
# does, and the printer cannot print.
#
# THE .cfg FILES ARE NOT THE FAULT, though they look like the obvious suspect.
# The stock package's run.sh does
#
# cp $WORK_DIR/klipper/config/* /usr/data/config/ -rf
#
# and the printer's busybox replaces a dangling symlink with the real file, so
# printer.base.cfg and the seven beside it come back on any stock flash. The
# ff-*.cfg and printer.chamber.cfg links left behind resolve to nothing and
# are included by nothing -- stock's printer.base.cfg does not name them. The
# SAVE_CONFIG block our Klipper wrote into printer.cfg is harmless too:
# configfile.py marks every autosave section valid through access_tracking, so
# `[ff_tool 0]` in there is not a config error on a stock klippy. This script
# restores those files anyway (below) because a machine that has been through
# several releases is not always the machine the last one left, but the
# klipperDaemon restore is the repair.
#
# ORDER: THE STOCK PACKAGE FIRST, THEN THIS. This carries the one file a stock
# flash cannot put back, not the 26MB of FlashForge software component that it
# can. On a printer where the mod is still the running firmware there is
# nothing here to repair and restoring klipperDaemon would put a second klippy
# beside the supervised one, so that case refuses and says so.
#
# The contract with app_startup.sh is runFirmwareExe.sh's, unchanged:
#
# exit 0 the stick is unmounted, /usr/data/update deleted, end.img on
# the panel, "power-cycle me".
# exit != 0 the same cleanup, and the printer goes on booting.
#
# This runs under the printer's busybox ash. Keep it dialect-clean;
# qa/static/test_shell_syntax.py checks it as an installer/*.sh.
WORK_DIR=`dirname $0`
MODDIR=/usr/data/anvil
# FlashForge's config directory -- the one a stock klippy reads. (The mod's own
# is /usr/data/anvil-data/config, and this script does not touch it: it holds
# the owner's calibrated printer.cfg and is what a reinstall of the mod comes
# back to.)
STOCK_CONFIG=/usr/data/config
LOG=/usr/data/anvil-recovery.log
# The stick this was flashed from, which is the only place an owner with no UI
# and no ssh can read an answer from. app_startup.sh unmounts it after we exit,
# so anything written here has to be written before that.
REPORT=/mnt/anvil-recovery.txt
# Rewritten by bin/pack-recovery.sh from the stock package's own values, the
# same way bin/pack.sh does it for the mod installer. The line shape is read
# back with `sed -n 's/^MACHINE=//p'`, so keep it.
MACHINE=Creator5Pro
PID=0029
# ---------------------------------------------------------------- the gates --
# To the console: they run before there is anything to log about, and a refusal
# nobody can see is a printer that "did nothing" for no visible reason.
CHECK_ARCH=`uname -m`
if [ "$CHECK_ARCH" != mips ]; then
echo "Machine architecture error: $CHECK_ARCH"
exit 1
fi
# Empty arguments mean an old app_startup.sh that passed none, which FlashForge
# treated as installable. Two non-empty ones must match: klipperDaemon is not
# model-specific, but a package that installs on the wrong machine is a habit
# worth not having.
if [ -n "$1" ] && [ -n "$2" ]; then
if [ "$1" != "$MACHINE" ] || [ "$2" != "$PID" ]; then
echo "Firmware does not match machine type: got $1/$2, this package is $MACHINE/$PID."
exit 1
fi
fi
[ -f "$WORK_DIR/start.img" ] && cat "$WORK_DIR/start.img" > /dev/fb0 2>/dev/null
exec >>"$LOG" 2>&1
STAMP=`date +%Y%m%d-%H%M%S 2>/dev/null || echo manual`
echo "=== recovery $STAMP ==="
# Collected as it goes and written to the stick at the end, because the panel
# says only "done" and this is a repair an owner is entitled to see the shape
# of. `printf %b` and a growing string rather than a temp file: /tmp is a
# tmpfs the next boot clears, and there is no step here that could not hold
# its own output in a variable.
SUMMARY=""
note() {
echo "$1"
SUMMARY="$SUMMARY$1
"
}
finish() {
# Best-effort: a stick mounted read-only, or one already gone, must not
# turn a completed repair into a failed one. The log on /usr/data is the
# copy that always exists.
if [ -d /mnt ]; then
{
echo "Reforge recovery -- $STAMP"
echo
printf %s "$SUMMARY"
} > "$REPORT" 2>/dev/null
sync
fi
exit "$1"
}
# ------------------------------------------- is there anything to repair yet --
# The mod links three stock paths at itself. A stock flash restores two of
# them; if either is STILL a symlink then no stock package has been installed
# since the mod was, and this is a working modded printer rather than a broken
# stock one.
#
# Refusing is the whole point of the check. Restoring FlashForge's
# klipperDaemon here would leave a machine whose stock start.sh is gone (so
# nothing calls it) but whose next mod uninstall-by-flash is no longer the
# thing this was tested against -- and on a printer that still boots the mod,
# anything that DOES call klipperDaemon now forks an unsupervised klippy
# beside the s6 one, fighting it for /dev/ttyS4 and /tmp/uds.
still_modded=""
for stock_path in /usr/prog/PROGRAM/software/firmwareExe /usr/prog/klipper/start.sh; do
if [ -L "$stock_path" ]; then
still_modded="$still_modded $stock_path"
fi
done
if [ -n "$still_modded" ]; then
note "This printer is still running Reforge, so there is nothing to repair."
note ""
note "These are still the mod's links:$still_modded"
note ""
note "Flash the stock FlashForge package first. Then flash this one, which"
note "puts back the single file that stock flash cannot: klipperDaemon."
finish 1
fi
# --------------------------------------------------------- 1. klipperDaemon --
# THE REPAIR. Everything else in this file is tidying.
DAEMON=/usr/prog/klipper/klipperDaemon
SHIPPED=$WORK_DIR/prog/klipperDaemon
if [ ! -f "$SHIPPED" ]; then
# Nothing to restore FROM, which makes the package the broken thing. Said
# rather than worked around: an invented klipperDaemon would be a printer
# starting klippy on arguments nobody has ever run.
note "!! this package carries no prog/klipperDaemon -- it cannot repair anything"
finish 1
fi
if [ -L "$DAEMON" ]; then
target=`readlink "$DAEMON"`
note "klipperDaemon was a link to $target -- the mod's, and the reason"
note "klippy never started. Restoring FlashForge's own."
restore_daemon=1
elif [ ! -f "$DAEMON" ]; then
note "klipperDaemon was missing entirely. Restoring FlashForge's own."
restore_daemon=1
else
# A regular file is FlashForge's own, whatever version of it this machine
# has. It is left alone: this package carries one copy, taken from one
# firmware, and overwriting a working file with it would be a change made
# for the sake of making one.
note "klipperDaemon is a real file already -- left as it is."
restore_daemon=0
fi
if [ "$restore_daemon" = 1 ]; then
mkdir -p /usr/prog/klipper
# Through a temp name and a rename: this is the file the next boot's
# start.sh executes, and a half-written one is a printer that boots into
# nothing. The rm is because `cp` onto a dangling symlink would create the
# file at the link's target instead -- inside a /usr/data/anvil that may
# not even exist.
rm -f "$DAEMON.recovery-new"
if cp "$SHIPPED" "$DAEMON.recovery-new" &&
chmod 755 "$DAEMON.recovery-new" &&
rm -f "$DAEMON" &&
mv -f "$DAEMON.recovery-new" "$DAEMON"; then
sync
note "klipperDaemon restored."
else
rm -f "$DAEMON.recovery-new"
note "!! could not write $DAEMON -- the printer is unchanged."
finish 1
fi
fi
# ----------------------------------------------- 2. the mod's leftover links --
# BY WHAT THEY ARE, not by a list of names. Releases shipped different sets of
# ff-*.cfg over time and a name this file does not know is exactly the one
# still sitting on somebody's printer. A symlink into $MODDIR in FlashForge's
# config directory can only have been put there by the mod, and after a stock
# flash it resolves to nothing.
#
# Only symlinks, and only ones pointing into $MODDIR: a real file here is
# either FlashForge's or the owner's, and neither is this script's to delete.
swept=0
for f in "$STOCK_CONFIG"/*; do
if [ -L "$f" ]; then
target=`readlink "$f"`
case "$target" in
"$MODDIR"/*)
rm -f "$f" && swept=`expr $swept + 1`
echo "swept $f -> $target"
;;
esac
fi
done
if [ "$swept" -gt 0 ]; then
note "Removed $swept of the mod's dead config links from $STOCK_CONFIG."
fi
# ------------------------------------------------- 3. FlashForge's own .cfg --
# The eight files the stock software component ships, copied straight out of
# it by bin/pack-recovery.sh. A stock flash restores these itself, so this is
# belt and braces for a machine that has been through more releases than
# anyone can reconstruct -- but it costs nothing and it is the difference
# between "should be fine" and "is".
#
# printer.cfg IS NOT AMONG THEM and is never written here. It is per-unit: the
# SAVE_CONFIG block at its end is every calibrated value on the machine, and
# no package has ever shipped one to put back in its place.
restored=0
if [ -d "$WORK_DIR/config" ]; then
mkdir -p "$STOCK_CONFIG"
for f in "$WORK_DIR"/config/*.cfg; do
[ -f "$f" ] || continue
name=`basename "$f"`
# rm first for the same reason as above: if a link is still standing
# here, cp would follow it instead of replacing it.
rm -f "$STOCK_CONFIG/$name"
if cp "$f" "$STOCK_CONFIG/$name"; then
restored=`expr $restored + 1`
else
note "!! could not restore $STOCK_CONFIG/$name"
fi
done
sync
fi
if [ "$restored" -gt 0 ]; then
note "Restored $restored of FlashForge's own Klipper config files."
note "Your printer.cfg -- and the calibration saved in it -- was not touched."
fi
note ""
note "Done. Power-cycle the printer; Klipper starts with the UI again."
finish 0
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh
#
# Start klipepr
#
PYTHON=/usr/prog/Python-3.8.2/bin/python3
KLIPPER=/usr/prog/klipper/klippy/klippy.py
#KLIPPER_CONF=/usr/prog/klipper/runConfig/printer.cfg
KLIPPER_CONF=/usr/data/config/printer.cfg
KLIPPER_LOG=/usr/data/logs/printer.log
PID_FILE=/run/klipper.pid
KLIPPER_UDS=/tmp/uds
KLIPPER_NICENESS=-20
start() {
mkdir -p $(dirname $KLIPPER_LOG) # make sure the log directory exists
start-stop-daemon -S -b -m -p $PID_FILE -N $KLIPPER_NICENESS --exec $PYTHON -- $KLIPPER $KLIPPER_CONF -l $KLIPPER_LOG -a $KLIPPER_UDS
}
stop() {
start-stop-daemon -K -p $PID_FILE
}
restart() {
stop
sleep 2
start
}
case "$1" in
start)
start
;;
stop)
stop
;;
restart|reload)
restart
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
esac
exit $?
+1
View File
@@ -82,6 +82,7 @@ nav:
- Pressure advance: pressure-advance.md
- VFA calibration: vfa-calibration.md
- Support: support.md
- Going back to stock: going-back-to-stock.md
- Known issues: known-issues.md
- Versions: versions.md
+22 -2
View File
@@ -414,7 +414,13 @@ def mod_package(config):
return chosen
out = ROOT / "work" / "out"
found = [p for p in out.glob("*-*.tgz") if p.is_file()]
# bin/pack-recovery.sh writes to work/recovery/, not here. The name filter
# is the second lock on the same door:
# a recovery package copied up here by hand would otherwise be installed
# as the mod, and the failure it produces -- "nothing reached the userdata
# partition" -- names neither the file nor the reason.
found = [p for p in out.glob("*-*.tgz")
if p.is_file() and "-recovery-" not in p.name]
if not found:
raise ReplicaMissing(
"no package in work/out, so there is nothing to install and the "
@@ -455,7 +461,21 @@ def installed_image(config=None, on_output=None):
base = resolve_image(config, docker)
pkg = mod_package(config)
tag = "creator5-printer-anvil:%s" % _md5(pkg)[:12]
# KEYED ON THE BASE TOO, not on the package alone. The bake installs a
# package INTO a replica image, so the result is a function of both --
# and a tag that named only the package silently handed back an image
# baked from a different PRINTER_IMAGE. That is not hypothetical: a fix
# to tools/replica/printer/seed-prog.sh was verified against a rebuilt
# base, the lane reused yesterday's bake, and the fix read as ineffective.
#
# The image ID when docker has it, the name when it does not (the first
# run pulls it during the bake below): either distinguishes one base from
# another, which is all this key has to do.
base_id = subprocess.run([docker, "image", "inspect", "-f", "{{.Id}}", base],
capture_output=True, text=True)
base_key = base_id.stdout.strip() if base_id.returncode == 0 else base
tag = "creator5-printer-anvil:%s" % hashlib.md5(
("%s\n%s" % (base_key, _md5(pkg))).encode()).hexdigest()[:12]
have = subprocess.run([docker, "image", "inspect", tag],
capture_output=True)
if have.returncode == 0:
+26
View File
@@ -339,6 +339,32 @@ def test_the_user_printer_cfg_was_not_clobbered(box):
assert cfg.exists, "user printer.cfg was clobbered by the install"
def test_the_save_config_block_is_still_readable(box):
"""Every line after the SAVE_CONFIG header starts with '#*#'.
That is configfile.py's rule, and it fails silently: `_find_autosave_data`
meets one line that breaks it, logs "autosave state corrupted" and throws
away the ENTIRE autosave block -- every PID constant, the input shaper, the
bed mesh, the probe offset. klippy then stops on "Option 'control' in
section 'heater_bed' must be specified", which names none of that.
Asserted here because the replica did exactly this to itself:
seed-prog.sh's USER-CONFIG-MUST-SURVIVE marker was appended as a plain
comment, landing after the header, so on this machine klippy could not see
one saved value. Nothing noticed until a test ran a stock klippy on it.
"""
text = box.file("/usr/data/config/printer.cfg").text
header = "SAVE_CONFIG"
if header not in text:
return # nothing has been saved on this machine yet
tail = text.split(header, 1)[1].split("\n", 1)[-1]
bad = [ln for ln in tail.split("\n") if ln.strip() and not ln.startswith("#*#")]
assert not bad, (
"these lines sit after the SAVE_CONFIG header and do not start with "
"'#*#', so Klipper discards the whole autosave block and the machine "
"loses every calibrated value in it: %r" % bad[:5])
# ------------------------------------------------------------ the install log
def test_the_installer_said_it_installed(box):
+357
View File
@@ -0,0 +1,357 @@
"""The printer that came back from a stock flash with a UI and no Klipper.
WHAT IS BROKEN ON IT, and it is one file. Every release links three stock
paths at the mod -- /usr/prog/PROGRAM/software/firmwareExe,
/usr/prog/klipper/start.sh and /usr/prog/klipper/klipperDaemon. A stock
FlashForge package restores the first two, because its run.sh copies its own
over them and the printer's busybox `cp -f` unlinks a symlink rather than
writing through it. It restores nothing over the third: the stock package has
no klipperDaemon at all -- not in the software component, not in its
md5sum.list, not in run.sh.
So the link survives, and the mod's klipperDaemon is a shim whose `start` does
nothing on purpose (klippy is an s6-rc longrun under the mod, and a second
unsupervised one would fight it for /dev/ttyS4 and /tmp/uds). Stock's start.sh
ends in `klipperDaemon start`. The UI comes up, klippy does not, and no number
of further stock flashes changes it.
WHAT THIS FILE ASSERTS, in the order the machine goes through it:
1. the mod really does leave klipperDaemon pointing into $MODDIR,
2. the recovery package REFUSES to run while the mod is still the firmware,
3. after the stock flash's effect on those three paths, `klipperDaemon
start` starts nothing -- the brick, demonstrated rather than described,
4. the recovery installer puts FlashForge's own file back, and then the same
command forks a real klippy,
5. it clears the mod's dead config links and restores FlashForge's .cfg,
6. and it does not touch printer.cfg, which holds the machine's calibration.
THE DOWNGRADE IS SIMULATED IN ONE RESPECT ONLY. Running the real 93MB stock
package through app_startup.sh here would prove nothing this does not: what a
stock flash does to these three paths is copy two files over two of them, and
`test_a_stock_cp_replaces_a_dangling_link` runs that exact `cp` shape, with the
printer's own busybox, against a dangling link. The fixture then puts the
machine in the state that produces -- two real files, one surviving mod link --
and everything after it is real.
WHAT THIS MODULE DOES TO THE MACHINE: it replaces two paths under /usr/prog
and installs FlashForge's klipperDaemon over the mod's. The container is
module-scoped and nothing else shares it. Tests are sequential steps against
one machine, in file order.
"""
import pytest
from lib.paths import ROOT
pytestmark = pytest.mark.replica
MODDIR = "/usr/data/anvil"
STOCK_CONFIG = "/usr/data/config"
DAEMON = "/usr/prog/klipper/klipperDaemon"
FIRMWARE_EXE = "/usr/prog/PROGRAM/software/firmwareExe"
START_SH = "/usr/prog/klipper/start.sh"
# Staged from the CHECKOUT, like test_upgrade.py stages the mod installer: the
# file under test is the one in the tree, not the copy some earlier bake left
# on the replica. Unsubstituted -- bin/pack-recovery.sh rewrites MACHINE=/PID=
# when it packs it, and the model gate is skipped here by passing no arguments,
# the case FlashForge's own script documents as "old firmware, upgradeable".
SOURCE = ROOT / "installer" / "recovery.sh"
DAEMON_SOURCE = ROOT / "installer" / "stock" / "klipperDaemon"
# $WORK_DIR for the run: app_startup.sh unpacks a package into a directory and
# runs runFirmwareExe.sh out of it, so the installer finds prog/ and config/
# beside itself. This is that directory.
WORK = "/tmp/recovery"
INSTALLER = WORK + "/runFirmwareExe.sh"
# Planted in printer.cfg by tools/replica/printer/seed-prog.sh. The only thing
# that can tell the machine's own config from one a package put there.
MARKER = "USER-CONFIG-MUST-SURVIVE"
# FlashForge's own, off a factory /usr/prog. bin/pack-recovery.sh gates the
# committed copy on this too; here it is what proves the file on the machine
# afterwards came from the package rather than from anything the mod left.
STOCK_DAEMON_MD5 = "773741f6a2df3231cd52a3f441014037"
def _md5(box, path):
return box.sh("md5sum %s 2>/dev/null" % path).out.split(" ")[0].strip()
@pytest.fixture(scope="module")
def staged(printer):
"""The recovery package, unpacked on the machine the way app_startup.sh
would leave it: the installer, prog/klipperDaemon, and FlashForge's .cfg.
The .cfg files come off the machine's OWN /usr/data/config rather than out
of the checkout, and that is not a shortcut -- the replica is baked by
installing the stock package, so those files are literally the ones stock's
run.sh copied there. Staging them from the host would mean shipping a copy
of work/software into the container to prove a point about a directory the
container already has.
"""
box = printer
for f in (SOURCE, DAEMON_SOURCE):
if not f.is_file():
pytest.fail("no %s in the checkout, and it is under test" % f)
box.sh("rm -rf %s; mkdir -p %s/prog %s/config" % (WORK, WORK, WORK))
box.write(INSTALLER, SOURCE.read_text(), mode="755")
box.write(WORK + "/prog/klipperDaemon", DAEMON_SOURCE.read_text(),
mode="755")
copied = box.sh(
"set -e\n"
"for f in %s/printer.base.cfg %s/printer.filament.cfg "
"%s/printer.macro.cfg %s/printer.mesh.cfg %s/printer.motor.cfg "
"%s/printer.override.cfg %s/printer.probe.cfg "
"%s/printer.vibration.cfg; do\n"
" [ -f \"$f\" ] || continue\n"
" cp \"$f\" %s/config/\n"
"done\n"
% ((STOCK_CONFIG,) * 8 + (WORK,)))
if not copied.ok:
pytest.fail("could not stage the stock configs: %s" % copied.text)
staged_daemon = _md5(box, WORK + "/prog/klipperDaemon")
assert staged_daemon == STOCK_DAEMON_MD5, (
"the klipperDaemon staged from the checkout is not FlashForge's: "
"md5 %s, expected %s. Everything below would then be testing a file "
"no printer has ever run." % (staged_daemon, STOCK_DAEMON_MD5))
return box
def _run_recovery(box):
"""Run the staged installer with no arguments, and hand back its Result."""
return box.sh("sh %s" % INSTALLER, timeout=300)
# ------------------------------------------------- 1. what the mod leaves
def test_the_mod_points_klipperdaemon_at_itself(staged):
"""The precondition for everything else, asserted rather than assumed.
If this ever stops being true -- a release that stops linking it, or an
installer that puts the stock file back itself -- the recovery package has
no reason to exist and this whole module should go with it.
"""
box = staged
link = box.sh("readlink %s" % DAEMON).out.strip()
assert link.startswith(MODDIR), (
"%s is not a link into %s (readlink said %r), so this replica is not "
"a machine that has had the mod installed and nothing below is "
"testing the case it claims to." % (DAEMON, MODDIR, link))
def test_a_stock_cp_replaces_a_dangling_link(staged):
"""`cp -rf` over a dangling symlink: does it repair, or write through?
This is the line stock's run.sh uses for the config directory, and the
whole reason the .cfg files are NOT what breaks a downgraded printer. The
answer is busybox's, not POSIX's, so it is asked of the printer's own
busybox rather than reasoned about.
"""
box = staged
out = box.sh(
"set -e\n"
"rm -rf /tmp/cp-probe; mkdir -p /tmp/cp-probe/src /tmp/cp-probe/dst\n"
"echo STOCK > /tmp/cp-probe/src/printer.base.cfg\n"
"ln -s /usr/data/gone/printer.base.cfg /tmp/cp-probe/dst/printer.base.cfg\n"
"cp /tmp/cp-probe/src/* /tmp/cp-probe/dst/ -rf\n"
"if [ -L /tmp/cp-probe/dst/printer.base.cfg ]; then echo LINK; "
"else cat /tmp/cp-probe/dst/printer.base.cfg; fi\n")
assert out.out.strip() == "STOCK", (
"a stock flash does NOT repair a dangling .cfg link on this busybox "
"(got %r). The recovery package's config pass is then load-bearing "
"rather than belt and braces, and its header says otherwise."
% out.text)
# --------------------------------- 2. it refuses while the mod is installed
def test_it_refuses_while_the_mod_is_still_the_firmware(staged):
"""A modded printer has nothing to repair, and repairing it would hurt.
Restoring FlashForge's klipperDaemon here would put a `start` that forks an
unsupervised klippy next to the s6-supervised one, both holding
/dev/ttyS4. So the refusal is the feature, and the exit code has to be
non-zero -- app_startup.sh reads exit 0 as "flashed, now power-cycle".
"""
box = staged
before = _md5(box, DAEMON)
done = _run_recovery(box)
assert not done.ok, (
"the recovery installer ran to completion on a machine that still has "
"the mod installed. It must refuse: %s" % done.text)
assert _md5(box, DAEMON) == before, (
"it refused but still changed %s" % DAEMON)
report = box.file("/mnt/anvil-recovery.txt")
assert report.exists and "still running Reforge" in report.text, (
"the refusal left nothing on the USB stick, which is the only place "
"an owner with no UI can read it. /mnt held: %r"
% (report.text if report.exists else "<no file>"))
# ------------------------------------------- 3. the machine after a downgrade
@pytest.fixture(scope="module")
def downgraded(staged):
"""The machine as a stock flash leaves it.
firmwareExe and start.sh become real files, because that is what stock's
run.sh copies over them (see test_a_stock_cp_replaces_a_dangling_link for
the copy itself). klipperDaemon is left exactly as it is -- the mod's link
-- because nothing in the stock package touches it, and that is the defect
under test.
The two files are given the content of the mod's own copies, which is not
a claim that FlashForge's are the same. Nothing below reads them: they
exist to answer "is this still a modded printer", which the installer asks
with `[ -L ]`.
"""
box = staged
made = box.sh(
"set -e\n"
"for p in %s %s; do\n"
" [ -L \"$p\" ] || continue\n"
" cp \"$p\" \"$p.real\"\n" # follows the link: content, not link
" rm -f \"$p\"\n"
" mv \"$p.real\" \"$p\"\n"
"done\n" % (FIRMWARE_EXE, START_SH))
if not made.ok:
pytest.fail("could not simulate the stock flash: %s" % made.text)
return box
def test_the_downgraded_machine_starts_no_klipper(downgraded):
"""The bug itself: stock's start.sh calls this, and nothing happens.
A pid file is the test because start-stop-daemon -m writes one when it
forks something; the mod's shim writes none, prints a line about s6 and
exits 0 -- a success code for a klippy that does not exist, which is
exactly why the printer looks fine and cannot print.
"""
box = downgraded
box.sh("rm -f /run/klipper.pid")
done = box.sh("sh %s start" % DAEMON)
assert done.ok, (
"the mod's klipperDaemon did not even exit 0 -- this machine is in "
"some other broken state and the test below would not mean what it "
"says: %s" % done.text)
assert not box.file("/run/klipper.pid").exists, (
"something DID start klippy on a downgraded machine, so the defect "
"this package repairs is not present here and the repair below proves "
"nothing. klipperDaemon said: %r" % done.text)
# ------------------------------------------------------------ 4. the repair
@pytest.fixture(scope="module")
def repaired(downgraded):
"""The downgraded machine, after the recovery installer has run on it."""
box = downgraded
# printer.cfg as the machine had it, so the last test can compare rather
# than merely check that a file is still there.
box.printer_cfg_before = box.file(STOCK_CONFIG + "/printer.cfg").text
done = _run_recovery(box)
if not done.ok:
pytest.fail(
"the recovery installer failed on a downgraded machine, so "
"nothing below means anything:\n%s\n--- log ---\n%s"
% (done.text, box.file("/usr/data/anvil-recovery.log").text))
box.recovery_out = done.text
return box
def test_flashforges_own_klipperdaemon_is_back(repaired):
"""A real file, byte-for-byte the factory one -- not a link, not ours."""
box = repaired
is_link = box.sh("[ -L %s ] && echo link || echo file" % DAEMON).out.strip()
assert is_link == "file", (
"%s is still a symlink after the repair" % DAEMON)
got = _md5(box, DAEMON)
assert got == STOCK_DAEMON_MD5, (
"%s is a real file now but not FlashForge's: md5 %s, expected %s"
% (DAEMON, got, STOCK_DAEMON_MD5))
def test_and_it_starts_a_real_klippy(repaired):
"""The point of the whole package, put to the machine.
klippy will not get far -- there is no MCU on the other end of /dev/ttyS2
in a container -- but "start-stop-daemon forked the printer's python at
the printer's klippy.py" is the thing that was missing, and it is either
in the process table or it is not.
"""
box = repaired
box.sh("rm -f /run/klipper.pid")
done = box.sh("sh %s start" % DAEMON)
assert done.ok, "klipperDaemon start failed: %s" % done.text
running = box.pgrep("klippy.py")
pid_file = box.file("/run/klipper.pid")
assert running or pid_file.exists, (
"klipperDaemon start left neither a klippy process nor "
"/run/klipper.pid, so a downgraded printer still has no Klipper. It "
"said: %r" % done.text)
# Left running, it holds the log and the uds for whatever runs next.
box.sh("sh %s stop" % DAEMON)
def test_the_mods_dead_config_links_are_gone(repaired):
"""Every symlink into $MODDIR in FlashForge's directory, swept by shape.
Not by name: releases shipped different sets of ff-*.cfg, and the one
still on somebody's printer is the name this repo would forget to list.
"""
box = repaired
left = box.sh(
"for f in %s/*; do [ -L \"$f\" ] || continue; "
"case \"$(readlink \"$f\")\" in %s/*) echo \"$f\";; esac; done"
% (STOCK_CONFIG, MODDIR)).out.split()
assert not left, (
"these links into %s are still in %s after the repair: %s"
% (MODDIR, STOCK_CONFIG, " ".join(left)))
def test_flashforges_own_configs_are_real_files(repaired):
"""The eight the stock package ships, each a file rather than a link."""
box = repaired
for name in ("printer.base.cfg", "printer.filament.cfg",
"printer.macro.cfg", "printer.mesh.cfg", "printer.motor.cfg",
"printer.override.cfg", "printer.probe.cfg",
"printer.vibration.cfg"):
path = "%s/%s" % (STOCK_CONFIG, name)
kind = box.sh("if [ -L %s ]; then echo link; elif [ -f %s ]; then "
"echo file; else echo missing; fi" % (path, path)).out.strip()
assert kind == "file", "%s is %s after the repair" % (path, kind)
def test_printer_cfg_is_not_touched(repaired):
"""The owner's calibration, which no package has a copy of to put back.
printer.cfg is per-unit and its SAVE_CONFIG block is every value the
machine was calibrated to. A recovery that "restored the config directory"
including this file would fix the boot and lose the printer.
"""
box = repaired
now = box.file(STOCK_CONFIG + "/printer.cfg")
assert now.exists, "the repair removed printer.cfg"
assert MARKER in now.text, (
"printer.cfg no longer carries the machine's own marker -- it was "
"replaced by something the package brought")
assert now.text == box.printer_cfg_before, (
"printer.cfg changed during the repair")
def test_it_says_what_it_did_on_the_usb_stick(repaired):
"""The owner has no UI worth reading and probably no ssh. The stick is it."""
box = repaired
report = box.file("/mnt/anvil-recovery.txt")
assert report.exists, "no /mnt/anvil-recovery.txt after a successful repair"
assert "klipperDaemon restored" in report.text, (
"the report does not mention the repair it performed: %r" % report.text)
assert "Power-cycle" in report.text, (
"the report does not tell the owner what to do next: %r" % report.text)
+20 -8
View File
@@ -32,6 +32,18 @@ cd "$(cd "$(dirname "$0")/../.." && pwd)"
NS="${IMAGE_NS:-monstrofil}"
NAME="${IMAGE_NAME:-creator5-printer}"
FWVER="${FW_VERSION:-1.9.7-1.2.9-20260810}"
# The published tag, which is the firmware version by default and is separable
# from it on purpose. FW_VERSION also builds the download URLs, so it cannot be
# the knob for "the same firmware, baked again": rebuilding this image after a
# fix to one of the scripts baked into it -- seed-prog.sh, entrypoint.sh --
# needs a NEW tag, because ci.yml and release.yml pin the old one and a release
# is blocked on it. Pushing a different image to a pinned tag would move the
# substrate a release was proved on without a commit saying so.
#
# IMAGE_TAG=1.9.7-1.2.9-20260810-r2 make printer-image-push
#
# then bump PRINTER_IMAGE in test.env.example, ci.yml and release.yml.
TAG="${IMAGE_TAG:-$FWVER}"
REL="https://github.com/ghzserg/FF/releases/download/R"
# Either model's package yields the same rootfs; the Pro's is the one used.
@@ -46,13 +58,13 @@ DOCKER=docker
command -v docker >/dev/null 2>&1 || DOCKER=docker.exe
echo "=============================================================="
echo " $NS/$NAME:$FWVER"
echo " $NS/$NAME:$TAG"
echo "=============================================================="
echo " stock: $STOCK_URL"
echo " factory: $FACTORY_URL"
echo
RAW="$NS/$NAME:$FWVER-unbaked"
RAW="$NS/$NAME:$TAG-unbaked"
$DOCKER build -t "$RAW" \
--build-arg "STOCK_URL=$STOCK_URL" \
@@ -81,23 +93,23 @@ if [ "$BAKE" = 1 ]; then
$DOCKER commit \
--change 'ENTRYPOINT ["/opt/printer/entrypoint.sh"]' \
--change "LABEL com.flashforge.baseline=$(basename "$STOCK_URL")" \
c5bake "$NS/$NAME:$FWVER" >/dev/null
c5bake "$NS/$NAME:$TAG" >/dev/null
$DOCKER rm c5bake >/dev/null
$DOCKER rmi "$RAW" >/dev/null 2>&1 || true
else
$DOCKER tag "$RAW" "$NS/$NAME:$FWVER"
$DOCKER tag "$RAW" "$NS/$NAME:$TAG"
$DOCKER rmi "$RAW" >/dev/null 2>&1 || true
fi
$DOCKER tag "$NS/$NAME:$FWVER" "$NS/$NAME:latest"
$DOCKER tag "$NS/$NAME:$TAG" "$NS/$NAME:latest"
if [ "$PUSH" = 1 ]; then
echo ">> pushing"
$DOCKER push "$NS/$NAME:$FWVER"
$DOCKER push "$NS/$NAME:$TAG"
$DOCKER push "$NS/$NAME:latest"
fi
echo
echo "built: $NS/$NAME:$FWVER"
echo "built: $NS/$NAME:$TAG"
echo
echo "Use it instead of building the replica locally:"
echo " PRINTER_IMAGE=$NS/$NAME:$FWVER make test-install"
echo " PRINTER_IMAGE=$NS/$NAME:$TAG make qa-replica"
+28 -2
View File
@@ -38,7 +38,33 @@ fi
# A marker that must survive every install. Appended rather than written, so
# it works whether printer.cfg came from the factory image or not.
grep -q USER-CONFIG-MUST-SURVIVE $R/usr/data/config/printer.cfg 2>/dev/null \
|| echo '# USER-CONFIG-MUST-SURVIVE' >> $R/usr/data/config/printer.cfg
#
# INSIDE THE SAVE_CONFIG BLOCK WHEN THERE IS ONE, and that is not cosmetic.
# configfile.py reads everything after the SAVE_CONFIG header as the autosave
# config and refuses the lot -- "autosave state corrupted" in klippy.log, and
# nothing else -- the moment it meets a line there that does not start with
# '#*# '. A plain comment appended to a factory printer.cfg therefore threw
# away every saved value on the replica: the PID constants, the input shaper,
# the bed mesh, the probe offset. klippy then failed on "Option 'control' in
# section 'heater_bed' must be specified", which names none of that.
#
# '#*# ' + a comment survives the strip as a comment, so the block still
# parses and the marker is still greppable. A printer.cfg with no SAVE_CONFIG
# block has no autosave data to protect and takes the plain form -- there,
# '#*#' in the regular part is what would trip the same warning.
#
# THE sed REPAIRS A MACHINE THAT ALREADY HAS THE PLAIN ONE. The published
# PRINTER_IMAGE was baked with it, and a plain `grep -q || append` would find
# the marker there, do nothing, and leave the autosave block dead for the life
# of that image.
CFG=$R/usr/data/config/printer.cfg
if grep -q '^#\*# <-* SAVE_CONFIG' $CFG 2>/dev/null; then
sed -i 's/^# USER-CONFIG-MUST-SURVIVE$/#*# # USER-CONFIG-MUST-SURVIVE/' $CFG
grep -q USER-CONFIG-MUST-SURVIVE $CFG \
|| echo '#*# # USER-CONFIG-MUST-SURVIVE' >> $CFG
else
grep -q USER-CONFIG-MUST-SURVIVE $CFG 2>/dev/null \
|| echo '# USER-CONFIG-MUST-SURVIVE' >> $CFG
fi
exit 0