The wifi oneshot backgrounded a subshell and returned at once, so that the 15s wait for wlan0 and the 20s wait to associate did not stall the boot transition firmwareExe blocks on. Its comment was explicit that nothing could depend on it, which was true and was not the problem. The problem is the subshell it left behind: a child of s6rc-oneshot-runner with its stdout on a pipe s6-rc closes the moment the up script returns, so a sleep in the wlan0 wait followed by an echo is a write to a closed pipe. OBSERVED on a printer: a healthy supervision tree, s6-rc reporting wifi up, no wlan0 configured, no lease, and not one line anywhere saying why -- HelixScreen calling it "wifi system is not ready" because there was no supplicant holding a control socket. Getting in at all needed a repacked .tgz that brought wifi up ahead of s6. The subshell was never caught in the act and could not be: once its pipe is gone it cannot report its own death. Which is the argument, whatever the exact signal was. A oneshot that forks and returns cannot be relied on and cannot be debugged when it isn't. So: type longrun, no -B, exec wpa_supplicant in the foreground. Blocking IS the job now, s6-supervise holds the process, and a supplicant that dies is restarted rather than mourned. up/down are gone -- a longrun's down is silently dropped anyway. wifi-dhcp is new and is not optional. wpa_supplicant only associates; it never asks for an address, and running udhcpc was the oneshot's last act. bin/wifi-action.sh has shipped since the s6 migration for exactly this hook and was invoked by nothing at all -- `wpa_cli -a` is what invokes it. That also fixes what the oneshot never covered: it ran udhcpc ONCE after boot, so an owner joining a network from the HelixScreen panel an hour later was associated with no address, which the panel reports as a connection timeout. Its run script re-runs the action script when wpa_state is already COMPLETED. wpa_cli fires on CHANGES, and it is not established that it emits CONNECTED on attach to an already-connected supplicant, so without this the lease that finish drops would not come back on a restart. The action script replaces any previous client first, so it is idempotent with a real event. udhcpc keeps -b inside the action script. Different flag, different program: wpa_cli blocks until the script returns, so a foreground udhcpc would wedge event processing. ntp/run reasoned about "the wifi oneshot" and about nothing being allowed to depend on it; it still takes no wifi dependency, now because wifi being up means a supplicant is running rather than that anything is routable. The service tables in how-it-works.md and building.md, and test_s6rc.py -- where wifi sat in HW_SET as "the oneshot above" and test_execline_shipped_too used it as its example -- follow. Both wifi and wifi-dhcp stay out of BOOT_SET: they want a real wlan0, which the replica has not got. qa-static passes. The database is NOT compiled here -- s6-rc-compile is a MIPS binary that runs in the replica -- so make qa-replica is still owed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPQqLMitZzrRcaehiYLHdx
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.shruns 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 isinstaller/runFirmwareExe.sh;bin/pack.shbakes 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 grepspsforfirmwareExefive seconds after launch. The wrapper isfirmwareExeand 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
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)
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_PATHandFF_PYTHON, in the one place that defines them.runFirmwareExe.sh,firmwareExe,start.shand 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 onlibpython3.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 .ipk 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/dataand 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 asmoonraker.modoldand 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-runmvover ssh to put the.modoldtree back. Neither the.modoldpath 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.