Files
firmware/docs/how-it-works.md
Oleksandr Shyshatskyi 80a4d64ffd Prepare next firmware release
Add Fluidd and custom boot scripts, migrate DC24V control, update the web UIs and filament macros, improve startup status reporting, and expand upgrade and replica coverage.
2026-09-24 11:37:28 +03:00

15 KiB

How it works

Reusing FlashForge's own updater

We reuse FlashForge's updater rather than writing a parallel one. The stock chain, recovered from app_startup.sh, the unTar binary and the rootfs image:

busybox init → /etc/init.d/rcS → S99factory_test_shell
             → /usr/prog/app_startup.sh
                 ├─ mounts /dev/sda* on /mnt, globs Creator5Pro-*.tgz
                 ├─ /usr/prog/bin/unTar  (des3 decrypt → /usr/data/update)
                 └─ runFirmwareExe.sh Creator5Pro 0029   ← OURS
                        ├─ gates: architecture, model/PID
                        ├─ per component: extract → md5 gate → run.sh
                        └─ the mod: payload, symlinks, configs, password
             → firmwareExe  ← the UI, and what starts Klipper

The mod meets the stock printer in exactly two files, and owns both:

  • runFirmwareExe.sh, the installer. app_startup.sh runs whatever it finds under that name in the package it just decrypted, so owning the name is all it takes to own the install. Ours is installer/runFirmwareExe.sh; bin/pack.sh bakes the model gate and the password mode into it.
  • /usr/prog/PROGRAM/software/firmwareExe, the UI binary, replaced by a shell script that starts the supervisor.

Because everything funnels through those two, app_startup.sh, rcS and the whole init chain stay completely stock and unpatched.

A release ships no FlashForge component at all — just the installer and the payload — so nothing it does touches /usr/prog/PROGRAM/software except the one symlink. Two things follow, and both are why it is worth doing:

  • app_startup.sh's watchdog greps ps for firmwareExe five seconds after launch. The wrapper is firmwareExe and stays in the foreground, so the stock watchdog supervises the mod correctly.
  • When nothing by that name is running, the same watchdog copies one out of the version directory, /usr/prog/PROGRAM/software/<version>/firmwareExe. That directory is still FlashForge's, with their genuine binary in it, so a printer whose payload failed to install comes up on the stock UI instead of a blank screen. A release that shipped its own component would have replaced that directory and taken the recovery with it.

Flashing the stock FlashForge package is still the uninstall.

Services

Services are s6-rc services, defined in payload/etc/s6-rc/source/, compiled into a database at build time and supervised by s6-svscan:

mcu-bringup  oneshot   toolhead boards out of their bootloaders
klipper      longrun   klippy            (depends on mcu-bringup)
wifi         longrun   wpa_supplicant on wlan0
wifi-dhcp    longrun   udhcpc, on wpa_supplicant's association
                       events                      (depends on wifi)
nginx        longrun   nginx (Mainsail on :80 and Fluidd on :81)
moonraker    longrun   moonraker on :7125
camera       longrun   mjpg-streamer on :8080 (nginx proxies it at /webcam/)
ntp          longrun   sntpd, the clock (there is no RTC and no battery)
run-scripts  oneshot   user *.sh files, with a 30-second boot deadline
ff-startup   oneshot   waits until the printer is usable
                       (depends on klipper, moonraker)
ui           longrun   HelixScreen       (depends on ff-startup)
ok-all       bundle    everything except mcu-bringup, which klipper pulls in

firmwareExe starts s6-svscan, runs s6-rc-init, and asks for one machine state — that transition is the whole boot order. There is no init.d/ directory and no wrapper scripts: s6-rc is the command line.

s6-rc -u change moonraker           # start it (and its dependencies)
s6-rc -d change moonraker           # stop it (and whatever depends on it)
s6-svc -r /usr/data/anvil/etc/s6/moonraker    # restart, leaving s6-rc's view intact
s6-svstat /usr/data/anvil/etc/s6/moonraker    # is it up, and for how long
s6-rc -a list                       # what s6-rc believes is up

Two things are worth knowing before you type them. -d change follows dependencies, so stopping moonraker also stops ff-startup and therefore the screen — that is correct, there is nothing for a printer UI to show, and -u change brings them back. And moonraker and camera are the only services with a notification-fd, so only their "up" means usable rather than forked; klipper's does not, because klippy's readiness is a state on moonraker's API rather than something klippy reports.

nginx is deliberately not a dependency of moonraker even though it proxies it: an edge would mean stopping nginx also stopped the API, which is the thing splitting the old single S60web script in two was meant to prevent.

One sourced library sits beside them, installed to /usr/data/anvil and not executable:

  • anvil-env.sh — PATH, LD_LIBRARY_PATH and FF_PYTHON, in the one place that defines them. runFirmwareExe.sh, firmwareExe, start.sh and every service script source it. A private copy per script is how the installer's check and the boot script came to disagree about the library list, with moonraker dying on libpython3.8.so.1.0: cannot open shared object file.

Everything the mod installs lives under /usr/data/anvil on the data partition, which a FlashForge OTA cannot delete. Nothing at all is installed to /usr/prog: it is the firmware partition, it keeps only one version of a component (the installer wipes every other version directory), and it could not fit ~100MB of web UI in any case.

Moonraker

The mod ships its own Moonraker (pinned in versions.env) and runs it from /usr/data/anvil/moonraker/moonraker.py. FlashForge's is a 2022 build that reports API 1.0.5, old enough that the current Mainsail hides features it cannot see. The visible one is the camera: Moonraker only grew the webcam enabled flag in April 2023, Mainsail filters its webcam list on exactly that field, and so every [webcam] entry is discarded and the panel disappears — while the stream itself is perfectly healthy behind nginx. No config change can fix that from either side.

It is installed by being extracted. The tree rides in the mod payload, the payload unpacks to /usr/data/anvil, and that is the whole installation — there is no separate Moonraker step that can fail on its own. Nothing is written to /usr/prog: FlashForge's tree at /usr/prog/moonraker/moonraker/ is left exactly where it is and simply never used again, and the moonraker service starts ours by absolute path with no fallback.

An earlier release copied the same tree over /usr/prog/moonraker as well. That is gone. It put a second, byte-identical Moonraker on the one partition with no room to spare — the only step of the install that could fail on disk space, failing as "no working web UI" — and because /usr/prog is what a stock FlashForge flash overwrites while /usr/data/anvil survives one, it made "which Moonraker is this printer running?" depend on what was flashed last. Both things thought to require that location turned out not to: the moonraker-env virtualenv beside it was never on sys.path (checked by running the printer's own interpreter against the real image), and moonrakerDaemon, the one thing that did exec that tree by absolute path, is never invoked — the moonraker service starts the server itself.

FlashForge's python 3.8.2 is not what this runs on any more. FF_PYTHON in anvil-env.sh names a CPython 3.13 of our own, cross-built for mipsel by pkgs/3rdparty/python, with every third-party C extension Moonraker needs beside it in $MODDIR/lib/python3.13/site-packages — nineteen packages, one pkgs/3rdparty/python-* recipe and one .apk each — and libsodium in $MODDIR/lib; none of it borrowed from /usr/prog. Measured through the real boot path (the scandir, the moonraker service, readiness gating on :7125 actually listening, a kill -9 respawn, a stop that stays stopped). klippy is on it too: the klipper service execs $FF_PYTHON against the klippy tree in $MODDIR/klipper, so both halves are ours.

Moonraker is a release now, v0.11.0, and no longer a 2023 commit. FlashForge built python 3.8.2 without the _sqlite3 module, and Moonraker moved its database from lmdb to sqlite in v0.9.0, so on 3.8.2 every release from there on got as far as loading the database component and died with ModuleNotFoundError: No module named '_sqlite3'. Our 3.13 has a working _sqlite3 (measured: create, insert, select, reopen -- and exercised for real every run, since Moonraker itself comes up on it in qa/replica/test_web.py), which is what unpinned it.

Upgrading a printer that has run an older release converts its database: Moonraker reads the existing data.mdb once and writes an sqlite store beside it, which is why anvil-python-lmdb still ships even though nothing imports lmdb at runtime any more. Without that module Moonraker tries to pip install one, and a printer has no index to install from -- the old database would be dropped in silence. Reverting to stock is still a clean round trip: the lmdb store is read, not deleted.

The dependency set moved with it. dbus-next became dbus-fast, importlib-metadata (and its zipp) is new, and pyserial-asyncio is gone, imported by nothing in 0.11.0.

This was not worked out in advance; v0.9.3 was built, shipped and tried on the printer first, and that is what it said. What came out of it: when bumping the pin, the blocker to check is native modules, not the python version.

There is nothing left for the installer to do about Moonraker. An earlier release copied the tree over /usr/prog/moonraker, and needed a preflight import check and a move-aside-and-roll-back dance because that copy could fail on a full firmware partition and leave the printer with no web UI. Both are gone with the copy: the payload extracts to /usr/data/anvil and that is the installation, so there is no separate Moonraker step that can fail on its own.

make test covers this end to end on the printer replica: that the swap happened, that the installed webcam.py has the enabled field, that the shipped tree runs on our own CPython 3.13 ($FF_PYTHON) and is still running after a settle, and that its log has no import errors. The settle matters — a build that dies on a missing module is alive for a second or two first, and checking only "did it start" is what let the sqlite3 failure through. BUILD_MOONRAKER=0 builds a package that leaves the stock server alone.

Five things the stock firmware does that will surprise you

Each of these silently breaks a package or a printer, and each is enforced by a check in make test:

Reality
*.tar.xz is not xz The components are plain tar archives carrying an .xz name. The installer runs a bare tar -xvf, so a genuinely xz-compressed file does not install.
.tgz is not gzip DES3-encrypted plain tar. The key is hardcoded in the printer's own unTar binary. Modern OpenSSL needs -md md5 to match its OpenSSL 1.0.2.
Nothing in init starts Klipper firmwareExe runs /usr/prog/klipper/start.sh. Replace the UI without accounting for this and the printer boots to a working screen that cannot move or heat.
The installer wipes the software dir first ls | grep -v temp | xargs rm -rf runs before run.sh, so the previous firmwareExe is already gone. Uninstalling means flashing the stock package, which still ships the genuine binary; nothing can back one up.
Binaries must be nan2008 The Ingenic X2000 kernel returns ENOEXEC for legacy-NaN MIPS. Debian's gcc-mipsel-linux-gnu cannot produce a usable binary — its libc is legacy-NaN.

And one pleasant surprise, found by unpacking rootfs.squashfs out of the kernel component: ssh needs no work at all. The stock rootfs already ships /usr/sbin/dropbear and an enabled /etc/init.d/S50dropbear, so port 22 is already open on a stock printer. There is simply no published root password. Setting ROOT_PW_HASH is the whole feature.

Two models, two packages

Creator 5 and Creator 5 Pro are not interchangeable. Each stock package carries a MACHINE=/PID= gate that refuses to install on the other model, and — the part that actually matters — they ship different firmwareExe binaries. A package built for one model would hand the other the wrong firmware, so each must be built from its own stock package.

Model MACHINE PID USB filename the printer looks for
Creator 5 Creator5 0028 /mnt/Creator5-*.tgz
Creator 5 Pro Creator5Pro 0029 /mnt/Creator5Pro-*.tgz

The filename prefix and the gate inside must agree: app_startup.sh globs for the prefix, and runFirmwareExe.sh checks the gate. A mismatch means the printer picks the file up and then refuses it. Nothing on the build host checks the pair any more: make qa-replica installs the package the way the printer does, off a real FAT filesystem through app_startup.sh, so a mismatch stops the install there rather than being predicted beforehand.

Recovery

Recovery is the stock FlashForge package for your model. Flashing it restores the files the mod replaced out of that package; the payload under /usr/data/anvil survives but is inert. make test-recovery used to do exactly that inside the replica -- comparing firmwareExe, app_startup.sh and klipper/start.sh byte for byte and checking that nothing stock still referenced the leftovers -- but that gate has been retired without a replacement; see qa-migration.md.

One thing it does not cover, worth knowing before you rely on it:

  • Moonraker. The mod's Moonraker lives on the data partition at /usr/data/anvil/moonraker, and it is started by the mod's own moonraker service — which a stock flash removes along with the rest of the mod's boot path. So a stock reflash goes back to FlashForge's own 2022 tree at /usr/prog/moonraker/moonraker/, which was never touched and is still exactly what the factory image shipped. The mod's copy is left behind on /usr/data and is inert: nothing stock knows the path.

    This used to be a caveat in the other direction. An earlier release installed its Moonraker over /usr/prog/moonraker, keeping the stock tree beside it as moonraker.modold and deleting that once the copy succeeded — so a stock reflash silently kept running the mod's build, and losing power mid-swap left the printer with no Moonraker at all and needed a hand-run mv over ssh to put the .modold tree back. Neither the .modold path nor the recovery instruction exists any more. If Moonraker is missing or broken now, the fix is to reflash the mod package: it carries the whole tree, and extracting the payload is the entire installation.