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