Commit Graph
49 Commits
Author SHA1 Message Date
Oleksandr ShyshatskyiandClaude Opus 5 2a6331a831 We own runFirmwareExe.sh; the install is no longer spliced into run.sh
app_startup.sh runs whatever it finds under that name in the package it just
decrypted. That is the entire contract -- plus the exit code, where 0 means
"flashed, power-cycle me" and non-zero means "carry on booting" -- so owning
the name is all it takes to own the install. We were not using it. We shipped
FlashForge's copy verbatim and reached the printer through the one exec hook
their file offers: the software component's run.sh, into which bin/patch.sh
spliced two marked blocks with 40 lines of regex.

installer/runFirmwareExe.sh is that file now. run-pre.sh and run-append.sh are
folded into it unchanged in substance -- the manifest delete, the HelixScreen
settings rescue, the s6 scandir sweep, the config merge, the password dance are
all the same code -- and the two orderings that mattered are kept explicitly
rather than by where a regex put them: the backups run BEFORE any component is
installed, because the software component ships /usr/prog's shadow and passwd
and its run.sh copies them over the live ones.

The four component installers become one function. Their semantics are kept
exactly, including that a component failing md5sum.list is discarded rather
than half-installed, and that every previous version directory is wiped before
the new one lands. What is NOT kept is the third of that file which never ran:
create_key(), defined and never called, and built its paths without a slash so
it would have written /usr/progprivate.pem; SSID_NAME/SSID_PASS, assigned and
never read; the commented-out kernel version gate; the screwflag factory bypass
of the model check, which we ship no screwflag for.

bin/pack.sh stages it, rewriting MACHINE=, PID= and MOD_PW_AUTO= and then
GREPPING for all three -- a package whose gate still said Creator5Pro because a
sed missed would install on the wrong machine. MOD_PW_AUTO is 0 in the
checkout, which is the safe default and not an arbitrary one: test_upgrade.py
runs the unsubstituted file, and 1 would have it reroll the replica's root
password and drop a password file on /mnt every run.

The software component still ships, unchanged, and its run.sh is left exactly
as FlashForge wrote it. Dropping it is the next commit; this one moves the
vehicle without changing what reaches /usr/prog.

Gates: qa/static/test_shell_syntax.py already covered installer/*.sh under both
bash -n and the printer's ash plus shellcheck, so the new file was checked the
day it landed -- 91 pass. test_upgrade.py runs the real installer over two
synthetic payloads and is repointed at it.

Two doc claims corrected while their sections were open, both false before this
change rather than because of it: how-it-works.md said app_startup.sh's
firmwareExe recovery "still works" -- it cannot, because it copies out of the
version directory and ours has had firmwareExe deleted from it -- and it
described a Moonraker preflight-and-rollback that went with the /usr/prog copy;
moonraker-preflight.py exists nowhere in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH7Yb4wuPhnppHAB25UX7e
2026-08-30 15:34:19 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c92b3c9e86 anvil.conf is gone; a component runs because it is installed
The file held three unrelated things behind one name: gates deciding which
services boot, switches for the splash / the startup wait / the first-boot
import, and tunables for the camera and the nice values. Every one of them
defaulted to on, and what each offered was a half-installed printer -- a state
nobody chose on purpose and everything downstream carried a second answer for.

firmwareExe no longer expands the ok-all bundle and filters it, it asks for the
bundle: BOOT_SET=ok-all. A printer that should not run a component does not
have it -- `opkg remove`, or a stock FlashForge package.

The settings worth keeping are each stated once now, in the service that uses
them: the camera's resolution in its run script, nice 10 there and nice 5 in
moonraker's (the values every install was getting from the template, not the
0 the inline fallback said). ff-startup's `up` stops passing --timeout and
--klipper-tries at all, because 240 and 6 are already ff-startup.py's own
defaults and two statements of a default can disagree.

Nothing sources the file, so nothing reads it, so it must not survive an
upgrade looking editable. run-append.sh stops preserving it and removes it
unconditionally beside init.d and anvil-service.sh -- unconditionally because
the pre-manifest sweep removes seven DIRECTORIES and would leave this file
sitting at the top of $MODDIR. The two upgrade tests that asserted the edit
survived now assert the file does not.

seed/ leaves anvil-core entirely, which takes with it the recipe's one
exception to "every sibling directory is shipped", the seed mount in the
replica plumbing, and its exemption in the .ipk allowlist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015E7armHFDcnZFZwdAbqkbt
2026-08-30 01:09:02 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 214a4e58b9 anvil-core owns firmwareExe and start.sh; kill the 3.8.2 myth
Three things, all downstream of klippy having moved to $MODDIR and our own
CPython 3.13.

SECTIONS 8 AND 9 ARE GONE. Both files install at $MODDIR/prog/ and
anvil-link-prog.sh points the stock paths at them -- from run-append.sh on a
flash and from anvil-core's postinst on `opkg upgrade anvil-core`. Nothing
execs either between run.sh distributing the component and link-prog running,
so the component copy bought no ordering. It cost, though: our firmwareExe
with no payload behind it logs "no anvil-env.sh", finds no s6-svscan and then
`while true; do sleep 3600; done` -- a printer that boots to a blank screen for
ever and never reaches app_startup.sh's own recovery. Absent, that recovery
restores a firmwareExe from a version directory. So patch.sh now REMOVES both
from the component: the stock component carries them, and deleting the two cp
lines alone would have shipped FlashForge's originals.

SECTION 5c IS GONE. It did no work -- two du prints and a comment whose first
line was the falsehood below.

THE FALSEHOOD. "klippy runs on the stock 3.8.2, started by
/usr/prog/klipper/start.sh" had spread well past patch.sh, and it had reached
things people follow:

  - docs/toolchange.md told owners to scp the ff_*.py extras to
    /usr/prog/klipper/klippy/extras/ -- the wrong partition -- and warned that
    an OTA would delete them there. Same wrong path in three ff-*.cfg headers
    and in ff_toolchange.py.
  - versions.env labelled our CPython 3.13 and its eighteen packages
    "SHIPPED BUT NOT USED".
  - docs/how-it-works.md said Moonraker runs on python3.8, contradicting line
    126 of the same file.

All corrected. Historical notes keep their history and get a [Superseded: ...]
marker instead -- including 80-s6-migration.md's "s6-rc-compile is not
shipped", which pkgs/3rdparty/s6-rc/build.sh contradicts deliberately.

Found while reviewing: every docs/ reference to the ff-*.cfg pointed at
pkgs/anvil-core/payload/config/, which does not exist -- they are
anvil-klipper-config's. 28 references across 9 files, several of them broken
markdown links on the published site. All 24 relative links under docs/ now
resolve.

Gates: verify.sh syntax-checks prog/start.sh and prog/firmwareExe out of the
payload (firmwareExe was never syntax-checked before) and fails if the
component carries either. A new replica test asserts both stock paths are
symlinks into $MODDIR/prog and do not dangle. One existing replica test stubbed
firmwareExe by redirecting into $FE, which now writes THROUGH the symlink into
the packaged wrapper; it moves the link aside instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01La5MaDKqTW675k5fsXsP6S
2026-08-30 00:37:28 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 6aa622d2ff 6,280 lines of case scripts reported 13 bits. They are 118 tests now.
test/run-tests.py is gone, and with it the bespoke harness that wrapped
pytest, re-parsed its JUnit XML, and drove thirteen replica case scripts to
get one boolean back from each. Every gate it ran is either a module under
qa/replica/ or deliberately retired.

PORTED. case-install.sh (579 lines, 1 bit) is test_install.py, 22 tests, asked
of the machine the printer's own app_startup.sh built rather than one the
harness assembled. case-upgrade.sh is 21, case-supervisor.sh 31,
case-boot-screen.sh 17, case-mcu-bringup.sh 8, and case-nginx.sh plus
case-moonraker313-s6.sh are test_web.py's 7.

RETIRED, subject deleted by the s6-rc switch: case-services.sh, case-nginx.sh,
case-moonraker.sh, case-moonraker313-s6.sh, case-camera.sh and
case-priority.sh drove $MODDIR/init.d/S* or sourced anvil-service.sh, neither
of which exists. Decided against real output -- `make test-services` failed
with "the payload ships no anvil-service.sh". case-pyext.sh and
case-moonraker313.sh went too: no gate, no target, no CI step, never run once.

RETIRED as a coverage decision: case-python.sh (test_web.py runs real Moonraker
on that interpreter, which is harder), case-libpath.sh, and case-recovery.sh.
The last is the brick-safety rollback and has no replacement; it is recorded in
docs/qa-migration.md rather than left to be discovered.

THREE ASSERTIONS HAD STOPPED MEANING ANYTHING, and each had been green for
months:

  * case-mcu-bringup.sh's negative control had INVERTED. It asserted the
    bring-up fails without LD_LIBRARY_PATH; CPython is --disable-shared now and
    needs no library path, so the case's own skip branch was the one firing.
  * case-upgrade.sh's headline assertion was VACUOUS. run-append.sh:150 does
    `rm -rf $MODDIR/init.d` unconditionally, outside the manifest branch, so
    "the update removed the stale S60web" passed regardless of the manifest
    logic it was written to test.
  * case-boot-screen.sh CONFIRMED ITS OWN ARITHMETIC, computing expected pixel
    packing by calling ffscreen._pixel inside the replica -- a swapped R/B
    matched itself.

case-install.sh's boot-log grep is not reproduced for the same reason: the log
line names every service in the boot set including the ones that then fail, so
grepping it cannot go red. The compiled database is asked instead.

TWO REAL BUGS FOUND ON THE WAY.

assemble.sh attached /stick.img with `losetup` and wrote the device to
/stick.loop, which NOTHING HAS EVER READ. Loop devices are the host's, not the
container's, so every USB_STICK=1 run leaked one permanently and a WSL2 kernel
has thirteen. Once they were gone every replica run failed with "failed to
setup loop device", which looks like a docker fault and is not. Detached now in
entrypoint.sh on the way out, and in qa's stop() for the held-open containers
that never take that path.

test_startup.py was 43 tests red: it built its argparse.Namespace by hand and
had never been taught --only-bringup / --no-bringup, and its FakeKlipper still
modelled start.sh plus a separate stop when klipper is restarted with one
`s6-svc -wr -t`.

CI AND RELEASE. The replica job was getting its package as a side effect of
run-tests.py running unpack/patch/pack; that is an explicit step now, as the
comment there predicted it would have to be. release.yml tested the exact files
it ships through sim-install.py, whose exit status the loop was discarding --
it is `REAL_PKG=<pkg> pytest ./qa/replica` per model now, 22 named results each.

WHAT IS NOT TESTED ANY MORE: bin/unpack.sh, patch.sh, pack.sh and verify.sh.
The packaging build on a synthetic stock package went with the runner and has
no counterpart in qa/. That is the path producing the .tgz a user flashes.
make-stock-fixture.sh is kept as the input a replacement needs.

Measured: qa/static 246 passed, qa/replica 110 passed and 8 blocked on hardware
the replica has not got (no /dev/ttyS*, no /dev/video*, so ok-all is
unreachable), test/ pytest 185 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BTAXgjtF8GdhqSxvdXCFW
2026-08-29 22:51:50 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c7b3a2a6c4 Catch up to master: the docs are a built site now
master rewrote the documentation into a nine-page manual with an mkdocs build,
and this branch forked before it. Five commits, and the conflicts were all
places where both sides had edited the same page.

.gitignore and .github/workflows/ci.yml keep BOTH sides: master adds the
docs/index.md generation and a `docs` job that runs `mkdocs build --strict`,
this branch adds the qa suite step. They are independent.

docs/hardware-testing.md and docs/toolchange.md take master's: it moved the
safety prose into docs/printing.md rather than deleting it -- checked, the
text is there -- so keeping our copy would have duplicated a page that CI now
link-checks.

The chamber-config and s6-rc rows this branch added to docs/toolchange.md
survived; they are in the table, which master did not touch.

Left for a follow-up rather than folded in here, so this merge stays readable:
docs/building.md still lists the seven init.d/S* scripts anvil-core no longer
ships, and testing.md and qa-migration.md still name S62moonraker, S60nginx
and S80ui. mkdocs --strict will not catch those -- it checks links, not
claims.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 20:07:34 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 abd4a01d73 Adopt the s6-rc boot graph -- DOES NOT BOOT YET, see below
Merge of qa-suite-bridgehead into the packaging line. The seven init.d/S*
scripts and the hand-written etc/s6 servicedirs are gone; anvil-core ships
etc/s6-rc/source/ and bin/patch.sh compiles it into etc/s6-rc/compiled/ after
the payload is installed. klipperDaemon joins firmwareExe and start.sh in
payload/prog/, all three linked into place by anvil-link-prog.sh.

WHAT IS KNOWN BROKEN. qa/replica/test_s6rc.py fails: the tree never comes up,
because s6-rc-init dies with

    unable to supervise service directories in /run/s6-rc/servicedirs:
    Value too large for defined data type

That is EOVERFLOW out of the readdir() loop in s6rc_servicedir_manage.c, which
checks errno after every entry. Measured, and these are ruled out:

  * not the live directory's filesystem -- fails identically with -l on
    /usr/data as with the default /run, and the inodes there are 3 and
    1358496, nowhere near 32 bits
  * not the database -- s6-rc-db lists all eleven services out of it, and a
    database recompiled inside the replica by the TARGET s6-rc-compile fails
    exactly like the one compiled natively on x86
  * not a missing -D_FILE_OFFSET_BITS=64 -- both s6-svscan and s6-rc-init
    reference readdir64 and never plain readdir

What is left is the toolchain. qa-suite-bridgehead built s6, s6-rc, execline
and skalibs with the Bootlin musl toolchain, where readdir has no EOVERFLOW
path at all; the packaging line builds them as recipes against the Ingenic
glibc, where it does. s6-svscan looked healthy only because it was started on
an EMPTY scandir, which never reaches the loop.

Everything else is green: 262 static tests, and bin/verify.sh passes on the
73MB package, including the graph's own "compiled s6-rc database" check.

Resolution notes: the five MOD_* switches stay deleted -- firmwareExe defaults
every gate to 1, so nothing changes -- but MOD_CAM is now worth having by hand
on a cameraless printer, because the camera is a longrun that waits out
timeout-up. anvil-service.sh went with init.d; nothing sourced it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 19:27:07 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5f36cd14e3 Say what the code does, not what it used to
Comment cleanup over the files this branch touched, plus the dead code the
last two commits left:

  * pkgs/lib.sh's pkg_buildopkg, 89 lines building the x86-64 opkg nothing
    calls any more, and the qa test that checked its configure flags
  * patch.sh's "5d. what is no longer here" section, and its mkdir of the
    prefix directories -- build-payload.py replaces $PAYLOAD_DIR wholesale, so
    they were created and then thrown away
  * the two emptied creator5*-config recipe directories

The rest is prose. Comments that narrated a previous design -- what the host
opkg did, which package used to own the chamber config, that prog/ was once a
sibling of payload/ -- are gone rather than shortened; the ones explaining
constraints that still hold are trimmed to the constraint.

docs/building.md and docs/toolchange.md describe the packages as they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 18:42:57 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5b463b889a Five switches that were only ever set to 1
MOD_WEB, MOD_CAM, MOD_UI, MOD_SSH and MOD_WIFI all defaulted to on, and what
each one really offered was a half-installed printer: Mainsail with no
moonraker behind it, a camera that would not stream, a screen dark on purpose,
a root password deliberately not set on a machine whose entire recovery story
is "ssh in". Nobody wants those states. What someone who sets one actually
wants is a stock printer, and that is one FlashForge package away.

What they cost was a second answer to every question. Each init script carried
a gate, each `down` file argued its existence from a flag rather than from the
ordering that was the real reason, and anvil.conf had five lines that told a
user they had a choice.

MOD_SSH was the clearest case: it never turned ssh off. dropbear is the
rootfs's own and listens either way, so all the flag did was decline to set a
password.

Each gate is replaced by the fact it was standing in front of, not by a
constant:

  S60nginx, S62moonraker  nothing -- the web stack is what this mod is
  S65camera               there is no video device, which the script already
                          finds out and reports
  S50wifi                 there is no $WIFI_CONF, likewise
  S80ui                   HelixScreen is not installed, which was already the
                          other half of the condition

To leave a piece out, leave it out: BUILD_MAINSAIL, BUILD_MOONRAKER and
BUILD_HELIX decide what goes into a build, and `opkg remove` decides what stays
on a printer. What survives in anvil.conf is what has a range rather than a
state -- MOD_CAM_RES/FPS/PORT and the NICE_* priorities -- plus MOD_SPLASH,
MOD_STARTUP, MOD_IMPORT and MOD_S6, which are untouched here.

The `down` files stay. They were argued from MOD_WEB and MOD_CAM, but the
argument never depended on them: s6-svscan starts a service the instant the
directory appears, which is before any init script has checked a prerequisite.
Their text now says so.

Measured: `make build` produces a payload with no live read of any of the five,
and qa-static is 265 passed with shellcheck over the edited init scripts. The
test suite still exercises the removed flags and has not been updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gKRUCqj18TVF1Vz1oMpmC
2026-08-29 15:24:36 +03:00
Oleksandr Shyshatskyi 53a2bc010b Five extras were on the firmware partition because of a flag
pkgs/klipper/prog/klippy/extras/ff_*.py sat on /usr/prog for one
reason, and it was not a technical one: anvil-klipper answers to
BUILD_KLIPPER and the extras answered to BUILD_TOOLCHANGE, and a
package cannot hold files gated differently from itself. So they stayed
outside every package, copied to the firmware partition by bin/patch.sh.

They are anvil-klipper's now, at payload/klipper/klippy/extras/, and
BUILD_TOOLCHANGE no longer gates them. An extra is inert until a config
section instantiates it, and those sections are anvil-klipper-config's,
where the flag still applies -- so the gate bought nothing and cost the
mismatch.

Inside the tree is the only place they can go: klippy resolves an extra
as dirname(klippy.py)/extras/<name>.py (klippy.py:90-103), with no
search path and no environment variable.

bin/patch.sh still copies them into the software component -- klippy
runs from /usr/prog until the s6 service lands -- but reads them out of
the payload now, the way section 1 already reads the klippy tree, so the
recipe directory is not a second source for a file a package holds. It
names the five rather than globbing ff_*.py: the fork ships an
ff_eddy.py of its own and the glob could not tell them apart. Verified
the component still gets exactly the same five files.

The flags are coupled by this, and patch.sh says so rather than shipping
a broken printer: BUILD_TOOLCHANGE=1 with BUILD_KLIPPER=stock now fails
with the fix in the message. It has to -- the extras are written against
the fork's v0.13 klippy, and dropping them onto FlashForge's v0.12 is
the half-overwritten mixture that shipped as v20260824. The fixture lane
in test/run-tests.py used exactly that pair (BUILD_KLIPPER=stock is a
no-network convenience there) and now sets BUILD_TOOLCHANGE=0.

pkgs/klipper/pkg.conf gains PKG_STAMP_EXTRA=$(pkg_payload_hash):
KLIPPER_VERSION says nothing about payload/, so without it an edit to
ff_toolchange.py would read as "already current".

make qa-static 261 passed; make test 9 gates, 0 failed. The .ipk carries
all five under klipper/klippy/extras/.
2026-08-29 13:26:09 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 65a7528e09 The manual assumed a printer that arrives broken. It arrives calibrated.
The pages written on top of the old README inherited its premise: that after
flashing you face a gauntlet -- go/no-go checks, a safety net, a mandatory
calibration -- before the machine can be trusted with a model. That is not
what happens. The factory numbers import themselves on the first boot, a bed
mesh loads at the start of every print, and the mod is built to run the files
the stock firmware ran. A printer that printed before the flash prints after
it. "Your first print" now says so in its first line, and everything under it
is optional, ordered by what you would actually reach for: check the offsets
are applied, and calibrate only if that disagrees with the machine.

Three audiences now, because owner and contributor was not the real split.
"For experienced users" sits between them: How a print runs, and How
calibration works. Someone who knows Klipper and wants to change what happens
around their file has a place to go that is neither a walkthrough nor the
build system.

What was cut is most of what changed. Pages that were cryptic to the reader
they were aimed at -- Printing, Upgrading -- are out of the nav and kept on
disk to be rewritten rather than deleted. Within the pages that stayed:
Expected output, Probe sampling, What is being measured, the symptom table,
the dark-screen diagnosis, TOOL_Z_ADJUST, PLATE_CHECK, the ff-*.cfg
inventory, [ff_legacy] and SAVE_CONFIG in a paragraph about factory
calibration. Filenames and internals earn their place on an owner page only
when the reader has to type them or look at them; the rest moved to the
experienced pages or to Internals, where the audience wants them.

Calibration became a section rather than a page, because tool offsets are not
the only calibration: bed mesh has its own page now, and its own gotcha
written down -- BED_MESH_CALIBRATE writes `default`, a print loads
`MESH_DATA`, so a mesh probed by hand is a mesh no print will use. Input
shaping is described but has no procedure page yet.

Several claims came out because they were not true. "Is this for you" listed
calibration and input shaping as things the mod gives you; both are the
touchscreen's own sequences, recovered and reimplemented, and the notes say
so plainly. What the mod actually gives you is that anything speaking
Moonraker can drive the machine -- on stock, FlashForge's Klipper does not
toolchange at all, it raises a flag and waits for an application that is not
watching a Mainsail print, and the print hangs at the first T2. The page now
opens by telling most owners not to flash it.

Contributors are still deferred. Their pages are regrouped, not rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr
2026-08-28 23:33:29 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a6098c7f64 The README was the manual. It is a front door now, and the manual is nine pages.
Fourteen sections, install through troubleshooting, in the one file that
existed when there was nowhere else to put them. The site gave the manual a
home; this moves it there and orders it by what the reader is doing rather
than by which subsystem owns the answer. Someone whose printer will not boot
should not have to know that the symptom table lives in a file called
hardware-testing.md.

Three topics were documented two and three times over, and each copy was free
to drift: install in README and toolchange.md, calibration in README,
calibration.md and toolchange.md, slicer setup in README and toolchange.md,
"before you start" in all three. Each is now in one place.

The owner path is Is this for you -> Installing -> After the flash -> Your
first print -> Nozzle-offset calibration -> Printing -> Upgrading -> When
something goes wrong, with error codes, versions and how-it-works behind a
Reference group. The README keeps what a front door keeps: what this is, the
status checkboxes, what you give up, which file to download, and where to go
next.

toolchange.md was four documents in one 38 KB file. The owner half -- slicer
setup, filament, runout, the offset check before a first print -- is in
Printing and Your first print; the design notes, input shaper, roadmap,
HelixScreen and reverse-engineering notes stay, and the page moves to
Internals. hardware-testing.md was split down the middle by audience the same
way: the go/no-go checks and the symptom table were the owner's, and only
keeping the two verification files honest as the macros change is ours.

Two things this fixes rather than moves. The error-code table was
notes/70-error-codes.md, an internals note the README sent owners to; it is
docs/error-codes.md now, a page. And toolchange.md still opened "Not yet run
on hardware" while the README's checkboxes described a Creator 5 Pro that has
been printing for weeks -- the two pages have disagreed since the first
hardware run.

Contributors are deferred, as agreed: those pages are regrouped, not
rewritten. The nav says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3XZh4SqPdXAowVndz6tMr
2026-08-28 22:20:19 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 dff6fe3198 anvil-core was holding four other components' configuration
It held all of it for one reason: it was the first recipe written, so
everything of ours started there. With the files visible per recipe the
misfilings read straight off the tree.

    moonraker.conf              -> pkgs/moonraker, with the server it configures
    ff-*.cfg, printer.base.cfg  -> pkgs/klipper-config (new)
    printer.chamber.cfg         -> pkgs/klipper-creator5-config and
                                   pkgs/klipper-creator5pro-config (new)

PRINTER.BASE.CFG WAS IN pkgs/klipper AND THAT WAS WRONG TWICE. It is Klipper
CONFIG rather than any part of the Klipper program -- it is the file carrying
[include ff-toolchange.cfg] and [include printer.chamber.cfg], so it is the
entry point to everything in the config package -- and pkgs/klipper is gated
on BUILD_KLIPPER while this file is installed under BUILD_TOOLCHANGE. A
BUILD_KLIPPER=stock build has no pkgs/klipper recipe at all and was still
reaching into its prog/ directory for a file.

THE ff-*.cfg ARE NOT IN pkgs/klipper EITHER, for the same gate: ask for
BUILD_KLIPPER=stock and that recipe does not exist, and the toolchanger would
have become a silent casualty of a flag about which klippy is shipped.
FlashForge's own klippy loads ff_*.py and reads these .cfg perfectly well.
They travel with the feature they configure and the feature's flag is
BUILD_TOOLCHANGE.

THE MODEL IS A PACKAGE NAME NOW, not a build flag. printer.chamber.cfg.creator5
and .creator5pro used to be picked by patch.sh from TARGET_MACHINE and copied
under their real name, which made the model a property of the BUILD: two
builds of one commit produced different bytes under one version number, and
nothing in the feed or on the printer recorded which machine a package was
for. Both are built on every build; patch.sh stages the one this tarball is
for. This is the feed's first use of Provides/Conflicts, both on a virtual
anvil-klipper-chamber-config -- Provides so anvil-klipper-config can depend on
"a chamber config" and either satisfies it, Conflicts so a printer cannot
install both and have two packages own one path with opkg letting whichever
unpacked last win. Neither field alone expresses "exactly one", and
printer.base.cfg's [include printer.chamber.cfg] is unconditional, so "none"
is a printer whose klippy does not start.

printer.chamber.cfg also CHANGED WHERE IT LANDS: $MODDIR/config, like the
ff-*.cfg, instead of riding the software component to /usr/prog and reaching
/usr/data/config only because the stock run.sh copies that whole directory.
run-append.sh overwrites it on the same mod-owned terms, and by this repo's
own rule -- files the update overwrites must say so -- it now carries the
DO NOT EDIT banner the ff-*.cfg have always had. test_config_ownership.py is
what demanded that, which is what it is for.

TWO FLAGS BECAME HONEST, and both are behaviour changes:

  * BUILD_TOOLCHANGE=0 shipped the toolchanger's .cfg anyway, because they
    rode in anvil-core while everything else the flag controls was gated.
    It no longer does.
  * BUILD_MOONRAKER=0 overwrote the printer's moonraker.conf with one written
    for a server it was not going to install -- [webcam anvil] and all. It no
    longer does, which is what docs/building.md already said the flag meant.

WHAT DELIBERATELY DID NOT MOVE. init.d/ and etc/s6/ look like they belong to
the services they start, and moving them would brick a printer: those services
are gated at RUNTIME by flags in anvil.conf, while the packages that would own
the scripts are gated at BUILD time. A BUILD_KLIPPER=stock tarball has no
anvil-klipper package, so it would carry no S70klipper and nothing would start
Klipper at all. The script that reads a flag must not sit behind a second,
different flag. nginx.conf stays for a plainer reason: nginx is the rootfs's
binary and there is no package here to give it to.

Measured:
  * 44 packages (41 + three config packages), feed builds clean.
  * anvil-moonraker carries config/moonraker.conf; anvil-klipper-config the
    seven ff-*.cfg; each model package one printer.chamber.cfg. Checked by
    listing the built .ipk, which is how the first attempt was caught staging
    moonraker.conf and never shipping it -- pkg_ship takes an explicit list.
  * make test: 8 passed, 0 failed, including unpack/patch/pack/verify.
  * bin/verify.sh gained two gates over the payload -- a chamber config and
    the ff-*.cfg -- and BOTH WERE PROVEN BY BREAKING THE BUILD: deleting the
    staging line fails patch.sh's own check, and bypassing that too fails
    verify with "mod payload has no config/printer.chamber.cfg". Which is
    also the proof that a normal build carries them.
  * make qa-static: 256 passed. make test-py: 181 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:24:54 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 f67e7a269f ls pkg/ was thirty-eight entries and four of them mattered
pkg/ -> pkgs/, and the thirty-four recipes that build a pinned tarball and
carry no files of this repo move down a level into pkgs/3rdparty/. What is
left at the top is the four components a person actually edits: anvil-core,
klipper, moonraker, helixscreen.

THE LINE IS MECHANICAL, NOT EDITORIAL, and that is why it can be a directory
rather than a field in pkg.conf. "Do we actively modify it" drifts -- the day
somebody patches zlib nobody would agree which side it belongs on, and the
answer would be a rename touching CI and docs decided by taste. "Does it carry
files of ours" is a fact about the tree: it is exactly whether the recipe has
a payload/, prog/ or seed/, checkable in one assertion, and
test_recipe_layout.py now demands the move in both directions -- a 3rdparty
recipe that grows one of the three must come up, and a top-level recipe with
none of them must go down, or the top level slowly refills and the split stops
meaning anything.

ONE FUNCTION KNOWS THE TWO DEPTHS. pkgs/lib.sh's pkg_dir resolves a recipe id
to its directory and nothing else spells either path -- not a recipe, not
bin/build-packages.sh, not a test. Names stay unique across both levels
because pkg_out, pkg_stamp and the .ipk filename are all keyed by the bare
name; a duplicate would resolve to whichever level is searched first and
build the wrong thing silently, so that is a test too.

Recipes now source `. ./bin/common.sh` instead of counting `..` from $0.
Depth-counting would have needed two spellings for two levels, and it was
never buying anything: `. pkg/lib.sh` on the next line already required the
working directory to be the repo root.

A SECOND SILENT-SHRINK BUG, and this one was live. qa/static's _files()
expanded a list of globs and returned whatever matched, so "pkgs/*/build.sh"
quietly stopped covering thirty-four recipes the moment they moved -- the lane
stayed green, and the only visible sign was a test count dropping by 34 in a
run nobody was counting. _files() now refuses a pattern that matches nothing.
That is the same failure the ash-compatibility check had a guard for and the
syntax check did not, which is why the guard reads the way it does.

AND ONE PACKAGE WAS SHIPPING PYTHON BYTECODE. anvil-core stages a directory of
.py helpers wholesale, so any test that imported one left a __pycache__ beside
it -- gitignored, invisible in a diff, copied by cp -a like anything else. The
package's contents depended on whether pytest had been run on the machine that
built it: two builds of one commit, different sha256, cause nowhere in the
tree. What it shipped was worthless as well as unstable, compiled by the build
image's python 3.11 for a printer running the 3.13 this repo cross-builds. The
sweep is in pkg_ship now, once, and the two hand-written copies in
pkgs/klipper and pkgs/moonraker are deleted -- the trap is set for any recipe
that stages a tree of .py files, and the two recipes that had hit it had each
solved it locally while the third, which had not, was the one shipping.

Measured:
  * the rename changes no package bytes. Same cached build trees, feed built
    with the diff stashed and unstashed: all 41 .ipk match.
  * with the bytecode sweep, anvil-core returns to exactly the bytes the
    previous commit produced.
  * no package in the feed contains a .pyc or a __pycache__.
  * make qa-static: 247 passed (236 before this work + 11 layout tests).
  * make test: 8 passed, 0 failed, including unpack/patch/pack/verify on the
    synthetic stock package. make test-py: 181 passed.

Not explained: anvil-python-dev produced two different sha256 from an
UNCHANGED work/pkg/python at two points today, under code that was identical
both times -- so it is not reproducible from a cached tree, and it is not this
commit's doing (the stashed and unstashed builds above agree on it). Worth a
look; the dev split in bin/build-packages.sh is where to start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:04:20 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 54a1e72cc4 A file that ships belongs to a package, and the path should say which
payload/ was organised by DESTINATION -- init.d/, bin/, etc/s6/,
klipper/config/ -- plus an assets/ directory of three .conf files. That was
right when there was one owner. With 38 recipes it held three different kinds
of file behind identical-looking paths:

  * files a recipe stages into its .ipk,
  * files that go to /usr/prog and CANNOT be in a package of ours, because
    every path in one lands under $MODDIR,
  * files that never ship as files at all -- run-pre.sh and run-append.sh are
    spliced into FlashForge's own run.sh at build time.

The only thing recording which package owned any of it was a thirty-line
comment at the top of pkg/anvil-core/build.sh. A comment cannot be checked.

Files now live with the recipe that owns them, and the subtree says how they
ship: payload/ is staged into the .ipk laid out exactly as it lands under
$MODDIR, prog/ is placed on /usr/prog by patch.sh, seed/ is templated or
seeded user state. installer/ sits outside pkg/ and holds the two files that
are never files on a printer. qa/static/test_recipe_layout.py fails if a
fourth kind of directory appears, which is the failure this is actually for:
a misfiled file is caught by a build, an unruled directory is caught by
nothing.

anvil-core's recipe is a loop over a directory now. Six pkg_stage lines with
hand-written destinations plus a glob became one loop, because payload/ IS the
overlay -- a file added to that tree ships without an edit to build.sh.

A REAL BUG FELL OUT. anvil-core's PKG_STAMP_EXTRA was an eight-path list
naming payload/ and assets/ subtrees by hand, and one of those paths was
payload/helixscreen/ -- HelixScreen's printer-database entry, hashed into the
cache key of the package that does NOT contain it. Editing that json rebuilt
anvil-core and left pkg/helixscreen sitting in the cache. pkg_payload_hash is
now the one way a recipe keys its own files and it can only hash its own.

WHAT ELSE MOVED, and why each is not where it looks like it should be:
  * ff-*.cfg stay with anvil-core rather than pkg/klipper. They are the
    package that has always shipped them; ownership moves in the next commit,
    with the two model-config packages. This one is a move, not a repackaging.
  * printer.base.cfg is pkg/klipper's prog/, not payload/: it is FlashForge's
    file with one block replaced and it lands beside their Klipper tree.
  * moonraker.conf is anvil-core's for now; moonraker-custom.conf is
    pkg/moonraker's seed/, because it is the user's and a package member is
    overwritten on every upgrade by definition.

The cfgdir fixture is a merged copy of both config directories rather than a
path into the checkout. The split is a fact about which package ships what --
on the printer there is no split, the stock run.sh force-copies
printer.base.cfg into /usr/data/config/ beside the ff-*.cfg, which is why its
includes are bare and relative. A test about include resolution has to model
where files END UP or it is testing the repository's filing system.

qa/lib/replica.py's one payload mount is three, and entrypoint.sh reassembles
/tmp/payload out of them byte-for-byte as it was. Not re-pointed: every
case-*.sh copies out of /tmp/payload with 2>/dev/null, so a path that stops
resolving does not fail -- it copies nothing and the case runs green against
an empty $MODDIR.

Measured:
  * all 41 .ipk byte-identical. anvil-core and helixscreen were rebuilt from
    HEAD and from this tree and compared directly; the other 39 match the
    feed built before the move.
  * that comparison caught a mistake in this commit: the HelixScreen json was
    mirrored one directory too high (helixscreen/printer_database.d/ instead
    of helixscreen/config/printer_database.d/). A move that changes no bytes
    is only a claim until it is diffed.
  * anvil-core: two cold builds byte-identical.
  * make test: 8 passed, 0 failed -- including unpack/patch/pack/verify on the
    synthetic stock package, which is the gate that covers patch.sh end to end.
  * make qa-static: 244 passed (236 + 8 new). make test-py: 181 passed.

Known gap, pre-existing and left alone: pkg/*/payload/bin/*.sh is in no ash
glob, so wifi-action.sh's dialect is unchecked. The old glob was payload/*.sh,
one level above it, so this is not a regression -- it belongs with the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:50:30 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 057a3a11d7 The boot was seven scripts in filename order. It is one compiled graph now.
firmwareExe ran $MODDIR/init.d/S* one at a time in the foreground, and that
loop WAS the boot order. Three of the seven were already thin wrappers over a
supervisor that was already running, so what the loop still carried was not
supervision, it was sequencing -- expressed as filenames, in a directory
nothing but the loop read.

This ships s6-rc and moves the sequencing into a database compiled at build
time. firmwareExe now starts the scanner as its own child, initialises s6-rc,
asks for one machine state, and blocks on `wait "$SCANNER_PID"`.

WHAT THE GRAPH ACTUALLY SAYS

    mcu-bringup -> klipper -> ff-startup -> ui
                                  ^
                              moonraker

    ok-all = wifi nginx moonraker camera klipper ff-startup ui

Those are the first real edges the plan has ever had; it predicted they would
not arrive until phase 7. bin/ff-startup.py became two services, which is what
made them possible: the MCU bring-up has to precede klipper and the readiness
wait has to follow it, and both facts now live where a program can read them.

Deliberately still absent: nginx before moonraker. S60nginx's header wants it,
but dependencies.d/nginx under moonraker means `s6-rc -d change nginx` stops
moonraker too -- the exact failure splitting S60web into two scripts prevented.
The cost of not writing it is a second where a reloading browser gets a refused
connection instead of a 502.

AND WHAT THEY DO NOT SAY. klipper has no notification-fd, so s6-rc calls it up
when it is forked, not when klippy is usable. ff-startup keeps its own poll of
moonraker's API. moonraker and camera DO carry one, so their readiness is real.
The asymmetry is written down at each site rather than glossed.

init.d/ IS DELETED, REVERSING PHASE 4

Phase 4 promised the S* scripts would survive as thin wrappers, because
`S60nginx restart` is in people's hands. They did not survive, and the reason
is the argument nginx/run already made: a service that only works when a shell
script happened to run first is not supervised, it is lucky. Every prerequisite
a wrapper did belongs in the run script anyway, because s6 restarts a service
without going near a shell. Once they moved, the wrappers were verb translation
in a directory whose S<NN> numbers meant nothing. s6-rc is the interface now.

anvil-service.sh goes with them -- 625 lines whose last caller was firmwareExe,
and a shared library for one consumer is the redundancy the wrappers were.

So do the fallbacks. S60nginx and S62moonraker each carried a second,
unsupervised implementation reachable only via MOD_S6=0; that switch is gone,
and with it the argument for a code path exercised only on broken printers. The
cost is stated plainly in anvil.conf: a printer whose s6 fails now has nothing.

klipperDaemon is replaced with a no-op `start`. It is FlashForge's script and
off-repo callers remain -- stock start.sh, the UI's restart path, habits over
ssh -- and each of them would fork a second klippy beside the supervised one.

WHAT SUPERVISES THE SUPERVISOR, ANSWERED

Phase 3 left this open and guessed a dead scanner was a dead printer either
way. It is not. firmwareExe's tail used to be `while true; do sleep 3600; done`
purely to satisfy app_startup.sh's watchdog, which greps ps for our name 5s in.
It waits on the scanner instead, so exiting IS the recovery: app_startup.sh --
FlashForge's own init, which we never patch -- re-execs us and the tree comes
back. Not a respawn loop in ash, which phase 3 rightly refused. It restarts
everything rather than the scanner alone, and that is written down.

MEASURED, NOT READ OFF THE MANUAL

Host- and target-compiled databases are byte-identical (db md5
80f5261437191fa591744ada6087c6f7 both ways), so the database is built here and
shipped. /run is tmpfs and writable, so the live directory needs no fallback.
Both were BLOCKING UNKNOWNs in the note and are now settled sections.

Three traps the replica found that reading could not:

  * s6-svscan resolves s6-supervise through PATH. Without $MODDIR/bin on it the
    scanner answers s6-svscanctl -a perfectly and then fails every spawn with
    ENOENT for ever, and s6-rc-init HANGS rather than failing.
  * s6-rc-init creates one symlink per service in the scandir and dies with
    "File exists" if the name is taken. So the cutover is not additive, and
    run-append.sh sweeps.
  * s6-rc-compile does not reject a `down` file. It accepts it and SILENTLY
    DROPS it -- the servicedir is byte-identical. "Down by default" can only
    mean absent from the bundle, which is why mcu-bringup is the one service
    outside ok-all and arrives as klipper's dependency.

execline turned out not to be optional: s6-rc GENERATES two servicedirs whose
run scripts are execline, and every oneshot's `up` goes through one of them.
The binary list was read off a real compile, not guessed -- seven execline
binaries of 53, plus six s6 ones we built and did not ship.

Two build bugs fell out. fetch-assets.sh compared against a stamp patch.sh
never wrote, so every build pulled the ~100MB toolchain the skip exists to
avoid; S6_STAMP is one string in common.sh now. And `chmod +x .../bin/s6-*`
would have missed all seven execline binaries.

qa: 83 static. The suite gained the s6-rc run scripts and klipperDaemon, which
no glob had ever covered, and lost a dead payload/init.d/S* glob -- exactly the
silently-retired check that file's own docstring warns about. The oneshots'
up/down are NOT in it: they are execline carrying shell inside a /bin/sh -c
argument, so both checkers would parse the wrapper and pass.

Not verified here: no config.env and no stock package on this machine, so the
mipsel cross-build of execline/s6-rc and the ABI gate over 33 binaries have
never run. The native compile and the database are proven; that half is not.
The replica gate is the next commit, and it still owes an answer on whether
helix-screen runs under s6-supervise at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:09:58 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 eea7025596 The per-tool frame exists before anything asks for it
__init__ called refresh_offsets() nineteen lines before it built the
transform that refresh_offsets() reads to decide whether gcode_move's
position cache needs invalidating. The read is guarded by `changed`, and
nothing has changed only when every derived offset is zero -- so the
section parsed on a factory-fresh machine and raised AttributeError on any
printer that had ever been calibrated. Which is also why the suite missed
it: the replica boots uncalibrated, and the transform tests build the
object with __new__. Both are now pinned.

While in there: the three offset series were one list bound to three names.

The job term gets its own slot. It shared homing_origin with the operator's
babystep, and SET_GCODE_OFFSET Z=0 -- what the end/cancel block issued --
cannot tell them apart, so ending a print wiped the babystep too. It is a
term of the tool frame now, set absolutely by TOOLCHANGE_SET_PRINT_OFFSET
and dropped by its new CLEAR=1, which moves nothing and so no longer needs
END_PRINT's homed/unhomed fork. Reported as toolchanger.print_z_offset,
because a macro converting a G-code Z to a machine Z now has three layers
to subtract, not two.

And a way back from an aborted toolchange, which leaves the frame off with
a tool still on the carriage -- a bare G1 Z0 then aims 3.2 mm into the
plate. INITIALIZE_TOOLCHANGER re-applies the frame, as upstream's does,
touching no dock; TOOLCHANGE_STATUS says which frame is on; and applying an
uncalibrated tool's frame says so at the moment it goes on rather than only
at startup. _log_transform_chain now checks that bed_mesh and
skew_correction really did end up below us instead of only printing the
chain. TOOL_Z_ADJUST no longer claims "in force now": it is
SET_GCODE_OFFSET Z_ADJUST without MOVE=1, so it lands on the next Z move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 16:04:01 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 43cfccb016 TOOL_Z_ADJUST applies now and saves when asked
Taking effect and persisting were one act, and they should not be.
SAVE_CONFIG is a restart, so a per-tool trim that needed one could never
be made during a print -- which is precisely when you know what the
number should be. Staging on every tweak also left the config dirty for
anyone dialling one in by feel.

Now the value reaches the tool's frame immediately and stages nothing;
SAVE=1 also writes it for the next SAVE_CONFIG. Nothing moves, as with
SET_GCODE_OFFSET Z_ADJUST without MOVE=1: the frame shifts and the next
move lands in it, which is what babystepping into a live print has to do.

This is only possible because the per-tool offsets are a move transform
now (previous commit). While they lived in gcode_move's single offset,
"live" meant rewriting the number the operator's babystep also sat in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:08:49 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c9eaadf939 Per-tool offsets move below Klipper's G-code offset
Tool XYZ was folded into gcode_move's homing_origin, which is ONE number
per axis. Everything awkward about the old code followed from sharing it:

  - _z_tool_term, a shadow copy of which part of that number was ours, so
    a grab could subtract the old tool's term and add the new one;
  - reapply_offsets_after_external_zero(), which existed because probing
    zeroes the offsets and the bookkeeping then pointed at a term that was
    no longer applied;
  - SET_GCODE_OFFSET Z=0 -- issued by the end/cancel block, and the
    natural thing for anyone to type -- wiping the tool's ~3.2 mm
    nozzle-to-station gap along with the babystep;
  - HelixScreen showing homing_origin[2] under a "baby stepping" label
    while it actually held gap + job terms + babystep.

klipper-toolchanger solves this with a move transform under gcode_move,
and it is the right shape here too: move() adds the mounted tool's
offsets, get_position() takes them off, so G-code coordinates stay
tool-independent and M114 keeps reporting the frame the file is written
in. Selecting a tool is one assignment plus reset_last_position(). The
tool INDEX is stored rather than the offsets, so refresh_offsets() reaches
the live frame on its own.

Three independent layers instead of one shared number: SET_GCODE_OFFSET
is the operator's (babystep, and the job terms), the transform is the
tool's measured calibration plus its z_adjust.

TOOLCHANGE_SET_PRINT_OFFSET therefore sets the JOB terms only -- temp,
bed, thin layer. The gap and z_adjust are the per-tool frame, already
applied by every T<n>, and adding them again would double them. The X/Y
it used to write go for the same reason.

Two consequences worth naming. The docking dance ran in machine
coordinates by zeroing X/Y with SET_GCODE_OFFSET; that no longer reaches
the tool layer, so it sets the frame to None instead -- and on a failed
grab it stays None, which is the truth. The offset calibration has the
same problem and needs both layers out of the way, so ff_tool_offset
gained _enter_raw_frame/_leave_raw_frame: probing that ran with the
previous tool's offsets still applied would measure numbers that look
entirely plausible and are wrong by exactly those offsets. Leaving the
raw frame is now in a finally, so a probe that raises no longer strands
Z=0 at the eddy plane.

The transform installs at connect, not config time, so anything
registering in its own __init__ (bed_mesh, skew_correction) ends up below
us -- the tool offset has to shift X/Y before bed_mesh looks up the mesh
Z for that point. force=True is how every transform after the first
registers and it is silent, so the resulting chain is logged at ready.

TOOL_Z_ADJUST still stages a config write; the live/save split is the
next commit, so a position regression here bisects on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:07:10 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a22f3b04ef Tool calibration speaks klipper-toolchanger's G-code
STATION_CALIBRATE is TOOL_LOCATE_SENSOR and TOOL_OFFSET_CALIBRATE is
TOOL_CALIBRATE_TOOL_OFFSET, with upstream's call shape as well as its
names: no TOOL= (the mounted tool is the one measured, so SELECT_TOOL
first, as upstream's macro does) and no PLATE_REMOVED=.

The renames are not just conformance. Upstream says "run
TOOL_LOCATE_SENSOR with tool 0" because tool 0's nozzle is the only
thing on their machine that can reach the pin -- their baseline is a
tool, so tool 0's own error rides into every other tool. Here the
carriage's probe element sits above the tool mount and reaches the
station directly with nothing installed, so the command means what it
says: locate the sensor, with no tool. That baseline cannot be spoiled
by a badly seated T0, which is what lets nozzle_x/y/z stay absolute and
per-tool independent.

PLATE_REMOVED=1 goes because the plate check that runs anyway is the
better guard: it parks the carriage and MEASURES whether the plate is
there, where the flag only asked the operator to promise. Dropping it
also removes the last non-upstream required parameter.

Losing TOOL= means losing TOOL=ALL, so CALIBRATE_TOOL_OFFSETS loops over
printer.toolchanger.tool_numbers -- the same shape as upstream's macro.
GRAB=/RELEASE= go with it: they cannot mean anything once the tool has
to be mounted already. The plate check still needs an empty carriage, so
the command parks the selected tool and picks it straight back up.

Probe sampling gets klipper-toolchanger's parameters -- SAMPLES,
SAMPLES_TOLERANCE, SAMPLES_TOLERANCE_RETRIES, SAMPLES_RESULT,
SAMPLE_RETRACT_DIST, PROBE_SPEED -- on both commands and in
[ff_tool_offset]. The fork's e_stop ALREADY samples (sub_cycle_cnt
touches, spread rejected above error_v, retried main_cycle_cnt times,
averaged); what was missing was reaching those numbers from a command
and a median. So _estop runs that loop itself over the fork's
single-touch _probe, every default is read off the [e_stop <axis>]
object, and an unparameterised command probes exactly as it did before.
Spread is judged after each touch, as the fork does, so a round already
known bad is not finished. Two things the fork does not bound are
bounded here: the 9999 move-failure sentinel it spins on forever, and an
e_stop with no _probe, which falls back to run_probe as one sample
rather than cubing the touch count.

Also fixes _plate_check reaching for z_trigger where it had bound
station_z -- a NameError left by the rename sweep in 4704ca0, on the one
path that runs before a nozzle is allowed to descend, and now the only
guard that the plate is off.

docs/calibration.md is the operator's side of all of it: the order, what
each command prints, and every refusal it can raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:49:14 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c7a821bb9d ff_mcu_bringup.py is importable by name
The filename was the only reason for the importlib shim: a module called
ff-mcu-bringup cannot be written as an import, so ff-startup.py loaded it from
a spec and hand-rolled the failure handling. Renaming it removes all of that
and leaves a plain guarded import beside the one ffscreen already had. start.sh
still runs it as a script, by path, on the fallback route -- a rename costs
that nothing.

What the shim was hiding is worth keeping visible, though: these imports
resolve only because python puts a SCRIPT's own directory on sys.path, which
is true when the wrapper runs the file by path and false the moment anything
loads it another way. So there is now a --selftest that reports what it can
actually reach, and the replica gate calls that instead of loading the module
itself -- which it was doing before, and which would have passed while the
real path was broken. It is worth having on a printer over ssh too.

The unit tests were making the same mistake in the other direction: loading
ff-startup.py through importlib with payload/bin absent from sys.path, so its
siblings were missing and the selftest correctly said so. The loader now puts
that directory on the path, which is what the runtime looks like, and a
separate test runs the script from another directory entirely to prove the
premise rather than assume it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:23:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 44306b52a9 ff-startup.py owns the boot: bring-up, klipper, and the retry that fixes it
The status file is gone, and so is the reason for it. ff-mcu-bringup.py is
imported and called directly, with a progress callback where the file used to
be -- no second process, no format to agree on, no half-written reads to cope
with, and the panel still names the board being waited for.

That only works because this program now starts klipper too. The bring-up has
to happen before klippy opens the serial ports, and the retry that actually
rescues a board which missed its window is "hand it over again, then reopen"
-- so the thing doing the handshake has to be the thing restarting klippy.
Split across two processes, this program could only watch, through a file,
work it was not allowed to do. It runs start.sh with FF_SKIP_MCU_BRINGUP=1 so
the handshake is not done twice, and asks moonraker whether klippy came up
rather than tailing printer.log for markers, which is all a shell script had.

It also stops waiting out the full timeout on a klippy that has already
reported an error: a failed connect is final until something reopens the
ports, so sitting there only delays the retry that can fix it. And the fault
frame appears once, at the end -- one shown between attempts would be
replaced two seconds later and read as a failure the printer then ignored.

init.d/S70klipper stands aside when this program is going to run, checking the
same three conditions the wrapper does, and does the whole job the old way
when any of them is false. The wrapper then force-starts it if ff-startup
returned and left no klippy behind. Klipper starting is the single most
important thing the mod does; it must not come to depend on a python
interpreter, and now it does not.

The replica confirms the part that cannot be checked here: FlashForge's 3.8
really does load ff-mcu-bringup.py through importlib by path, and names all
three boards. 22 frames, still byte-identical host to replica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:19:50 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5798606e48 The pre-HelixScreen program waits for the boards, and says which one
It only ever ran on a first boot, and only to migrate. Every other boot it
exited at its stamp in microseconds and HelixScreen started immediately --
against a klippy that was very often still hunting for its MCUs. What the
owner saw then was a UI reporting a disconnected printer, with nothing
anywhere naming the board that had not answered.

So ff-firstboot-import.py becomes ff-startup.py, with the migration demoted to
its second job. Every boot it now waits: first for ff-mcu-bringup.py, which
start.sh runs alongside it, to finish handing the toolhead boards over from
their bootloaders, then for klipper and moonraker to be ready. Only after that
does it look at the stamp and decide whether there is a migration to do.

Naming the board needed a source. ff-mcu-bringup.py now publishes what it is
still working on to /tmp/ff-mcu-bringup.status -- rewritten only when the
outstanding set actually shrinks, renamed into place so a reader never sees
half a file, and on a tmpfs so it cannot survive a reboot and be mistaken for
this boot's. The panel turns the serial port into something an owner can act
on: WAITING FOR THE HEATER BOARD, not /dev/ttyS4. When klippy fails outright
its own state_message is the only place the guilty board is named, so that is
parsed too and becomes THE HEATER BOARD DID NOT ANSWER.

The MCU wait is one we are happy to abandon. A bring-up that never reports --
because it has not started yet, or wrote something malformed, or is simply
absent -- times out into the klipper wait rather than holding the boot: klippy
is the real authority on whether a board answered, and it has a far better
message than we could write. Tests pin that, including a parametrised sweep of
malformed status files.

The two halves of that file format are checked against each other rather than
against a copy of it written down twice.

MOD_STARTUP=0 skips the whole thing; MOD_IMPORT=0 keeps the wait and skips
only the migration. 20 frames, still byte-identical host to replica.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:04:07 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 04aa29a898 The boot screen draws the right way up on a panel that is mounted sideways
Everything drawn so far would have appeared rotated 90 degrees on the real
machine -- a screenful of text running bottom to top, which is worse than the
black screen it replaced.

The framebuffer is PORTRAIT 480x800 at 32bpp; the panel is landscape 800x480,
and the display is that buffer turned 90 degrees clockwise. FlashForge's own
boot splash settles it: /usr/prog/start.img is 1536000 bytes, written with
`cat start.img > /dev/fb0`, and it decodes into a picture at 480x800x4 and at
no other reading -- taken as landscape it comes out as five sheared copies.
Decoded correctly it is their "Upgrading, please wait..." screen, upright only
after a clockwise turn. So the size assumed until now, 1024x600, was wrong in
both dimensions AND in orientation.

Drawing stays in landscape coordinates and _rect turns each rectangle on its
way into the buffer. A right-angle rotation maps an axis-aligned rectangle to
an axis-aligned one, so this is a few lines of arithmetic per rectangle and
nothing per pixel -- rotating finished frames instead would have meant a
per-pixel Python loop over 384000 pixels per repaint on a 1GHz MIPS core. A
portrait framebuffer is taken to mean a turned panel, which needs no config to
be right here, and rotate= forces it either way.

Both renders now show what the eye sees rather than what memory holds, and
both replica lanes agree with the host byte for byte across all eight frames
at 800x480. The gate confirms the frame is 1536000 bytes -- the same number
start.img has, which is the whole argument in one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:28:06 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 56cf907447 The boot screen can be looked at, rendered by the printer's own python
"It works" was resting on assertions about byte counts and pixel colours,
which is a poor way to review something whose entire job is to look right.
So there are now two renders of the same eight frames.

make boot-screen draws them on the host in about a second, no docker.
make boot-screen-sim draws them inside the replica -- FlashForge's own
python3, MIPS under qemu, the printer's real rootfs -- and pipes them out as
base64 PNGs, because every mount the replica gets is read-only and stdout is
the only way anything leaves it.

The two agree byte for byte across all eight, which is the part that matters:
it means the fast host preview is a faithful stand-in for the panel, and
reviewing a layout change does not need docker at all. The replica render is
there for when that claim needs re-establishing.

case-boot-screen.sh, the gate, ran green against the same replica: the
importer starts under the printer's interpreter, ffscreen imports beside it,
the frame is exactly one screen at stride 4096, background, title and bar all
land, clear() blacks the panel, and a missing framebuffer stays a no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:19:53 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 ac4f72a9d7 The first boot says what it is doing instead of showing a black screen
The migration runs before HelixScreen and can take minutes: it waits for
klipper to find its MCUs, which on this machine routinely needs a restart or
two, then makes a SAVE_CONFIG that restarts klippy again. All of that happened
with nothing on the panel. A black screen through the longest wait of the
install reads as a brick, and the thing people reach for is the power switch
-- which is the one action that can leave the config half written.

bin/ffscreen.py paints straight onto /dev/fb0: a 5x7 font compiled into the
file, geometry read from sysfs rather than assumed, no toolkit and no
dependencies, because this runs before anything that could provide one. The
importer drives it through each phase with a progress bar, and hands the
panel back black so HelixScreen starts on its own splash.

None of it is allowed to matter. Panel.say swallows everything and retires
the screen on first failure, an unfamiliar pixel format draws nothing rather
than guessing, and a test asserts the migration completes identically against
a screen that throws on every call -- which is how that boundary got found.

Geometry can also be passed explicitly. The replica mounts the HOST's /sys
read-only and makes /dev/fb0 a plain file, so probing there would describe a
developer's monitor; case-boot-screen.sh passes the panel size instead and
checks the frame is exactly one screen, has its background, title and bar,
and that clear() leaves it black -- under FlashForge's own python3 on qemu,
which is the half no amount of reading can settle. Wired up as `make
test-boot-screen` and into `make test`.

Also `make boot-screen`: renders every phase to PNGs with nothing but the
standard library, so a layout change can be looked at without a printer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:15:22 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 1fd374e42f The first-boot import stops depending on the web UI
The health gate required Mainsail to be serving on :80 before it would run
anything, which was wrong in two ways at once. It is not something this path
uses -- both commands travel over moonraker's API on :7125 -- and it is not
something that reliably exists: the browser UI is a build-time choice, so a
Fluidd build or a plain MOD_WEB=0 printer would have sat waiting out the
timeout on every boot and never migrated its factory calibration.

Klipper and moonraker are now the only hard requirements. The browser UI is
still waited for, but with its own short grace period (30s) and no power to
veto: on a normal printer that keeps the import ahead of the first person to
open a browser, and everywhere else it just gets skipped. --mainsail became
--webui, --webui '' skips the wait entirely, and the wrapper passes that when
MOD_WEB=0 so nothing even knocks on port 80.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:01:04 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 97caf27e1c The factory-calibration import moves out of klippy
Importing this unit's nozzle/dock/station numbers out of firmwareExe's JSON
is a Creator-5 chore that happens once per INSTALL, and it lived inside a
general-purpose Klipper extra on a klippy:ready hook -- the one moment klippy
cannot know whether the rest of the machine is up. It also had no memory of
having run: auto_import re-decided itself on every ready by guessing from
"has anything been calibrated yet", so the lifetime was wrong in both
directions.

It is now bin/ff-firstboot-import.py, its own program with one job, which the
firmwareExe wrapper runs ahead of HelixScreen. It exits on a stat() of its
stamp (every boot but the first) or when there is no firmwareRes JSON at all;
otherwise it waits until klipper, moonraker AND Mainsail are all answering,
sends FF_IMPORT_FIRMWARE_CONFIG and SAVE_CONFIG over the moonraker API, rides
out the klippy restart that save causes, verifies the values came back saved,
and only then stamps. It stands down if a tool is already calibrated or if
someone else's SAVE_CONFIG is pending -- neither is ours to overwrite.

Nothing short of a verified save writes the stamp. The heater board on this
machine routinely needs several klippy restarts before it answers, so "the
stack was not up in time" is a normal event to retry on the next boot, not a
failure to record forever. It runs in the foreground before the UI because
the save restarts klippy, and doing that behind a HelixScreen already showing
a printer is worse than making the splash wait; MOD_IMPORT_TIMEOUT bounds it
and MOD_IMPORT=0 turns it off.

BREAKING: [ff_legacy] auto_import and auto_save are gone, and klipper refuses
to start on an unknown option -- anyone who set either one in printer.cfg has
to remove it. FF_IMPORT_FIRMWARE_CONFIG itself is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 19:59:19 +03:00
Oleksandr ShyshatskyiandClaude Fable 5 64ea5a84a9 The first boot imports the factory calibration and keeps it
auto_import already loaded firmwareExe's per-unit numbers whenever no tool
had a saved nozzle position -- but only live, so every boot re-imported,
TOOL_OFFSET_STATUS nagged about an unsaved calibration, and the SAVE_CONFIG
was homework for the user. Now the first successful import persists itself:
ff_legacy schedules SAVE_CONFIG one reactor callback after klippy:ready, the
restart that save causes lands seconds into the first boot -- before
HelixScreen is anywhere near its setup wizard -- and the saved nozzle
positions are exactly what stops the import from ever running again.

Guarded on three sides: nothing is saved when something else was already
staged before the import (not ours to commit), when the JSON put no tool on
its feet (a restart with nothing to keep), or when auto_save is off in
[ff_legacy], which restores the old live-but-unsaved contract. A failed save
never restarted klippy, so there is no loop behind any of it.

The extras finally get a klippy-free unit lane:
test/integration/test_ff_legacy.py stubs the four objects ff_legacy touches
and pins the once-ness, both guards, and the failure path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 23:06:03 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 ac558f6686 Document the Moonraker swap honestly, and finish its doc coverage
The reversibility claim was the strongest promise in the README and a40bc86
quietly invalidated it. Moonraker is not in the update package -- it ships only
on the factory image -- and run-append.sh deletes the stock tree it moved aside
once the copy succeeds. So a stock reflash cannot restore it and nothing local
can either. README and how-it-works now say so plainly, with BUILD_MOONRAKER=0
as the way to opt out and the ssh one-liner that repairs an interrupted swap.
Per the decision taken, the swap itself is left as it is.

Two claims that rested on it are corrected rather than repeated:
bin/common.sh no longer offers a stock reflash as a whole uninstall, and
testing.md's test-recovery row says which three files it actually compares
instead of "back to stock byte-for-byte".

Moonraker was also missing from eight doc surfaces that still described a
two-asset world: the pipeline box, the third-party section and its bump recipe,
MOONRAKER_TGZ as a local-copy override, the versions.env line in the layout
tree, `make vendor`'s help and the comment above it, config.env.example, the CI
step name, and printer-replica's "genuine from the factory image" list.

Other cross-document contradictions found this pass:

* versions.env carried the same wrong bump recipe that was fixed in
  building.md last pass -- it needs the sha256 set to SKIP first.
* docs/building.md said `make build` is "all four" while listing five scripts;
  build.sh does not run verify.sh.
* toolchange.md's image resolved to docs/docs/molten-toolhead.jpg -- broken on
  GitHub -- and still said ff-legacy.cfg is included "only for the import step,
  then remove", which printer.base.cfg contradicts.
* hardware-testing.md stated the random root password unconditionally, in the
  one document a person reads while standing at the machine.
* notes 30 and 00 kept claims their siblings now contradict (servo_min/max,
  motor_value), and 47 pointed at a note that does not exist.
* "nozzle-to-eddy-trigger gap" survived in two places after being corrected in
  a third; the station is not the eddy coil.
* ff-toolchange.cfg's restore_axis note had the hazard backwards -- parking
  zeroes X and Y, not Z -- and called `xy` the default when the option is unset.

54 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 b5982b09bb Fix eleven defects the first pass introduced
An adversarial re-read of master..HEAD, checking the new text rather than
trusting it. The substantive work held up -- all eight dead-code removals prove
out, SAFE-MODE->MOD_UI is complete, A3's reordering is correct -- but eleven
claims were wrong, and one made a document worse than it found it:

* docs/notes/10-hardware.md said `temperature_sensor motor_value` on
  eboard:PA1 is "not in anything this repo ships". It is:
  printer.motor.cfg:37 declares it and that file is in the package, which is
  also why ff-toolchange.cfg can name MOTOR_STOP. The real nuance is that
  printer.base.cfg does not include printer.motor.cfg. A correct claim in
  25-app-vs-klipper-ownership.md was "corrected" into a false denial.
* docs/toolchange.md pointed at a worked example that reaches 0.125 mm. The
  only example is NOZZLE=220 BED=80, which gives 0.045; the 0.08 bed term
  needs a bed at 100 or above. Both numbers now stated, no forward reference.
* docs/testing.md said 48 tests; pytest collects 53.
* test_config_ownership.py cited bin/patch.sh:80/:88/:128 -- correct on
  master, shifted by two when this branch deleted MARK_BEGIN/MARK_END. The
  line numbers are dropped rather than re-pinned, so they cannot rot again.
* test_includes.py invented a reason for ff-legacy being last ("so its import
  sees every [ff_tool] already declared"). ff_legacy resolves tools at
  klippy:ready; order does not matter.
* fetch-assets.sh's replacement usage text omitted BUILD_MOONRAKER and was
  less accurate than the vague line it replaced.
* 49-runout's sensor table still claimed `extruder: extruderN` after the A1
  fix made that true only of our override, not of the stock file it describes.
* firmwareExe kept "newest version directory" after the same claim was
  corrected in how-it-works.md.
* 25-app inverted the preflight call order and omitted that an M23 print gets
  neither call.
* creator5-safe-moves.gcode called the prepare's G1 Z10 "the one move" below
  Z50; the G28 and offset move before it also go below.
* DOC-DRIFT-AUDIT.md asserted both "Every finding has been applied" and
  "Nothing in this file has been applied", and cited five commits after a
  hash the rebase had rewritten.

53 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:44 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 d05565284d Sections E, F, G: wrong values, stale claims, overclaims
E -- values and counts. The START_PRINT prologue has no M400 and an idle
timeout of 864000, not 1800000. The nozzle-clean temperature comes from the
material table FIRST, so NOZZLE=240 with no per-tool temp cleans at 220 --
documented backwards before. TOOLS is the paired 0:220,2:240 form. The wipe Z
is the raw eddy frame, not the homed one. The runout M117 strings are
"T<n> clog" / "T<n> out of filament", not E-codes. Grab-verify raises
E0051+tool, not E0143. Docks are at X~297; 250/280 are staging waypoints. The
grab sequence is documented in the order _grab actually emits it. ff_print
exposes seven status fields, not nine, and never emits TOOLS=. Package sizes
reconciled against the real artifacts (62MB built, ~93MB stock, ~370MB of CI
assets, ~60s not 90s). testing.md's sample output block matched no header the
code prints, so it is replaced with the real ones; its pytest row listed two
of six suites.

F -- stale claims. _FF_PREFLIGHT is not "missing". SDCARD_SET_CHANNEL is not
still issued (764af2a). The app and Moonraker do not coexist; the app is gone.
TOOLCHANGE_STATUS has nothing to compare against since the JSON reader was
removed. The JSON is re-imported every boot until a nozzle is saved, not once.
The 23-check render harness was deleted in 91604c2. PURGE now wipes by
default. 50-print-lifecycle.md gained the section on how the sequences are
actually entered now -- [ff_print], not the slicer. The dead "profiles"
concept is gone from three files, one of which printed it on every replica run.

G -- overclaims. The README no longer says a stock reflash puts the printer
back "exactly as it was": it says what is restored and what survives inert,
which is what test-recovery actually asserts. The random ssh password is
conditional on ROOT_PW_HASH being unset. verify.sh no longer warns "ssh will
not start" on every build for a dropbear we deliberately do not ship -- the
check now reads the way round it means. case-install.sh's read-only block says
it asserts nothing. An uncalibrated tool contributes z_adjust, not zero, in
three operator-facing places. offset_base does not cancel out in X/Y. The
safe-moves file's Z50 promise is scoped to its own lines, with the implicit
prepare's G1 Z10 spelled out.

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:43 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 ce06b7e88d Take initial_WHITE back out of printer.base.cfg; the plate check is one-sided
A7: f10d002 put initial_WHITE: 1.0 in printer.base.cfg as well as
ff-chamber.cfg, and recorded the cost -- bin/unpack.sh diffs our copy against
each stock package to notice FlashForge changing a pin map or a stepper
current under us, and initial_WHITE is a real option line, so the warning
fired on every unpack. A warning that always fires is not read.

The line is removed instead of filtered: ff-chamber.cfg is included after
printer.base.cfg and already sets it, sections merge under
RawConfigParser(strict=False), so the light still comes up lit and the base
file is FlashForge's again -- which is what its own header, and
ff-chamber.cfg's, have been claiming all along. Restored byte for byte,
trailing whitespace included, and checked through unpack.sh's own strip()
filter: strip()-equal to the pre-f10d002 file, so the check is quiet.

A4: the plate-removed guard is `zp > expected + plate_z_tolerance` -- an
upper bound, not the +/-0.8 mm band three places described. That is right: a
build plate can only hold the probe HIGH, so a low reading is not a plate.
Documented as one-sided in docs/toolchange.md, ff-tool-offset.cfg and
ff_tool_offset.py rather than made symmetric.

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:42 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 9b1f944293 Ship the ff-*.cfg includes in printer.base.cfg
The package installed every file the mod needs and then relied on the user
to hand-edit printer.cfg with seven [include] lines. That was the one
install step nobody could automate, because printer.cfg is the user's file
and run-append.sh may not write it -- so a freshly flashed printer had all
seven configs sitting in /usr/data/config/ and not one of them loaded, and
came up as a plain printer with no toolchanger and nothing saying so.

printer.base.cfg is ours, and the stock run.sh force-copies it to
/usr/data/config/ on every flash -- the same directory as the ff-*.cfg, so
relative includes resolve. Putting them at its end wires the mod up at
flash time and makes the undo button complete: flashing the stock
FlashForge package restores its own printer.base.cfg and the includes go
with it, leaving the ff-*.cfg as inert files rather than as a config
referencing extras that are no longer in /usr/prog.

It also retires the trap in the old instructions. "Append to printer.cfg"
is wrong for any printer that has run SAVE_CONFIG: an [include] below the
"#*#" marker makes Klipper read the whole autosave block as corruption
("modifications after header") and discard every calibrated value -- PID,
mesh, shaper, nozzle and dock positions.

Most of the documented ordering rules turn out not to exist. Nothing needs
to follow [virtual_sdcard]: virtual_sdcard registers SDCARD_PRINT_FILE and
M23 in __init__, ff_print takes them over at klippy:connect, and by then
every section is loaded. That was true of the old gcode_macro wrapper and
stayed in the docs after the move to ff_print.py. gcode_macro's
rename_existing defers to klippy:connect too, so the G28 and
SHAPER_CALIBRATE wrappers do not need their targets declared first either,
and ff-print-macros.cfg reads _FF_FILAMENT through a runtime template
lookup rather than at parse time. What survives is override order:
ff-runout.cfg after printer.filament.cfg's sensors, ff-chamber.cfg after
printer.chamber.cfg. End-of-file satisfies both.

ff_print.py's error message said "must be loaded after [virtual_sdcard]",
which pointed at the wrong problem; it can only fire when there is no
[virtual_sdcard] at all.

Both halves are checked on the replica now: case-install.sh asserts the
includes and files are present after a flash, case-recovery.sh asserts a
stock flash takes the includes away again. test_includes.py covers the
wiring in the repo -- every shipped ff-*.cfg included, every include
resolving, and the block after everything it overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:48:35 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5bf57cb75a ff_toolchange: RESTORE_AXIS, and three more upstream commands
Stage 3 -- RESTORE_AXIS on the change itself. We registered SELECT_TOOL
and UNSELECT_TOOL but never read the parameter, and Klipper does not
error on unknown parameters, so a config ported from klipper-toolchanger
silently lost its position restore.

Resolved inside _toolchange so T<n> and TOOLCHANGE -- what a file
actually issues -- honour it too, defaulting to [ff_toolchange]
restore_axis, itself empty: nothing is restored unless asked, which is
how this machine has always behaved.

A G-code position is captured and replayed, not a machine one, so it
reads back through the offsets in force after the change and the new
tool's nozzle lands where the old one was. X/Y first, Z last, so the
nozzle is never dragged across the part. Z after a park is the sharp
edge and is documented as such: parking zeroes the tool offsets, so the
same G-code Z is ~3.2 mm of machine Z away.

Stage 4 -- SET_TOOL_TEMPERATURE, VERIFY_TOOL_DETECTED and
SELECT_TOOL_ERROR, which UIs send and we answered with "Unknown
command". Upstream's docking-mode and tool-parameter commands stay
absent: calibration here is TOOL_OFFSET_CALIBRATE / STATION_CALIBRATE /
TOOL_Z_ADJUST, already tied to the factory numbers.

Untested on hardware: RESTORE_AXIS moves the machine and is off by
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:11:58 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 71044d5867 Drop runout_distance and its watcher
The deferred pause was the one thing here Klipper cannot express natively,
and it cost a delayed_gcode re-arming itself every second for the length of a
print, three scratch variables living in the middle of the policy knobs, and
a fourth branch in _FF_RUNOUT -- all to save filament the app also wasted, and
only once someone measured their own switch-to-gear path and set the number.
It ran on nobody's machine at the default of 0.

_FF_RUNOUT_CFG is now two toggles that nothing writes to, so "edit this to
configure" and "the macros scribble here" are no longer the same object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:02:15 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 1d8f3091dd Say what happened instead of quoting the app's error codes
E0162 / E0163 are FlashForge's numbers, meaningful to someone reading the
stock firmware's tables and to nobody standing at the printer. The pause
messages now say what happened and what to do, and the two cases stop sharing
one string: a clog does not want "LOAD_FILAMENT and RESUME", it wants the jam
cleared. The codes stay in the comments and notes that record what the app
did -- that is provenance, not user-facing text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:02:15 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 15ef06dcef Ship [ff_print], fold _FF_PRINT_END into END_PRINT, drop the origin flag
Three things the ff_print move left behind.

[ff_print] was declared nowhere. The module shipped (bin/patch.sh copies
ff_*.py) and the docs promised a stock Orca profile would work, but the
section that instantiates it existed only as a commented-out snippet in a
header and was missing from the required-includes list in docs/toolchange.md.
Following the docs got you the module on disk and nothing wrapping
SDCARD_PRINT_FILE -- so a stock profile printed from its own start block,
whose first motion is G1 Z5 F2400 on an unhomed machine. Declared in
ff-print-macros.cfg, which is already required and already included after
[virtual_sdcard].

_FF_PRINT_END existed only so END_PRINT and CANCEL_PRINT could share a body.
END_PRINT is the public exit macro, symmetric with START_PRINT, so it holds
the body and everything calls it.

_FF_JOB.mainsail told CANCEL_PRINT whether we started the job, because
FlashForge's touchscreen sends CANCEL_PRINT itself and then runs its own
motion cleanup that ours must not double. HelixScreen starts prints through
Moonraker exactly as Mainsail does, and the stock UI is not one this mod
ships, so the flag guarded a caller that no longer exists -- along with a
documented corner case where an abnormal end left it stuck at 1.

Plus the text those moves invalidated: the orphaned "WHY THIS WRAPPER EXISTS"
block in ff-toolchange.cfg (its FIXME described four SDCARD_* lines deleted in
848db02), _FF_REQUIRE_CALIBRATION / _FF_REQUIRE_TOOLS in six doc sites (they
merged into _FF_PREFLIGHT), the doc rows for both cfg files, and a missing
table row for ff_print.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:02:15 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 801c772c21 ff_toolchange: align the compat views with klipper-toolchanger
Four field-level differences against viesturz/klipper-toolchanger:

  - detect_state said 'present'; upstream's DETECT_PRESENT is "mounted".
    A stock toolchanger UI read every tool as unavailable.
  - toolchanger lacked name, detected_tool, detected_tool_number and
    has_detection. We keep no commanded tool state, so detected_* is
    the same derived value as tool* -- reported anyway, because a UI
    that only reads detected_* must still see a tool.
  - tool lacked toolchanger, heater and extruder_stepper.
  - the header comment claimed detect_state was omitted on purpose;
    it has been emitted from the grab sensor all along.

No behaviour change: the views are read-only and every other addition
is a new key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:10:22 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 91604c2dfb Cut the suite to four gates, and rewrite the Python half as pytest
Twelve gates, most of them the weaker copy of a gate that survives. What is
left: pytest for the Klipper config, and three replica gates -- the end-to-end
install, MCU bring-up, and stock recovery.

Fix first, because the cut depends on it: a skip counted as a pass. Every
launcher exits 0 when it decides it cannot run, and sub() reported that as
`ok`. That is how test-abi.sh sat in the suite printing green while checking
nothing -- run-tests.sh deleted work/modpayload immediately before it ran and
CI set KLIPPER_FORK="", so it had no targets on any run, ever. Skips are now
counted apart, the summary says so, and the suite exits non-zero unless
ALLOW_SKIP=1 says the gap is deliberate. With four gates instead of twelve, a
gate that silently does not run is the failure mode that matters most.

Dropped, each because something else already does the job properly:

  test-abi.sh              bin/patch.sh refuses to build a non-nan2008
                           c_helper.so, bin/verify.sh re-checks the packaged
                           one, and case-install.sh reads the ELF header of
                           the file that actually landed on the machine.
  test-ash-conformance.sh  case-install.sh runs `sh -n` over every installed
                           script with the same qemu'd busybox and then greps
                           the boot log for "Syntax error".
  test-model-gate.sh       verify.sh SS8b/SS9 checks the same invariant and
                           pack.sh exits on MODEL MISMATCH before a package
                           exists. Its header claimed it proved the two models
                           ship different files; no such check was in it.
  test-macros.py           It used jinja2.Environment() -- default '{{ }}'.
                           The configs have no '{{' at all, only single-brace
                           expressions, so it checked '{% %}' block structure
                           and never looked inside an expression. Folded into
                           the chamber tests, which had the delimiters right.
  test-base-cfg.py         It read work/software/klipper/config/printer.base.cfg
                           as its stock reference -- the file bin/patch.sh
                           overwrites with ours. It was diffing our file
                           against itself: green on a cold tree, red on a
                           second run, blind to real drift either way. The
                           comparison moves to bin/unpack.sh, where a pristine
                           tree exists, as a warning rather than a gate.
  lint-danger.sh           Its structural greps were satisfiable by comments:
                           firmwareExe:44 and S80ui:16 *mention* helix and
                           SAFE-MODE, so they passed with the logic deleted.
  case-ui.sh               Goes with the helix-only decision.
  applets.allow            Ten entries, none of which the extractor had asked
                           about in a long time.

The command-word half of test-applets went too. It extracted only the first
word of a simple unprefixed command, so `if timeout 5 foo; then` yielded
['if','echo','fi'] -- the one failure its docstring named was invisible to it.
The absolute-path half survives: S50wifi's binaries sit behind a
[ -d /sys/class/net/wlan0 ] guard no simulation ever reaches, so nothing else
checks they exist.

The Python half is pytest now. Real assertions instead of hand-rolled
PASS/FAIL counters, underscored filenames so the modules import normally
(ffcfg keeps only the parser; the dash in test-macros.py was why one test used
to reach the other's parser through importlib), and a `rootfs` marker that
splits the tests needing the proprietary package from those that do not --
deselected in the PR lane, run in the replica half, so neither lane reports
skips it did not expect. test_default_delimiters_are_blind pins the jinja2 bug
so it cannot come back.

The config gate also moves out of the replica half into the half that runs on
a plain pull request. It needs python3, jinja2 and files in the repo, and it
is the only safety-relevant check in the suite -- a chamber heater declared on
a Creator 5 that has no element for it -- so it had no business being gated
behind the proprietary firmware.

Verified: pytest 21 passed / 5 rootfs-marked; the fixture pipeline
(unpack, patch, pack, verify) passes end to end; injecting a broken
single-brace expression fails test_every_macro_parses with the exact line, and
parses clean under the old defaults; the skip path reports SKIP and exits 1,
and ALLOW_SKIP=1 accepts it.

Also drops the CI `abi` job, which ran on a bare checkout with no binaries and
so was permanently green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:00:12 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 2ad7304397 Drop assets/orca and the last four fork-only calls
The Orca profile is left stock, so there is nothing to ship for it: assets/orca
held copies of FlashForge's own start/end blocks, which a user already has and
is told not to touch. Removed, along with the two docs/toolchange.md references.

The four remaining fork-only calls in _FF_PRINT_END are gone too. Each undid a
line-rewriting override that only FlashForge's virtual_sdcard ever performed,
and that nothing in this tree switches on:

  SET_FAN_M106[P2] ADJUSTED= FACTOR=  added FACTOR*255/100 to every M106 the
                                      fork passed through (fork lines 498-504)
  SET_PA_ADVANCE T0..T3 ENABLE=       replaced the file's own
                                      SET_PRESSURE_ADVANCE with a per-tool
                                      value (fork line 479)
  MUTE_MODE_DISABLE                   never a command at all, here or in the
                                      fork: mute was M204 S61166 (0xEEEE), a
                                      sentinel accel read in toolhead.py that
                                      clamped max_accel

Upstream rewrites nothing, so the file's own M106 and SET_PRESSURE_ADVANCE
apply as written and there is no override to clear. Nothing in payload/ ever
set ENABLE=1 or ADJUSTED=1, and HelixScreen does not send any of them, so no
behaviour is lost. Should a UI want live fan, PA or quiet overrides they are
new features against upstream Klipper, not end-of-print cleanup -- noted where
the calls used to be.

That is the last of them: no SDCARD_*, SET_PA_ADVANCE, SET_FAN_M106[P2] or
MUTE_MODE call remains in payload/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:21 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 35aa61fccb Keep the Orca profile stock; prepare from the file by default
The decision is that a FlashForge Orca profile is not edited at all: no
START_PRINT pasted into Machine start G-code, and files sliced before the mod
keep printing. So FF_BEFORE_PRINT_START.prepare is now 1, and [ff_print] runs
the whole of START_PRINT before the file's first line.

The stock block needs exactly that. It prepares nothing: M140 and M104
without waiting, fans, M191, then `G1 Z5 F2400` as its first motion on a
machine nothing has homed, then T<n>, M109 and the prime line. No G28, so
nothing homes; no M190, so nothing waits for the bed. The touchscreen app did
both before handing the file over. Its end block is one move -- no heaters
off, no M84, no park -- so FF_AFTER_PRINT_END is what ends the job.

assets/orca/*.txt now hold the stock blocks themselves, as the reference for
what the mod expects rather than something to paste. The previous contents
were a modified start block carrying a START_PRINT call, which is no longer
what a user should have.

docs/toolchange.md said to paste both files into the profile; it now says to
leave the profile alone, and documents prepare=0 plus an explicit START_PRINT
as the way to drive the sequence from the slicer instead. Its TOOLS=/TEMPS=
paragraph is updated for the paired TOOLS=0:220,2:240 form and for clean
temperatures now coming from _FF_FILAMENT.tool_material.

The two prepare=0 cases stay documented on the variable: a profile that calls
START_PRINT itself, and one that homes for itself -- a generic Orca block runs
G28 after M140/M104, and the G28 wrapper docks a mounted tool before homing Z.

Untested on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:03:18 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3b7da4489b Rename the printer-database drop-in: it covers both Creator 5 models
The file gained the non-Pro entry, so flashforge_creator5_pro.json no longer
says what is in it. The Pro's entry keeps its id -- ids are what HelixScreen
merges on, and renaming one would orphan any saved printer type.

Nothing resolves this file by name: PrinterDetector scans
printer_database.d/*.json and merges by id, and bin/patch.sh installs the
directory with a glob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cBeTWFQunbuEEg51ifhhZ
2026-08-23 22:20:14 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3369a5ee1b Creator 5: ship a chamber config with no heater, not a disabled one
The plain Creator 5 (PID 0028) has a chamber thermistor but no heating
element. That is the ONLY functional difference between the two stock
firmwareExe builds: the Pro's carries "SET_HEATER_TEMPERATURE
HEATER=chamber_heater" in CommMgr::heatManager case 5 and this model's does
not. Everything else that differs between the two binaries is branding.

Both models ship the same Klipper tree, so printer.base.cfg declared
[heater_generic chamber_heater] either way and Mainsail would happily let
someone set a target on a machine that cannot heat -- ~15 min later
verify_heater calls invoke_shutdown, mid-print. Declaring the heater anyway
and refusing the command only covered the commands we wrap: PID_CALIBRATE
HEATER=chamber_heater went straight past it.

Klipper config can override an option but cannot un-declare a section, so a
heater that does not exist has to be absent from the file. Ship our own
printer.base.cfg -- FlashForge's with the chamber block replaced by
[include printer.chamber.cfg] -- plus one small file per model:

  printer.chamber.cfg.creator5pro   their [heater_generic] + [verify_heater]
  printer.chamber.cfg.creator5      [temperature_sensor chamber], same pin

Nothing is edited at build time. Anything that differs between models exists
once per model with a .creator5 / .creator5pro suffix and is installed under
its real name; the suffixed names deliberately do not match the ff-*.cfg
glob, so a variant cannot leak into the wrong package.

Carrying a copy of someone else's 377-line pin map is only safe if something
proves it is still their file, so test-base-cfg.py reconstructs the stock one
from ours plus the Pro's chamber file and compares section for section. A
FlashForge change to printer.base.cfg now fails the build instead of being
silently overridden by a stale copy. (It is unchanged between 1.9.7 and
1.9.8.)

ff-model.cfg is gone: GetStatusWrapper implements __contains__, so the macros
ask Klipper whether the object exists rather than being told by a flag --
nothing left to keep in sync. ff-chamber.cfg still earns its place with the
heater absent: a typed command should fail on an unknown heater, but an M141
in a sliced file must not abort the print, so it warns and continues.

Known: the stock UI on a non-Pro subscribes to "heater_generic
chamber_heater" and will now read 0 for chamber temperature. Harmless --
webhooks._do_query substitutes {} for missing objects -- and Mainsail still
reads it through temperature_sensor chamber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cBeTWFQunbuEEg51ifhhZ
2026-08-23 22:08:27 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5bdf78109a Retire firmwareExe: own the web stack, and say what goes with it
The mod replaces FlashForge's application outright, so the reasoning that
still framed the web stack as coexisting with it was wrong on its own terms.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:50:32 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8df9baf179 Do the MCU handshake in Python, from start.sh; ff-*.cfg are mandatory
Replaces the shell handshake with payload/bin/ff-mcu-bringup.py and moves
the call into start.sh, next to the bring-up for the other two boards:

    /dev/ttyS2  mcu           cmd_mcu bootup
    /dev/ttyS4  eheaterboard  ff-mcu-bringup.py   <- was nobody's job
    /dev/ttyS5  eboard        checkEboard
    /dev/ttyS7  levelboard    ff-mcu-bringup.py   <- was nobody's job

start.sh is the right home: it is the file whose job is starting Klipper,
it already exports the Python environment, and it runs again on every
restart S70klipper issues -- so a board that missed its bootloader window
gets another chance without any extra plumbing. S70klipper keeps only the
supervision loop.

The interpreter is not a new dependency: klippy is Python, so if that
interpreter cannot run there is no Klipper either.

Verified under qemu-mipsel against the printer's own Python 3.8.2 with a
pty standing in for the board: "Ready" -> 'A' -> 0x06, a silent board gets
nothing written to its port at all, and a missing device is skipped.
Python reports termios.B115200 == 0x1002 and VMIN/VTIME == 4/5 on MIPS,
matching the constants in firmwareExe's bootSerial* routines exactly.

Also: the ff-*.cfg set is mandatory, not a menu of optional drop-ins --
nothing works without them. Says so now, in the configs and in
docs/toolchange.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cBeTWFQunbuEEg51ifhhZ
2026-08-23 20:52:21 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 801cd4e897 Drop the implicit start-G-code layer; keep START_PRINT and END_PRINT
Reverts the ff-orca-compat.cfg approach from 3fa8d33. The idea was to let
FlashForge's stock slicer blocks run untouched by hooking homing onto
SDCARD_PRINT_FILE and the print Z offset onto M109. It worked for the
offset but not for the job as a whole.

What killed it: START_PRINT is not mostly about the Z offset. T<n>
already applies the ~3.2 mm gap on every grab, absolutely, so the offset
command only adds three small job terms -- thermal (+0.045 mm at 220 C),
hot bed (+0.08 at >=100 C), thin layer (-0.06 at <=0.10 mm). What
START_PRINT really contributes is the sequence around them: G28, the bed
mesh, M190 to wait for the bed, the re-home of Z once the bed is hot, the
tool-presence gate and the nozzle clean.

The stock block has no M190 at all -- the app waited for the bed before
handing the file over -- so the implicit layer would have started
printing on a cold bed. That is a first-layer adhesion failure with no
error message, which is a worse outcome than the pasted macro call it was
trying to save. Reproducing the rest of the sequence at hook points that
do not line up with it would have meant reimplementing START_PRINT badly.

So: one path, explicit. The Orca blocks call START_PRINT and END_PRINT,
which do the work in the right order and in one readable place.

Kept from the reverted work, all independent of which path is taken:
test/test-macros.py and jinja2 in the build image, and a homed-axes guard
on TOOLCHANGE_PARK in _FF_PRINT_END -- reached from CANCEL_PRINT after an
error the axes may be unhomed, and raising there would abort the rest of
the cleanup (heaters, fans, offset reset). Skipping the park is safe: the
G28 wrapper docks a mounted tool before homing Z on the next job.

Suite is 30 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 18:44:32 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 827730761f Run FlashForge's stock slicer start G-code unmodified
Migration cost was a re-profiled slicer: every Mainsail print needed an
edited machine start G-code carrying START_PRINT BED=/TOOL=/NOZZLE=/
LAYER=, because the touchscreen app used to do the print preparation
itself and the stock block therefore assumes a prepared machine. Files
sliced with the stock profile printed ~3.2 mm low, or aborted outright.

Three things stood in the way, and each has a place to hook that the
stock block already provides:

  M191 S0     not a command in this fork -- the app intercepted it and
              there is no controllable chamber heater. Now an accepted
              no-op that says so once. M141 likewise.
  G1 Z5       arrives before anything has homed. Homing and the bed mesh
              move into the SDCARD_PRINT_FILE wrapper, which runs before
              the file's first line, so the move is legal.
  Z offset    M109 is the last command before the prime line and comes
              after T<n>, so by then every value is known: NOZZLE from
              its own S, BED from heater_bed.target (the file's M140),
              TOOL from its T. It applies the offset once per job.

No M104/M140 overrides: the targets are already in printer state, so
only M109 and the new M191/M141 are touched, all gated on _FF_JOB.mainsail
so touchscreen prints are untouched.

Layer height is the one value the stock block never states, and it turns
out barely to matter: TOOLCHANGE_SET_PRINT_OFFSET uses it only as a
threshold (-0.06 mm at or below 0.10 mm, nothing above), so at 0.12
through 0.25 the assumed 0.2 is exactly right. _FF_AUTOSTART.default_layer
covers thin-layer profiles, and the explicit START_PRINT block, kept as
machine-start-gcode-explicit.txt, still passes the real value along with
TOOLS=/TEMPS=/CLEAN=/LEVEL=. The two compose: START_PRINT sets
offset_done and the M109 hook stands down, so migration can be gradual
and nothing is applied twice.

test-macros.py parses every ff-*.cfg macro body as Jinja, since Klipper
renders these at runtime and a syntax error would otherwise surface on
the printer at the start of a print. jinja2 added to the build image so
it does not silently skip in CI. Suite is 30 passed, 0 failed.

NOT YET RUN ON HARDWARE: the printer is down on the c_helper.so
mismatch, so the Z-offset path here has not been exercised. First print
should be watched, with the first layer checked before it commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 18:25:08 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 44d45f2c46 Repoint the toolchange docs at their new paths
The imported README and notes still described a standalone checkout:
relative links to klippy-extras/ and config/ that no longer resolve, an
scp recipe naming the old directories, and a header about the
klipper-vanilla and main branches, neither of which exists here.

Paths now point at payload/klipper/ and assets/orca/, and the header
keeps the not-yet-run-on-hardware warning without the branch
bookkeeping. Every relative link in docs/ resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 17:20:04 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 0ef9d6b86b Move the imported toolchange files into this repo's layout
Pure renames, no content changes, so `git log --follow` walks straight
back into the toolchange history imported by the previous commit.

  klippy-extras/ff_*.py -> payload/klipper/extras/
  config/ff-*.cfg       -> payload/klipper/config/
  helixscreen/*.json    -> payload/helixscreen/printer_database.d/
  orca/                 -> assets/orca/
  docs/notes/           -> docs/notes/
  README.md             -> docs/toolchange.md

payload/ is the right home for the first three: they are files that
ship to the printer, which is what everything else under payload/ is.
The Orca snippets go to assets/ instead -- they are pasted into a
slicer on the desktop and never reach the machine.

bin/patch.sh still reads them from $TOOLCHANGE; the next commit points
it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 16:48:25 +03:00