diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb76e85..73fa3a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 08755dc..c390d42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ Release preparation and documentation sweep. ### Added +- Fluidd is available beside Mainsail at `http://: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 diff --git a/README.md b/README.md index 6033927..d846443 100644 --- a/README.md +++ b/README.md @@ -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:///`, with a current Moonraker behind it — anything that speaks the Klipper API works +- [ ] **Fluidd** at `http://: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 --- diff --git a/bin/common.sh b/bin/common.sh index 0b4c41f..e5b0198 100644 --- a/bin/common.sh +++ b/bin/common.sh @@ -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 diff --git a/bin/fetch-assets.sh b/bin/fetch-assets.sh index d40ad8d..8a2d90f 100755 --- a/bin/fetch-assets.sh +++ b/bin/fetch-assets.sh @@ -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. diff --git a/bin/pack.sh b/bin/pack.sh index e25422a..6bf6c0d 100755 --- a/bin/pack.sh +++ b/bin/pack.sh @@ -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 diff --git a/bin/payload.sh b/bin/payload.sh index c3690cf..faaf844 100755 --- a/bin/payload.sh +++ b/bin/payload.sh @@ -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 diff --git a/bin/preview-boot-screen.py b/bin/preview-boot-screen.py index 0530b25..b62c3bd 100755 --- a/bin/preview-boot-screen.py +++ b/bin/preview-boot-screen.py @@ -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() diff --git a/config.env.example b/config.env.example index 60ee384..dd3538e 100644 --- a/config.env.example +++ b/config.env.example @@ -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. diff --git a/docs/boot-screen/complete.png b/docs/boot-screen/complete.png index 2458806..db037e8 100644 Binary files a/docs/boot-screen/complete.png and b/docs/boot-screen/complete.png differ diff --git a/docs/boot-screen/importing.png b/docs/boot-screen/importing.png index 0061649..3ea2219 100644 Binary files a/docs/boot-screen/importing.png and b/docs/boot-screen/importing.png differ diff --git a/docs/custom-scripts.md b/docs/custom-scripts.md new file mode 100644 index 0000000..4647e70 --- /dev/null +++ b/docs/custom-scripts.md @@ -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 +``` diff --git a/docs/features.md b/docs/features.md index 716ed23..30b1be2 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 diff --git a/docs/first-print.md b/docs/first-print.md index 176deb4..6c4fed5 100644 --- a/docs/first-print.md +++ b/docs/first-print.md @@ -18,6 +18,7 @@ Everything is on the printer's own address: | | | |---|---| | `http:///` | Mainsail | +| `http://:81/` | Fluidd | | `http://:7125` | Moonraker's API — what slicers upload to | | `http:///webcam/` | the camera stream (mjpg-streamer on `:8080`) | | `ssh root@` | 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 diff --git a/docs/how-it-works.md b/docs/how-it-works.md index 2d69d5f..a22bd61 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -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) diff --git a/docs/installing.md b/docs/installing.md index 8cb1156..0b3a402 100644 --- a/docs/installing.md +++ b/docs/installing.md @@ -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: -Boot screen: reading factory calibration Boot screen: setup complete +Boot screen: Reforge is starting and reading factory calibration 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 diff --git a/docs/notes/48-filament-load-port.md b/docs/notes/48-filament-load-port.md index ddc5d85..8a2dfd3 100644 --- a/docs/notes/48-filament-load-port.md +++ b/docs/notes/48-filament-load-port.md @@ -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 | diff --git a/docs/printing.md b/docs/printing.md index 7743f19..ac872a7 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -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 diff --git a/docs/toolchange.md b/docs/toolchange.md index be396aa..2163e4b 100644 --- a/docs/toolchange.md +++ b/docs/toolchange.md @@ -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) | diff --git a/docs/upgrading.md b/docs/upgrading.md index ea2b8f6..bb9da05 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 3fb38e6..5c434d3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: diff --git a/pkgs/3rdparty/fluidd/build.sh b/pkgs/3rdparty/fluidd/build.sh new file mode 100755 index 0000000..445c0e6 --- /dev/null +++ b/pkgs/3rdparty/fluidd/build.sh @@ -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 diff --git a/pkgs/3rdparty/fluidd/pkg.conf b/pkgs/3rdparty/fluidd/pkg.conf new file mode 100644 index 0000000..f3a5a5a --- /dev/null +++ b/pkgs/3rdparty/fluidd/pkg.conf @@ -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." diff --git a/pkgs/3rdparty/mainsail/build.sh b/pkgs/3rdparty/mainsail/build.sh index a114f6d..fb0b444 100755 --- a/pkgs/3rdparty/mainsail/build.sh +++ b/pkgs/3rdparty/mainsail/build.sh @@ -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 diff --git a/pkgs/3rdparty/mainsail/pkg.conf b/pkgs/3rdparty/mainsail/pkg.conf index 0d502b8..fa095a8 100644 --- a/pkgs/3rdparty/mainsail/pkg.conf +++ b/pkgs/3rdparty/mainsail/pkg.conf @@ -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. diff --git a/pkgs/anvil-core/payload/bin/anvil-link-prog.sh b/pkgs/anvil-core/payload/bin/anvil-link-prog.sh index cc3bfdb..e4c2f56 100755 --- a/pkgs/anvil-core/payload/bin/anvil-link-prog.sh +++ b/pkgs/anvil-core/payload/bin/anvil-link-prog.sh @@ -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 diff --git a/pkgs/anvil-core/payload/bin/anvil-migrate-printer-config.sh b/pkgs/anvil-core/payload/bin/anvil-migrate-printer-config.sh new file mode 100755 index 0000000..257105d --- /dev/null +++ b/pkgs/anvil-core/payload/bin/anvil-migrate-printer-config.sh @@ -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" diff --git a/pkgs/anvil-core/payload/bin/ff-startup.py b/pkgs/anvil-core/payload/bin/ff-startup.py index df586fd..65a2fca 100755 --- a/pkgs/anvil-core/payload/bin/ff-startup.py +++ b/pkgs/anvil-core/payload/bin/ff-startup.py @@ -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 diff --git a/pkgs/anvil-core/payload/bin/ffscreen.py b/pkgs/anvil-core/payload/bin/ffscreen.py index 9fb7698..284b75c 100644 --- a/pkgs/anvil-core/payload/bin/ffscreen.py +++ b/pkgs/anvil-core/payload/bin/ffscreen.py @@ -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 diff --git a/pkgs/anvil-core/payload/bin/run-scripts.sh b/pkgs/anvil-core/payload/bin/run-scripts.sh new file mode 100755 index 0000000..f5942b6 --- /dev/null +++ b/pkgs/anvil-core/payload/bin/run-scripts.sh @@ -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" diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/ok-all/contents.d/run-scripts b/pkgs/anvil-core/payload/etc/s6-rc/source/ok-all/contents.d/run-scripts new file mode 100644 index 0000000..e69de29 diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/timeout-up b/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/timeout-up new file mode 100644 index 0000000..3a05c8b --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/timeout-up @@ -0,0 +1 @@ +30000 diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/type b/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/type new file mode 100644 index 0000000..bdd22a1 --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/type @@ -0,0 +1 @@ +oneshot diff --git a/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/up b/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/up new file mode 100644 index 0000000..dd2f565 --- /dev/null +++ b/pkgs/anvil-core/payload/etc/s6-rc/source/run-scripts/up @@ -0,0 +1 @@ +/bin/sh /usr/data/anvil/bin/run-scripts.sh diff --git a/pkgs/anvil-core/payload/nginx/nginx.conf b/pkgs/anvil-core/payload/nginx/nginx.conf index 63ee415..6700a4d 100644 --- a/pkgs/anvil-core/payload/nginx/nginx.conf +++ b/pkgs/anvil-core/payload/nginx/nginx.conf @@ -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; + } + } } diff --git a/pkgs/anvil-core/payload/prog/firmwareExe b/pkgs/anvil-core/payload/prog/firmwareExe index 9b93e11..964c5ea 100755 --- a/pkgs/anvil-core/payload/prog/firmwareExe +++ b/pkgs/anvil-core/payload/prog/firmwareExe @@ -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 ------------------------------------------------ # diff --git a/pkgs/klipper-config/payload/config/ff-filament.cfg b/pkgs/klipper-config/payload/config/ff-filament.cfg index deea3de..119ad0e 100644 --- a/pkgs/klipper-config/payload/config/ff-filament.cfg +++ b/pkgs/klipper-config/payload/config/ff-filament.cfg @@ -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=PLA] [BOOST=30] -# [LENGTH=] [SPEED=] [RELEASE=1] [HEAT_OFF=1] +# [LENGTH=] [SPEED=] [RETRACT=] +# [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=PLA] -# [LENGTH=] [RELEASE=1] [HEAT_OFF=1] +# [BOOST=30] [LENGTH=] [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 diff --git a/pkgs/klipper-config/payload/config/printer.base.cfg b/pkgs/klipper-config/payload/config/printer.base.cfg index a9aa283..21fd9e7 100644 --- a/pkgs/klipper-config/payload/config/printer.base.cfg +++ b/pkgs/klipper-config/payload/config/printer.base.cfg @@ -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 diff --git a/pkgs/klipper/build.sh b/pkgs/klipper/build.sh index aea0e5e..4bed742 100755 --- a/pkgs/klipper/build.sh +++ b/pkgs/klipper/build.sh @@ -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 diff --git a/pkgs/klipper/payload/klipper/klippy/extras/exclude_object.py b/pkgs/klipper/payload/klipper/klippy/extras/exclude_object.py new file mode 100644 index 0000000..54057a6 --- /dev/null +++ b/pkgs/klipper/payload/klipper/klippy/extras/exclude_object.py @@ -0,0 +1,370 @@ +# Exclude moves toward and inside objects +# +# Copyright (C) 2019 Eric Callahan +# Copyright (C) 2021 Troy Jacobson +# +# 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 '' + 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) diff --git a/qa/replica/test_boot_screen.py b/qa/replica/test_boot_screen.py index 310b50f..1d500e6 100644 --- a/qa/replica/test_boot_screen.py +++ b/qa/replica/test_boot_screen.py @@ -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" diff --git a/qa/replica/test_custom_scripts.py b/qa/replica/test_custom_scripts.py new file mode 100644 index 0000000..4ad5b5a --- /dev/null +++ b/qa/replica/test_custom_scripts.py @@ -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 diff --git a/qa/replica/test_upgrade.py b/qa/replica/test_upgrade.py index 9def7ef..935f3cc 100644 --- a/qa/replica/test_upgrade.py +++ b/qa/replica/test_upgrade.py @@ -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): diff --git a/qa/replica/test_web.py b/qa/replica/test_web.py index 85526f2..2456672 100644 --- a/qa/replica/test_web.py +++ b/qa/replica/test_web.py @@ -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 diff --git a/qa/static/test_boot_status.py b/qa/static/test_boot_status.py new file mode 100644 index 0000000..80f0710 --- /dev/null +++ b/qa/static/test_boot_status.py @@ -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 diff --git a/qa/static/test_dc24v_config_migration.py b/qa/static/test_dc24v_config_migration.py new file mode 100644 index 0000000..cec8cdc --- /dev/null +++ b/qa/static/test_dc24v_config_migration.py @@ -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 == "" diff --git a/qa/static/test_exclude_object.py b/qa/static/test_exclude_object.py new file mode 100644 index 0000000..91d523e --- /dev/null +++ b/qa/static/test_exclude_object.py @@ -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 diff --git a/qa/static/test_klipper_config.py b/qa/static/test_klipper_config.py index 8eb705e..6605c96 100644 --- a/qa/static/test_klipper_config.py +++ b/qa/static/test_klipper_config.py @@ -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) diff --git a/qa/static/test_shell_syntax.py b/qa/static/test_shell_syntax.py index 83b3b2e..7eec3db 100644 --- a/qa/static/test_shell_syntax.py +++ b/qa/static/test_shell_syntax.py @@ -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") diff --git a/tools/replica/printer/case-boot-screen-dump.sh b/tools/replica/printer/case-boot-screen-dump.sh index 8d664d6..7bf2383 100755 --- a/tools/replica/printer/case-boot-screen-dump.sh +++ b/tools/replica/printer/case-boot-screen-dump.sh @@ -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 diff --git a/versions.env b/versions.env index a9c6412..d72c18d 100644 --- a/versions.env +++ b/versions.env @@ -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.