Retire firmwareExe: own the web stack, and say what goes with it

The mod replaces FlashForge's application outright, so the reasoning that
still framed the web stack as coexisting with it was wrong on its own terms.

* nginx and moonraker are documented as the only web stack on the printer.
  MOD_WEB=0 is a debugging switch that leaves ssh and the touchscreen, not a
  way back to stock -- that is a stock FlashForge package. The own-prefix rule
  stays, for the reason that is actually true: /usr/prog is the firmware
  partition and every FlashForge package reinstalls it, so a config written
  there is undone by the next stock flash.

* Configs the mod ships now update when you have not edited them. A live file
  still byte-identical to the one the last package wrote is replaced; one you
  changed is kept and the new version lands beside it as .mod-new.
  $MODDIR/config-installed holds that last-written copy. Without this a config
  we own could only ever be written once: it exists on the second flash, so it
  would land as .mod-new forever and updates would never reach the printer.

* Dropped the three dead /server/files/ alias blocks from nginx.conf. The regex
  location above them matches /server/ too and nginx gives regex locations
  precedence, so they never fired -- and had they fired, serving those paths
  from disk would 405 the DELETE and POST that Mainsail sends to the very same
  URLs.

* Recorded what dies with firmwareExe, in docs/notes/25 and in the README: the
  :8898 REST API (FlashPrint, Orca's FlashForge profile, the mobile app) and
  the :8080 camera stream. The camera is the one that looks restorable and is
  not -- the MJPEG server is compiled into firmwareExe (its own accept loop,
  `GET /?action=stream`, boundarydonotcross) rather than being mjpg-streamer,
  so there is no daemon left to point a [webcam] at. assets/moonraker.conf
  carries the block, commented, for when a streamer is built for this board.

Also in this change: the MOD_UI switch and the stock-UI path are gone, the
chamber-heater model gate arrives with ff-model.cfg/ff-chamber.cfg, and the
tests follow.

make test: 32 passed, 1 failed. The failure is the probe end-to-end, which
dies at "mount: failed to setup loop device for /stick.img" -- leaked loop
devices on this build host, not the package. Probe installs nothing, and the
default profile's install -> re-install -> boot passes in the same run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oleksandr ShyshatskyiandClaude Opus 5 committed 2026-08-23 21:50:32 +03:00
1 parent 493ef79e45
commit 5bdf78109a
41 files changed
+720 -324

No files matched your search

-1
View File
@@ -133,7 +133,6 @@ jobs:
STOCK_TGZ=""
KLIPPER_FORK=""
DEFAULT_PROFILE=probe
MOD_UI=stock
ROOT_PW_HASH='\$6\$ci\$abcdefghijklmnopqrstuvwxyz'
FF_KEY='FFP0331&*%root'
CFG
-1
View File
@@ -108,7 +108,6 @@ jobs:
STOCK_TGZ=""
KLIPPER_FORK=""
DEFAULT_PROFILE=$PROFILE
MOD_UI=stock
ROOT_PW_HASH='${{ secrets.ROOT_PW_HASH }}'
FF_KEY='FFP0331&*%root'
CFG
+6 -2
View File
@@ -65,7 +65,7 @@ endif
.PHONY: help image shell build vendor probe default all-profiles \
rootfs verify test test-lint test-install test-applets \
printer-image printer-image-push \
test-recovery test-ui test-mcu test-ash test-abi test-chelper test-macros test-model release clean distclean
test-recovery test-ui test-mcu test-ash test-abi test-chelper test-macros test-chamber test-model release clean distclean
help:
@echo 'creator5-custom-firmware -- everything runs in Docker'
@@ -88,7 +88,7 @@ help:
@echo ' make test-lint brick-risk lint'
@echo ' make test-install end-to-end: USB stick -> update -> reboot'
@echo ' make test-recovery install mod -> flash stock -> back to stock'
@echo ' make test-ui UI selection, crash fallback, SAFE-MODE'
@echo ' make test-ui UI startup, crash protection, SAFE-MODE'
@echo ' make test-mcu ff-mcu-bringup.py runs on the printer own python'
@echo ' make test-model both models gated + firmware correct'
@echo ' make test-applets every command the payload uses exists on the printer'
@@ -96,6 +96,7 @@ help:
@echo ' make test-abi MIPS ELF ABI checks'
@echo ' make test-chelper c_helper.so exports everything klippy declares'
@echo ' make test-macros the ff-*.cfg gcode macros parse as Jinja'
@echo ' make test-chamber the chamber heater is off on the Creator 5'
@echo
@echo 'test-install, test-recovery, test-ui, test-mcu and test-ash run inside'
@echo 'a replica of the printer: the real rootfs.squashfs under qemu-mipsel, with'
@@ -219,6 +220,9 @@ test-chelper: image
test-macros: image
@$(RUN) python3 ./test/test-macros.py
test-chamber: image
@$(RUN) python3 ./test/test-chamber.py
test-applets: image
@$(RUN) python3 ./test/test-applets.py
+28 -8
View File
@@ -20,12 +20,28 @@ does the work.
| **Mainsail in your browser** | The full Klipper web interface at `http://<printer-ip>/` — upload gcode, watch the print, tune on the fly. Moonraker comes with it, so anything that speaks the Klipper API works. |
| **A shell on your printer** | ssh as root. Dropbear is already running on stock firmware; this simply gives you a password you know — the installer picks a random one and writes it to `anvil-password.txt` on your USB stick. |
| **Real toolchanger Klipper** | A current Klipper with proper tool-change support, replacing the 0.12-era tree FlashForge ships. |
| **A modern touchscreen UI** | HelixScreen instead of the stock interface — optional, and the stock UI is kept on disk as a fallback. |
| **A modern touchscreen UI** | HelixScreen replaces the stock interface on the printer's own screen. |
| **Nothing you cannot undo** | Flashing the stock FlashForge package puts every file back. |
Your printer keeps working as a printer throughout: the stock screen, the
stock boot process and the stock recovery path are all left in place until you
choose a stage that replaces them.
The stock boot process and the stock recovery path are left in place: the mod
replaces one file (`firmwareExe`) and nothing in `rcS` or `app_startup.sh` is
patched, so flashing the stock FlashForge package puts the printer back exactly
as it was.
### What you give up
That one file is FlashForge's whole application, and two things people use
live inside it rather than in some separate service. Both stop working:
* **The camera.** The stream on `:8080` is served by the app itself, not by a
camera daemon, so it goes when the app does. Mainsail shows no webcam.
Restoring it needs a streamer built for this board — not done yet.
* **The FlashForge network API on `:8898`**, and with it FlashPrint, Orca's
FlashForge profile and the mobile app. Moonraker's API takes over, which is
what Mainsail, OrcaSlicer's Klipper/Moonraker upload and Fluidd speak.
Slice for the toolchanger and send it there instead.
Flashing the stock package brings both back.
---
@@ -82,11 +98,15 @@ installs the same way — stick in, power on.
A few things are deliberately built in so a bad flash cannot cost you the
machine:
* **The screen always comes back.** If the new UI cannot start or crashes
repeatedly, the printer latches SAFE-MODE and boots the stock interface.
* **A crashing UI cannot lock you out.** If HelixScreen cannot start or
crashes repeatedly, the printer latches SAFE-MODE and boots *headless* —
no UI at all — instead of looping. Delete `/usr/data/anvil/SAFE-MODE` over
ssh to try again.
* **ssh stays up** even when the screen does not, so you can get in and look.
* **Your `printer.cfg` is never overwritten.** A config that already exists is
left alone and the new one lands beside it as `.mod-new`.
* **Your `printer.cfg` is never overwritten** — it is not a file this
firmware ships. Of the configs it does ship, one you have edited is left
alone and the new version lands beside it as `.mod-new`; one you never
touched is updated in place.
* **The kernel and the motion board are never touched** by a normal package.
More detail, including what to do when the printer will not boot at all:
+17
View File
@@ -31,3 +31,20 @@ trusted_clients:
::1/128
[history]
# No [webcam] entry, and none is possible yet.
#
# The stock MJPEG stream on :8080 was not mjpg-streamer: firmwareExe served it
# itself, from an HTTP server built into the binary (`GET /?action=stream`,
# multipart/x-mixed-replace, its own accept loop). The mod replaces firmwareExe,
# so the stream goes with it and there is no daemon left to point a [webcam] at.
#
# Restoring it needs a streamer cross-compiled for MIPS (ustreamer or
# mjpg-streamer) shipped in the payload and started from an init script, the
# way S50wifi covers the other job firmwareExe used to do. Once that exists:
#
#[webcam anvil]
#location: printer
#service: mjpegstreamer-adaptive
#stream_url: /webcam/?action=stream
#snapshot_url: /webcam/?action=snapshot
+17 -15
View File
@@ -1,7 +1,14 @@
# anvil nginx -- serves Mainsail and proxies Moonraker.
# Run with: nginx -p /usr/data/anvil/nginx -c /usr/data/anvil/nginx/nginx.conf
# We use our own prefix so FlashForge's /usr/prog/nginx/conf/nginx.conf is
# never touched and the stock web endpoints keep working.
# FlashForge's own web stack is gone: the mod replaces firmwareExe, which is
# what served the stock HTTP API, and their nginx was already commented out of
# klipper/start.sh. Nothing else listens on :80, so this vhost owns it.
#
# We still run on our OWN prefix rather than editing
# /usr/prog/nginx/conf/nginx.conf -- not to preserve stock endpoints, but
# because /usr/prog is the firmware partition: every FlashForge package
# reinstalls it, so a config written there is undone by the next stock flash.
# Ours lives on /usr/data with the rest of the mod.
worker_processes 1;
error_log logs/error.log warn;
@@ -76,18 +83,13 @@ http {
proxy_set_header X-Scheme $scheme;
}
# Moonraker's own file trees, served straight from disk.
location /server/files/gcodes/ {
alias /usr/data/gcodes/;
add_header Content-Disposition "attachment";
}
location /server/files/config/ {
alias /usr/data/config/;
add_header Content-Disposition "attachment";
}
location /server/files/logs/ {
alias /usr/data/logs/;
add_header Content-Disposition "attachment";
}
# No static /server/files/ blocks here on purpose. Moonraker serves its
# own file trees and must keep doing so: nginx would only ever answer
# GET, while the SAME paths carry DELETE (Mainsail's delete button) and
# POST (move/copy). Serving them from disk would 405 those.
#
# A prefix location would not have worked anyway -- the regex above
# matches /server/ too, and nginx gives regex locations precedence over
# prefix ones, so such blocks are dead the moment they are written.
}
}
-1
View File
@@ -1 +0,0 @@
T[next_extruder] ; ff-toolchange
Binary file not shown.
+23 -7
View File
@@ -126,6 +126,22 @@ if [ "${BUILD_TOOLCHANGE:-1}" = "1" ]; then
# .cfg files belong on the data partition; run.sh installs them without
# clobbering a config the user already tuned.
cp -f payload/klipper/config/ff-*.cfg "$MP/config/"
# Anything that differs between models exists once per model, named
# <file>.creator5 / <file>.creator5pro, and the matching one is installed
# under its real name. Nothing is edited: the suffixed file IS the
# difference. The suffixed names do not match the ff-*.cfg glob above, so
# a variant cannot leak into the wrong package.
SUFFIX=$(printf '%s' "$TARGET_MACHINE" | tr 'A-Z' 'a-z')
for variant in payload/klipper/config/*."$SUFFIX"; do
[ -e "$variant" ] || continue
cp -f "$variant" "$MP/config/$(basename "$variant" ".$SUFFIX")"
say "Model: $(basename "$variant" ".$SUFFIX") for $TARGET_MACHINE"
done
# ff-model.cfg is what tells ff-chamber.cfg whether this machine has a
# chamber heater. Missing, every macro that reads it fails at runtime, so
# this is a broken build rather than a silent default.
[ -f "$MP/config/ff-model.cfg" ] \
|| { echo "no ff-model.cfg.$SUFFIX for TARGET_MACHINE=$TARGET_MACHINE" >&2; exit 1; }
else
skip "Toolchange"
fi
@@ -210,11 +226,12 @@ chmod +x "$SW/start.sh"
# firmwareExe, and firmwareExe is also what starts Klipper. Replacing this
# one file is therefore enough to own the whole userspace boot, which means
# app_startup.sh, rcS and the init chain are left COMPLETELY STOCK.
say "firmwareExe: installing wrapper (original kept as firmwareExe.stock)"
if [ -f "$SW/firmwareExe" ] && ! head -c 2 "$SW/firmwareExe" | grep -q '#!'; then
mv -f "$SW/firmwareExe" "$SW/firmwareExe.stock"
echo " stock binary -> firmwareExe.stock ($(du -h "$SW/firmwareExe.stock" | cut -f1))"
fi
#
# The genuine binary is replaced, not kept aside: HelixScreen is the only UI,
# and the installer wipes the software dir before run.sh anyway, so nothing
# here could ever be a reliable backup. Flashing the stock FlashForge package
# -- which still ships the binary -- is the uninstall.
say "firmwareExe: installing wrapper (replaces the stock binary)"
cp -f payload/firmwareExe "$SW/firmwareExe"
chmod +x "$SW/firmwareExe"
@@ -223,8 +240,7 @@ mkdir -p "$MP/init.d"
[ -d payload/bin ] && cp -f payload/bin/* "$MP/bin/" && chmod +x "$MP/bin"/*
cp -f payload/init.d/S* "$MP/init.d/"
chmod +x "$MP/init.d"/S*
sed -e "s/^MOD_UI=.*/MOD_UI=${MOD_UI:-stock}/" \
-e "s/^MOD_WEB=.*/MOD_WEB=${MOD_WEB:-1}/" \
sed -e "s/^MOD_WEB=.*/MOD_WEB=${MOD_WEB:-1}/" \
-e "s/^MOD_SSH=.*/MOD_SSH=${MOD_SSH:-1}/" \
-e "s/^MOD_WIFI=.*/MOD_WIFI=${MOD_WIFI:-1}/" \
payload/anvil.conf > "$MP/anvil.conf"
-1
View File
@@ -72,7 +72,6 @@ BUSYBOX_BIN="$DESKTOP/creator5/busybox" # optional richer busybox
# BUILD_MAINSAIL, BUILD_HELIX, BUILD_TOOLCHANGE, BUILD_APPLY, BUILD_REPORT)
# are owned by profiles/*.env and are deliberately not set here: the profile is
# sourced after this file and would override anything you put in.
MOD_UI=stock # stock | helix -- which UI owns the screen
MOD_WEB=1 # 1 = start nginx (Mainsail) + moonraker
MOD_SSH=1 # 1 = start dropbear on port 22
MOD_WIFI=1 # 1 = bring up wlan0 (firmwareExe used to do this)
+1 -1
View File
@@ -79,7 +79,7 @@ This distinction is the one to keep straight:
* **`MOD_*`** are runtime switches. They are written into
`/usr/data/anvil/anvil.conf`, which the printer re-reads at every boot, so
they can be changed over ssh afterwards and survive a mod update.
(`MOD_UI`, `MOD_WEB`, `MOD_SSH`.)
(`MOD_WEB`, `MOD_SSH`, `MOD_WIFI`.)
`BUILD_REPORT` is the diagnostic report, and it is a debug payload:
`payload/report.sh` copies `/etc`, `passwd` and `shadow` onto the USB stick.
+9 -7
View File
@@ -118,17 +118,19 @@ channel, so confirm it before you touch anything else.
[docs/toolchange.md](toolchange.md). A wrong offset drives the nozzle into
the plate.
Understand the safety net first, because the stock UI is no longer driving the
screen:
Understand the safety net first, because FlashForge's UI is gone from the
screen and there is nothing to fall back to:
- The genuine binary is kept on disk as `firmwareExe.stock` and is the
fallback. Nothing is deleted.
- ssh and Mainsail do not depend on the screen. `/etc/init.d/S50dropbear` is
stock and runs long before the UI, and `init.d/S60web` starts nginx and
moonraker independently. They are your way in when the screen is dark.
- `init.d/S70klipper` owns Klipper startup, because on stock firmware it was
`firmwareExe` — not any init script — that ran `/usr/prog/klipper/start.sh`.
Without this the printer would boot to a working screen and be unable to
move.
- If the UI dies repeatedly, `SAFE-MODE` latches after 3 boots and the printer
falls back to the stock UI on its own.
boots **headless** — no UI started at all — rather than crash-looping. ssh
and Mainsail stay up. Delete `/usr/data/anvil/SAFE-MODE` to try again.
**Go/no-go, in this order — with the emergency stop within reach:**
@@ -162,7 +164,7 @@ in the order given in the toolchanger README, then run
```sh
ssh root@PRINTER
/usr/data/anvil/init.d/S80ui status # what did it choose, and why
touch /usr/data/anvil/SAFE-MODE # force the stock UI on next boot
touch /usr/data/anvil/SAFE-MODE # boot headless next time (ssh + Mainsail only)
reboot
```
If ssh is gone too, flash the stock FlashForge package for your model.
@@ -173,7 +175,7 @@ If ssh is gone too, flash the stock FlashForge package for your model.
| Symptom | Do this |
|---|---|
| Printer boots, screen blank | ssh in; `touch /usr/data/anvil/SAFE-MODE`; reboot |
| Printer boots, screen blank | ssh in; `/usr/data/anvil/init.d/S80ui status`; `touch /usr/data/anvil/SAFE-MODE`; reboot |
| No ssh, no screen | flash the stock package for your model |
| Recovery stick does not help | try a newer stock FlashForge package for your model |
| Still broken | factory package (`Creator5Pro-factory-*.tgz` **plus** the separate `Creator5Pro-factory.tar.xz` on the same stick; needs 800 MB free) |
+8 -5
View File
@@ -17,10 +17,13 @@ busybox init → /etc/init.d/rcS → S99factory_test_shell
```
The mod's entire integration point is **one file**: it replaces
`/usr/prog/PROGRAM/software/firmwareExe` with a shell script. Because
everything funnels through that binary, `app_startup.sh`, `rcS` and the whole
init chain stay **completely stock and unpatched**. The genuine binary is kept
beside it as `firmwareExe.stock` and remains the default UI.
`/usr/prog/PROGRAM/software/firmwareExe` with a shell script that runs
HelixScreen. Because everything funnels through that binary, `app_startup.sh`,
`rcS` and the whole init chain stay **completely stock and unpatched**. The
genuine binary is not kept anywhere — the stock installer wipes the software
directory before `run.sh` runs, so nothing on the printer could ever be a
reliable backup of it. Flashing the stock FlashForge package, which still
ships it, is the uninstall.
Two properties come for free from keeping that name and that place:
@@ -38,7 +41,7 @@ filename order and individually restartable over ssh:
```
S60web nginx (Mainsail) + moonraker
S70klipper Klipper
S80ui decides which UI runs; owns SAFE-MODE
S80ui decides whether the UI runs; owns SAFE-MODE
```
Everything the mod installs lives under `/usr/data/anvil` on the **data**
+10 -5
View File
@@ -22,14 +22,19 @@ if line.startswith("T") and line in VALID_GCODE_T:
The fork does **no motion at all** — it raises `doingChangeEx`/`refuelling` and waits
for firmwareExe to perform the physical change and send `SDCARD_CLEAR_REFUELLING`.
For a Mainsail-started print nobody services that state, so a bare `Tn` freezes the
print. `line` is taken from `data.split('\n')` and never stripped, so
`T2 ; ff-toolchange` does NOT match and falls through to `gcode.run_script()` — the
basis of this repo's approach (see README).
print. `line` is taken from `data.split('\n')` and never stripped, so any trailing
comment (`T2 ; anything`) does NOT match and falls through to `gcode.run_script()`.
Earlier releases of this repo exploited exactly that with a `; ff-toolchange` marker
in the slicer's change-filament G-code. **We no longer ship this fork**: the
`creator5` Klipper branch carries upstream `virtual_sdcard`, so bare `Tn` reaches
`ff_toolchange.py` directly and the marker is gone. This section stays as a record of
stock behaviour.
Couplings to `load_channel` worth knowing: bare `M104`/`M109` get ` T<print_channel>`
appended (543-547), and `SET_PRESSURE_ADVANCE` is rewritten to the per-channel value
when `pa_enable == 1` (479-487). `ff_toolchange.py` keeps the channel in sync via
`SDCARD_SET_CHANNEL`.
when `pa_enable == 1` (479-487). `ff_toolchange.py` still issues `SDCARD_SET_CHANNEL`
to keep that channel in sync — a no-op-turned-unknown-command on the upstream
`virtual_sdcard` we now ship, which does neither rewrite.
## Custom commands (by area)
+31 -2
View File
@@ -55,8 +55,9 @@ Missing: a START_PRINT gate refusing when a requested tool is not docked (`_FF_R
No ML runtime linked (no ncnn/tflite/onnx; OpenCV only for capture), no upload endpoint, no
MQTT verdict topic. The feature is one boolean `aiCheck` in general.json that boots to `false`
(`MainWindow.c:275`); `SettingInfo::doAiDetected()` hard-writes 0. `E0164` is a dead string.
→ nothing to port; Obico off-box (host is MIPS X2000, 128–256 MB, no NPU) with a `[webcam]`
entry in moonraker.conf pointing at the existing mjpg-streamer `:8080`.
→ nothing to port; Obico off-box (host is MIPS X2000, 128–256 MB, no NPU). Note the `[webcam]`
entry this used to suggest is not available: the `:8080` stream is firmwareExe's own, see
"What is lost with firmwareExe" below.
## Corrections to earlier notes
- The "station" used for nozzle offsets is **not** the eddy probe. `[e_stop X|Y|Z]` all sit on
@@ -65,3 +66,31 @@ entry in moonraker.conf pointing at the existing mjpg-streamer `:8080`.
(`testStationPosFourPointTwoCheck`). `[probe]` (`eboard:PG0`) is the carriage eddy used only
for G28 Z / mesh. The gap `t<n>_offset_z − z_station_pos` (~3.19 mm) is still the print-start
Z term; only its provenance was wrong.
## What is lost with firmwareExe (2026-08-23)
The mod replaces firmwareExe outright -- HelixScreen is the only UI and there is no fallback.
Everything in the "app only" row of the table above therefore stops existing on the printer,
not just stops being used. Two of them were load-bearing for users:
### The HTTP REST API on :8898 -- gone
FlashForge's AD5M-style API (`/detail`, `/gcodeList`, `/uploadGcode`, `/printGcode`, and the
`materialMappings` multi-tool start) was served by firmwareExe. Nothing else on the box
implements it, so FlashPrint, Orca's FlashForge profile, the mobile app and any script written
against it stop working the moment the mod is installed. Moonraker's API replaces it
(`/server/files/upload`, `/printer/print/start`); tool-to-slot mapping stops being a request
field and becomes the slicer's job, baked into the gcode.
### The camera stream on :8080 -- gone, and not restorable by restarting anything
This one is easy to get wrong, because the URL looks like mjpg-streamer:
`http://<ip>:8080/?action=stream`, `multipart/x-mixed-replace;boundary=boundarydonotcross`.
It is not. `strings firmwareExe` shows that request line, that boundary, `:8080`, and the
binary's own accept/bind/pthread_create error strings alongside `cameraOpen` and
`/usr/data/firmwareRes/camera/*` -- the MJPEG server is compiled into the app, over its OpenCV
capture. There is no mjpg-streamer process on the box to keep alive.
So unlike WiFi -- where firmwareExe merely *drove* stock binaries and `S50wifi` could take over
the same wpa_supplicant -- there is nothing here to take over. Restoring the camera means
shipping a streamer built for MIPS (ustreamer or mjpg-streamer) in the payload and starting it
from an init script, then adding the `[webcam]` block that `assets/moonraker.conf` keeps
commented out. Until then the printer has no camera and Mainsail correctly shows none.
+1 -1
View File
@@ -3,7 +3,7 @@
Source of truth for the sequence: [`46-offset-calibration-recovered.md`](46-offset-calibration-recovered.md),
recovered 2026-08-21 from MIPS machine code with a custom disassembler (annotated `.asm` listing not in repo).
Design decision (2026-08-21): firmwareExe's JSON is no longer a runtime source. The stock UI
Design decision (2026-08-21): firmwareExe's JSON is no longer a runtime source. FlashForge's UI
cannot use Klipper-side results anyway and HelixScreen is the target, so calibration is stored
the way Klipper's own calibrators store theirs — `configfile.set()` + `SAVE_CONFIG` — in
per-tool sections, modelled on `[bed_mesh <profile>]` / klipper-toolchanger's `[tool Tn]`.
+1 -1
View File
@@ -197,7 +197,7 @@ logged instead.
make rootfs # once -- extracts rootfs.squashfs from the stock package
make test-install # install the built package into the replica
make test-recovery # mod in, stock package back out
make test-ui # UI selection and crash fallback, on the printer's shell
make test-ui # UI startup and crash protection, on the printer's shell
make test-applets # every command the payload uses exists on the printer
make test # all of it
```
+3 -3
View File
@@ -38,9 +38,9 @@ make test # everything below
|---|---|:-:|
| `test-lint` | Scans on-printer scripts for brick patterns: raw block-device writes, `rm -rf` of top-level paths, unguarded `rm -rf $VAR/`, missing backups, a boot chain that could end with no UI | |
| `test-model` | Each package's filename prefix matches the gate inside, the PID matches the model, and the two models ship **different** `firmwareExe` binaries | |
| `test-install` | **End-to-end.** The package sits on a real FAT filesystem exposed as `/dev/sda1`, and the machine's own `app_startup.sh` runs verbatim through three boots: stick in → it installs; stick still in → it installs again (idempotence); stick pulled → the machine boots with the mod running and the stock `ps`-watchdog satisfied. Asserts along the way: UI present and executable, boot scripts unmodified and still parsing, Klipper owned by a service, `c_helper.so` still nan2008 MIPS, user `printer.cfg` preserved, `firmwareExe.stock` never overwritten | ✓ |
| `test-install` | **End-to-end.** The package sits on a real FAT filesystem exposed as `/dev/sda1`, and the machine's own `app_startup.sh` runs verbatim through three boots: stick in → it installs; stick still in → it installs again (idempotence); stick pulled → the machine boots with the mod running and the stock `ps`-watchdog satisfied. Asserts along the way: UI present and executable, boot scripts unmodified and still parsing, Klipper owned by a service, `c_helper.so` still nan2008 MIPS, user `printer.cfg` preserved, the wrapper unchanged by a re-install | ✓ |
| `test-recovery` | Installs the mod, then flashes the **stock** package, and asserts the machine is genuinely back to stock byte-for-byte and the leftover payload is inert | ✓ |
| `test-ui` | Drives the UI selection logic on the printer's shell: helix missing, crash-loop, SAFE-MODE latch and release, **and the no-UI-at-all case** | ✓ |
| `test-ui` | Drives the UI decision logic on the printer's shell: HelixScreen missing, crash-loop, SAFE-MODE latch and release, and that SAFE-MODE means **headless, not a respawn loop** | ✓ |
| `test-applets` | Every command the payload runs exists on the printer. Its busybox has no `timeout`, no `bash`, no `systemctl`; reaching for one is a blank screen at boot | ✓ |
| `test-ash` | Parses every on-printer script with the printer's own busybox 1.31.1 ash | ✓ |
| `test-abi` | Every shipped MIPS binary is `nan2008`/`mips32r2`/`o32` | |
@@ -80,7 +80,7 @@ say where it goes — `run-tests.sh` stamps every header with elapsed seconds:
== brick-risk lint == [0s]
== profile: probe / default (on the fixture) == [1s]
== extracting the printer rootfs == [16s]
== applets / ash / ABI / UI fallback == [18s]
== applets / ash / ABI / UI safety == [18s]
== end-to-end update on the replica: probe == [28s]
== end-to-end update on the replica: default == [111s]
== recovery: a stock package reverts the mod == [250s]
+13 -36
View File
@@ -33,7 +33,9 @@ documented in the file headers.
| [`payload/klipper/config/ff-print-macros.cfg`](../payload/klipper/config/ff-print-macros.cfg) | `/usr/data/config/` | `START_PRINT` / `END_PRINT` / `PAUSE` / `RESUME` / `CANCEL_PRINT`, reconstructed from the app's sequences, plus the calibration gate |
| [`payload/klipper/config/ff-filament.cfg`](../payload/klipper/config/ff-filament.cfg) | `/usr/data/config/` | `LOAD_FILAMENT` / `UNLOAD_FILAMENT` / `PURGE` — the touchscreen's filament-load sequence (grab tool, purge chute, feed) recovered from the binary; unload is a designed retract (the stock app has none) |
| [`payload/klipper/config/ff-runout.cfg`](../payload/klipper/config/ff-runout.cfg) | `/usr/data/config/` | Runout / clog handling: gives the stock `fd_ex*` / `fm_ex*` sensors a `runout_gcode` that pauses a Mainsail print when the **mounted** tool runs out or clogs (the app's E0162 / E0163), optionally after printing through the PTFE buffer (`runout_distance`); `ff_toolchange` arms only the mounted tool's sensors |
| [`assets/orca/`](../assets/orca/) | OrcaSlicer printer profile | Machine start/end G-code, change-filament G-code, example project |
| [`payload/klipper/config/ff-model.cfg.creator5`](../payload/klipper/config/ff-model.cfg.creator5) · [`.creator5pro`](../payload/klipper/config/ff-model.cfg.creator5pro) | `/usr/data/config/ff-model.cfg` | Per-model hardware facts (today: does this machine have a chamber heater?). Anything that differs between models exists once per model with a `.creator5` / `.creator5pro` suffix and is installed under its real name — **nothing is edited at build time**, and every other file is byte-identical in every package |
| [`payload/klipper/config/ff-chamber.cfg`](../payload/klipper/config/ff-chamber.cfg) | `/usr/data/config/` | `M141` / `M191` for the chamber heater (Klipper has neither, and the stock app drove the chamber only from its own UI), plus the model gate: the Creator 5 Pro has a chamber heater and the plain **Creator 5 does not**, so a non-zero chamber target is refused when `ff-model.cfg` says the machine has none. Identical in every package |
| [`assets/orca/`](../assets/orca/) | OrcaSlicer printer profile | Machine start/end G-code, example project |
| [`docs/notes/`](notes/) | (reference only) | Condensed reverse-engineering notes: what the stock app actually does, with binary addresses |
## ⚠️ Use at your own risk
@@ -108,6 +110,8 @@ scp payload/klipper/config/ff-*.cfg \
[include ff-filament.cfg]
[include ff-print-macros.cfg] ; after ff-filament.cfg (the nozzle clean uses it)
[include ff-runout.cfg] ; after printer.base.cfg (overrides its sensor sections)
[include ff-model.cfg] ; per-model facts; BEFORE ff-chamber.cfg
[include ff-chamber.cfg] ; after printer.base.cfg (gates chamber_heater by model)
[include ff-legacy.cfg] ; temporary — only for step 5
```
@@ -325,17 +329,15 @@ In the printer profile:
[`assets/orca/machine-start-gcode.txt`](../assets/orca/machine-start-gcode.txt)
* **Machine end G-code**: contents of
[`assets/orca/machine-end-gcode.txt`](../assets/orca/machine-end-gcode.txt)
* **Change filament G-code**: contents of
[`assets/orca/change-filament-gcode.txt`](../assets/orca/change-filament-gcode.txt) —
`T[next_extruder] ; ff-toolchange`, where the trailing comment is
load-bearing (see below)
Or skip the copy-pasting: open
[`assets/orca/creator-mainsail.3mf`](../assets/orca/creator-mainsail.3mf) in OrcaSlicer —
an example project with the "Mainsail - Flashforge Creator 5 Pro 0.4
nozzle" printer profile already carrying all three G-code blocks above
(its start G-code predates `TOOLS=`/`TEMPS=` — paste the current
`machine-start-gcode.txt` over it to get the presence gate and nozzle clean).
Leave **Change filament G-code** empty. With no custom block Orca emits its
own bare `Tn` at each tool change, which reaches `ff_toolchange.py` directly.
Older instructions here asked for a `T[next_extruder] ; ff-toolchange` line to
dodge a trap in FlashForge's Klipper fork; we ship upstream `virtual_sdcard`
now and that trap is gone, so **clear the field** if it still holds anything.
Files already sliced with the old marker keep printing correctly — the G-code
parser discards the comment and `T2` arrives either way, so no re-slice is
needed for that.
Re-slice anything sliced with older start G-code — old files won't call
`TOOLCHANGE_SET_PRINT_OFFSET` and will print ~3.2 mm low.
@@ -350,31 +352,6 @@ station, cooled by 100 °C and docked while the bed heats — the app's
first print); `SOAK=<seconds>` dwells after the bed reaches target (the app
waits 5 min; default 0).
### Why `T[next_extruder] ; ff-toolchange` and not just `T[next_extruder]`
The comment satisfies two different parsers at once:
* **OrcaSlicer** reads the change-filament block with a real G-code parser.
It sees an actual `T<n>` command in there, decides the custom G-code
already changes the tool (`custom_gcode_changes_tool()`), and stops
emitting its own bare `Tn` after the block — so the tool change happens
exactly once.
* **FlashForge's Klipper fork** traps toolchange lines *before* they reach
the G-code interpreter: `virtual_sdcard.py` (line 566) checks
`line.startswith("T") and line in VALID_GCODE_T`, where `line` comes
straight from `data.split('\n')` and is **never stripped or normalized**.
A bare `T2` matches the exact string in `VALID_GCODE_T`, gets swallowed
by the fork, and is handed to the touchscreen app, which then blocks the
print on its own `doingChangeEx`/`refuelling` state — state that only the
touchscreen UI advances. `T2 ; ff-toolchange` is not the exact string
`T2`, so the trap does not fire; the line falls through to
`gcode.run_script()`, where `ff_toolchange.py`'s `T2` handler services it.
This is also why touchscreen-started prints are untouched: FF-sliced files
carry bare `Tn` lines, which the fork still intercepts and the app still
services exactly as before. Only lines with the marker comment reach this
module.
## Design notes
* Per-tool X/Y offsets are **differences vs a base tool (T0)**, applied on
+4 -5
View File
@@ -1,15 +1,14 @@
# /usr/data/anvil/anvil.conf -- runtime switches. Edit on the printer, reboot.
# PRESERVED across mod updates.
# Which UI owns the screen: stock = FlashForge firmwareExe, helix = HelixScreen
MOD_UI=stock
# Web stack: nginx (Mainsail on :80) + moonraker (:7125)
# Web stack: nginx (Mainsail on :80) + moonraker (:7125).
# The only one there is -- FlashForge's HTTP API went with firmwareExe.
# 0 = no web UI at all, ssh only.
MOD_WEB=1
# SSH: dropbear on :22
MOD_SSH=1
# WiFi: bring up wlan0 + wpa_supplicant + udhcpc, because firmwareExe used to
# do it and no longer runs. Only used when MOD_UI is not stock.
# do it and no longer runs.
MOD_WIFI=1
+24 -34
View File
@@ -10,8 +10,9 @@
# with a script is the cleanest possible integration point: app_startup.sh,
# rcS and the whole init chain stay 100% STOCK and are never patched.
#
# The original binary is preserved next to us as firmwareExe.stock and is
# still the default UI; nothing is lost by installing the mod.
# HelixScreen is the only UI. FlashForge's binary is not kept anywhere: the
# installer wipes the software dir before run.sh anyway, so the way back is
# flashing the stock FlashForge package, which still ships it.
#
# Two further properties we get for free:
# * app_startup.sh's own watchdog greps `ps` for "firmwareExe" 5s after
@@ -21,13 +22,11 @@
# from the newest version directory -- the stock recovery path still works.
MODDIR=/usr/data/anvil
SELFDIR=`dirname "$0"`
STOCK_UI="$SELFDIR/firmwareExe.stock"
[ -f $MODDIR/anvil.conf ] && . $MODDIR/anvil.conf
mkdir -p /usr/data/logs
exec >>/usr/data/logs/anvil-boot.log 2>&1
echo "=== mod firmwareExe `date 2>/dev/null` (UI=${MOD_UI:-stock}) ==="
echo "=== mod firmwareExe `date 2>/dev/null` ==="
# ---- services ------------------------------------------------------------
# init.d-style scripts, run in filename order. One failing service must never
@@ -41,35 +40,26 @@ if [ -d $MODDIR/init.d ]; then
fi
# ---- the UI, in the foreground so the stock watchdog can see us ----------
# S80ui already handled SAFE-MODE, crash counting and fallback selection and
# wrote its verdict here.
UI_CHOICE=`cat $MODDIR/.ui-choice 2>/dev/null || echo stock`
echo "ui: running $UI_CHOICE in the foreground"
# S80ui already handled SAFE-MODE and crash counting and wrote its verdict
# here: "helix" to start the UI, "none" to stay headless.
UI_CHOICE=`cat $MODDIR/.ui-choice 2>/dev/null || echo none`
echo "ui: choice is $UI_CHOICE"
case "$UI_CHOICE" in
helix)
HELIX_DATA_DIR=$MODDIR/helixscreen
HELIX_LOG_DEST=file
HELIX_LOG_FILE=/usr/data/logs/helixscreen.log
export HELIX_DATA_DIR HELIX_LOG_DEST HELIX_LOG_FILE
if [ -x $MODDIR/helixscreen/bin/helix-launcher.sh ]; then
$MODDIR/helixscreen/bin/helix-launcher.sh
else
$MODDIR/helixscreen/bin/helix-screen
fi
;;
*)
if [ -x "$STOCK_UI" ]; then
exec "$STOCK_UI"
else
echo "ui: firmwareExe.stock missing -- staying alive headless so ssh keeps working"
while true; do sleep 3600; done
fi
;;
esac
if [ "$UI_CHOICE" = helix ]; then
HELIX_DATA_DIR=$MODDIR/helixscreen
HELIX_LOG_DEST=file
HELIX_LOG_FILE=/usr/data/logs/helixscreen.log
export HELIX_DATA_DIR HELIX_LOG_DEST HELIX_LOG_FILE
if [ -x $MODDIR/helixscreen/bin/helix-launcher.sh ]; then
$MODDIR/helixscreen/bin/helix-launcher.sh
else
$MODDIR/helixscreen/bin/helix-screen
fi
fi
# If the UI exits we do NOT exit: dropping out of this script makes
# app_startup.sh think the UI died and re-exec us, which would restart every
# service. Hold the process instead; ssh and Mainsail remain available.
echo "ui: $UI_CHOICE exited -- holding process so services stay up"
# If the UI exits -- or was never started at all -- we do NOT exit: dropping
# out of this script makes app_startup.sh think the UI died and re-exec us,
# which would restart every service. Hold the process instead; ssh and
# Mainsail remain available.
echo "ui: nothing left in the foreground -- holding process so services stay up"
while true; do sleep 3600; done
+2 -8
View File
@@ -25,8 +25,8 @@
# services' lines, out of order.
#
# The saved networks live in /usr/prog/wifi/wpa_supplicant.conf on the
# firmware partition -- we never ship or touch that file, so whatever the
# stock UI saved is still there. It sets ctrl_interface=DIR=/var/run/wpa_supplicant,
# firmware partition -- we never ship or touch that file, so whatever
# FlashForge's UI saved there before the mod is still there. It sets ctrl_interface=DIR=/var/run/wpa_supplicant,
# which is also one of the paths HelixScreen probes for a control socket, so
# its WiFi panel drives this very daemon (and update_config=1 lets it save new
# networks back).
@@ -85,12 +85,6 @@ start() {
echo "wifi: disabled by MOD_WIFI"
return 0
fi
# When the stock UI owns the screen it also owns the network, exactly as
# on unmodified firmware. Racing it would fight over the interface.
if [ "${MOD_UI:-stock}" = "stock" ]; then
echo "wifi: stock UI owns the network -- leaving it alone"
return 0
fi
if supplicant_running; then
echo "wifi: wpa_supplicant already running"
return 0
+7 -2
View File
@@ -2,8 +2,13 @@
# nginx (Mainsail) + moonraker.
#
# Both binaries already ship on stock firmware -- FlashForge simply comments
# them out of /usr/prog/klipper/start.sh. We run nginx on our OWN prefix so
# their config is never modified and MOD_WEB=0 restores stock behaviour.
# them out of /usr/prog/klipper/start.sh and serves its own HTTP API from
# firmwareExe instead. The mod replaces firmwareExe, so that API is gone and
# this is the only web stack on the printer.
#
# MOD_WEB=0 therefore does NOT fall back to anything: it leaves the printer
# with ssh and the touchscreen only. It is a debugging switch, not a way back
# to stock -- that is a stock FlashForge package.
MODDIR=/usr/data/anvil
[ -f $MODDIR/anvil.conf ] && . $MODDIR/anvil.conf
NGINX=/usr/prog/nginx/sbin/nginx
+5 -16
View File
@@ -8,11 +8,11 @@
# printer boots to a working screen with NO klippy behind it: no motion, no
# heaters, no printing.
#
# Therefore Klipper startup is owned here, independently of whichever UI runs.
# Therefore Klipper startup is owned here, independently of the UI.
#
# Double-start guard: when MOD_UI=stock, firmwareExe still starts Klipper
# itself, so we must not race it. We only start Klipper if it is not already
# running, and start.sh carries the same guard.
# Double-start guard: we only start Klipper if it is not already running, and
# start.sh carries the same guard, so a restart over ssh cannot end up with two
# klippys against the same MCU.
#
# WHY IT ALSO SUPERVISES -- the heater board (mcu 'eheaterboard' on
# /dev/ttyS4) is frequently not answering yet when klippy first opens the
@@ -21,7 +21,7 @@
# _connect() sets the error state, returns, and fires FlashForge's own
# klippy:notify_mcu_error event over /tmp/uds. On stock, firmwareExe is the
# API client listening for that event, and it restarts the firmware -- which
# is why the stock UI needs two or three attempts before every board is
# is why FlashForge's UI needs two or three attempts before every board is
# found. Nothing in the mod plays that role, so klippy would sit in the error
# state forever with no heaters. Restarting klippy achieves what the stock
# retry achieves: reopening the port toggles DTR/RTS and the board comes up.
@@ -109,17 +109,6 @@ start() {
echo "klipper: already running"
return 0
fi
if [ "${MOD_UI:-stock}" = "stock" ]; then
# firmwareExe will start it, and it does its own retrying. Give it a
# moment, then only step in if it did not, so a stock-UI machine
# behaves exactly like stock.
sleep 15
if klippy_running; then
echo "klipper: started by firmwareExe (stock path)"
return 0
fi
echo "klipper: firmwareExe did not start it -- starting directly"
fi
[ -x /usr/prog/klipper/start.sh ] || { echo "klipper: start.sh missing"; return 1; }
# start.sh does the MCU bring-up itself, and that costs a few seconds per
# unresponsive board -- so it goes to the background; boot must not stall.
+17 -19
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# Decide WHICH UI should run, and record the verdict in $MODDIR/.ui-choice
# Decide WHETHER the UI should run, and record the verdict in $MODDIR/.ui-choice
# for the firmwareExe wrapper to act on.
#
# The decision is separated from the launching because the UI must run in the
@@ -7,32 +7,30 @@
# firmwareExe` watchdog sees a live process). An init script cannot hold that
# foreground, so it only chooses.
#
# Safety rules encoded here -- they matter because the goal is to retire
# FlashForge's firmwareExe, and once it no longer drives the screen a UI
# crash must not cost you the machine:
# HelixScreen is the only UI: FlashForge's firmwareExe is retired and there is
# nothing to fall back to. So the safety rules encoded here are what stands
# between a crash-looping UI and losing the machine:
# * ssh comes from the stock /etc/init.d/S50dropbear, which busybox init
# already ran long before this script. It is up regardless of what
# happens here.
# happens here, and so are S60web (Mainsail) and S70klipper.
# * A UI that crash-loops latches SAFE-MODE after MAX_UI_FAILURES boots and
# the printer falls back to the stock UI.
# * Delete /usr/data/anvil/SAFE-MODE (over ssh) to try the new UI again.
# the printer then boots HEADLESS -- no UI at all -- rather than looping.
# * Delete /usr/data/anvil/SAFE-MODE (over ssh) to try the UI again.
MODDIR=/usr/data/anvil
[ -f $MODDIR/anvil.conf ] && . $MODDIR/anvil.conf
MAX_UI_FAILURES="${MOD_MAX_UI_FAILURES:-3}"
FAILFILE=$MODDIR/ui-failures
SAFEFILE=$MODDIR/SAFE-MODE
CHOICE=$MODDIR/.ui-choice
STOCK_UI=/usr/prog/PROGRAM/software/firmwareExe.stock
have_helix() { [ -x $MODDIR/helixscreen/bin/helix-launcher.sh ] || [ -x $MODDIR/helixscreen/bin/helix-screen ]; }
have_stock() { [ -x "$STOCK_UI" ]; }
start() {
want="${MOD_UI:-stock}"
want=helix
if [ -f "$SAFEFILE" ]; then
echo "ui: SAFE-MODE latched -- forcing stock UI (delete $SAFEFILE to retry)"
want=stock
echo "ui: SAFE-MODE latched -- staying headless (delete $SAFEFILE to retry)"
want=none
fi
failures=`cat "$FAILFILE" 2>/dev/null || echo 0`
@@ -44,16 +42,16 @@ start() {
if [ "$failures" -ge "$MAX_UI_FAILURES" ]; then
echo "ui: $failures consecutive short boots -- latching SAFE-MODE"
: > "$SAFEFILE"
want=stock
want=none
fi
if [ "$want" = helix ] && ! have_helix; then
echo "ui: helix requested but not installed -- falling back to stock"
want=stock
echo "ui: HelixScreen is not installed -- staying headless"
want=none
fi
if [ "$want" = stock ] && ! have_stock; then
echo "ui: WARNING no stock UI available either -- printer will be headless"
echo " ssh is still up via /etc/init.d/S50dropbear"
if [ "$want" = none ]; then
echo "ui: WARNING printer will be headless -- no UI on the screen"
echo " ssh is still up via /etc/init.d/S50dropbear, and so is Mainsail"
fi
echo "$want" > "$CHOICE"
@@ -66,7 +64,7 @@ start() {
if [ -s "$CHOICE" ]; then rm -f "$FAILFILE"; fi ) &
}
stop() {
killall helix-screen helix-watchdog helix-splash firmwareExe.stock 2>/dev/null
killall helix-screen helix-watchdog helix-splash 2>/dev/null
echo "ui: stopped"
}
case "$1" in
+95
View File
@@ -0,0 +1,95 @@
# By using these files I affirm my respect for Ukraine's sovereignty and
# territorial integrity and my support for UN GA resolution ES-11/1.
# ---------------------------------------------------------------------------
# ff-chamber.cfg -- chamber heater for Mainsail prints, off on the Creator 5
#
# The Creator 5 Pro has a chamber heater; the plain Creator 5 does not. The
# two models ship the SAME Klipper tree -- printer.base.cfg declares
# [heater_generic chamber_heater] (heater_pin PE13 / sensor_pin PC1, both on
# the main mcu) on both. The whole difference lives in firmwareExe: the Pro
# binary carries "SET_HEATER_TEMPERATURE HEATER=chamber_heater TARGET=" in
# CommMgr::heatManager case 5 and the non-Pro binary does not. The non-Pro app
# still READS the chamber sensor and still runs the chamber fans -- it simply
# never commands the heater, so the target stays 0 forever.
#
# That is load-bearing, because FlashForge patched extras/verify_heater.py to
# HARDCODE its parameters (chamber_heater -> check_gain_time 900, max_error
# 100, heating_gain 5) and ignore the config. Overriding [verify_heater
# chamber_heater] in a drop-in therefore does nothing at all. What keeps a
# non-Pro safe is only that nothing ever sets a target: check_event() returns
# early while target <= 0. Set one on a machine with no heating element and
# ~15 min later (900 s gain window, then error 100 accrues in seconds) klippy
# calls invoke_shutdown("Heater chamber_heater not heating at expected rate")
# -- mid-print.
#
# Mainsail and HelixScreen expose every heater_generic as a settable heater,
# so on a non-Pro they hand the user that shutdown. This file closes it by
# gating the command instead of removing the section: the object has to stay
# declared because HelixScreen and Mainsail subscribe to
# "heater_generic chamber_heater" on both models.
#
# Whether this machine HAS the heater is not decided here: it comes from
# ff-model.cfg, one of which is selected per model at build time (see
# payload/klipper/config/models/). This file is byte-identical in every
# package.
#
# REQUIRED -- not an optional drop-in, and after ff-model.cfg:
# [include printer.base.cfg]
# ...
# [include ff-model.cfg]
# [include ff-chamber.cfg]
# ---------------------------------------------------------------------------
[gcode_macro _FF_CHAMBER]
description: M141/M191 tunables (presence lives in ff-model.cfg)
# M191 stops waiting this far below the target. The chamber is a slow, lossy
# volume; the app never waited on it tighter than a couple of degrees.
variable_wait_band: 2.0
gcode:
{ action_respond_info("chamber heater installed=%d" % (printer['gcode_macro _FF_MODEL'].chamber_heater|int)) }
# ---------------------------------------------------------------------------
# SET_HEATER_TEMPERATURE -- stock command, chamber target gated by model.
# Everything that is not a non-zero chamber target passes straight through, so
# hotends, the bed and "chamber off" (TARGET=0) behave exactly as before.
# A blocked call warns and continues rather than raising: a sliced file with
# M141 in it must not abort a print on a machine that simply has no heater.
# ---------------------------------------------------------------------------
[gcode_macro SET_HEATER_TEMPERATURE]
description: Set a heater target (chamber ignored on models without the heater)
rename_existing: SET_HEATER_TEMPERATURE_BASE
gcode:
{% set heater = params.HEATER|default('')|lower %}
{% set target = params.TARGET|default(0)|float %}
{% if heater == 'chamber_heater' and target > 0
and printer['gcode_macro _FF_MODEL'].chamber_heater|int == 0 %}
{ action_respond_info(
"no chamber heater on this model -- ignoring TARGET=%.1f" % target) }
{% else %}
SET_HEATER_TEMPERATURE_BASE {rawparams}
{% endif %}
# ---------------------------------------------------------------------------
# M141 [S<temp>] -- set chamber temperature, do not wait.
# M191 [S<temp>] -- set chamber temperature and wait for it.
# Klipper has neither built in, and the stock app drove the chamber straight
# from its own UI, so nothing in the stock firmware ever accepted them from a
# sliced file. Both route through SET_HEATER_TEMPERATURE above and so inherit
# the model gate.
# ---------------------------------------------------------------------------
[gcode_macro M141]
description: Set chamber temperature (S=target, 0 = off)
gcode:
SET_HEATER_TEMPERATURE HEATER=chamber_heater TARGET={params.S|default(0)|float}
[gcode_macro M191]
description: Set chamber temperature and wait (S=target, 0 = off)
gcode:
{% set target = params.S|default(0)|float %}
{% set ff = printer['gcode_macro _FF_CHAMBER'] %}
{% set model = printer['gcode_macro _FF_MODEL'] %}
SET_HEATER_TEMPERATURE HEATER=chamber_heater TARGET={target}
{% if target > 0 and model.chamber_heater|int %}
TEMPERATURE_WAIT SENSOR="heater_generic chamber_heater" MINIMUM={target - ff.wait_band|float}
{% endif %}
@@ -0,0 +1,28 @@
# By using these files I affirm my respect for Ukraine's sovereignty and
# territorial integrity and my support for UN GA resolution ES-11/1.
# ---------------------------------------------------------------------------
# ff-model.cfg -- what this particular MODEL has. Creator 5 (PID 0028).
#
# One of these ships in every package. Every file that differs between models
# exists once per model with a .creator5 / .creator5pro suffix, and the build
# installs the matching one under its real name. Nothing is ever edited: the
# suffixed file IS the per-model difference, so a new model fact is a new
# variable here and nothing else has to become model-aware.
#
# REQUIRED, and it must be included BEFORE the files that read it:
# [include ff-model.cfg]
# [include ff-chamber.cfg]
# ---------------------------------------------------------------------------
[gcode_macro _FF_MODEL]
description: Per-model hardware facts (see models/<machine>/ff-model.cfg)
variable_machine: "Creator5"
# This machine has NO chamber heating element. printer.base.cfg still declares
# [heater_generic chamber_heater] (both models ship the same Klipper tree), so
# the object exists and can be commanded -- ff-chamber.cfg refuses it.
variable_chamber_heater: 0
gcode:
{ action_respond_info("model %s, chamber_heater=%d" % (
printer['gcode_macro _FF_MODEL'].machine,
printer['gcode_macro _FF_MODEL'].chamber_heater|int)) }
@@ -0,0 +1,28 @@
# By using these files I affirm my respect for Ukraine's sovereignty and
# territorial integrity and my support for UN GA resolution ES-11/1.
# ---------------------------------------------------------------------------
# ff-model.cfg -- what this particular MODEL has. Creator 5 Pro (PID 0029).
#
# One of these ships in every package. Every file that differs between models
# exists once per model with a .creator5 / .creator5pro suffix, and the build
# installs the matching one under its real name. Nothing is ever edited: the
# suffixed file IS the per-model difference, so a new model fact is a new
# variable here and nothing else has to become model-aware.
#
# REQUIRED, and it must be included BEFORE the files that read it:
# [include ff-model.cfg]
# [include ff-chamber.cfg]
# ---------------------------------------------------------------------------
[gcode_macro _FF_MODEL]
description: Per-model hardware facts (see models/<machine>/ff-model.cfg)
variable_machine: "Creator5Pro"
# The Pro has a heating element in the chamber (heater_pin PE13 on the main
# mcu). The plain Creator 5 does not -- that single fact is the only
# functional difference between the two stock firmwareExe builds.
variable_chamber_heater: 1
gcode:
{ action_respond_info("model %s, chamber_heater=%d" % (
printer['gcode_macro _FF_MODEL'].machine,
printer['gcode_macro _FF_MODEL'].chamber_heater|int)) }
+15 -48
View File
@@ -54,23 +54,15 @@
# ---------------------------------------------------------------------------
# How a tool line reaches this code
#
# Set in the OrcaSlicer printer profile:
# change_filament_gcode = T{next_extruder} ; ff-toolchange
# Nothing is needed in the OrcaSlicer printer profile: leave the change-filament
# G-code EMPTY. With no custom block, custom_gcode_changes_tool() is false and
# Orca emits its own bare `Tn` at each tool change, which reaches
# gcode.run_script() and ff_toolchange.py's T<n> command unimpeded.
#
# The trailing comment satisfies both consumers at once:
# * Orca parses the block with a G-code reader, sees a real `T<n>` command,
# and suppresses its own bare `Tn` (custom_gcode_changes_tool()).
# * The fork's trap is `line.startswith("T") and line in VALID_GCODE_T`
# (virtual_sdcard.py:566), and `line` is NEVER stripped -- it comes straight
# from data.split('\n') at :449. "T2 ; ff-toolchange" != "T2", so the trap
# does not fire, doingChangeEx/refuelling stay false, and nothing waits on
# the touchscreen app.
# * The line reaches gcode.run_script(), where ff_toolchange.py's T2 command
# handles it.
#
# A BARE "T2" is still swallowed by the fork before run_script and never
# reaches this code -- so untouched touchscreen jobs behave exactly as before
# and firmwareExe keeps servicing them.
# Earlier releases required a `T{next_extruder} ; ff-toolchange` marker to slip
# past a bare-`Tn` trap in FlashForge's virtual_sdcard fork. We ship upstream
# virtual_sdcard now, the trap is gone, and files sliced with the old marker
# still work: the G-code parser drops the comment and `T2` arrives regardless.
#
# ---------------------------------------------------------------------------
# INSTALL
@@ -207,40 +199,15 @@ stop_macro: MOTOR_STOP
#
# WHY THIS WRAPPER EXISTS
#
# The fork rewrites tool lines as it reads the file. virtual_sdcard.py:457-473,
# for every line that does not start with ";" and matches r'T(\d+)':
#
# if self.need_check_ex or self.no_filament_check_ex:
# i = self.gcode_ex_used.index("T<n>")
# if i >= 0: line = line.replace("T<n>", self.gcode_ex_used_changed[i], 1)
#
# That is the touchscreen's "print this job on a different extruder"
# substitution. While either flag is set, OUR `T2 ; ff-toolchange` line is
# looked up in that table and silently rewritten to whatever tool it maps to --
# the print runs on the wrong extruder, with no error.
#
# Neither flag is reset by cmd_M23 NOR by cmd_SDCARD_PRINT_FILE. They are
# cleared only by their own commands (:287, :293), which the touchscreen app
# issues at the right moments and Moonraker never does. So they persist from
# the last touchscreen job into every subsequent Mainsail print. Those two
# lines below are the load-bearing ones.
#
# cmd_M23 (touchscreen) also resets change_filament / enable_ffm /
# set_velocity_limit (:340-342); cmd_SDCARD_PRINT_FILE (Moonraker) resets none
# of them -- it only calls _reset_file() and do_resume() (:244-251). Of those:
# * SDCARD_CLEAR_REFUELLING -- change_filament is reported as `refuelling` in
# get_status (:188), so a stale true makes the UI show a refuelling state
# that never clears. It cannot deadlock us: the busy-wait at :572 is reached
# only from the bare-Tn trap, which our commented tool lines evade.
# * SDCARD_ENABLE_FFM ENABLE=0 -- belt and braces. enable_ffm is written in
# four places and READ NOWHERE in klippy; it is inert.
# * set_velocity_limit needs no line here: SDCARD_SET_CHANNEL clears it (:259)
# and ff_toolchange issues that on every change.
# ---------------------------------------------------------------------------
#
# It also refuses to start at all while the machine is uncalibrated: a file
# It refuses to start at all while the machine is uncalibrated: a file
# with its own start G-code would otherwise print in the raw eddy Z frame,
# ~3.2 mm into the plate. _FF_JOB.allow_uncalibrated=1 overrides for tests.
#
# The four SDCARD_* lines below clear per-job state that FlashForge's
# virtual_sdcard fork carried over from the last touchscreen job (a stale
# `refuelling` flag, FFM, and the two "print this job on another extruder"
# remap flags). Those commands exist only in the fork; on the upstream
# virtual_sdcard we now ship they are unknown commands. FIXME.
# ---------------------------------------------------------------------------
[gcode_macro SDCARD_PRINT_FILE]
rename_existing: _FF_SDCARD_PRINT_FILE
+9 -6
View File
@@ -19,9 +19,9 @@
# Install: copy ff_tool.py and ff_toolchange.py to
# /usr/prog/klipper/klippy/extras/, then see config/ff-toolchange.cfg.
#
# Klipper calls Tn only for lines that reach the gcode engine; a bare "Tn" in a
# printing file is still swallowed by the FlashForge virtual_sdcard fork, so
# touchscreen jobs are unaffected.
# Klipper calls Tn only for lines that reach the gcode engine. We ship upstream
# virtual_sdcard, which passes tool lines straight through, so a bare "Tn" from
# any slicer lands here -- no marker comment needed.
import contextlib
import logging
@@ -916,9 +916,12 @@ class FFToolchange:
% self._extruder_name(tool))
self._apply_tool_diff_offsets(tool)
self._arm_runout(tool)
# Keep the fork's channel state coherent: bare M104/M109 get
# " T<channel>" appended and SET_PRESSURE_ADVANCE is rewritten to
# pa_value_t<channel> (virtual_sdcard.py:543 / :479).
# FIXME: SDCARD_SET_CHANNEL exists only in FlashForge's
# virtual_sdcard fork, where it kept the channel state coherent
# (bare M104/M109 got " T<channel>" appended, SET_PRESSURE_ADVANCE
# was rewritten to pa_value_t<channel>). We ship upstream
# virtual_sdcard, which has no such command and no such rewriting,
# so this is an unknown command on the shipped Klipper.
self._run('SDCARD_SET_CHANNEL CHANNEL=%d' % tool)
except FFToolchangeError as e:
raise gcmd.error(str(e))
+29 -16
View File
@@ -49,30 +49,43 @@ else
fi
sync
# ---- the real UI binary, kept beside our wrapper -------------------------
# The stock run.sh copied our wrapper to firmwareExe; put the genuine binary
# next to it so the wrapper (and SAFE-MODE) can always fall back to it.
if [ -f "$WORK_DIR/firmwareExe.stock" ]; then
cp -f "$WORK_DIR/firmwareExe.stock" /usr/prog/PROGRAM/software/firmwareExe.stock
chmod +x /usr/prog/PROGRAM/software/firmwareExe.stock
echo "installed firmwareExe.stock (fallback UI)"
fi
sync
# ---- klipper configs -------------------------------------------------------
# Install only what is missing: never clobber a printer.cfg the user tuned.
# Anything that already exists lands as <name>.mod-new for manual merging.
# ---- klipper + moonraker configs -------------------------------------------
# Every file here is one the mod ships (ff-*.cfg, moonraker.conf); printer.cfg
# is the user's and is never shipped, so it is never a candidate.
#
# Install what is missing. For what already exists the question is whether the
# user edited it: a file still byte-identical to the one the LAST package wrote
# is ours to update, and one that differs is theirs to keep. $MODDIR/config-installed
# holds that last-written copy -- it is not in the rm -rf above, so it survives
# the payload swap and is refreshed only after the comparison below.
#
# Without this, a config we own could only ever be written once: it exists on
# the second flash, so it would land as .mod-new forever and updates would
# silently never reach the printer.
#
# First install after this rule arrives has no config-installed, so an existing
# file is treated as edited -- .mod-new, the conservative answer.
if [ -d $MODDIR/config ]; then
mkdir -p /usr/data/config
for f in $MODDIR/config/*; do
[ -f "$f" ] || continue
b=`basename "$f"`
if [ -f "/usr/data/config/$b" ]; then
cp -f "$f" "/usr/data/config/$b.mod-new"
live="/usr/data/config/$b"
prev="$MODDIR/config-installed/$b"
if [ ! -f "$live" ]; then
cp -f "$f" "$live"
elif [ -f "$prev" ] && [ "`md5sum < "$live"`" = "`md5sum < "$prev"`" ]; then
cp -f "$f" "$live"
echo "config: $b updated (was unmodified)"
else
cp -f "$f" /usr/data/config/
cp -f "$f" "$live.mod-new"
echo "config: $b kept -- new version left as $b.mod-new"
fi
done
# Snapshot what we just shipped, for the NEXT update to compare against.
rm -rf $MODDIR/config-installed
mkdir -p $MODDIR/config-installed
cp -f $MODDIR/config/* $MODDIR/config-installed/ 2>/dev/null
fi
sync
+3 -3
View File
@@ -22,9 +22,9 @@ export LD_LIBRARY_PATH=/usr/prog/Python-3.8.2/lib:$LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/usr/prog/openssl-1.0.2d/lib:$LD_LIBRARY_PATH
export LD_LIBRARY_PATH=/usr/prog/libffi-3.4.4/lib:$LD_LIBRARY_PATH
# Idempotence guard: firmwareExe runs this script, and so does
# init.d/S70klipper when the stock UI is not present. Running it twice would
# start a second klippy against the same MCU.
# Idempotence guard: init.d/S70klipper runs this script, and so does its own
# retry loop. Running it twice would start a second klippy against the same
# MCU.
if [ -S /tmp/uds ]; then
echo "start.sh: klippy already running, nothing to do"
exit 0
+4 -4
View File
@@ -7,9 +7,10 @@
# machine, and keep a USB stick with the STOCK FlashForge package within
# reach -- flashing that is the uninstall (proven by `make test-recovery`).
#
# Stock firmwareExe is kept ON DISK as a fallback: the firmwareExe wrapper
# reverts to it if HelixScreen crashes, and S80ui latches SAFE-MODE after 3
# failed boots. ssh and Mainsail are your recovery path if the screen is dark.
# FlashForge's firmwareExe is REPLACED, not kept: HelixScreen is the only UI.
# If it crash-loops, S80ui latches SAFE-MODE after 3 failed boots and the
# printer boots headless instead of looping. ssh and Mainsail are your
# recovery path if the screen is dark.
PROFILE_DESC="the firmware: forked Klipper + toolchanger + Mainsail + ssh + HelixScreen"
BUILD_REPORT=0 # the report dumps /etc + shadow to the stick: probe only
@@ -21,4 +22,3 @@ BUILD_MAINSAIL=1
BUILD_HELIX=1
MOD_SSH=1
MOD_WEB=1
MOD_UI=helix
+4 -4
View File
@@ -56,11 +56,11 @@ done <<< "$FILES"
# their absence is itself a failure.
UIW=payload/firmwareExe
if [ ! -f "$UIW" ]; then
bad "payload/firmwareExe" "missing -- nothing replaces the stock UI binary"
bad "payload/firmwareExe" "missing -- nothing replaces FlashForge's UI binary"
else
grep -q 'firmwareExe.stock' "$UIW" \
&& ok "firmwareExe falls back to the stock UI" \
|| bad "$UIW" "no stock-UI fallback -- a missing HelixScreen means a blank screen"
grep -q 'helix' "$UIW" \
&& ok "firmwareExe launches HelixScreen" \
|| bad "$UIW" "never mentions HelixScreen -- nothing would drive the screen"
grep -qE '^[[:space:]]*set -e' "$UIW" \
&& warn "$UIW" "an early exit here can leave the printer with no UI" \
|| ok "$UIW has no bare 'set -e'"
+10 -13
View File
@@ -131,12 +131,9 @@ if head -c 2 "$FE" 2>/dev/null | grep -q '#!'; then
WRAPPER=1
sh -n "$FE" 2>/dev/null && ok "firmwareExe wrapper parses under the printer's busybox" \
|| { bad "BRICK: wrapper syntax error"; sh -n "$FE" 2>&1 | sed 's/^/ /'; }
[ -s "$SW/firmwareExe.stock" ] \
&& ok "stock UI binary preserved as firmwareExe.stock" \
|| bad "BRICK: wrapper installed but the stock UI binary is gone"
head -c 4 "$SW/firmwareExe.stock" 2>/dev/null | grep -q 'ELF' \
&& ok "firmwareExe.stock is a real ELF binary" \
|| bad "firmwareExe.stock is not an ELF -- the UI could not be restored"
grep -q 'helix' "$FE" \
&& ok "the wrapper launches HelixScreen" \
|| bad "BRICK: wrapper installed but it starts no UI at all"
elif head -c 4 "$FE" | grep -q 'ELF'; then
ok "stock firmwareExe binary in place (no UI replacement in this profile)"
fi
@@ -206,7 +203,7 @@ fi
echo
echo " -- boot 2: the stick was left in the slot --"
cp -a $APP /tmp/app_startup.after1
[ "$WRAPPER" = 1 ] && cp -a "$SW/firmwareExe.stock" /tmp/stock-ui.after1
[ "$WRAPPER" = 1 ] && cp -a "$FE" /tmp/firmwareExe.after1
boot /tmp/boot2.log 900 || bad "boot 2 never settled"
case "$BOOT_RESULT" in
@@ -218,12 +215,12 @@ cmp -s /tmp/app_startup.after1 $APP && ok "re-install is idempotent (app_startup
[ -s "$FE" ] && ok "firmwareExe still present after re-install" \
|| bad "BRICK: re-install left no firmwareExe"
if [ "$WRAPPER" = 1 ]; then
head -c 4 "$SW/firmwareExe.stock" 2>/dev/null | grep -q 'ELF' \
&& ok "firmwareExe.stock is still the stock UI, not the wrapper" \
|| bad "BRICK: firmwareExe.stock is now the wrapper -- stock UI lost forever"
cmp -s /tmp/stock-ui.after1 "$SW/firmwareExe.stock" \
&& ok "the preserved stock UI is byte-identical after the second install" \
|| bad "BRICK: the second install overwrote firmwareExe.stock"
head -c 2 "$FE" 2>/dev/null | grep -q '#!' \
&& ok "firmwareExe is still the wrapper after the re-install" \
|| bad "BRICK: the second install left something else at firmwareExe"
cmp -s /tmp/firmwareExe.after1 "$FE" \
&& ok "the installed wrapper is byte-identical after the second install" \
|| bad "re-install changed firmwareExe"
fi
# ================================================================== boot 3 ==
+23 -24
View File
@@ -1,12 +1,11 @@
#!/bin/sh
# Exercise the UI selection and crash-fallback logic on the printer's own
# Exercise the UI decision and crash-protection logic on the printer's own
# shell.
#
# This matters because the plan is to retire FlashForge's firmwareExe in
# favour of HelixScreen. Once the stock UI no longer drives the screen, a UI
# failure must not cost you the machine: ssh comes from the stock
# /etc/init.d/S50dropbear and is unaffected, and the mod has to fall back
# rather than crash-loop.
# This matters because FlashForge's firmwareExe is retired in favour of
# HelixScreen and there is nothing to fall back to. A UI failure must not cost
# you the machine: ssh comes from the stock /etc/init.d/S50dropbear and is
# unaffected, and the mod has to go headless rather than crash-loop.
#
# The payload under test is mounted at /tmp/payload.
FAIL=0
@@ -15,52 +14,52 @@ bad() { echo " FAIL $*"; FAIL=1; }
MOD=/usr/data/anvil
SWD=/usr/prog/PROGRAM/software
setup() { # $1=helix installed? $2=stock UI installed? $3=MOD_UI
setup() { # $1=helix installed?
killall sleep 2>/dev/null
rm -rf $MOD $SWD
mkdir -p $MOD/init.d $MOD/helixscreen/bin /usr/data/logs $SWD
cp /tmp/payload/init.d/S* $MOD/init.d/; chmod +x $MOD/init.d/S*
printf 'MOD_UI=%s\nMOD_WEB=0\nMOD_SSH=1\n' "$3" > $MOD/anvil.conf
printf 'MOD_WEB=0\nMOD_SSH=1\n' > $MOD/anvil.conf
[ "$1" = yes ] && { printf '#!/bin/sh\nsleep 300\n' > $MOD/helixscreen/bin/helix-launcher.sh
chmod +x $MOD/helixscreen/bin/helix-launcher.sh; }
[ "$2" = yes ] && { printf '#!/bin/sh\nsleep 300\n' > $SWD/firmwareExe.stock
chmod +x $SWD/firmwareExe.stock; }
return 0
}
choice() { cat $MOD/.ui-choice 2>/dev/null; }
run_ui() { $MOD/init.d/S80ui start >/tmp/ui.out 2>&1; }
echo " case 1: helix installed and requested"
setup yes yes helix; run_ui
echo " case 1: helix installed"
setup yes; run_ui
[ "$(choice)" = helix ] && ok "chose helix" || bad "chose '$(choice)', expected helix"
echo
echo " case 2: helix requested but NOT installed"
setup no yes helix; run_ui
[ "$(choice)" = stock ] && ok "fell back to stock UI" || bad "chose '$(choice)', expected stock"
echo " case 2: helix NOT installed -- headless, not a blank crash loop"
setup no; run_ui
[ "$(choice)" = none ] && ok "chose none" || bad "chose '$(choice)', expected none"
grep -q 'headless' /tmp/ui.out && ok "headless state reported" || bad "headless state not reported"
echo
echo " case 3: repeated short boots latch SAFE-MODE"
setup yes yes helix
setup yes
echo 3 > $MOD/ui-failures
run_ui
[ -f $MOD/SAFE-MODE ] && ok "SAFE-MODE latched" || bad "SAFE-MODE not latched"
[ "$(choice)" = stock ] && ok "forced to stock UI" || bad "chose '$(choice)', expected stock"
[ "$(choice)" = none ] && ok "SAFE-MODE means no UI at all" || bad "chose '$(choice)', expected none"
echo
echo " case 4: SAFE-MODE is honoured on the next boot"
rm -f $MOD/ui-failures
run_ui
[ "$(choice)" = stock ] && ok "still stock while SAFE-MODE is latched" || bad "SAFE-MODE ignored"
[ "$(choice)" = none ] && ok "still headless while SAFE-MODE is latched" || bad "SAFE-MODE ignored"
grep -q "$MOD/SAFE-MODE" /tmp/ui.out && ok "the log says which file to delete" \
|| bad "the log never names the SAFE-MODE file"
rm -f $MOD/SAFE-MODE
run_ui
[ "$(choice)" = helix ] && ok "clearing SAFE-MODE re-enables helix" \
|| bad "still '$(choice)' after clearing SAFE-MODE"
echo
echo " case 5: NO UI AT ALL (the firmwareExe-is-retired scenario)"
setup no no helix; run_ui
grep -q 'headless' /tmp/ui.out && ok "headless state reported" || bad "headless state not reported"
echo " case 5: the wrapper holds the foreground even with no UI"
setup no; run_ui
cp /tmp/payload/firmwareExe $SWD/firmwareExe; chmod +x $SWD/firmwareExe
# The printer's busybox has no `timeout` applet, so hold-and-check by hand.
sh $SWD/firmwareExe >/dev/null 2>&1 &
@@ -75,7 +74,7 @@ fi
echo
echo " case 6: Klipper is started independently of the UI"
setup yes yes helix
setup yes
mkdir -p /usr/prog/klipper
printf '#!/bin/sh\ntouch /tmp/klipper-started\nsleep 60\n' > /usr/prog/klipper/start.sh
chmod +x /usr/prog/klipper/start.sh
@@ -88,6 +87,6 @@ sleep 2
killall sleep 2>/dev/null
echo
[ "$FAIL" = 0 ] && echo " UI selection and fallback behave correctly" \
|| echo " UI FALLBACK TESTS FAILED"
[ "$FAIL" = 0 ] && echo " UI decision and crash protection behave correctly" \
|| echo " UI SAFETY TESTS FAILED"
exit $FAIL
+5 -3
View File
@@ -106,7 +106,6 @@ MAINSAIL_ZIP="$FXASSETS/mainsail.zip"
BUSYBOX_BIN=""
DEFAULT_PROFILE=probe
TARGET_MACHINE=Creator5Pro
MOD_UI=stock
ROOT_PW_HASH='\$6\$ci\$abcdefghijklmnopqrstuvwxyz'
FF_KEY='FFP0331&*%root'
CFG
@@ -186,11 +185,14 @@ else
hdr "klipper macros parse"
sub "macros" python3 ./test/test-macros.py
hdr "chamber heater is gated by model"
sub "chamber gate" python3 ./test/test-chamber.py
hdr "MCU bring-up runs on the printer's own Python"
sub "mcu bring-up" ./test/sim-mcu-bringup.sh
hdr "UI selection and fallback (on the printer's shell)"
sub "ui fallback" ./test/sim-ui-fallback.sh
hdr "UI decision and crash protection (on the printer's shell)"
sub "ui safety" ./test/sim-ui-fallback.sh
for PROF in probe default; do
hdr "end-to-end update on the printer replica: $PROF"
+2 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
# UI selection and crash-fallback, exercised on the printer's own shell.
# The UI decision and its crash protection, exercised on the printer's own
# shell.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
# shellcheck disable=SC1091
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""The chamber-heater gate in ff-chamber.cfg must actually gate.
The Creator 5 Pro has a chamber heater and the plain Creator 5 does not, but
both ship the same printer.base.cfg, so [heater_generic chamber_heater] is
declared either way. FlashForge hardcoded verify_heater's parameters, so the
ONLY thing keeping a non-Pro safe is that nothing ever sets a target -- set
one and klippy shuts the printer down ~15 min later, mid-print.
ff-chamber.cfg is byte-identical in every package; the answer comes from
ff-model.cfg, which exists once per model as ff-model.cfg.creator5 /
ff-model.cfg.creator5pro and is installed under its real name. Syntax alone does
not prove the selection has any effect, so this renders the macro bodies
through Klipper's own Jinja environment (delimiters '{%','%}','{','}' -- NOT
jinja2's defaults) and asserts what comes out, for each model in turn.
./test/test-chamber.py
"""
import ast
import glob
import importlib.util
import os
import sys
try:
import jinja2
except ImportError:
print(" SKIP: jinja2 not installed")
sys.exit(0)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFGDIR = os.path.join(ROOT, "payload", "klipper", "config")
CFG = os.path.join(CFGDIR, "ff-chamber.cfg")
# TARGET_MACHINE -> the suffix bin/patch.sh derives from it, and the answer
# that model is expected to give. A model that is buildable but missing from
# here would otherwise go untested.
MODELS = {"Creator5Pro": ("creator5pro", 1), "Creator5": ("creator5", 0)}
# Reuse the config reader from its sibling; the '-' in the name rules out a
# plain import.
_spec = importlib.util.spec_from_file_location(
"test_macros", os.path.join(ROOT, "test", "test-macros.py"))
_tm = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_tm)
PASS, FAIL = [], []
def ok(name):
PASS.append(name)
print(" \033[32mPASS\033[0m %s" % name)
def bad(name, detail=""):
FAIL.append(name)
print(" \033[31mFAIL\033[0m %s" % name)
if detail:
print(" %s" % detail)
def variables_of(secs):
"""The chamber_heater answer this config combination gives."""
raw = secs.get("gcode_macro _FF_MODEL", {}).get("variable_chamber_heater")
try:
return int(raw)
except (TypeError, ValueError):
return raw
def load(machine):
"""The shared logic file plus the ff-model.cfg that patch.sh would pick."""
secs = dict(_tm.sections(CFG))
model = os.path.join(CFGDIR, "ff-model.cfg.%s" % MODELS[machine][0])
if not os.path.exists(model):
bad("ff-model.cfg.%s exists" % MODELS[machine][0],
"bin/patch.sh would fail the build for TARGET_MACHINE=%s" % machine)
return None
secs.update(dict(_tm.sections(model)))
return secs
def render(secs, section, params, rawparams=""):
"""Render one macro body the way Klipper's gcode_macro does."""
env = jinja2.Environment("{%", "%}", "{", "}")
said = []
def action_respond_info(msg):
said.append(msg)
return ""
# Klipper exposes a macro's variables as attributes of its status object
# after ast.literal_eval; a plain dict is close enough for these lookups.
def variables(section):
out = {}
for k, v in secs.get(section, {}).items():
if not k.startswith("variable_"):
continue
try:
out[k[len("variable_"):]] = ast.literal_eval(v)
except (ValueError, SyntaxError):
out[k[len("variable_"):]] = v
return out
printer = {"gcode_macro _FF_CHAMBER": variables("gcode_macro _FF_CHAMBER"),
"gcode_macro _FF_MODEL": variables("gcode_macro _FF_MODEL")}
out = env.from_string(secs["gcode_macro " + section]["gcode"]).render(
printer=printer, params=params, rawparams=rawparams,
action_respond_info=action_respond_info)
return out, said
def main():
if not os.path.exists(CFG):
print(" SKIP: no ff-chamber.cfg")
return 0
# bin/patch.sh must actually do the selecting.
patch_sh = open(os.path.join(ROOT, "bin", "patch.sh"), encoding="utf-8").read()
if '*."$SUFFIX"' in patch_sh and 'tr \'A-Z\' \'a-z\'' in patch_sh:
ok("bin/patch.sh installs <file>.<model> under its real name")
else:
bad("bin/patch.sh installs <file>.<model> under its real name",
"nothing selects the per-model variants")
# A variant must never match the plain ff-*.cfg glob, or it would ship to
# every model under its suffixed name as well.
leaked = [os.path.basename(f)
for f in glob.glob(os.path.join(CFGDIR, "ff-*.cfg"))
if any(os.path.basename(f).endswith("." + sfx)
for sfx, _ in MODELS.values())]
if not leaked:
ok("no per-model variant matches the plain ff-*.cfg glob")
else:
bad("no per-model variant matches the plain ff-*.cfg glob", repr(leaked))
# Every file that has one variant must have all of them, or some model
# silently ships without it.
bases = {os.path.basename(f).rsplit(".", 1)[0]
for sfx, _ in MODELS.values()
for f in glob.glob(os.path.join(CFGDIR, "*." + sfx))}
missing = [(b, sfx) for b in sorted(bases) for sfx, _ in MODELS.values()
if not os.path.exists(os.path.join(CFGDIR, "%s.%s" % (b, sfx)))]
if not missing:
ok("every per-model file exists for every model (%d file(s))" % len(bases))
else:
bad("every per-model file exists for every model",
", ".join("%s.%s" % m for m in missing))
for machine, (_sfx, want) in sorted(MODELS.items()):
secs = load(machine)
if secs is None:
continue
got = variables_of(secs)
if got == want:
ok("%s declares chamber_heater=%d" % (machine, want))
else:
bad("%s declares chamber_heater=%d" % (machine, want), "got %r" % got)
pro, nonpro = load("Creator5Pro"), load("Creator5")
if FAIL:
# The config no longer has the shape patch.sh edits; rendering it would
# only bury that under a Jinja traceback.
print("\n %d passed, %d failed" % (len(PASS), len(FAIL)))
return 1
BLOCKED = "SET_HEATER_TEMPERATURE_BASE"
# --- the whole point: a chamber target on a machine with no heater ------
out, said = render(nonpro, "SET_HEATER_TEMPERATURE",
{"HEATER": "chamber_heater", "TARGET": "50"},
"HEATER=chamber_heater TARGET=50")
if BLOCKED not in out and said:
ok("non-Pro: chamber TARGET=50 is blocked and explained")
else:
bad("non-Pro: chamber TARGET=50 is blocked and explained",
"rendered %r, said %r" % (out.strip(), said))
out, _ = render(pro, "SET_HEATER_TEMPERATURE",
{"HEATER": "chamber_heater", "TARGET": "50"},
"HEATER=chamber_heater TARGET=50")
if BLOCKED in out and "TARGET=50" in out:
ok("Pro: chamber TARGET=50 reaches the real command")
else:
bad("Pro: chamber TARGET=50 reaches the real command", repr(out.strip()))
# --- everything else must pass through on BOTH models -------------------
# Turning the chamber off has to work even where there is no heater: the
# stock app and our own macros send TARGET=0 unconditionally.
for label, secs in (("non-Pro", nonpro), ("Pro", pro)):
out, _ = render(secs, "SET_HEATER_TEMPERATURE",
{"HEATER": "chamber_heater", "TARGET": "0"},
"HEATER=chamber_heater TARGET=0")
if BLOCKED in out:
ok("%s: chamber TARGET=0 still passes through" % label)
else:
bad("%s: chamber TARGET=0 still passes through" % label, repr(out.strip()))
out, _ = render(secs, "SET_HEATER_TEMPERATURE",
{"HEATER": "extruder", "TARGET": "220"},
"HEATER=extruder TARGET=220")
if BLOCKED in out and "extruder" in out:
ok("%s: hotends are untouched" % label)
else:
bad("%s: hotends are untouched" % label, repr(out.strip()))
# --- M191 must not wait for a heater that cannot heat -------------------
out, _ = render(nonpro, "M191", {"S": "60"})
if "TEMPERATURE_WAIT" not in out:
ok("non-Pro: M191 does not wait forever")
else:
bad("non-Pro: M191 does not wait forever", repr(out.strip()))
out, _ = render(pro, "M191", {"S": "60"})
if "TEMPERATURE_WAIT" in out and "MINIMUM=58" in out:
ok("Pro: M191 waits to within wait_band")
else:
bad("Pro: M191 waits to within wait_band", repr(out.strip()))
print("\n %d passed, %d failed" % (len(PASS), len(FAIL)))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())
+4 -1
View File
@@ -59,7 +59,10 @@ def sections(path):
def main():
env = jinja2.Environment()
files = sorted(glob.glob(os.path.join(CFGDIR, "ff-*.cfg")))
# <file>.creator5 / <file>.creator5pro are installed under their real
# name by bin/patch.sh, so they are Klipper config too and must parse.
files = sorted(glob.glob(os.path.join(CFGDIR, "ff-*.cfg"))
+ glob.glob(os.path.join(CFGDIR, "ff-*.cfg.*")))
if not files:
print(" SKIP: no ff-*.cfg in %s" % CFGDIR)
return 0
+10 -20
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
# Model-gate regression test.
#
# The two models are NOT interchangeable: Creator5 and Creator5Pro ship
# different firmwareExe binaries, and each package's runFirmwareExe.sh carries
# The two models are NOT interchangeable: their stock packages ship different
# firmwareExe binaries, and each package's runFirmwareExe.sh carries
# a MACHINE=/PID= gate that refuses to install on the other one. An earlier
# version of pack.sh emitted BOTH filenames from a single build, which would
# have handed one model the other's firmware.
@@ -10,7 +10,13 @@
# Asserts, for every package in dist/ (or work/out):
# * the filename prefix matches the gate inside
# * a Pro package and a non-Pro package are not the same file
# * each carries the firmwareExe from its own stock package
# * each carries our firmwareExe wrapper
#
# The gate check above is what catches a cross-model build: runFirmwareExe.sh
# comes from the stock package the build was made from, so a Pro-named package
# built off the non-Pro stock tgz says MACHINE=Creator5 inside. The wrapper
# itself is identical on both models -- HelixScreen is the UI now and the
# per-model binary is not shipped at all -- so it cannot discriminate.
set -uo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
@@ -24,7 +30,6 @@ PKGS=$(ls -1 "$DIR"/Creator5*-*.tgz 2>/dev/null || true)
[ -n "$PKGS" ] || { echo " SKIP: no packages (run 'make release')"; exit 0; }
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
declare -A FEMD5
while IFS= read -r p; do
[ -n "$p" ] || continue
@@ -52,27 +57,12 @@ while IFS= read -r p; do
tar -xf "$d"/software-*.tar.xz -C "$d/sw" 2>/dev/null
if [ -f "$d/sw/firmwareExe" ]; then
# The wrapper is installed as firmwareExe; the genuine per-model
# binary must be alongside it.
real="$d/sw/firmwareExe.stock"
[ -f "$real" ] || real="$d/sw/firmwareExe"
FEMD5[$want]=$(md5sum "$real" | cut -d' ' -f1)
ok "$b: carries a firmwareExe (${FEMD5[$want]:0:12})"
ok "$b: carries a firmwareExe ($(md5sum "$d/sw/firmwareExe" | cut -c1-12))"
else
bad "$b: no firmwareExe in the software component"
fi
done <<< "$PKGS"
if [ -n "${FEMD5[Creator5Pro]:-}" ] && [ -n "${FEMD5[Creator5]:-}" ]; then
if [ "${FEMD5[Creator5Pro]}" = "${FEMD5[Creator5]}" ]; then
bad "both models ship the SAME firmwareExe -- one of them got the wrong firmware"
else
ok "the two models ship different firmwareExe binaries (as they must)"
fi
else
echo " .... only one model built; run 'make release' to check both"
fi
echo
[ "$FAIL" = 0 ] && echo " model gates are correct" || echo " MODEL GATE TEST FAILED"
exit $FAIL