Prepare next firmware release

Add Fluidd and custom boot scripts, migrate DC24V control, update the web UIs and filament macros, improve startup status reporting, and expand upgrade and replica coverage.
This commit is contained in:
Oleksandr Shyshatskyi committed 2026-09-24 11:37:28 +03:00
1 parent 56836ca9e9
commit 80a4d64ffd
52 files changed
+1321 -112

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+1
View File
@@ -136,6 +136,7 @@ jobs:
# build-packages.sh like every other one.
cat > config.env <<'CFG'
BUILD_MAINSAIL=0
BUILD_FLUIDD=0
BUILD_HELIX=0
BUILD_MOONRAKER=0
CFG
+17
View File
@@ -10,6 +10,10 @@ Release preparation and documentation sweep.
### Added
- Fluidd is available beside Mainsail at `http://<printer-ip>:81/`, using the
same Moonraker API and webcam proxy.
- Custom `*.sh` files in `/usr/data/anvil-data/scripts` run in filename order
during boot, survive updates, and write `/usr/data/logs/custom-scripts.log`.
- The installer refuses to install on a printer running FlashForge firmware
older than 1.9.6, and says to install FlashForge's current firmware first.
On those releases the board firmware and Klipper do not agree, the MCU never
@@ -21,6 +25,14 @@ Release preparation and documentation sweep.
### Changed
- The normal boot screen says that Reforge is starting, names Moonraker and
Klipper while it waits, and advances its progress bar through a complete
normal boot instead of describing every boot as printer setup.
- Mainsail is updated to 2.19.0. Both web interfaces now return their SPA
shell for client-side routes while keeping API routes behind Moonraker.
- Filament load and unload use the selected tool's saved material and shorter,
overridable feed lengths; loading finishes with a retract.
- The X and Y rotation distances are both 40.4.
- Klipper's and Moonraker's config now live in Reforge's own directory,
`/usr/data/anvil-data/config`, seeded once on the first install with a copy
of `printer.cfg` and the config files beside it. FlashForge's
@@ -39,6 +51,11 @@ Release preparation and documentation sweep.
### Fixed
- Upgrading removes FlashForge's obsolete `[output_pin DC24V_CTL]` section
when present, before the mod configures PA3 as a heater-following 24 V
control.
- Object cancellation keeps extrusion and retraction history separate for
every tool instead of carrying one tool's adjustment into another.
- Flashing the stock FlashForge package back over Reforge no longer leaves the
printer with a Klipper config it cannot resolve. `/usr/data` is the data
partition, so a stock flash does not clean it: the symlinked
+4
View File
@@ -38,6 +38,8 @@ A checked box happened on that machine; an empty one has not, yet.
- [x] **Nozzle-offset calibration**
- [x] **Mainsail** at `http://<printer-ip>/`, with a current Moonraker behind
it — anything that speaks the Klipper API works
- [ ] **Fluidd** at `http://<printer-ip>:81/`, beside Mainsail and using the
same Moonraker and camera
- [x] **Camera** — mjpg-streamer, already on the printer; the mod just
starts it
- [x] **Wifi**
@@ -55,6 +57,8 @@ A checked box happened on that machine; an empty one has not, yet.
- [x] **ssh as root** — a random password is chosen on the **first** install
and written to `anvil-password.txt` on your stick; updates keep it
(`make passwd` bakes in your own)
- [ ] **Custom boot scripts** — ordered `*.sh` hooks under
`/usr/data/anvil-data/scripts`, preserved across updates and logged
---
+3 -1
View File
@@ -14,6 +14,7 @@ fi
BUILD_TOOLCHANGE="${BUILD_TOOLCHANGE:-1}"
BUILD_MAINSAIL="${BUILD_MAINSAIL:-1}"
BUILD_FLUIDD="${BUILD_FLUIDD:-1}"
BUILD_MOONRAKER="${BUILD_MOONRAKER:-1}"
BUILD_HELIX="${BUILD_HELIX:-1}"
@@ -43,6 +44,7 @@ export MODDIR PY_HOST PY_TOOLCHAIN_DIR IPK_ARCH
# shellcheck disable=SC1091
[ -f "$ROOT/versions.env" ] && . "$ROOT/versions.env"
MAINSAIL_ZIP="${MAINSAIL_ZIP:-$ROOT/vendor/mainsail-${MAINSAIL_VERSION:-unpinned}.zip}"
FLUIDD_ZIP="${FLUIDD_ZIP:-$ROOT/vendor/fluidd-${FLUIDD_VERSION:-unpinned}.zip}"
HELIX_TGZ="${HELIX_TGZ:-$ROOT/vendor/${HELIX_FILE:-helixscreen.tar.gz}}"
MOONRAKER_TGZ="${MOONRAKER_TGZ:-$ROOT/vendor/moonraker-${MOONRAKER_VERSION:-unpinned}.tar.gz}"
TIMELAPSE_TGZ="${TIMELAPSE_TGZ:-$ROOT/vendor/moonraker-timelapse-${TIMELAPSE_VERSION:-unpinned}.tar.gz}"
@@ -55,7 +57,7 @@ S6_TGZ="${S6_TGZ:-$ROOT/vendor/s6-${S6_VERSION:-unpinned}.tar.gz}"
EXECLINE_TGZ="${EXECLINE_TGZ:-$ROOT/vendor/execline-${EXECLINE_VERSION:-unpinned}.tar.gz}"
S6RC_TGZ="${S6RC_TGZ:-$ROOT/vendor/s6-rc-${S6RC_VERSION:-unpinned}.tar.gz}"
export MAINSAIL_ZIP HELIX_TGZ MOONRAKER_TGZ KLIPPER_TGZ MIPS_TOOLCHAIN_TGZ
export MAINSAIL_ZIP FLUIDD_ZIP HELIX_TGZ MOONRAKER_TGZ KLIPPER_TGZ MIPS_TOOLCHAIN_TGZ
export SKALIBS_TGZ S6_TGZ EXECLINE_TGZ S6RC_TGZ
export TIMELAPSE_TGZ X264_TGZ FFMPEG_TGZ
+5
View File
@@ -141,6 +141,11 @@ if [ "$ALL" = 1 ] || [ "${BUILD_MAINSAIL:-0}" = "1" ]; then
"$MAINSAIL_ZIP" "$MAINSAIL_SHA256"
fi
if [ "$ALL" = 1 ] || [ "${BUILD_FLUIDD:-0}" = "1" ]; then
get "https://github.com/fluidd-core/fluidd/releases/download/$FLUIDD_VERSION/fluidd.zip" \
"$FLUIDD_ZIP" "$FLUIDD_SHA256"
fi
if [ "$ALL" = 1 ] || [ "${BUILD_HELIX:-0}" = "1" ]; then
# Klipper4FlashForge is the org's current name; the old URL only worked
# through GitHub's rename redirect.
+2 -2
View File
@@ -74,9 +74,9 @@ OUT_PID="${PKG_PID:-${TARGET_PID:-0029}}"
# The payload rides here so it lands on /usr/data, not the firmware partition.
if [ -d "$PAYLOAD_DIR" ]; then
# This one IS really xz: we extract it ourselves with `xz -dc`.
echo ">> compressing anvil.tar.xz (Mainsail / HelixScreen / Moonraker / bin)"
echo ">> compressing anvil.tar.xz (Mainsail / Fluidd / HelixScreen / Moonraker / bin)"
tar -cf - -C "$PAYLOAD_DIR" . | xz -T0 -6 > work/stage/anvil.tar.xz
ls -lh work/stage/anvil.tar.xz | awk '{print " "$5}'
ls -lh work/stage/anvil.tar.xz | awk '{print " "$5}'
fi
# OUR installer, not FlashForge's. app_startup.sh runs whatever it finds under
+1 -1
View File
@@ -47,7 +47,7 @@ say "payload: installing the feed with the printer's own apk"
# is the one command that has to work on a printer.
MOD_ROOTS="anvil-core anvil-s6-rc anvil-klipper
anvil-moonraker anvil-python-pillow anvil-python-preprocess-cancellation
anvil-mainsail anvil-helixscreen anvil-busybox anvil-sntpd"
anvil-mainsail anvil-fluidd anvil-helixscreen anvil-busybox anvil-sntpd"
# THE PACKAGE MANAGER IS A ROOT TOO. Appended rather than written into the
# list above so that the list stays a literal one: qa/static reads it out of
+21 -17
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
# Render the first-boot screen to PNGs so you can LOOK at it.
# Render the boot screen to PNGs so you can LOOK at it.
#
# payload/bin/ffscreen.py draws onto a framebuffer, which is a flat array of
# pixels and therefore untestable by reading the code. This wraps it in the
@@ -10,8 +10,8 @@
#
# The default size is the real one: the framebuffer is PORTRAIT 480x800 and
# the panel is that buffer turned 90 degrees clockwise, so what is written
# here is rotated back before it becomes a PNG. It renders every phase the
# migration goes through, in order.
# here is rotated back before it becomes a PNG. It renders the normal boot and
# first-boot calibration phases in order.
import argparse
import glob
import importlib.util
@@ -25,18 +25,20 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Every (status, progress) ff-startup.py can put on the panel, in the
# order it happens. Keep this in step with the panel.say() calls there.
PHASES = [
('starting-services', 'STARTING SERVICES', 0.05),
('mcu-boards', 'WAKING THE TOOLHEAD BOARDS', 0.05),
('mcu-heater', 'WAKING THE HEATER BOARD', 0.08),
('mcu-both', 'WAKING THE HEATER BOARD AND THE LEVEL BOARD', 0.08),
('waiting', 'WAITING FOR THE PRINTER', 0.22),
('importing', 'READING FACTORY CALIBRATION', 0.5),
('saving', 'SAVING CALIBRATION', 0.7),
('restarting', 'RESTARTING THE PRINTER', 0.85),
('complete', 'SETUP COMPLETE', 1.0),
('already', 'ALREADY CALIBRATED', 1.0),
('launching-services', 'LAUNCHING SERVICES', 0.02),
('mcu-boards', 'WAKING THE TOOLHEAD BOARDS', 0.12),
('mcu-heater', 'WAKING THE HEATER BOARD', 0.18),
('mcu-both', 'WAKING THE HEATER BOARD AND THE LEVEL BOARD', 0.18),
('starting-services', 'STARTING SERVICES', 0.30),
('moonraker', 'STARTING MOONRAKER', 0.45),
('klipper', 'STARTING KLIPPER', 0.60),
('ready', 'KLIPPER IS READY', 0.78),
('importing', 'READING FACTORY CALIBRATION', 0.82),
('saving', 'SAVING CALIBRATION', 0.88),
('restarting', 'RESTARTING KLIPPER', 0.94),
('complete', 'STARTUP COMPLETE', 1.0),
]
NO_NOTE = ('complete', 'already')
NO_NOTE = ('complete',)
# Every way the migration can end badly, with the reason it puts on the panel.
# Kept in step with the panel.failed() calls in ff-startup.py.
@@ -54,7 +56,7 @@ FAILURES = [
('fail-restart', 'KLIPPER DID NOT RESTART AFTER SAVING'),
('fail-unsaved', 'THE CALIBRATION DID NOT SAVE'),
]
RETRY = 'SETUP WILL RETRY ON NEXT START'
RETRY = 'STARTUP WILL RETRY ON NEXT BOOT'
LOGFILE = '/USR/DATA/LOGS/ANVIL-BOOT.LOG'
@@ -149,8 +151,10 @@ def main(argv):
screen = ffscreen.Screen(fb, geometry=geometry, rotate=args.rotate)
if not screen.ok:
raise SystemExit('ffscreen refused this geometry: %s' % args.size)
note = '' if (name in NO_NOTE or detail) else 'DO NOT TURN THE PRINTER OFF'
screen.show('SETTING UP YOUR PRINTER', status, note, progress,
calibration = name in ('importing', 'saving', 'restarting')
note = ('' if (name in NO_NOTE or detail) else
'DO NOT TURN THE PRINTER OFF' if calibration else 'PLEASE WAIT')
screen.show('REFORGE IS STARTING', status, note, progress,
detail, bool(detail))
with open(fb, 'rb') as fh:
buf = fh.read()
+4 -3
View File
@@ -58,8 +58,8 @@ STOCK_TGZ=""
# repo under payload/klipper/, so there is nothing to set. BUILD_TOOLCHANGE=0
# here still turns it off.
# Mainsail, the HelixScreen build for the Creator 5 (MIPS static) and Moonraker
# are NOT set here. They are downloaded on demand into vendor/ by
# Mainsail, Fluidd, the HelixScreen build for the Creator 5 (MIPS static) and
# Moonraker are NOT set here. They are downloaded on demand into vendor/ by
# bin/fetch-assets.sh, pinned by version and sha256 in versions.env, so the
# repo carries no binaries. Set any of these ONLY to build against your own
# local copy.
@@ -69,6 +69,7 @@ STOCK_TGZ=""
# sha256 in versions.env; only an exact match is left alone ("cached"):
#HELIX_TGZ="/path/to/helixscreen-creator5-v0.99.118.tar.gz"
#MAINSAIL_ZIP="/path/to/mainsail.zip"
#FLUIDD_ZIP="/path/to/fluidd.zip"
#MOONRAKER_TGZ="/path/to/moonraker-v0.9.3.tar.gz"
# ---- optional extras -- leave empty to skip that feature ---------------------
@@ -138,7 +139,7 @@ APK_SIGN_KEY=""
# downstream had to keep a second answer for.
#
# The BUILD_* flags that decide what goes INTO a package (BUILD_TOOLCHANGE,
# BUILD_MAINSAIL, BUILD_MOONRAKER, BUILD_HELIX) are not set
# BUILD_MAINSAIL, BUILD_FLUIDD, BUILD_MOONRAKER, BUILD_HELIX) are not set
# here: bin/common.sh defaults them to the full firmware, and setting one in
# this file overrides that. Only touch them if you want a piece left out.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+51
View File
@@ -0,0 +1,51 @@
# Custom boot scripts
Reforge can run small shell scripts once during every boot. Put files ending
in `.sh` here:
```text
/usr/data/anvil-data/scripts/
```
This is user-owned storage and survives firmware updates. Do not put scripts
under `/usr/data/anvil`: an update replaces that whole directory.
Scripts run in filename order, so numeric prefixes make the order explicit:
```text
10-mount-share.sh
20-set-local-option.sh
```
An executable file is run directly and must have a valid shebang. A file
without its executable bit is run with `/bin/sh`. Files that do not end in
`.sh`, directories, and missing matches are ignored.
The runner creates the directory when it does not exist. It continues with
the next file when one script exits unsuccessfully, and the boot transition
allows 30 seconds for all custom scripts together. Keep them short and do not
start a foreground process that stays running. Services start as part of the
same boot transition, so a script that needs Moonraker or another service must
check that it is ready rather than assume an ordering.
Scripts run as `root`. Treat the directory as executable system
configuration: only place code there that you have read and trust.
## Output and troubleshooting
Standard output, errors, and the name of each script go to:
```text
/usr/data/logs/custom-scripts.log
```
The log is replaced on each run so it describes the current boot. A failed
script does not prevent later scripts from running; its own output is the
record of the failure.
To test the complete set over SSH without rebooting:
```sh
/usr/data/anvil/bin/run-scripts.sh
cat /usr/data/logs/custom-scripts.log
```
+2
View File
@@ -20,8 +20,10 @@ with Klipper and a toolchanger-aware web and touchscreen experience.
| VFA calibration | Klipper VFA support with required MCU library and dependency | ✅ Yes |
| Chamber | Pro heater/light configuration and heater-following fan; heater-free Creator 5 config | ✅ Yes |
| Mainsail | Mainsail over Moonraker, webcam stream, Klipper API compatibility | ✅ Yes |
| Fluidd | Fluidd on port 81, beside Mainsail, using the same Moonraker and webcam | ✅ Yes |
| Screen | HelixScreen with tool/material-aware filament controls and persistent settings | ✅ Yes |
| SSH | Root access with per-printer first-install password on the USB stick | ✅ Yes |
| Custom boot scripts | Persistent, ordered `*.sh` hooks with a separate boot log | ✅ Yes |
| Updates | APK feed, signed packages, preserved user settings and password | ✅ Yes |
\* Pressure advance is a manual check. See the [pressure advance calibration
+2
View File
@@ -18,6 +18,7 @@ Everything is on the printer's own address:
| | |
|---|---|
| `http://<printer-ip>/` | Mainsail |
| `http://<printer-ip>:81/` | Fluidd |
| `http://<printer-ip>:7125` | Moonraker's API — what slicers upload to |
| `http://<printer-ip>/webcam/` | the camera stream (mjpg-streamer on `:8080`) |
| `ssh root@<printer-ip>` | the shell — password from `anvil-password.txt` |
@@ -27,6 +28,7 @@ Everything is on the printer's own address:
/usr/data/logs/anvil-boot.log services + UI choice at each boot
/usr/data/logs/printer.log klipper
/usr/data/logs/helixscreen.log helixscreen
/usr/data/logs/custom-scripts.log custom boot scripts, when configured
```
They are the first thing to read when something is wrong, and the first thing
+2 -1
View File
@@ -57,10 +57,11 @@ 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
nginx longrun nginx (Mainsail on :80 and Fluidd on :81)
moonraker longrun moonraker on :7125
camera longrun mjpg-streamer on :8080 (nginx proxies it at /webcam/)
ntp longrun sntpd, the clock (there is no RTC and no battery)
run-scripts oneshot user *.sh files, with a 30-second boot deadline
ff-startup oneshot waits until the printer is usable
(depends on klipper, moonraker)
ui longrun HelixScreen (depends on ff-startup)
+5 -3
View File
@@ -104,7 +104,7 @@ The first boot after a flash is longer than the ones after it, and the screen
shows a progress bar rather than HelixScreen for most of it. What you'll see,
partway through and at the end:
<img src="boot-screen/importing.png" width="48%" alt="Boot screen: reading factory calibration"> <img src="boot-screen/complete.png" width="48%" alt="Boot screen: setup complete">
<img src="boot-screen/importing.png" width="48%" alt="Boot screen: Reforge is starting and reading factory calibration"> <img src="boot-screen/complete.png" width="48%" alt="Boot screen: Reforge startup complete">
Two things happen that happen only once:
@@ -142,8 +142,10 @@ Nothing you have to configure. The package brings its own Klipper additions
and config files with it and wires them up itself, so the printer comes up
working.
**Your `printer.cfg` is never touched** — not by this flash and not by any
update. It is your file, and it is where your own settings go.
**Your `printer.cfg` is preserved** across every flash and update. One narrow
hardware migration removes FlashForge's `[output_pin DC24V_CTL]`, because the
mod-owned base config replaces it with `[heater_fan dc24v_ctl]` on the same
pin. Your other settings and `SAVE_CONFIG` data are left in place.
The first flash does copy it. Reforge keeps its Klipper config in a directory
of its own, `/usr/data/anvil-data/config`, and seeds it once with your
+1
View File
@@ -36,6 +36,7 @@ Port: `pkgs/klipper-config/payload/config/ff-filament.cfg` — `LOAD_FILAMENT`,
| temp | table by filament.json type, +30 | `TEMP=` (HelixScreen pre-fills) or `MATERIAL=PLA` → same table; `BOOST=30` |
| wait | 1 s poll ±3 | `TEMPERATURE_WAIT MIN/MAX ±3` |
| tool | grab, release at end | `SELECT_TOOL`, `UNSELECT_TOOL` (`RELEASE=0` keeps it) |
| idle load | E150 then E145 | E80 then E50 by default, followed by E−5; `LENGTH=` and `RETRACT=` override |
| paused print | LoadFilamentPrint path | mounted tool only (else refuse), E100/E−5, no Z, tool and heater left for RESUME |
| printing | UI disabled | refuse |
| unload | same as load | **designed**: E+10 prime, E−20 F1200, remainder of `unload_length` (80) F600 |
+5 -2
View File
@@ -99,7 +99,9 @@ cooperate.
(recovered from the binary — [`docs/notes/47-filament-load-recovered.md`](notes/47-filament-load-recovered.md)):
each tool has its own direct-drive extruder, so loading means *grab the tool,
drive it to the purge chute at the back right (X275 Y254), heat to material
temperature + 30, push 150 + 145 mm at F240, park the tool, heater off*.
temperature + 30, feed the filament, retract 5 mm, park the tool, heater off*.
The stock app feeds 150 + 145 mm; the mod defaults to 80 + 50 mm and accepts
`LENGTH=` and `RETRACT=` overrides.
```gcode
LOAD_FILAMENT TOOL=1 TEMP=220 ; or MATERIAL=PETG (app's temperature table)
@@ -108,7 +110,8 @@ PURGE TOOL=1 PURGE_TEMP=220 LENGTH=50 ; app's clearNozzlePrint purge + cold wip
```
* `TOOL` defaults to the mounted tool; `RELEASE=0` keeps the tool on the
carriage afterwards, `HEAT_OFF=0` leaves the heater on.
carriage afterwards, `HEAT_OFF=0` leaves the heater on. With no explicit
`MATERIAL=`, load and unload use the material assigned to that tool.
* `PURGE` ends the way the app's pre-print clean does: the nozzle rests on
the front-right wipe spot (266.5, 13.8) 1 mm above the eddy trigger height
while the hotend cools by 100 °C, then lifts — the purge string freezes
+1 -1
View File
@@ -28,7 +28,7 @@ documented in the file headers.
| [`pkgs/klipper-config/payload/config/ff-toolchange.cfg`](../pkgs/klipper-config/payload/config/ff-toolchange.cfg) | `/usr/data/anvil-data/config/` | empty `[ff_tool 0..3]` sections (the per-unit dock/nozzle data is autosaved, nothing unit-specific ships), `[ff_toolchange]` feeds/geometry, the `G28` dock-first wrapper |
| [`pkgs/klipper-config/payload/config/ff-tool-offset.cfg`](../pkgs/klipper-config/payload/config/ff-tool-offset.cfg) | `/usr/data/anvil-data/config/` | `[ff_tool_offset]` — probe geometry and guards for the calibration commands |
| [`pkgs/klipper-config/payload/config/ff-legacy.cfg`](../pkgs/klipper-config/payload/config/ff-legacy.cfg) | `/usr/data/anvil-data/config/` | `[ff_legacy]` — stays included permanently; declares the section and, optionally, `firmware_config_dir` |
| [`pkgs/anvil-core/payload/bin/ffscreen.py`](../pkgs/anvil-core/payload/bin/ffscreen.py) | `/usr/data/anvil/bin/` | A few lines of text and a progress bar drawn straight onto `/dev/fb0`, geometry read from sysfs. The framebuffer is **portrait 480×800@32** and the panel is that buffer turned 90° clockwise (landscape 800×480) — established from FlashForge's own `/usr/prog/start.img`, 1536000 bytes, which only decodes into a picture read that way. Drawing is done in landscape coordinates and each rectangle is rotated on the way into the buffer, so it costs arithmetic per rectangle and nothing per pixel. All of the first boot happens before HelixScreen starts, so without it the panel is black for the longest wait of the install — which reads as a brick and invites a power cut mid-`SAVE_CONFIG`. `make boot-screen` renders every frame to PNG on the host; `make boot-screen-sim` renders the same frames inside the replica using FlashForge's own python3 on MIPS (they come out byte-identical); `qa/replica/test_boot_screen.py` is the gate |
| [`pkgs/anvil-core/payload/bin/ffscreen.py`](../pkgs/anvil-core/payload/bin/ffscreen.py) | `/usr/data/anvil/bin/` | Startup status and a progress bar drawn straight onto `/dev/fb0`. The framebuffer is **portrait 480×800@32** and the panel is that buffer turned 90° clockwise (landscape 800×480) — established from FlashForge's own `/usr/prog/start.img`, 1536000 bytes, which only decodes into a picture read that way. Drawing is done in landscape coordinates and each rectangle is rotated on the way into the buffer, so it costs arithmetic per rectangle and nothing per pixel. Every boot waits for the boards, Klipper, and Moonraker before HelixScreen starts; the first boot also imports calibration. Without this screen the panel stays black through those waits, which reads as a brick and invites a power cut mid-`SAVE_CONFIG`. `make boot-screen` renders every frame to PNG on the host; `make boot-screen-sim` renders the same frames inside the replica using the shipped Python on MIPS; `qa/replica/test_boot_screen.py` is the gate. |
| [`pkgs/anvil-core/payload/bin/ff-startup.py`](../pkgs/anvil-core/payload/bin/ff-startup.py) | `/usr/data/anvil/bin/` | Everything before HelixScreen. **Every boot** it hands the toolhead boards over from their bootloaders (calling `ff_mcu_bringup.py` directly — it owns when klippy opens the ports, so it owns doing this first), starts klipper, and waits for klipper + moonraker to be ready, naming the board or service holding things up and re-handing the boards over on each retry. It runs as two s6-rc oneshots (`mcu-bringup`, then `ff-startup`), and the UI depends on the second — starting the UI before that is what produces a screen reporting a disconnected printer with no clue which board is missing. **First boot only**, once that has happened, it sends `FF_IMPORT_FIRMWARE_CONFIG` and `SAVE_CONFIG` over the moonraker API and stamps `/usr/data/anvil/.firmware-config-imported`. Only a verified save stamps, so a slow boot retries. It always runs and always waits — the `MOD_STARTUP` and `MOD_IMPORT` switches went with `anvil.conf` |
| [`pkgs/klipper-config/payload/config/ff-print-macros.cfg`](../pkgs/klipper-config/payload/config/ff-print-macros.cfg) | `/usr/data/anvil-data/config/` | `START_PRINT` / `END_PRINT` / `PAUSE` / `RESUME` / `CANCEL_PRINT`, reconstructed from the app's sequences, plus the `_FF_PREFLIGHT` calibration and tool-presence gate; declares `[ff_print]` and the `FF_BEFORE_PRINT_START` / `FF_AFTER_PRINT_END` entry points it calls |
| [`pkgs/klipper-config/payload/config/ff-filament.cfg`](../pkgs/klipper-config/payload/config/ff-filament.cfg) | `/usr/data/anvil-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) |
+6 -1
View File
@@ -11,8 +11,9 @@ that line is what makes an update safe.
| File | Whose |
|---|---|
| `/usr/data/anvil-data/config/printer.cfg` | **Yours** — never overwritten. Overrides go here, after the includes: restate only what you change, the last value wins. |
| `/usr/data/anvil-data/config/printer.cfg` | **Yours** — preserved across updates. The installer removes FlashForge's obsolete `[output_pin DC24V_CTL]` once; overrides otherwise stay here after the includes. |
| `/usr/data/anvil-data/config/moonraker-custom.conf` | **Yours** — created once, never rewritten, included last so your settings win. Do not delete it. |
| `/usr/data/anvil-data/scripts/*.sh` | **Yours** — custom boot scripts, preserved across updates. |
| `ff-*.cfg`, `printer.base.cfg`, `moonraker.conf` | **The mod's** — overwritten on every update; do not edit. |
| `anvil/helixscreen/config/settings.json` | **Yours** — everything you set on the screen. Written by HelixScreen itself; carried across updates along with `helixscreen.env` and its spool map. |
@@ -39,6 +40,10 @@ root password** and everything you set on the screen survive; the `ff-*.cfg`
family is replaced. If a release changes `helixscreen.env`, yours is kept and
the new one is left beside it as `helixscreen.env.mod-new`.
Custom boot scripts under `/usr/data/anvil-data/scripts` survive too. The
payload itself lives under `/usr/data/anvil` and is replaced as a unit, so do
not put user files there. See [Custom boot scripts](custom-scripts.md).
---
## The config files in detail
+1
View File
@@ -90,6 +90,7 @@ nav:
- For experienced users:
- How a print runs: how-a-print-runs.md
- How calibration works: how-calibration-works.md
- Custom boot scripts: custom-scripts.md
# Not yet redesigned -- these are the pages as they stand, regrouped.
- For contributors:
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Fluidd publishes a zip of static files with no top-level directory. Install
# that tree beside Mainsail; nginx serves it from the separate port 81 vhost.
set -euo pipefail
. ./bin/common.sh
. pkgs/lib.sh
pkg_begin fluidd || exit 0
pkg_unpack "$FLUIDD_ZIP"
[ -f "$PKG_WORK/src/index.html" ] || pkg_die \
"fluidd: no index.html at the root of $(basename "$FLUIDD_ZIP") -- the
archive's shape changed and www/fluidd would be served empty"
pkg_stage "$PKG_WORK/src" "www/fluidd"
pkg_ship "www/fluidd"
pkg_end
+11
View File
@@ -0,0 +1,11 @@
PKG_NAME=anvil-fluidd
PKG_ARCH=all
PKG_VERSION="${FLUIDD_VERSION#v}"
PKG_RELEASE=1
PKG_EXCLUDE=".version"
PKG_DEPENDS=""
PKG_BUILD_DEPENDS=""
PKG_WHEN='[ "${BUILD_FLUIDD:-1}" = "1" ]'
PKG_DESCRIPTION="Fluidd, the Klipper web interface. Static files served by the printer's own nginx from /usr/data/anvil/www/fluidd."
+1 -1
View File
@@ -10,7 +10,7 @@
# and the rest at the root of the archive, so $PKG_WORK/src IS the web root
# after unpacking and gets copied wholesale to www/mainsail. This was checked
# against the pinned artefact rather than assumed -- an archive that grew a
# wrapper directory would put the UI at www/mainsail/mainsail-2.18.2/ and
# wrapper directory would put the UI below www/mainsail/ and
# serve a 404 from a build that looked clean, so the assertion below is the
# thing standing between that and a printer.
set -euo pipefail
+1 -1
View File
@@ -11,7 +11,7 @@
PKG_NAME=anvil-mainsail
PKG_ARCH=all
# The 'v' comes off: MAINSAIL_VERSION is a git tag ("v2.18.2") and a package
# The 'v' comes off: MAINSAIL_VERSION is a git tag and a package
# version is a version, not a tag name. The package manager compares these
# fields, and a
# leading letter is one more thing for it to be wrong about.
+11 -1
View File
@@ -262,7 +262,9 @@ restore_stock_chelper
# ONCE, and never again: after this, $CONFIG_DIR is the live config and
# $STOCK_CONFIG is left as the machine had it, untouched from here on.
# Re-seeding would overwrite an owner's printer.cfg with a stale one on every
# update.
# update. The one migration below removes FlashForge's DC24V_CTL output_pin;
# printer.base.cfg now owns that hardware as a heater_fan, and leaving both
# definitions would make Klipper refuse the duplicate eheaterboard:PA3 pin.
#
# The mod's own .cfg files are NOT copied: they are symlinked in below, so an
# `apk upgrade` changes what Klipper reads without going near this directory.
@@ -309,6 +311,14 @@ if [ ! -f "$CONFIG_DIR/printer.cfg" ]; then
fi
mkdir -p "$CONFIG_DIR"
# FlashForge 1.9.9 may have written [output_pin DC24V_CTL] into the live file.
# printer.base.cfg now owns PA3 as a heater_fan, so remove the obsolete section
# before Klipper sees two objects claiming the same pin.
if [ -f "$MODDIR/bin/anvil-migrate-printer-config.sh" ]; then
/bin/sh "$MODDIR/bin/anvil-migrate-printer-config.sh" \
"$CONFIG_DIR/printer.cfg"
fi
# Klipper resolves [include] against the directory of the file doing the
# including -- configfile.py: `dirname = os.path.dirname(source_filename)`,
# the path as opened, NOT the resolved target. So a symlinked printer.base.cfg
@@ -0,0 +1,19 @@
#!/bin/sh
set -eu
config=${1:-/usr/data/anvil-data/config/printer.cfg}
[ -f "$config" ] || exit 0
header='^[[:space:]]*\[output_pin[[:space:]][[:space:]]*DC24V_CTL\][[:space:]]*$'
grep -Eq "$header" "$config" || exit 0
# Delete the complete legacy section. Preserve the next ordinary section and
# Klipper's commented #*# SAVE_CONFIG block when the legacy section is last.
sed -i '/^[[:space:]]*\[output_pin[[:space:]][[:space:]]*DC24V_CTL\][[:space:]]*$/,/^[[:space:]]*\[/ {
/^[[:space:]]*\[output_pin[[:space:]][[:space:]]*DC24V_CTL\][[:space:]]*$/d
/^[[:space:]]*\[/b
/^#\*#/b
d
}' "$config"
echo "config: removed obsolete [output_pin DC24V_CTL]; printer.base.cfg now controls PA3"
+37 -20
View File
@@ -115,9 +115,10 @@ BOARDS = {
'levelboard': 'THE LEVEL BOARD',
}
TITLE = 'SETTING UP YOUR PRINTER'
TITLE = 'REFORGE IS STARTING'
PLEASE_WAIT = 'PLEASE WAIT'
KEEP_POWER = 'DO NOT TURN THE PRINTER OFF'
RETRY = 'SETUP WILL RETRY ON NEXT START'
RETRY = 'STARTUP WILL RETRY ON NEXT BOOT'
LOGFILE = '/USR/DATA/LOGS/ANVIL-BOOT.LOG'
@@ -153,11 +154,11 @@ class Panel:
self.screen = screen
# Both of these swallow everything. This is the boundary where drawing
# stops being allowed to matter: the migration must run identically with
# stops being allowed to matter: startup must run identically with
# no panel, a panel that dies mid-write, or one that throws something
# unanticipated. One failure retires the screen for the rest of the run.
def say(self, status, progress, note=KEEP_POWER, detail='', fault=False):
def say(self, status, progress, note=PLEASE_WAIT, detail='', fault=False):
if self.screen is None:
return
try:
@@ -169,7 +170,7 @@ class Panel:
def failed(self, reason):
"""The one frame a person is actually left looking at.
"Setup will retry" on its own tells them nothing they can act on, so
"Startup will retry" on its own tells them nothing they can act on, so
the reason goes underneath -- and the log line goes under that,
because the reason has to be short and the log never is."""
log('giving up: %s' % reason)
@@ -276,7 +277,7 @@ def board_name(key):
return BOARDS.get(key, str(key).upper())
def hand_over_boards(panel, timeout):
def hand_over_boards(panel, timeout, progress_base=0.12):
"""Bring the toolhead boards out of their bootloaders.
Called directly rather than run as a subprocess: this program owns when
@@ -290,8 +291,8 @@ def hand_over_boards(panel, timeout):
if not devices:
return
names = [board_name(device) for device in devices]
panel.say('WAKING ' + ' AND '.join(names[:2]), 0.08)
panel.say('WAKING THE TOOLHEAD BOARDS', 0.05)
panel.say('WAKING ' + ' AND '.join(names[:2]), progress_base + 0.06)
panel.say('WAKING THE TOOLHEAD BOARDS', progress_base)
try:
all_awake = bringup.bringup(list(bringup.DEFAULT_PORTS), timeout,
on_progress=progress)
@@ -395,11 +396,21 @@ def wait_for_stack(moonraker, panel, deadline, started, quiet=False):
log('waiting: moonraker=%s klipper=%s'
% ('up' if state is not None else 'down', state or 'unknown'))
last_state = state
elapsed_fraction = 0.3 * (time.time() - started) / window
# The bar represents the whole normal boot. This wait owns its middle
# forty percent; calibration, when needed, uses the final stages.
elapsed_fraction = 0.4 * (time.time() - started) / window
if state is None:
panel.say('STARTING SERVICES', 0.05 + elapsed_fraction)
panel.say('STARTING MOONRAKER', 0.35 + elapsed_fraction,
detail='WAITING FOR THE PRINTER API')
elif state != 'ready':
panel.say('WAITING FOR THE PRINTER', 0.05 + elapsed_fraction)
if state == 'startup':
detail = 'CONNECTING KLIPPER TO THE PRINTER BOARDS'
elif state == 'error':
detail = 'A BOARD DID NOT ANSWER; PREPARING A RETRY'
else:
detail = 'KLIPPER STATE: %s' % str(state).upper()
panel.say('STARTING KLIPPER', 0.35 + elapsed_fraction,
detail=detail)
if state == 'ready':
return True
if state == 'error' and quiet:
@@ -463,7 +474,7 @@ def run(args):
hand_over_boards(panel, args.mcu_timeout)
return 0
moonraker = Moonraker(args.moonraker)
panel.say('STARTING SERVICES', 0.05)
panel.say('STARTING SERVICES', 0.30)
try:
return startup(args, moonraker, panel, started, deadline)
finally:
@@ -485,19 +496,22 @@ def bring_up_printer(args, moonraker, panel, started, deadline):
# that is not there at all because its run script is failing.
if attempt > 1 or not klippy_running():
if attempt > 1 or not args.no_bringup:
hand_over_boards(panel, min(args.mcu_timeout,
max(1.0, deadline - time.time())))
hand_over_boards(
panel,
min(args.mcu_timeout, max(1.0, deadline - time.time())),
progress_base=0.68 if attempt > 1 else 0.12)
if not args.no_klipper and not restart_klipper(args):
panel.failed('KLIPPER COULD NOT BE STARTED')
return False
if wait_for_stack(moonraker, panel, deadline, started, quiet=True):
panel.say('KLIPPER IS READY', 0.78)
return True
if time.time() >= deadline or attempt == args.klipper_tries:
break
board = klippy_fault(moonraker)
log('attempt %d/%d failed (%s) -- restarting klipper'
% (attempt, args.klipper_tries, board or 'no board named'))
panel.say('RETRYING %s' % (board or 'THE PRINTER'), 0.15,
panel.say('RETRYING %s' % (board or 'THE PRINTER'), 0.68,
detail='ATTEMPT %d OF %d' % (attempt + 1,
args.klipper_tries))
report_stack_failure(moonraker, panel)
@@ -511,14 +525,17 @@ def startup(args, moonraker, panel, started, deadline):
# -- first boot only -------------------------------------------------
if args.no_import:
panel.say('STARTUP COMPLETE', 1.0, note='')
return 0
if os.path.exists(args.stamp):
log('already migrated (%s) -- nothing else to do' % args.stamp)
panel.say('STARTUP COMPLETE', 1.0, note='')
return 0
extruder_json = os.path.join(args.dir, 'extruder.json')
if not os.path.exists(extruder_json):
# Not a unit migrated from stock firmware (or the files were wiped).
log('no %s -- nothing to import' % extruder_json)
panel.say('STARTUP COMPLETE', 1.0, note='')
return 0
log('first boot: importing this unit\'s factory calibration from %s'
% args.dir)
@@ -536,7 +553,7 @@ def migrate(args, moonraker, panel):
# Someone got there first: a hand calibration, or a restored config.
# Importing over it would throw away the better numbers.
log('a tool already carries a nozzle position -- nothing to import')
panel.say('ALREADY CALIBRATED', 1.0, note='')
panel.say('STARTUP COMPLETE', 1.0, note='')
stamp(args.stamp, 'already calibrated; nothing imported')
return 0
@@ -556,7 +573,7 @@ def migrate(args, moonraker, panel):
log('dry run: would run FF_IMPORT_FIRMWARE_CONFIG and SAVE_CONFIG')
return 0
panel.say('READING FACTORY CALIBRATION', 0.5)
panel.say('READING FACTORY CALIBRATION', 0.82, note=KEEP_POWER)
applied, detail = moonraker.gcode('FF_IMPORT_FIRMWARE_CONFIG')
if not applied:
log('FF_IMPORT_FIRMWARE_CONFIG failed: %s' % detail)
@@ -571,13 +588,13 @@ def migrate(args, moonraker, panel):
return 1
log('imported; persisting with SAVE_CONFIG -- klipper restarts once')
panel.say('SAVING CALIBRATION', 0.7)
panel.say('SAVING CALIBRATION', 0.88, note=KEEP_POWER)
applied, detail = moonraker.gcode('SAVE_CONFIG', timeout=20.0)
if not applied:
# Expected: the restart cuts the connection before moonraker answers.
log('SAVE_CONFIG did not answer (%s) -- klipper is restarting' % detail)
panel.say('RESTARTING THE PRINTER', 0.85)
panel.say('RESTARTING KLIPPER', 0.94, note=KEEP_POWER)
if not wait_for_ready(moonraker, max(deadline, time.time() + 90.0)):
log('klipper did not come back after SAVE_CONFIG -- no stamp')
panel.failed('KLIPPER DID NOT RESTART AFTER SAVING')
@@ -590,7 +607,7 @@ def migrate(args, moonraker, panel):
return 1
log('done: the factory calibration is saved in printer.cfg')
panel.say('SETUP COMPLETE', 1.0, note='')
panel.say('STARTUP COMPLETE', 1.0, note='')
stamp(args.stamp, 'imported %s' % args.dir)
return 0
+6 -6
View File
@@ -1,14 +1,14 @@
# A few lines of text on /dev/fb0, for the moments when nothing else owns the
# screen and the printer would otherwise look dead.
#
# The first boot after a flash runs ff-startup.py before HelixScreen, and that
# can take a couple of minutes with the panel black. That reads as a brick,
# and the fix people reach for is a power cut -- the one thing that can leave
# the config half-written. So: no toolkit, no fonts on disk, no dependencies,
# just a plain framebuffer.
# Every boot runs ff-startup.py before HelixScreen while the boards, Klipper,
# and Moonraker come up. The first one also imports calibration and can take a
# couple of minutes. A black panel reads as a brick, and the fix people reach
# for is a power cut -- the one thing that can leave the config half-written.
# So: no toolkit, no fonts on disk, no dependencies, just a plain framebuffer.
#
# EVERY failure here is swallowed and turns the screen off, never into an
# error: this is decoration on top of a migration that must finish regardless.
# error: this is decoration on top of startup that must finish regardless.
#
# THE GEOMETRY IS HARDCODED, NOT READ FROM SYSFS: measured on real hardware,
# sysfs reports a geometry that does not match this panel, and trusting it
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# User-owned state lives outside /usr/data/anvil: the installer replaces that
# whole tree on every update. The overrides make the real runner testable under
# the printer's BusyBox without writing into either persistent directory.
SCRIPTS_DIR=${SCRIPTS_DIR:-/usr/data/anvil-data/scripts}
CUSTOM_SCRIPTS_LOG=${CUSTOM_SCRIPTS_LOG:-/usr/data/logs/custom-scripts.log}
mkdir -p "$SCRIPTS_DIR" "$(dirname "$CUSTOM_SCRIPTS_LOG")"
: > "$CUSTOM_SCRIPTS_LOG"
log() {
printf '[%s] %s\n' "$(date)" "$1" >> "$CUSTOM_SCRIPTS_LOG"
}
log "Starting custom scripts"
# POSIX sh leaves an unmatched glob unchanged, so guard each expansion with
# -f. The glob itself gives a stable alphabetical execution order.
for script in "$SCRIPTS_DIR"/*.sh; do
[ -f "$script" ] || continue
name=$(basename "$script")
if [ -x "$script" ]; then
log "Running $name directly"
"$script" >> "$CUSTOM_SCRIPTS_LOG" 2>&1
else
log "Running $name through sh"
sh "$script" >> "$CUSTOM_SCRIPTS_LOG" 2>&1
fi
done
log "Finished custom scripts"
Whitespace-only changes.
@@ -0,0 +1 @@
30000
@@ -0,0 +1 @@
oneshot
@@ -0,0 +1 @@
/bin/sh /usr/data/anvil/bin/run-scripts.sh
+54 -1
View File
@@ -55,11 +55,14 @@ http {
# Mainsail is a SPA: unknown paths must fall back to index.html
location / {
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /index.html =404;
}
error_page 404 /index.html;
# Never cache the shell, or updates silently do nothing.
location = /index.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
@@ -102,4 +105,54 @@ http {
# A prefix location would not have worked anyway -- the regex above
# matches /server/ too, and nginx gives regex locations precedence.
}
# Fluidd uses the same Moonraker and camera backends as Mainsail, but is
# exposed on a separate port so both interfaces can be installed together.
server {
listen 81;
server_name _;
root /usr/data/anvil/www/fluidd;
index index.html;
# Fluidd is a SPA too: preserve real API/static 404s while sending
# client-side routes back through its shell.
location / {
try_files $uri $uri/ /index.html =404;
}
error_page 404 /index.html;
location = /index.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
location /webcam/ {
proxy_pass http://127.0.0.1:8080/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_buffering off;
proxy_read_timeout 86400s;
}
location /websocket {
proxy_pass http://moonraker/websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
}
location ~ ^/(printer|api|access|machine|server)/ {
proxy_pass http://moonraker$request_uri;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Scheme $scheme;
}
}
}
+2 -1
View File
@@ -75,7 +75,8 @@ splash() {
[ -f $MODDIR/bin/ffscreen.py ] || return 0
"$FF_PYTHON" $MODDIR/bin/ffscreen.py "$@" 2>/dev/null || true
}
splash --title "REFORGE" --status "STARTING THE PRINTER" --progress 0.02
splash --title "REFORGE IS STARTING" --status "LAUNCHING SERVICES" \
--note "PLEASE WAIT" --progress 0.02
# ---- the supervision tree ------------------------------------------------
#
@@ -26,6 +26,8 @@
# * No M109, no filament-sensor arming, no sensor check after the feed.
#
# Deliberate divergences:
# * Idle loading defaults to 80 + 50 mm followed by a 5 mm retract; LENGTH=
# and RETRACT= override those values. The stock app pushes 150 + 145 mm.
# * Purge height: the app probes the eddy at (265, 4.8) and lifts to
# probeZ + 8; we go straight to `purge_z` (default 8) and skip the probe.
# Z is only ever raised, never lowered, before the XY travel.
@@ -56,10 +58,10 @@ variable_tool_dy: [0.0, 0.0, 1.5, -8.0]
variable_travel_x_feed: 12000
variable_travel_y_feed: 24000
variable_approach_feed: 2400
# Feed: app pushes load_length_1 then load_length_2 (150 + 145 = 295 mm),
# F240, or F80 when the tool's nozzle is 0.25 mm.
variable_load_length_1: 150.0
variable_load_length_2: 145.0
# The stock app pushes 150 + 145 mm. These shorter defaults suit the direct
# filament path while LENGTH= remains available for a measured override.
variable_load_length_1: 80.0
variable_load_length_2: 50.0
variable_load_feed: 240
variable_load_feed_thin: 80
variable_load_boost: 30.0
@@ -114,9 +116,10 @@ variable_tool_material: ['PLA', 'PLA', 'PLA', 'PLA']
variable_tool: -1
variable_in_print: 0
variable_saved_chamber_fan: 0.0
# Never called: this holds the tunables and the shared state above, nothing
# more. The commands themselves carry the description: line Mainsail shows.
# Calling this macro reports the material currently assigned to each tool.
gcode:
{% set mats = printer["gcode_macro _FF_FILAMENT"].tool_material %}
{ action_respond_info("Loaded materials: T0 %s | T1 %s | T2 %s | T3 %s" % (mats[0], mats[1], mats[2], mats[3])) }
# Internal: resolve TOOL / temperature, refuse in the wrong state, heat, mount
# the tool, travel to the purge chute, wait for temperature, chamber fans off.
@@ -213,7 +216,8 @@ gcode:
SET_GCODE_VARIABLE MACRO=_FF_FILAMENT VARIABLE=in_print VALUE=0
# LOAD_FILAMENT TOOL=<0..3> [TEMP=<material temp>] [MATERIAL=PLA] [BOOST=30]
# [LENGTH=<mm>] [SPEED=<mm/min>] [RELEASE=1] [HEAT_OFF=1]
# [LENGTH=<mm>] [SPEED=<mm/min>] [RETRACT=<mm>]
# [RELEASE=1] [HEAT_OFF=1]
# Feeds the spool of TOOL into its hotend at the purge chute, heating to
# TEMP + BOOST as the app does. While a print is paused: mounted tool only,
# in-print lengths (100 then -5), tool stays mounted.
@@ -222,19 +226,25 @@ description: Load filament into a tool's hotend (app FilamentLoad sequence)
gcode:
{% set ff = printer["gcode_macro _FF_FILAMENT"] %}
{% set tool = params.TOOL|default(-1)|int %}
{% set material = params.MATERIAL|default('')|upper %}
{% set t = printer.ff_toolchange.current_tool if tool < 0 else tool %}
{% if t < 0 or t > 3 %}
{ action_raise_error("TOOL=0..3 required (no tool mounted to default to)") }
{% endif %}
{% set default_material = ff.tool_material[t] if ff.tool_material|length > t else 'PLA' %}
{% set material = params.MATERIAL|default(default_material)|upper %}
{% set base = ff.temps[material] if material in ff.temps else ff.default_temp %}
{% set temp = params.TEMP|default(base)|float %}
{% set boost = params.BOOST|default(ff.load_boost)|float %}
{% set length = params.LENGTH|default(ff.load_length_1 + ff.load_length_2)|float %}
{% set retract = params.RETRACT|default(ff.print_retract)|float %}
{% set release = params.RELEASE|default(1)|int %}
{% set heat_off = params.HEAT_OFF|default(1)|int %}
{% set t = printer.ff_toolchange.current_tool if tool < 0 else tool %}
_FF_FILAMENT_PREP TOOL={tool} TEMP={temp + boost}
{ action_respond_info("LOAD T%s material=%s temp=%s boost=%s" % (t, material, temp, boost)) }
_FF_FILAMENT_PREP TOOL={t} TEMP={temp + boost}
{% set nozzle = printer.configfile.settings[printer["tool T%d" % t].extruder].nozzle_diameter|float if t >= 0 else 0.4 %}
{% set speed = params.SPEED|default(ff.load_feed_thin if nozzle <= 0.25 else ff.load_feed)|int %}
SAVE_GCODE_STATE NAME=_ff_filament
M82
M83
G92 E0
{% if printer.pause_resume.is_paused %}
G1 E{ff.print_load_length} F{speed} ; app LoadFilamentPrint: G1 E100
@@ -244,34 +254,42 @@ gcode:
M400
{% else %}
{% set first = [length, ff.load_length_1]|min %}
G1 E{first} F{speed} ; app: G1 E150 F240
G1 E{first} F{speed}
M400
{% if length > first %}
G1 E{length} F{speed} ; app: G1 E145 (absolute E, G92 not repeated)
G1 E{length - first} F{speed}
M400
{% endif %}
G92 E0
G1 E-{retract} F{ff.unload_feed}
M400
{% endif %}
G92 E0
RESTORE_GCODE_STATE NAME=_ff_filament
_FF_FILAMENT_FINISH RELEASE={release} HEAT_OFF={heat_off}
# UNLOAD_FILAMENT TOOL=<0..3> [TEMP=<material temp>] [MATERIAL=PLA]
# [LENGTH=<mm>] [RELEASE=1] [HEAT_OFF=1]
# [BOOST=30] [LENGTH=<mm>] [RELEASE=1] [HEAT_OFF=1]
# DESIGNED, no stock equivalent: heat to TEMP, renew the melt with a short
# push, pull `unload_fast` mm quickly out of the melt zone, then the remainder
# of LENGTH at `unload_feed`. Cut at the spool side first if it is tensioned.
[gcode_macro UNLOAD_FILAMENT]
description: Retract filament out of a tool's extruder (designed; stock app only pushes forward)
description: Retract filament out of a tool's extruder
gcode:
{% set ff = printer["gcode_macro _FF_FILAMENT"] %}
{% set tool = params.TOOL|default(-1)|int %}
{% set material = params.MATERIAL|default('')|upper %}
{% set tool = params.TOOL|default(printer.ff_toolchange.current_tool)|int %}
{% if tool < 0 or tool > 3 %}
{ action_raise_error("TOOL=0..3 required (no tool mounted to default to)") }
{% endif %}
{% set default_material = ff.tool_material[tool] if ff.tool_material|length > tool else 'PLA' %}
{% set material = params.MATERIAL|default(default_material)|upper %}
{% set base = ff.temps[material] if material in ff.temps else ff.default_temp %}
{% set temp = params.TEMP|default(base)|float %}
{% set boost = params.BOOST|default(ff.load_boost)|float %}
{% set length = params.LENGTH|default(ff.unload_length)|float %}
{% set release = params.RELEASE|default(1)|int %}
{% set heat_off = params.HEAT_OFF|default(1)|int %}
_FF_FILAMENT_PREP TOOL={tool} TEMP={temp}
_FF_FILAMENT_PREP TOOL={tool} TEMP={temp + boost}
SAVE_GCODE_STATE NAME=_ff_filament
M83
G92 E0
@@ -49,7 +49,7 @@ step_pin: PJ4
dir_pin: PJ5
enable_pin: !PJ6
microsteps: 16
rotation_distance: 40.318
rotation_distance: 40.4
endstop_pin:PA15
position_endstop: 0
position_max: 310.0
@@ -80,7 +80,7 @@ step_pin: PJ0
dir_pin: !PJ1
enable_pin: !PJ2
microsteps: 16
rotation_distance: 40.484
rotation_distance: 40.4
endstop_pin:!eboard:PA15
position_endstop: 0
position_max: 260.0
@@ -154,6 +154,17 @@ stall_threshold: 0.6
white_pin: PB5
hardware_pwm: False
# PA3 switches the heater board's shared 24 V rail. FlashForge 1.9.9 changed
# its old output_pin to default off; tie the rail to all four hotends so it is
# powered whenever any nozzle is hot and shuts down with Klipper.
[heater_fan dc24v_ctl]
pin: eheaterboard:PA3
heater: extruder,extruder1,extruder2,extruder3
heater_temp: 50
fan_speed: 1.0
shutdown_speed: 0.0
kick_start_time: 0
[heater_fan heat_fan]
pin:eheaterboard:PC11
max_power: 1.0
+8
View File
@@ -72,6 +72,14 @@ rm -rf "$PKG_WORK/stage$MODDIR/klipper/docs/_klipper3d"
for _e in "$PKG_DIR"/payload/klipper/klippy/extras/ff_*.py; do
pkg_stage "$_e" "klipper/klippy/extras/$(basename "$_e")"
done
# Upstream keeps exclude-object's spatial offsets per extruder but shares the
# retraction maxima and pending adjustment between them. A tool change after
# cancelling an object can therefore attach the old tool's E history to the
# new tool's travel. This complete extra overlays the pinned fork's copy just
# like the ff_ extras above; qa/static/test_exclude_object.py holds the
# multi-extruder regression.
pkg_stage "$PKG_DIR/payload/klipper/klippy/extras/exclude_object.py" \
"klipper/klippy/extras/exclude_object.py"
# FLASHFORGE'S OWN chelper, at $MODDIR/prog/stock-chelper and never on klippy's
# path. anvil-link-prog.sh puts it back over /usr/prog/klipper/klippy/chelper
@@ -0,0 +1,370 @@
# Exclude moves toward and inside objects
#
# Copyright (C) 2019 Eric Callahan <arksine.code@gmail.com>
# Copyright (C) 2021 Troy Jacobson <troy.d.jacobson@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license.
import logging
import json
LARGE_EXTRUDER_ADJUSTMENT = 5.0
class ExcludeObject:
def __init__(self, config):
self.printer = config.get_printer()
self.gcode = self.printer.lookup_object('gcode')
self.gcode_move = self.printer.load_object(config, 'gcode_move')
self.printer.register_event_handler('klippy:connect',
self._handle_connect)
self.printer.register_event_handler("virtual_sdcard:reset_file",
self._reset_file)
self.next_transform = None
self._reset_state()
self.gcode.register_command(
'EXCLUDE_OBJECT_START', self.cmd_EXCLUDE_OBJECT_START,
desc=self.cmd_EXCLUDE_OBJECT_START_help)
self.gcode.register_command(
'EXCLUDE_OBJECT_END', self.cmd_EXCLUDE_OBJECT_END,
desc=self.cmd_EXCLUDE_OBJECT_END_help)
self.gcode.register_command(
'EXCLUDE_OBJECT', self.cmd_EXCLUDE_OBJECT,
desc=self.cmd_EXCLUDE_OBJECT_help)
self.gcode.register_command(
'EXCLUDE_OBJECT_DEFINE', self.cmd_EXCLUDE_OBJECT_DEFINE,
desc=self.cmd_EXCLUDE_OBJECT_DEFINE_help)
def _register_transform(self):
if self.next_transform is None:
tuning_tower = self.printer.lookup_object('tuning_tower')
if tuning_tower.is_active():
logging.info('The ExcludeObject move transform is not being '
'loaded due to Tuning tower being Active')
return
self.next_transform = self.gcode_move.set_move_transform(
self, force=True)
# XYZ belongs to the one physical carriage. E history belongs to
# the active extruder; sharing its maxima and pending adjustment
# across ACTIVATE_EXTRUDER is capable of turning an XY travel into
# a very large extrusion.
self.xyz_offset = [0., 0., 0.]
self.physical_xyz = None
self.last_excluded_xyz = [0., 0., 0.]
self.e_states = {}
self.active_extruder = None
self.initial_extrusion_moves = 5
self.last_position = [0., 0., 0., 0.]
self.get_position()
def _handle_connect(self):
self.toolhead = self.printer.lookup_object('toolhead')
def _unregister_transform(self):
if self.next_transform:
tuning_tower = self.printer.lookup_object('tuning_tower')
if tuning_tower.is_active():
logging.error('The Exclude Object move transform was not '
'unregistered because it is not at the head of '
'the transform chain.')
return
self.gcode_move.set_move_transform(self.next_transform, force=True)
self.next_transform = None
self.gcode_move.reset_last_position()
def _reset_state(self):
self.objects = []
self.excluded_objects = []
self.current_object = None
def _reset_file(self):
self._reset_state()
self._unregister_transform()
def _extruder_name(self):
return self.toolhead.get_extruder().get_name()
@staticmethod
def _new_e_state(e_position):
# Seed every value from the selected extruder's real position, not
# zero. A non-zero or negative E origin must not manufacture a
# retraction difference on the first excluded region.
return {
'offset': 0.,
'last_virtual': e_position,
'last_normal': e_position,
'max_normal': e_position,
'last_excluded': e_position,
'max_excluded': e_position,
'pending_adjustment': 0.,
'was_ignored': False,
'last_excluded_object': None,
}
@staticmethod
def _state_log(state):
return ('offset=%.6f last=%.6f normal=%.6f/%.6f '
'excluded=%.6f/%.6f pending=%.6f ignored=%s'
% (state['offset'], state['last_virtual'],
state['last_normal'], state['max_normal'],
state['last_excluded'], state['max_excluded'],
state['pending_adjustment'], state['was_ignored']))
def _select_e_state(self, physical_e):
ename = self._extruder_name()
state = self.e_states.get(ename)
new_state = state is None
if new_state:
state = self._new_e_state(physical_e)
self.e_states[ename] = state
if ename != self.active_extruder:
previous = self.active_extruder or '<none>'
self.active_extruder = ename
logging.info(
"exclude_object: active extruder %s -> %s; physical_e=%.6f;"
" %s", previous, ename, physical_e, self._state_log(state))
if new_state and self._test_in_excluded_region():
logging.warning(
"exclude_object: first observation of %s is inside"
" excluded object %s; using physical E %.6f as a neutral"
" retraction baseline", ename, self.current_object,
physical_e)
return state
def _rebase_spatial_position(self, xyz):
if self.physical_xyz is None:
self.physical_xyz = list(xyz)
self.last_excluded_xyz = [xyz[i] + self.xyz_offset[i]
for i in range(3)]
return
# A tool-frame change below this transform changes the coordinates
# returned by get_position() without being an exclude-object move.
# Shift the comparison anchor with it while retaining the outstanding
# virtual-to-physical offset.
delta = [xyz[i] - self.physical_xyz[i] for i in range(3)]
if any(delta):
self.last_excluded_xyz = [self.last_excluded_xyz[i] + delta[i]
for i in range(3)]
self.physical_xyz = list(xyz)
def get_position(self):
pos = self.next_transform.get_position()
self._rebase_spatial_position(pos[:3])
state = self._select_e_state(pos[3])
state['last_virtual'] = pos[3] + state['offset']
self.last_position = [pos[i] + self.xyz_offset[i] for i in range(3)]
self.last_position.append(state['last_virtual'])
return list(self.last_position)
def _state_for_move(self):
ename = self._extruder_name()
if ename == self.active_extruder:
return self.e_states[ename]
# Normally gcode_move calls get_position() from its activation event
# before the next move. Select here too so correctness does not depend
# on event-handler registration order or on callers doing that reset.
pos = self.next_transform.get_position()
self._rebase_spatial_position(pos[:3])
state = self._select_e_state(pos[3])
state['last_virtual'] = pos[3] + state['offset']
self.last_position = [pos[i] + self.xyz_offset[i] for i in range(3)]
self.last_position.append(state['last_virtual'])
return state
def _calculate_adjustment(self, state):
adjustment = (state['max_excluded'] - state['last_excluded']
- (state['max_normal'] - state['last_normal']))
state['pending_adjustment'] = adjustment
state['was_ignored'] = False
if abs(adjustment) >= LARGE_EXTRUDER_ADJUSTMENT:
logging.warning(
"exclude_object: large E correction %.6f mm for %s after"
" excluded object %s; %s; xyz_offset=%s last_excluded_xyz=%s",
adjustment, self.active_extruder,
state['last_excluded_object'],
self._state_log(state), self.xyz_offset,
self.last_excluded_xyz)
else:
# Keep this at info level: it is the evidence that the skipped
# object's E history was reconciled without borrowing another
# tool's state. This is emitted once when leaving an excluded
# region, not once per move.
logging.info(
"exclude_object: E correction %.6f mm for %s after excluded"
" object %s; %s; xyz_offset=%s last_excluded_xyz=%s",
adjustment, self.active_extruder,
state['last_excluded_object'], self._state_log(state),
self.xyz_offset, self.last_excluded_xyz)
def _normal_move(self, newpos, speed):
state = self._state_for_move()
if state['was_ignored']:
self._calculate_adjustment(state)
if (self.initial_extrusion_moves > 0
and state['last_virtual'] != newpos[3]):
# Preserve upstream's one-time grace after installing the
# transform. It is global: restarting it for every tool would
# print the first moves of a cancelled object after every switch.
self.initial_extrusion_moves -= 1
if ((self.xyz_offset[0] != 0 or self.xyz_offset[1] != 0)
and (newpos[0] != self.last_excluded_xyz[0]
or newpos[1] != self.last_excluded_xyz[1])):
self.xyz_offset = [0., 0., 0.]
state['offset'] += state['pending_adjustment']
state['pending_adjustment'] = 0.
if (self.xyz_offset[2] != 0
and newpos[2] != self.last_excluded_xyz[2]):
self.xyz_offset[2] = 0.
if (state['pending_adjustment'] != 0
and newpos[3] != state['last_excluded']):
state['offset'] += state['pending_adjustment']
state['pending_adjustment'] = 0.
tx_pos = [newpos[i] - self.xyz_offset[i] for i in range(3)]
tx_pos.append(newpos[3] - state['offset'])
self.next_transform.move(tx_pos, speed)
self.physical_xyz = list(tx_pos[:3])
state['last_virtual'] = newpos[3]
state['last_normal'] = newpos[3]
state['max_normal'] = max(state['max_normal'], newpos[3])
self.last_position = list(newpos)
def _ignore_move(self, newpos, speed):
state = self._state_for_move()
self.xyz_offset = [newpos[i] - self.physical_xyz[i]
for i in range(3)]
state['offset'] += newpos[3] - state['last_virtual']
state['last_virtual'] = newpos[3]
state['last_excluded'] = newpos[3]
state['max_excluded'] = max(state['max_excluded'], newpos[3])
state['was_ignored'] = True
state['last_excluded_object'] = self.current_object
self.last_position = list(newpos)
self.last_excluded_xyz = list(newpos[:3])
def _test_in_excluded_region(self):
return (self.current_object in self.excluded_objects
and self.initial_extrusion_moves == 0)
def get_status(self, eventtime=None):
return {
"objects": self.objects,
"excluded_objects": self.excluded_objects,
"current_object": self.current_object
}
def move(self, newpos, speed):
self.last_speed = speed
if self._test_in_excluded_region():
self._ignore_move(newpos, speed)
else:
self._normal_move(newpos, speed)
cmd_EXCLUDE_OBJECT_START_help = "Marks the beginning the current object" \
" as labeled"
def cmd_EXCLUDE_OBJECT_START(self, gcmd):
name = gcmd.get('NAME').upper()
if not any(obj["name"] == name for obj in self.objects):
self._add_object_definition({"name": name})
self.current_object = name
self.was_excluded_at_start = self._test_in_excluded_region()
cmd_EXCLUDE_OBJECT_END_help = "Marks the end the current object"
def cmd_EXCLUDE_OBJECT_END(self, gcmd):
if self.current_object == None and self.next_transform:
gcmd.respond_info("EXCLUDE_OBJECT_END called, but no object is"
" currently active")
return
name = gcmd.get('NAME', default=None)
if name != None and name.upper() != self.current_object:
gcmd.respond_info("EXCLUDE_OBJECT_END NAME=%s does not match the"
" current object NAME=%s" %
(name.upper(), self.current_object))
self.current_object = None
cmd_EXCLUDE_OBJECT_help = "Cancel moves inside a specified objects"
def cmd_EXCLUDE_OBJECT(self, gcmd):
reset = gcmd.get('RESET', None)
current = gcmd.get('CURRENT', None)
name = gcmd.get('NAME', '').upper()
if reset:
if name:
self._unexclude_object(name)
else:
self.excluded_objects = []
elif name:
if name.upper() not in self.excluded_objects:
self._exclude_object(name.upper())
elif current:
if not self.current_object:
raise self.gcode.error('There is no current object to cancel')
self._exclude_object(self.current_object)
else:
self._list_excluded_objects(gcmd)
cmd_EXCLUDE_OBJECT_DEFINE_help = "Provides a summary of an object"
def cmd_EXCLUDE_OBJECT_DEFINE(self, gcmd):
reset = gcmd.get('RESET', None)
name = gcmd.get('NAME', '').upper()
if reset:
self._reset_file()
elif name:
parameters = gcmd.get_command_parameters().copy()
parameters.pop('NAME')
center = parameters.pop('CENTER', None)
polygon = parameters.pop('POLYGON', None)
obj = {"name": name.upper()}
obj.update(parameters)
if center != None:
obj['center'] = json.loads('[%s]' % center)
if polygon != None:
obj['polygon'] = json.loads(polygon)
self._add_object_definition(obj)
else:
self._list_objects(gcmd)
def _add_object_definition(self, definition):
self.objects = sorted(self.objects + [definition],
key=lambda o: o["name"])
def _exclude_object(self, name):
self._register_transform()
self.gcode.respond_info('Excluding object {}'.format(name.upper()))
if name not in self.excluded_objects:
self.excluded_objects = sorted(self.excluded_objects + [name])
def _unexclude_object(self, name):
self.gcode.respond_info('Unexcluding object {}'.format(name.upper()))
if name in self.excluded_objects:
excluded_objects = list(self.excluded_objects)
excluded_objects.remove(name)
self.excluded_objects = sorted(excluded_objects)
def _list_objects(self, gcmd):
if gcmd.get('JSON', None) is not None:
object_list = json.dumps(self.objects)
else:
object_list = " ".join(obj['name'] for obj in self.objects)
gcmd.respond_info('Known objects: {}'.format(object_list))
def _list_excluded_objects(self, gcmd):
object_list = " ".join(self.excluded_objects)
gcmd.respond_info('Excluded objects: {}'.format(object_list))
def load_config(config):
return ExcludeObject(config)
+2 -2
View File
@@ -1,4 +1,4 @@
"""The first-boot screen, drawn by the printer's own interpreter.
"""The boot screen, drawn by the printer's own interpreter.
A port of the retired test/integration/printer/case-boot-screen.sh.
@@ -166,7 +166,7 @@ def frame(box, palette):
# The first-boot frame itself: title, status, note,
# progress -- the same call ff-startup.py makes while
# klipper is finding its MCUs.
"s.show('SETTING UP YOUR PRINTER', 'READING FACTORY CALIBRATION',\n"
"s.show('REFORGE IS STARTING', 'READING FACTORY CALIBRATION',\n"
" 'DO NOT TURN THE PRINTER OFF', 0.5)\n"
"buf = open(%r, 'rb').read()\n"
"print('stride', s.stride)\n"
+64
View File
@@ -0,0 +1,64 @@
"""The user-owned boot-script hook, executed by the printer's BusyBox."""
import pytest
from lib.paths import ROOT
pytestmark = pytest.mark.replica
SOURCE = (ROOT / "pkgs" / "anvil-core" / "payload" / "bin" /
"run-scripts.sh")
RUNNER = "/tmp/anvil-run-scripts.sh"
SCRIPTS = "/tmp/anvil-custom-scripts"
LOG = "/tmp/anvil-custom-scripts.log"
SERVICE = "/usr/data/anvil/etc/s6-rc/source/run-scripts"
@pytest.fixture(scope="module")
def scripts(printer):
if not SOURCE.is_file():
pytest.fail("custom-script runner is missing from the checkout")
printer.write(RUNNER, SOURCE.read_text(), mode="755")
return printer
def _run(box):
return box.sh(
"SCRIPTS_DIR=%s CUSTOM_SCRIPTS_LOG=%s sh %s"
% (SCRIPTS, LOG, RUNNER))
def test_scripts_run_alphabetically_and_one_failure_does_not_stop_the_rest(scripts):
box = scripts
box.sh("rm -rf %s %s && mkdir -p %s" % (SCRIPTS, LOG, SCRIPTS))
box.write(SCRIPTS + "/10-first.sh", "echo first\n")
box.write(SCRIPTS + "/20-fails.sh", "echo failing\nexit 7\n")
box.write(SCRIPTS + "/30-last.sh", "#!/bin/sh\necho last\n", mode="755")
box.write(SCRIPTS + "/ignored.txt", "echo must-not-run\n", mode="755")
run = _run(box)
assert run.ok, "the runner failed after a user script failed: %s" % run.text
log = box.file(LOG).text
assert "must-not-run" not in log
assert log.index("first") < log.index("failing") < log.index("last")
assert "Running 10-first.sh through sh" in log
assert "Running 30-last.sh directly" in log
assert log.rstrip().endswith("Finished custom scripts")
def test_an_empty_run_is_successful_and_replaces_the_previous_log(scripts):
scripts.sh("rm -rf %s && mkdir -p %s" % (SCRIPTS, SCRIPTS))
run = _run(scripts)
assert run.ok, run.text
log = scripts.file(LOG).text
assert "first" not in log and "failing" not in log and "last" not in log
assert "Starting custom scripts" in log
assert log.rstrip().endswith("Finished custom scripts")
def test_boot_service_runs_the_hook_with_a_bounded_transition(scripts):
assert scripts.file(SERVICE + "/type").text.strip() == "oneshot"
assert scripts.file(SERVICE + "/timeout-up").text.strip() == "30000"
assert "run-scripts.sh" in scripts.file(SERVICE + "/up").text
assert scripts.file(
"/usr/data/anvil/etc/s6-rc/source/ok-all/contents.d/run-scripts"
).exists
+62 -8
View File
@@ -70,6 +70,10 @@ LOG = "/usr/data/anvil-install.log"
# "old firmware, upgradeable".
INSTALLER = "/tmp/anvil-installer.sh"
SOURCE = ROOT / "installer" / "runFirmwareExe.sh"
LINK_SOURCE = (ROOT / "pkgs" / "anvil-core" / "payload" / "bin" /
"anvil-link-prog.sh")
MIGRATION_SOURCE = (ROOT / "pkgs" / "anvil-core" / "payload" / "bin" /
"anvil-migrate-printer-config.sh")
# Where the two payloads are built, and where the installer looks for one.
BUILD = "/tmp/anvil-qa"
@@ -188,14 +192,14 @@ def upgraded(first_install):
release. All of them must go, and the wipe is the only thing that takes
them -- there is no list that names any of them.
Two things must survive, and are planted with values that could only have
come from this printer: HelixScreen's settings.json, and an edited
moonraker.conf in the live config directory, which is outside $MODDIR and
must be overwritten anyway because the mod owns it.
Three user values are planted: HelixScreen's settings.json and a custom
boot script must survive, while an edited moonraker.conf in the live
config directory must be overwritten because the mod owns it.
Version 2 drops helper-v1 and oldskin/, splits share/web-launcher in two --
the rename that started all this -- and ships a settings.json and a
moonraker.conf of its own for the two survivors to be measured against.
moonraker.conf of its own so preservation and replacement are both
measured against content that could only have come from this printer.
"""
box = first_install
planted = box.sh(
@@ -216,7 +220,10 @@ def upgraded(first_install):
# and not a second seeding pass.
"mkdir -p %(cfgdir)s\n"
"echo 'edited by hand' > %(cfgdir)s/moonraker.conf\n"
"echo 'tuned by hand' > %(cfgdir)s/printer.cfg\n"
# User-owned boot scripts live beside config, outside the payload tree
# that the upgrade wipes.
"mkdir -p /usr/data/anvil-data/scripts\n"
"echo 'echo owner script' > /usr/data/anvil-data/scripts/owner.sh\n"
"mkdir -p %(build)s/v2/bin %(build)s/v2/share %(build)s/v2/www "
"%(build)s/v2/helixscreen/config %(build)s/v2/config\n"
"echo '#!/bin/sh' > %(build)s/v2/bin/anvil-hello\n"
@@ -232,6 +239,27 @@ def upgraded(first_install):
if not planted.ok:
pytest.fail("could not set up the upgrade: %s" % planted.text)
# This is the FlashForge section shape the release has to migrate. Stage
# the checkout's real link and migration scripts into the synthetic upgrade:
# runFirmwareExe.sh will chmod and invoke them exactly as a release does.
box.write(
CONFIG_DIR + "/printer.cfg",
"[printer]\nkinematics: corexy\n"
"# tuned by hand\n\n"
"[output_pin DC24V_CTL]\n"
"pin: eheaterboard:PA3\n"
"value: 0\n"
"shutdown_value: 0\n\n"
"[gcode_macro KEEP_ME]\n"
"gcode:\n M117 kept\n"
"#*# <---------------------- SAVE_CONFIG ---------------------->\n"
"#*# [ff_tool_offset]\n"
"#*# station_z = -1.23\n")
box.write(BUILD + "/v2/bin/anvil-link-prog.sh",
LINK_SOURCE.read_text())
box.write(BUILD + "/v2/bin/anvil-migrate-printer-config.sh",
MIGRATION_SOURCE.read_text())
_pack(box, BUILD + "/v2")
box.upgrade_log = _install(box)
return box
@@ -414,9 +442,35 @@ def test_the_live_printer_cfg_survives_the_update(upgraded):
assert live.exists, (
"%s/printer.cfg is gone after an update -- the printer has no config "
"to start from" % CONFIG_DIR)
assert live.text.strip() == "tuned by hand", (
"%s/printer.cfg was rewritten by the update: %r"
assert "# tuned by hand" in live.text, (
"%s/printer.cfg lost the owner's edit: %r" % (CONFIG_DIR, live.text))
assert "[gcode_macro KEEP_ME]" in live.text and "M117 kept" in live.text, (
"%s/printer.cfg lost a neighboring user section: %r"
% (CONFIG_DIR, live.text))
assert "#*# station_z = -1.23" in live.text, (
"%s/printer.cfg lost its SAVE_CONFIG data: %r"
% (CONFIG_DIR, live.text))
def test_the_upgrade_migrates_flashforges_dc24v_output_pin(upgraded):
"""The release upgrade path, under BusyBox, not only the host-side unit.
FlashForge writes this section into printer.cfg. The new base config owns
the same PA3 pin as a heater_fan, so leaving the old section makes Klipper
reject the duplicate pin at startup.
"""
live = upgraded.file(CONFIG_DIR + "/printer.cfg").text
assert "output_pin DC24V_CTL" not in live
assert "eheaterboard:PA3" not in live
assert "removed obsolete [output_pin DC24V_CTL]" in upgraded.upgrade_log, (
"the section disappeared without the migration reporting it:\n%s"
% _tail(upgraded.upgrade_log))
def test_user_boot_scripts_survive_the_payload_wipe(upgraded):
script = upgraded.file("/usr/data/anvil-data/scripts/owner.sh")
assert script.exists, "the upgrade deleted the user's custom boot script"
assert script.text.strip() == "echo owner script"
def test_moonraker_conf_is_overwritten_even_when_edited(upgraded):
+56
View File
@@ -80,6 +80,62 @@ def test_nginx_is_listening(web):
assert web.listening(80), "nothing is listening on :80"
def _get(box, port, path):
return box.sh("wget -q -O - 'http://127.0.0.1:%d%s'" % (port, path))
def _get_error_body(box, port, path):
"""Read an nginx error response body; BusyBox wget discards it."""
probe = "/tmp/anvil-http-error-body.py"
box.write(probe, (
"import sys\n"
"from urllib.error import HTTPError\n"
"from urllib.request import urlopen\n"
"try:\n"
" response = urlopen(sys.argv[1])\n"
"except HTTPError as error:\n"
" response = error\n"
"sys.stdout.buffer.write((str(response.status) + '\\n').encode()"
" + response.read())\n"))
got = box.sh("%s/bin/python3.13 %s 'http://127.0.0.1:%d%s'"
% (MODDIR, probe, port, path))
assert got.ok, "could not read nginx response: %s" % got.text
status, _, body = got.out.partition("\n")
return int(status), body
def test_mainsail_serves_its_shell_for_client_side_routes(web):
index = _get(web, 80, "/")
status, body = _get_error_body(
web, 80, "/dashboard/this-route-does-not-exist")
assert index.ok and index.out, "Mainsail returned no index page: %s" % index.text
assert status == 404
assert body == index.out, "Mainsail route did not return its index page"
def test_fluidd_is_installed_and_listening(web):
assert web.file(MODDIR + "/www/fluidd/index.html").exists, (
"anvil-fluidd installed no index.html")
assert web.listening(81), "nothing is listening on Fluidd's :81"
def test_fluidd_serves_its_shell_for_client_side_routes(web):
index = _get(web, 81, "/")
status, body = _get_error_body(
web, 81, "/dashboard/this-route-does-not-exist")
assert index.ok and index.out, "Fluidd returned no index page: %s" % index.text
assert status == 404
assert body == index.out, "Fluidd route did not return its index page"
def test_fluidd_proxies_moonraker(web):
direct = _get(web, 7125, "/server/info")
proxied = _get(web, 81, "/server/info")
assert direct.ok, "Moonraker did not answer directly: %s" % direct.text
assert proxied.ok, "Fluidd did not proxy Moonraker: %s" % proxied.text
assert proxied.out == direct.out
def test_nginx_comes_back_after_a_kill(web):
"""The reason for shipping a supervisor at all. `s6-svc -wr` does not
return until s6 has it running again, so this is a verdict rather than a
+70
View File
@@ -0,0 +1,70 @@
"""Normal boot tells the person at the panel what it is waiting for."""
import importlib.util
import sys
from types import SimpleNamespace
import pytest
from lib.paths import ROOT
pytestmark = pytest.mark.static
SCRIPT = (ROOT / "pkgs" / "anvil-core" / "payload" / "bin" /
"ff-startup.py")
@pytest.fixture(scope="module")
def startup():
sys.path.insert(0, str(SCRIPT.parent))
try:
spec = importlib.util.spec_from_file_location("anvil_ff_startup", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
finally:
sys.path.pop(0)
class Panel:
def __init__(self):
self.frames = []
def say(self, status, progress, **kwargs):
self.frames.append((status, progress, kwargs))
class Moonraker:
def __init__(self, states):
self.states = iter(states)
def klippy_state(self):
return next(self.states)
def test_wait_names_moonraker_then_klipper_and_advances_the_bar(startup,
monkeypatch):
panel = Panel()
moonraker = Moonraker([None, "startup", "ready"])
clock = iter([10.0, 10.0, 40.0, 40.0, 60.0])
monkeypatch.setattr(startup.time, "time", lambda: next(clock))
monkeypatch.setattr(startup.time, "sleep", lambda _seconds: None)
assert startup.wait_for_stack(moonraker, panel, 100.0, 0.0)
assert [frame[0] for frame in panel.frames] == [
"STARTING MOONRAKER", "STARTING KLIPPER"]
assert "PRINTER API" in panel.frames[0][2]["detail"]
assert "PRINTER BOARDS" in panel.frames[1][2]["detail"]
assert 0.35 < panel.frames[0][1] < panel.frames[1][1] < 0.75
def test_an_ordinary_boot_finishes_as_startup_not_setup(startup, monkeypatch):
panel = Panel()
monkeypatch.setattr(startup, "bring_up_printer",
lambda *_args, **_kwargs: True)
args = SimpleNamespace(no_import=True)
assert startup.startup(args, object(), panel, 0.0, 1.0) == 0
assert panel.frames[-1][:2] == ("STARTUP COMPLETE", 1.0)
assert panel.frames[-1][2]["note"] == ""
assert startup.TITLE == "REFORGE IS STARTING"
assert "SETUP" not in startup.RETRY
+87
View File
@@ -0,0 +1,87 @@
import subprocess
import pytest
from lib.paths import ROOT
SCRIPT = (ROOT / "pkgs" / "anvil-core" / "payload" / "bin" /
"anvil-migrate-printer-config.sh")
def migrate(path):
return subprocess.run(
["sh", str(SCRIPT), str(path)], capture_output=True, text=True)
@pytest.mark.parametrize("header", [
"[output_pin DC24V_CTL]",
" [output_pin DC24V_CTL] ",
])
def test_removes_the_whole_legacy_section_and_is_idempotent(tmp_path, header):
config = tmp_path / "printer.cfg"
config.write_text(
"[printer]\nkinematics: corexy\n\n"
+ header + "\n"
"pin: eheaterboard:PA3\n"
"value: 0\n"
"shutdown_value: 0\n"
"cycle_time: 0.100\n"
"# a future FlashForge option or comment must not leak out\n\n"
"[gcode_macro KEEP_ME]\n"
"gcode:\n"
" M117 kept\n")
first = migrate(config)
assert first.returncode == 0, first.stderr
after_first = config.read_bytes()
text = after_first.decode()
assert "DC24V_CTL" not in text
assert "cycle_time" not in text
assert "future FlashForge" not in text
assert "[printer]\nkinematics: corexy" in text
assert "[gcode_macro KEEP_ME]\ngcode:\n M117 kept" in text
second = migrate(config)
assert second.returncode == 0, second.stderr
assert second.stdout == ""
assert config.read_bytes() == after_first
def test_preserves_a_trailing_save_config_block(tmp_path):
config = tmp_path / "printer.cfg"
# Stock files can arrive with CRLF. The section matcher must still find
# the header, and migration must not normalize the lines it preserves.
config.write_bytes(
b"[output_pin DC24V_CTL]\r\n"
b"pin: eheaterboard:PA3\r\n"
b"value: 0\r\n"
b"shutdown_value: 0\r\n"
b"#*# <---------------------- SAVE_CONFIG ---------------------->\r\n"
b"#*# DO NOT EDIT THIS BLOCK OR BELOW. The contents are auto-generated.\r\n"
b"#*#\r\n"
b"#*# [ff_tool_offset]\r\n"
b"#*# station_z = -1.23\r\n")
result = migrate(config)
assert result.returncode == 0, result.stderr
data = config.read_bytes()
assert b"DC24V_CTL" not in data
assert b"SAVE_CONFIG ---------------------->\r\n" in data
assert b"#*# [ff_tool_offset]\r\n" in data
assert b"#*# station_z = -1.23\r\n" in data
def test_absent_section_and_absent_file_are_noops(tmp_path):
config = tmp_path / "printer.cfg"
original = b"[printer]\nkinematics: corexy\n"
config.write_bytes(original)
result = migrate(config)
assert result.returncode == 0, result.stderr
assert result.stdout == ""
assert config.read_bytes() == original
result = migrate(tmp_path / "missing.cfg")
assert result.returncode == 0, result.stderr
assert result.stdout == ""
+182
View File
@@ -0,0 +1,182 @@
"""Regression tests for the multi-extruder exclude-object transform.
Upstream stores the skipped-move offset by extruder, but stores the maxima
used for retraction compensation globally. The old code therefore turns the
final XY-only move below into +99 mm of physical E after T2 -> T0.
"""
import importlib.util
import os
import pytest
from lib.paths import ROOT
pytestmark = pytest.mark.static
MODULE = os.path.join(
ROOT, "pkgs", "klipper", "payload", "klipper", "klippy", "extras",
"exclude_object.py")
def load_module():
spec = importlib.util.spec_from_file_location(
"anvil_exclude_object", MODULE)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class Extruder:
def __init__(self, name):
self.name = name
def get_name(self):
return self.name
class Toolhead:
def __init__(self, name):
self.extruder = Extruder(name)
def get_extruder(self):
return self.extruder
def select(self, name):
self.extruder = Extruder(name)
class Downstream:
def __init__(self, position):
self.position = list(position)
self.moves = []
def get_position(self):
return list(self.position)
def move(self, position, speed):
self.position = list(position)
self.moves.append((list(position), speed))
def build_transform(tool="extruder2", position=(0., 0., 0., 100.)):
module = load_module()
eo = module.ExcludeObject.__new__(module.ExcludeObject)
eo.toolhead = Toolhead(tool)
eo.next_transform = Downstream(position)
eo.xyz_offset = [0., 0., 0.]
eo.physical_xyz = None
eo.last_excluded_xyz = [0., 0., 0.]
eo.e_states = {}
eo.active_extruder = None
eo.initial_extrusion_moves = 0
eo.last_position = [0., 0., 0., 0.]
eo.current_object = None
eo.excluded_objects = ["BAD"]
eo.get_position()
return eo
def select(eo, name, physical_e):
eo.toolhead.select(name)
eo.next_transform.position[3] = physical_e
eo.get_position()
def test_tool_change_does_not_put_old_tools_e_history_on_xy_travel():
eo = build_transform()
# T2's E=100 must never enter T0's retraction calculation.
select(eo, "extruder", 0.)
eo.move([0., 0., 0., 1.], 20.)
eo.current_object = "BAD"
eo.move([10., 10., 0., 2.], 20.) # suppressed
assert eo.next_transform.position == [0., 0., 0., 1.]
eo.current_object = None
eo.move([20., 20., 0., 2.], 500.) # XY-only exit travel
assert eo.next_transform.position == [20., 20., 0., 1.]
assert eo.e_states["extruder2"]["max_normal"] == 100.
# The virtual E=2 travel becomes the new normal high-water mark for T0,
# but its downstream E correctly remains 1. It must not affect T2.
assert eo.e_states["extruder"]["max_normal"] == 2.
def test_each_tool_keeps_its_own_pending_retraction_adjustment():
eo = build_transform(tool="extruder", position=(0., 0., 0., 10.))
eo.move([0., 0., 0., 8.], 20.) # T0 retracted by 2
eo.current_object = "BAD"
eo.move([5., 5., 0., 7.], 20.) # skipped retract
eo.move([6., 6., 0., 9.], 20.) # skipped partial unretract
# T1 may consume the shared spatial offset, but not T0's E correction.
eo.current_object = None
select(eo, "extruder1", 0.)
eo.move([20., 20., 0., 0.], 100.)
assert eo.next_transform.position[3] == 0.
# Returning to T0 does not put its pending correction on an XY-only move.
# It is retained until T0's own next E change.
select(eo, "extruder", 8.)
eo.move([21., 20., 0., 9.], 100.)
assert eo.next_transform.position[3] == 8.
assert eo.e_states["extruder"]["pending_adjustment"] == -1.
assert eo.e_states["extruder1"]["pending_adjustment"] == 0.
eo.move([21., 20., 0., 10.], 20.)
assert eo.next_transform.position[3] == 10.
assert eo.e_states["extruder"]["pending_adjustment"] == 0.
def test_first_tool_state_uses_its_real_negative_e_origin():
eo = build_transform(tool="extruder", position=(0., 0., 0., -20.))
state = eo.e_states["extruder"]
assert state["last_normal"] == -20.
assert state["max_normal"] == -20.
assert state["last_excluded"] == -20.
assert state["max_excluded"] == -20.
def test_single_extruder_retraction_compensation_is_preserved():
eo = build_transform(tool="extruder", position=(0., 0., 0., 10.))
eo.move([0., 0., 0., 8.], 20.) # normal retract: 2 mm debt
eo.current_object = "BAD"
eo.move([5., 5., 0., 7.], 20.) # suppressed retract
eo.move([6., 6., 0., 9.], 20.) # suppressed partial unretract
eo.current_object = None
eo.move([20., 20., 0., 9.], 100.) # leaving object, XY only
# Excluded debt is 1 mm and normal debt was 2 mm, so the -1 adjustment
# combines with the skipped net +1 and virtual E9 maps to physical E9.
assert eo.next_transform.position == [20., 20., 0., 9.]
assert eo.e_states["extruder"]["pending_adjustment"] == 0.
def test_initial_five_extrusions_remain_one_global_grace_period():
eo = build_transform(tool="extruder", position=(0., 0., 0., 0.))
eo.initial_extrusion_moves = 5
eo.current_object = "BAD"
for e in range(1, 6):
eo.move([float(e), 0., 0., float(e)], 20.)
assert len(eo.next_transform.moves) == 5
select(eo, "extruder1", 0.)
eo.move([6., 0., 0., 1.], 20.)
assert len(eo.next_transform.moves) == 5
def test_large_adjustment_logs_complete_diagnostic(caplog):
eo = build_transform(tool="extruder", position=(0., 0., 0., 0.))
state = eo.e_states["extruder"]
state.update(max_normal=20., last_normal=0.,
max_excluded=1., last_excluded=1., was_ignored=True)
eo.move([1., 1., 0., 0.], 100.)
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "large E correction -20.000000 mm for extruder" in messages
assert "normal=0.000000/20.000000" in messages
assert "xyz_offset=" in messages
+15
View File
@@ -260,6 +260,21 @@ def test_the_shipped_config_parses(model):
assert cp.sections(), "%s assembled to nothing -- includes did not resolve" % model
@pytest.mark.parametrize("model", MODELS)
def test_dc24v_rail_follows_every_hotend(model):
cp = _parse(model)
section = "heater_fan dc24v_ctl"
assert cp.has_section(section), "%s has no [%s]" % (model, section)
assert cp.get(section, "pin").strip() == "eheaterboard:PA3"
assert cp.get(section, "heater").replace(" ", "") == \
"extruder,extruder1,extruder2,extruder3"
assert cp.getfloat(section, "heater_temp") == 50.0
assert cp.getfloat(section, "fan_speed") == 1.0
assert cp.getfloat(section, "shutdown_speed") == 0.0
assert cp.getfloat(section, "kick_start_time") == 0.0
assert not cp.has_section("output_pin DC24V_CTL")
@pytest.mark.parametrize("model", MODELS)
def test_no_output_pin_is_claimed_twice(model):
cp = _parse(model)
+2 -2
View File
@@ -51,6 +51,7 @@ pytestmark = pytest.mark.static
# under the printer's ash, and a bashism in one is a service that never starts
# and says so only in s6's own log.
SYNTAX_GLOBS = ("bin/*.sh", "pkgs/*/payload/*.sh",
"pkgs/*/payload/bin/*.sh",
"pkgs/*/payload/prog/*.sh", "pkgs/*/payload/prog/firmwareExe",
# FlashForge's own klipperDaemon, restored onto printers byte
# for byte. It is in the parse list and NOT in the ash list
@@ -89,9 +90,8 @@ SYNTAX_GLOBS = ("bin/*.sh", "pkgs/*/payload/*.sh",
# nothing between it and the machine. A bashism in it is a package that
# unpacks and then does nothing.
#
# NOT here, and a real gap rather than a decision: pkgs/*/payload/bin/*.sh.
# wifi-action.sh runs on the printer and no lane checks its dialect.
ASH_GLOBS = ("pkgs/*/payload/*.sh",
"pkgs/*/payload/bin/*.sh",
"pkgs/*/payload/prog/*.sh", "pkgs/*/payload/prog/firmwareExe",
"pkgs/*/payload/etc/s6-rc/" + "source/*/run",
"installer/*.sh", "qa/replica/actions/*.sh")
+19 -15
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# Render every first-boot screen inside the replica and print each one as a
# Render every boot-screen phase inside the replica and print each one as a
# base64 PNG on stdout.
#
# This is the answer to "I want to SEE it, not read assertions about it". The
@@ -27,18 +27,20 @@ W, H, BPP = 480, 800, 32 # the real framebuffer: portrait
# Keep in step with bin/preview-boot-screen.py, which renders the same list
# on the host -- the two are expected to agree byte for byte.
PHASES = [
('starting-services', 'STARTING SERVICES', 0.05, ''),
('mcu-boards', 'WAKING THE TOOLHEAD BOARDS', 0.05, ''),
('mcu-heater', 'WAKING THE HEATER BOARD', 0.08, ''),
('mcu-both', 'WAKING THE HEATER BOARD AND THE LEVEL BOARD', 0.08, ''),
('waiting', 'WAITING FOR THE PRINTER', 0.22, ''),
('importing', 'READING FACTORY CALIBRATION', 0.5, ''),
('saving', 'SAVING CALIBRATION', 0.7, ''),
('restarting', 'RESTARTING THE PRINTER', 0.85, ''),
('complete', 'SETUP COMPLETE', 1.0, ''),
('already', 'ALREADY CALIBRATED', 1.0, ''),
('launching-services', 'LAUNCHING SERVICES', 0.02, ''),
('mcu-boards', 'WAKING THE TOOLHEAD BOARDS', 0.12, ''),
('mcu-heater', 'WAKING THE HEATER BOARD', 0.18, ''),
('mcu-both', 'WAKING THE HEATER BOARD AND THE LEVEL BOARD', 0.18, ''),
('starting-services', 'STARTING SERVICES', 0.30, ''),
('moonraker', 'STARTING MOONRAKER', 0.45, ''),
('klipper', 'STARTING KLIPPER', 0.60, ''),
('ready', 'KLIPPER IS READY', 0.78, ''),
('importing', 'READING FACTORY CALIBRATION', 0.82, ''),
('saving', 'SAVING CALIBRATION', 0.88, ''),
('restarting', 'RESTARTING KLIPPER', 0.94, ''),
('complete', 'STARTUP COMPLETE', 1.0, ''),
]
RETRY = 'SETUP WILL RETRY ON NEXT START'
RETRY = 'STARTUP WILL RETRY ON NEXT BOOT'
LOG = '/USR/DATA/LOGS/ANVIL-BOOT.LOG'
for slug, reason in [
('fail-moonraker', 'MOONRAKER IS NOT RESPONDING'),
@@ -54,12 +56,14 @@ for slug, reason in [
('fail-restart', 'KLIPPER DID NOT RESTART AFTER SAVING'),
('fail-unsaved', 'THE CALIBRATION DID NOT SAVE')]:
PHASES.append((slug, RETRY, None, '%s. DETAILS IN %s' % (reason, LOG)))
NO_NOTE = ('complete', 'already')
NO_NOTE = ('complete',)
for name, status, prog, detail in PHASES:
open('/tmp/fb', 'wb').close()
s = ffscreen.Screen('/tmp/fb', geometry=(W, H, BPP))
note = '' if (name in NO_NOTE or detail) else 'DO NOT TURN THE PRINTER OFF'
s.show('SETTING UP YOUR PRINTER', status, note, prog, detail, bool(detail))
calibration = name in ('importing', 'saving', 'restarting')
note = ('' if (name in NO_NOTE or detail) else
'DO NOT TURN THE PRINTER OFF' if calibration else 'PLEASE WAIT')
s.show('REFORGE IS STARTING', status, note, prog, detail, bool(detail))
buf = open('/tmp/fb', 'rb').read()
# Emit what the EYE sees, not the buffer: the panel is this portrait
+5 -2
View File
@@ -28,8 +28,11 @@ STOCK_SHA256_CREATOR5PRO="1f3252feaae6d8fc72d7b0fd1ba832cc63d369659f8b2d2896efcf
STOCK_FILE_CREATOR5="Creator5-$STOCK_VERSION.tgz"
STOCK_SHA256_CREATOR5="72009be183e74a4562050a927787ba686c6b558a075f00958892197ac52659cc"
MAINSAIL_VERSION="v2.18.2"
MAINSAIL_SHA256="df2ba7c301f7bfc8ac9f122741a6ba08356d679ecfa1f62f898d0337802d5de5"
MAINSAIL_VERSION="v2.19.0"
MAINSAIL_SHA256="c4d9d96f89851c6ae0709c2f20725447f3fe8c57cf193869b0083441bd93378a"
FLUIDD_VERSION="v1.37.5"
FLUIDD_SHA256="d61d2f3a2ec69f1e6dd330dca17ba77700d4001cf5c4568dc192934691e3f33f"
# Klipper: Klipper4FlashForge's creator5 HEAD, so a COMMIT. The only klipper
# source -- pkg/klipper builds c_helper.so from this same tree.