From ebc3d60c587fbac64eeeaee2e4cd257cc05fe6ae Mon Sep 17 00:00:00 2001 From: Oleksandr Shyshatskyi Date: Mon, 7 Sep 2026 20:47:04 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_015hdzEqXuFcY4xXALGPbBFV --- .github/workflows/release.yml | 6 +- CHANGELOG.md | 28 +++ Makefile | 46 +++- bin/pack-recovery.sh | 125 ++++++++++ docs/building.md | 42 ++++ docs/going-back-to-stock.md | 68 +++++ docs/installing.md | 3 +- docs/known-issues.md | 12 + docs/support.md | 15 +- installer/recovery.sh | 269 ++++++++++++++++++++ installer/stock/klipperDaemon | 44 ++++ mkdocs.yml | 1 + qa/lib/replica.py | 24 +- qa/replica/test_install.py | 26 ++ qa/replica/test_recovery.py | 357 +++++++++++++++++++++++++++ tools/replica/build-printer-image.sh | 28 ++- tools/replica/printer/seed-prog.sh | 30 ++- 17 files changed, 1103 insertions(+), 21 deletions(-) create mode 100755 bin/pack-recovery.sh create mode 100644 docs/going-back-to-stock.md create mode 100644 installer/recovery.sh create mode 100755 installer/stock/klipperDaemon create mode 100644 qa/replica/test_recovery.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6558b0c..d325177 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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. --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b65ea..25f9fd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Makefile b/Makefile index 423a631..ced6b27 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/bin/pack-recovery.sh b/bin/pack-recovery.sh new file mode 100755 index 0000000..8bc09bb --- /dev/null +++ b/bin/pack-recovery.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Build the recovery package. +# ./bin/pack-recovery.sh -> work/recovery/-anvil-recovery-.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." diff --git a/docs/building.md b/docs/building.md index a92ac96..5a6297d 100644 --- a/docs/building.md +++ b/docs/building.md @@ -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: diff --git a/docs/going-back-to-stock.md b/docs/going-back-to-stock.md new file mode 100644 index 0000000..4f7d87f --- /dev/null +++ b/docs/going-back-to-stock.md @@ -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 `-anvil-recovery-.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. diff --git a/docs/installing.md b/docs/installing.md index cd80be4..e6870b6 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -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 diff --git a/docs/known-issues.md b/docs/known-issues.md index 6709bd8..6d370f5 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -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. diff --git a/docs/support.md b/docs/support.md index d397f0d..ae5b1fb 100644 --- a/docs/support.md +++ b/docs/support.md @@ -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 diff --git a/installer/recovery.sh b/installer/recovery.sh new file mode 100644 index 0000000..7f64b45 --- /dev/null +++ b/installer/recovery.sh @@ -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 diff --git a/installer/stock/klipperDaemon b/installer/stock/klipperDaemon new file mode 100755 index 0000000..967c218 --- /dev/null +++ b/installer/stock/klipperDaemon @@ -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 $? diff --git a/mkdocs.yml b/mkdocs.yml index d0944a6..06dbeb3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/qa/lib/replica.py b/qa/lib/replica.py index 26344e6..1b5c9f2 100644 --- a/qa/lib/replica.py +++ b/qa/lib/replica.py @@ -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: diff --git a/qa/replica/test_install.py b/qa/replica/test_install.py index 4d10670..edccf37 100644 --- a/qa/replica/test_install.py +++ b/qa/replica/test_install.py @@ -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): diff --git a/qa/replica/test_recovery.py b/qa/replica/test_recovery.py new file mode 100644 index 0000000..72147f1 --- /dev/null +++ b/qa/replica/test_recovery.py @@ -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 "")) + + +# ------------------------------------------- 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) diff --git a/tools/replica/build-printer-image.sh b/tools/replica/build-printer-image.sh index 6d2d981..97e35e1 100755 --- a/tools/replica/build-printer-image.sh +++ b/tools/replica/build-printer-image.sh @@ -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" diff --git a/tools/replica/printer/seed-prog.sh b/tools/replica/printer/seed-prog.sh index ced8ebf..decbf24 100755 --- a/tools/replica/printer/seed-prog.sh +++ b/tools/replica/printer/seed-prog.sh @@ -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