Commit Graph
47 Commits
Author SHA1 Message Date
Oleksandr ShyshatskyiandClaude Opus 5 f289d6cc8d Two notes still said verify.sh checks things it no longer can
Follow-on to fbb3182, found by grepping docs/ for live present-tense claims
about the deleted file rather than by reading the diff again.

  * 20-klipper-fork.md said "verify.sh still fails a fork package that lacks
    the klippy tree or its c_helper.so". False twice over: that file is gone,
    and 6dc8946 removed the same pair of gates from bin/patch.sh. The question
    is asked of the installed machine now, by test_install.py's
    test_klippy_is_present and by test_abi.py.
  * 85-packaging.md recorded a measurement in the present tense, so it read as
    a standing claim about a file and a make target that no longer exist. Put
    into the past and dated, because the measurement itself is still worth
    having -- it is the run that found the PKG_EXCLUDE `find -name` bug
    described immediately below it.

Every other mention of verify.sh in docs/ is already past-tense history and is
left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAQKdBfZxbnPagQyidFk6Y
2026-08-30 14:25:20 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 4850be432c Docs: say what is there now, not what the last three commits removed
Deleting test/ and anvil.conf left claims behind in nine files. Every one of
these was present tense about something that no longer exists.

THE CHELPER SYMBOL GATE, in three places that each described it as live:

  notes/20-klipper-fork.md   said pkgs/klipper gates the .so on "both the ELF
                             flags and test-chelper.py's symbol check". It
                             gates on neither now: the ABI is asked once of the
                             installed filesystem in qa/replica/test_abi.py,
                             and the single-source rule -- the .so is compiled
                             from the chelper sources of the tree it ships -- is
                             the whole defence. The page says that instead.
  notes/85-packaging.md      described the KLIPPER_FORK fallback the script no
                             longer had; the script itself is gone too.
  qa-migration.md            "What survives in test/" listed both the 28 unit
                             tests and test-chelper.py as surviving. It is
                             "What survived in test/, and then did not", and it
                             names what went uncovered rather than leaving that
                             to be discovered.

ANVIL.CONF, in the two notes that still read it at runtime:

  notes/80-s6-migration.md   is a dated plan, so it keeps its phases and gains
                             a "Where this ended up" note: anvil.conf and every
                             MOD_* switch were removed, so the phases that
                             source it describe an intermediate state, and the
                             case-*.sh gates they name are qa/replica/ modules.
  notes/85-packaging.md      predicted seed/ emptying via a postinst that seeds
                             anvil.conf.default. It emptied by anvil.conf being
                             deleted instead. Its payload allowlist said six
                             entries including anvil.conf and bin/busybox; the
                             real one in test_ipk.py has five and neither.

THE TWO STANDALONE TREES under tools/, stale before any of this and now fixed:
tools/python/README.md and tools/supervisor/README.md both told the reader to
run their gate with `test/integration/printer-exec.py` over a case-*.sh, and
one offered a `make test-python` target. None of the three has existed for
several commits. Both now point at qa/replica/, which installs the real package
and asks more than hand-feeding a tarball to a case script could. The four
places tools/python/README.md narrates what case-python.sh proves are past
tense, since that is now a record of how the measurements were taken.
versions.env's sqlite3 note pointed at the same dead target.

NAV: every notes/ page was listed except 85-packaging.md, and qa-migration.md
was reachable only by following a link out of Testing, Building or How it
works. Both are in the nav now, which also clears the two "not included in the
nav" lines from every build. .venv/ is gitignored -- docs/requirements.txt
tells you to make one right there in the tree.

Verified: `mkdocs build --strict` is clean and no longer reports pages outside
the nav. qa/static is 123 passed, its 110 errors the same missing
vendor/opkg-utils as before. No .md file is read by any lane, so the check that
matters here is the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bLGYpVAeVzAX6NeAxcU42
2026-08-30 01:16:54 +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 53292b4d78 klippy runs from $MODDIR, so patch.sh stops staging a second copy
The klipper s6-rc service already execs klippy under $FF_PYTHON -- our own
CPython 3.13 -- but it read the tree from /usr/prog/klipper/klippy: a copy
bin/patch.sh staged into the software component out of the very anvil-klipper
package that also installed it under $MODDIR. Two copies of one tree, and the
one that ran was the one a stock flash could overwrite.

One line moves it: KLIPPY=$MODDIR/klipper/klippy/klippy.py.

That deletes sections 1 and 2 of bin/patch.sh -- the cp -a of the klippy tree,
the chelper.tar wrapped around a .so the package already contained, the rm -rf
of the stock extras/ and kinematics/, and the ff_*.py copy. The software
component now carries firmwareExe, start.sh and run.sh and nothing else of
ours, which is the phase 7 end state in docs/notes/80-s6-migration.md.

It also lets pkgs/klipper declare the dependencies it has had since the
interpreter moved. cffi and greenlet reached the printer only through a
hardcoded list in patch.sh, so every build had them and no `opkg install
anvil-klipper` did; pyserial and jinja2 came only as anvil-moonraker's
Depends, so a BUILD_MOONRAKER=0 build shipped a klippy that dies the first
time it opens an MCU. Both are real Depends now and MOD_ROOTS drops to ten.

Also repointed: run-append.sh's __pycache__ sweep, which was clearing the tree
nothing imports while klippy writes bytecode into the one it does -- and those
runtime-created directories are in no .install-manifest, so the manifest
delete walks past them.

verify.sh reads the klippy tree and c_helper.so out of the payload (extracting
./klipper alone), and gains a check that the component does NOT carry a klippy
tree. New replica tests assert the service execs the payload tree under
$FF_PYTHON and that cffi/greenlet/serial/jinja2 import on it.

Comments in anvil-env.sh, verify.sh and pkgs/klipper/pkg.conf that still said
klippy runs on FlashForge's 3.8.2 are corrected; that stopped being true when
FF_PYTHON moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01La5MaDKqTW675k5fsXsP6S
2026-08-29 23:59:58 +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 39b5cb0d0b ipk-install was a stand-in for an opkg the printer now has
The script says so itself: it existed so phase 1 could ship without
waiting for a cross-built opkg, and it would hand its database over when
the real client arrived. pkgs/3rdparty/opkg builds that client, and
anvil-opkg is in bin/patch.sh's payload roots, so the printer has opkg
and the stand-in has nothing left to do.

Nothing called it. bin/build-payload.py assembles the payload with the
printer's own opkg inside the replica, and the test that existed only to
keep ipk-install off the build path was a guard against a file that is
now gone. Its five round-trip tests went with it.

The shellcheck lane loses one file rather than a rule: _files() refuses a
glob that matches nothing, so the two entries had to go with the script.

Cherry-picked from fc98b04 on worktree-drop-ipk-install, so the two
branches carry the same patch. Resolved against this branch's payload
build: the host opkg that commit still described is deleted here, and the
lane globs are payload/prog/ rather than prog/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 18:51:53 +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 21cf19f2e2 BUILD_KLIPPER=stock kept a Klipper nothing else was written for
The flag chose between the Klipper4FlashForge fork and FlashForge's own
v0.12 tree. Every other thing the mod ships had already stopped being
neutral about that choice: the ff_*.py extras are the fork's package's
own files, the ff-*.cfg declare sections only those extras implement,
and c_helper.so is compiled from the tree it ships beside. A stock build
was a printer with the mod's configuration and none of the code behind
it -- and the previous commit turned that into a hard error, which is
what showed the flag had nothing left to select.

So it is gone rather than guarded. pkgs/klipper loses its PKG_WHEN,
patch.sh section 1 loses the case and is straight-line, verify.sh
section 7 stops being conditional -- a package with no klippy tree is
now unrepresentable rather than merely gated -- and fetch-assets.sh
pulls the fork tarball on every build, the way it already does s6 and
CPython.

Two fixture-lane repairs come with it.

test/run-tests.py said BUILD_KLIPPER=stock to keep the lane off the
network. That reason had already expired: since the payload became the
feed installed, the lane runs build-packages first, and that needs the
~203MB Ingenic toolchain from vendor/ for CPython alone. The Klipper
tarball is 27MB beside it, cached on versions.env like everything else.
The lane now builds the fork, so verify.sh's ABI and symbol checks on
c_helper.so run there for the first time.

And the synthetic Moonraker tarball unpacked to moonraker-fixture/,
while pkgs/moonraker/build.sh looks for moonraker-$MOONRAKER_VERSION/ --
the shape GitHub generates. That lane could therefore never build
anvil-moonraker at all. It passed only while work/pkg/moonraker held a
tree from a REAL build, because pkg_stamp keys on the version and not on
the tarball; the first run against a cold cache died with "no
moonraker/moonraker.py", a true statement about a tarball nobody could
have made work. CI caches work/pkg on versions.env, so the first run
after any pin bump would have hit it. Confirmed pre-existing by running
the parent commit against the same cold cache: it fails identically.

make qa-static 261 passed; make test 9 gates, 0 failed, with the fixture
lane building Klipper and Moonraker from its own assets for the first
time; CI's shellcheck gate clean.
2026-08-29 13:33:46 +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 Shyshatskyi dce1f62146 Naming the release and letting Depends work out the rest
bin/patch.sh enumerated the payload by hand: every recipe from
pkg_recipes, minus the -dev packages, minus the chamber config for the
other model. Forty lines to compute a closure opkg already computes, and
computing it here meant the Depends fields were never read by anything.
A printer running `opkg install anvil-moonraker` would have been the
first to find out whether they were right.

The feed is indexed, so point opkg at it -- `src anvil file:$PKG_FEED`
and an `opkg update` -- and name the twelve packages the release IS.
opkg pulls the other nineteen and marks them Auto-Installed. `file:` is
answered before curl is reached at all (libopkg/opkg_download.c:134),
which is why this works with the --disable-curl host opkg.

The PKG_WHEN gates still reach here, by absence: a gated-off recipe
leaves no .ipk in the feed, so BUILD_KLIPPER=stock has nothing to
install and no BUILD_* flag is restated outside the pkg.conf that owns
it. The cost is that a misspelled root drops out the same silent way,
which test_the_payload_roots_name_real_packages now catches, and
test_the_payload_roots_stay_a_short_list keeps a missing file from being
answered by naming the package here instead of in the depending
recipe's PKG_DEPENDS.

Four of the twelve are Recommends, a field opkg does not have: pillow
and preprocess-cancellation are Moonraker's thumbnail path, which
pkgs/moonraker argues out of Depends; greenlet and cffi are klippy's,
which pkgs/klipper cannot declare while klippy still runs under
FlashForge's interpreter.

anvil-core is the one version that cannot be left to opkg -- its
PKG_VERSION is MOD_VER, today's date -- so its expected filename is
still checked before installing.

Payload verified against a build from the parent commit: 3298 paths,
modes and symlinks identical, every file's sha256 identical except
var/lib/opkg/status, which improves. Dependencies now say
"Auto-Installed: yes" and the flag field is "ok" rather than "user" on
packages nobody asked for by name. 31 packages, 154 ELF objects, as
before.

make qa-static 261 passed; make test 9 gates, 0 failed.
2026-08-29 12:31:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a588644c14 Phase 2's assembly half, written down as done rather than as a sketch
docs/notes/85-packaging.md sketched this phase as a five-line shell loop using
pkgs/ipk-install. What shipped differs in the tool and in three findings that
only turned up while doing it, so the section is rewritten as a description.

What it now records:

  Why a real opkg and not ipk-install, and that ipk-install keeps its job on
  the printer -- with the two configure flags that fail SILENTLY if dropped,
  --prefix=$MODDIR and --disable-shared, both of which the build checks
  against the binary it produced.

  That a chroot was tried and rejected, with the measurement: it needs root,
  unshare -Ur is blocked by seccomp, and the resulting payload cannot be
  deleted by the build-lane user -- which breaks the next make build.

  That -dev is filtered by package NAME rather than by "has a runtime half",
  and what asking the wrong question cost: 40 packages and 2165 ELF objects
  instead of 31 and 154.

  That the database ships and is in the manifest, why the two alternatives are
  worse, and the gap that remains -- a printer that opkg-installs something
  extra keeps its files and loses its stanza across a .tgz flash.

  THAT MAINTAINER SCRIPTS DO NOT RUN UNDER --offline-root and that opkg 0.7.0
  sets no IPKG_INSTROOT at all. This contradicts an assumption written earlier
  in the same document: the plan to package firmwareExe and start.sh and let a
  postinst place them on /usr/prog does not work as written. Said plainly,
  where the assumption was made.

  The before/after gate the migration cleared, and the PKG_EXCLUDE bug that
  fell out of it.

docs/building.md: the pipeline description, and that make packages comes
before a cold make build. The packages skill gains pkg_buildopkg and the
prefix trap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:33:56 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 e3e0c7d141 The model is the one fact opkg cannot work out for itself
anvil-klipper-config declared Depends: anvil-klipper-chamber-config, which
reads correctly -- ff-chamber.cfg drives a heater it does not declare -- and
points the arrow the wrong way. `opkg install anvil-klipper-config` resolves a
virtual name by picking whichever provider it finds first, and the two
providers here describe DIFFERENT HARDWARE: a plain Creator 5 handed the Pro's
config gets a [heater_generic] for an element it does not have. A coin flip
decided by feed order.

And the command a person would actually type, `opkg install
anvil-klipper-creator5-config`, pulled in nothing at all.

So the dependency runs from the model package to the shared one. You install
the package named after your printer and the toolchanger's config follows;
nothing sensible installs anvil-klipper-config by name. Provides and Conflicts
stay exactly as they were -- they are what makes the two models mutually
exclusive, which is a separate job from getting them installed.

The invariant this drops at the opkg layer -- that a chamber config is present
at all, since printer.base.cfg's include is unconditional -- is held two other
places that already existed: bin/patch.sh refuses to finish a payload without
one, and bin/verify.sh fails a package that ships without one. Restoring it
here would need a mutual dependency, and betting on opkg resolving a cycle is
worse than a check that has already been proven to fire.

Measured: control files carry the flipped Depends, make qa-static 256 passed,
make test 8 passed 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 20:26:49 +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 09a9f868e3 klippy compiles c_helper.so at first run, and this printer has no compiler
So the "build system" for that .so genuinely is one gcc line -- the argument
list in klippy/chelper/__init__.py -- and pkg_build could not express it,
because it always ran make. That is why Klipper was the last cross-build
still living in bin/patch.sh, which unpacked the Ingenic toolchain into
work/.mips-toolchain, wrote its own gcc invocation, cached on an mtime
comparison and gated the result itself: every one of the things pkg/lib.sh
exists to do once.

PKG_CC_SHARED is that line, appended to `$CC -shared -fPIC`. A knob and not
a verb, for the reason pkg_build's own header gives about the three
mechanisms with one user each: the recipe says WHAT to link, pkg_build owns
HOW, and -EL -mnan=2008 stay spelled once in pkg_toolchain where the wrapper
is gated before anything is built on it.

  pkg/klipper -> anvil-klipper_0.13.0+gitb722a4c8-1_mipsel_xburst2.ipk

Both gates moved into the recipe, next to the ship list they check -- the
same move the s6 presence checks made. They now run on `make packages` too,
so a c_helper.so that is not nan2008, or one missing a symbol the klippy
beside it cdefs, fails the build that produced the .ipk rather than only a
full firmware build. The symbol gate is the one that matters: cffi resolves
lazily, so a stale .so imports cleanly and dies at connect on the printer.

KLIPPER_FORK is gone. It pointed the build at a local checkout instead of
the pin, which is a second source for a recipe allowed exactly one -- and it
is the same variable that let v20260824-nova-kakhovka ship a klippy tree
nobody had built, set to "" in CI. To build your own fork, put its tarball
in vendor/ and re-pin KLIPPER_VERSION and KLIPPER_SHA256; the sha is then
the guarantee every other source in this repo carries. bin/fetch-assets.sh
loses two of its conditions with it, and test/test-chelper.py loses the
config.env fallback that printed "SKIP: no KLIPPER_FORK configured" -- a
green line for a check that had not run.

bin/patch.sh section 1 is now staging and nothing else: a cp -a into the
SOFTWARE component, where the stock klipperDaemon looks, plus the
chelper.tar its run.sh extracts. That tar stays out of the .ipk, where it
would be a second copy of a file the package already contains. The package
installs the tree under $MODDIR/klipper, inert until phase 7 of
docs/notes/80-s6-migration.md makes the installed copy the live one -- at
which point these lines are deleted rather than moved.

Measured:
  * two cold builds of pkg/klipper byte-identical, work/pkg deleted between
  * c_helper.so is 0x70001407 -- nan2008, o32, mips32r2
  * all 66 functions klippy declares are exported, checked after pkg_ship
    strips rather than before
  * the whole feed still builds: 38 recipes, 41 packages
  * 236 static tests, including the staged-recipe list that now names klipper
  * BUILD_KLIPPER=stock removes the recipe from pkg_recipes entirely

Not verified here: bin/patch.sh end to end. It needs the stock FlashForge
package, which the packaging lane deliberately does without; bin/verify.sh's
klippy-tree and chelper.tar assertions are what cover the staging rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 19:10:07 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8cc92e8266 A wheel could only be built while three scratch trees happened to coexist
That is why bin/patch.sh section 5c was 825 lines and why a Pillow bump
rebuilt CPython. Cross-building an extension module needs the untrimmed
staging tree (INCLUDEPY points into it), the private sysroot of static C
libraries (pillow links zlib, cffi links libffi) and an x86-64 build-python
of the same version -- and all three existed only mid-section, so the wheels
had to be built there too, on a cache with two stamps that invalidated each
other.

Each of those three is now a durable thing: anvil-python-dev carries the
headers, pkg_deps fills each recipe's own sysroot from the feed, and
pkg_buildpython caches one build-python for all nineteen recipes. So the
coupling goes away with them -- `make packages PKG=python-pillow` builds
Pillow.

  pkg/python            -> anvil-python + anvil-python-dev
  pkg/python-<name> x18 -> anvil-python-distro, anvil-python-pillow, ...

Three verbs in pkg/lib.sh, shared by all nineteen rather than one per awkward
project: pkg_buildpython provides the build-python, pkg_pytarget points its
sysconfig at the target headers in the sysroot, pkg_pywheel builds one wheel
and unpacks it into the staging site-packages. greenlet's gcc 7.2 designated
initializers, lmdb's LMDB_FORCE_CPYTHON and pillow's build_ext options are
recipe lines and a PKG_PY_SETUP_ARGS knob, not three more verbs.

Eighteen versions that were invisible are now in the feed index, anvil-moonraker
declares the thirteen packages it imports, and a printer that does not want
gcode thumbnails can leave Pillow uninstalled.

PY_STAMP, PYPKG_STAMP and PY_BUILD are gone: pkg_stamp derives the key from
PKG_BUILD_DEPENDS, and bin/fetch-assets.sh asks pkg_needs instead of
re-deriving a stamp bin/patch.sh also derived. Two of its four toolchain
conditions were that second spelling.

Measured:
  * 40 packages; 959 site-packages files, none owned by two packages
  * 12 mipsel extension modules -- the count the old gate demanded
  * every Depends resolves inside the feed (24 declare one, 0 unsatisfiable)
  * two cold builds byte-identical, with work/pkg, work/.py-host and the
    unpacked toolchain deleted in between
  * 233 static tests, 4 new: the PYPKG_LIST/recipe agreement both ways, no
    version pinned twice, patch.sh builds every package, and no Depends
    naming a package no recipe produces

Not verified here: the replica suite. It needs the stock FlashForge package,
which the packaging lane deliberately does without.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 18:28:30 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 46d2973165 A printer with no compiler was carrying 248K of headers
PKG_DEV_FILES splits one build into two archives: <name> with what a
printer runs, <name>-dev with the headers and the static library only a
build machine opens. Installing anvil-execline + anvil-s6 + anvil-s6-rc now
leaves bin/ and libexec/ and nothing else -- include/ and lib/ are gone.

The three libraries that are ONLY ever linked against -- zlib, libarchive,
skalibs -- have no runtime half at all, so they are renamed rather than
split: anvil-zlib-dev, anvil-libarchive-dev, anvil-skalibs-dev. The name
now says what the section already did.

THE RULE CHANGED, deliberately. "One recipe builds one package" becomes
"one recipe builds one SOURCE, and may emit several packages from that one
build". The rule was written to stop one script compiling several different
upstream projects -- zlib inside the opkg recipe -- and a dev split is not
that: one source, one configure, one version, sorted afterwards. The
invariant that actually catches the thing it was for is countable and
unchanged: exactly one source verb per recipe.

The split MOVES files, never copies. A path in both archives is a path two
packages own, opkg lets whichever installed last win, and removing either
deletes a file the other still lists -- damage that surfaces long after the
install that caused it. Two tests hold the line, both negative-tested by
making a runtime package carry a dev file.

Renaming also exposed a feed bug worth its own fix: the old anvil-zlib,
anvil-libarchive and anvil-skalibs archives stayed in work/packages and
opkg-make-index went on advertising three packages no recipe produced any
more. A full build now prunes what no recipe claims. A single-recipe build
does not, because it was not asked about the rest.

Fourteen packages, byte-identical across two cold builds. 154 static tests.

docs/notes/85-packaging.md phase 2 gains the target this is all for:
bin/pack.sh should be handed a tree produced by installing the feed, not one
patch.sh assembled, at which point every staging copy in patch.sh -- each of
them a second description of what a package already contains -- goes away.
CPython is the blocker and is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:55:18 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 6e97f946b0 execline was 2.1MB unstripped against musl; it is 1008KB and mandatory
Eight recipes: skalibs, execline, s6, s6-rc, mainsail, moonraker,
helixscreen, anvil-core. bin/patch.sh loses 183 lines and stops being a
build system for any of them.

The supervision stack moves to the Ingenic glibc toolchain and links libc
dynamically, so the musl toolchain -- the only second libc in this tree --
is deleted along with MUSL_TOOLCHAIN_*, S6_STAMP and S6_BUILD. The
measurement that chose musl compared two STATIC builds; dynamic glibc was
never in it. Measured now: s6's shipped tree is 696K against ~930K for the
static musl one, and the whole stack including execline and s6-rc is 2.0M.

Three things that were not guesses:

  * s6 and s6-rc do not link against glibc 2.29 without -lpthread. skalibs'
    pthread_mutex_tailock reaches pthread_mutex_timedlock, which glibc kept
    in libpthread until 2.34. Upstream never sees it and musl never had the
    split. PKG_MAKE_ARGS is the lever, because s6's PTHREAD_LIB is wired
    only to --enable-nsss and its Makefile appends LDLIBS last.
  * s6 ships 21 binaries, not 13. s6-rc's generated scripts exec s6-sudo,
    s6-sudoc, the s6-ipcserver chain, s6-sudod and s6-fdholder-daemon --
    found by running a real up/down cycle with a restricted PATH, not by
    reading the documentation.
  * --enable-static-libc has never been a skalibs option. Its configure
    warns on unknown flags and patch.sh sent that to /dev/null, so it was a
    no-op for as long as section 5b existed.

execline ships whole. A subset picked from today's services fails the day
somebody adds a pipeline, at boot, naming a program nobody chose to leave
out. tools/supervisor/README.md and 80-s6-migration.md said it was not
needed; both now say why that stopped being true.

PY_STAMP was spelled in three places and happened to agree -- the same
shape as the s6 stamp bug, caught before it inverted. It is in common.sh
now, and the test that guarded S6_STAMP by name is rewritten around the
rule so it covers both and dies with neither.

HelixScreen's binaries reach mips_abi_gate for the first time: patch.sh
never gated them. They pass, 3 objects, nan2008/o32/mips32r2.

anvil-core carries a content hash in PKG_STAMP_EXTRA because its version is
a release date: pinned, an edit to payload/ would read as current; unpinned,
it rebuilds daily. The version says which release, the stamp says what is
in it. anvil.conf stays out -- it is user state that run-append.sh
preserves, and a package member is overwritten on every upgrade.

Twelve packages, byte-identical across two cold builds. 152 static tests.
The replica suite has NOT been run: docker is unavailable here, and the s6
change ships without its behavioural gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 16:41:46 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 c8e33a5604 zlib was compiled twice because it belonged to whoever needed it first
pkg/opkg/build.sh unpacked zlib, built it into a private sysroot, unpacked
libarchive, built that against it, and then built the one binary it shipped.
Section 5c did the same with its own zlib, from the same pinned tarball with
the same flags. Neither copy could see the other, because a library built
inside somebody else's recipe has no version, no package, and no way to be
reused.

One build.sh now produces exactly one package. A recipe names what it builds
against in PKG_BUILD_DEPENDS and pkg_deps unpacks those out of the feed with
opkg-unbuild -- upstream's inverse of opkg-build, from the checkout that
already provides both. That dissolves the bootstrap: nothing needs a working
opkg in order to build packages, so opkg is an ordinary recipe that waits on
nothing except its own dependencies, and no qemu enters the build path.

Building against the package rather than the build tree is the part worth
paying for. A package that forgets a header fails the next recipe's configure,
on the build that produced it.

One libc. skalibs, s6 and opkg were musl because a STATIC glibc s6 tree
measured 73MB -- a static-to-static comparison that never considered dynamic.
Two libcs also meant two of every library both worlds wanted, which a feed
cannot express without lying about one of them. opkg moves to the Ingenic
toolchain and links libc.so.6 dynamically with zlib and libarchive inside it;
it got smaller (212K against 332K). Section 5b stays musl until it becomes a
recipe too, and is now the only place left where one script builds two
libraries.

Found on the way, each of which had been true for a while:

  * ar archives are not reproducible. Every member header carries an mtime and
    the builder's uid/gid, and the symbol index carries another timestamp --
    none of it reachable by SOURCE_DATE_EPOCH or opkg-build's -o 0 -g 0,
    because it is inside a file the tarball merely contains. objcopy -D fixes
    the members; the cross binutils is 2.27 and its ranlib -D writes a fresh
    current timestamp into the index instead of zeroing it, so the index is
    rewritten with the build image's ranlib.

  * The ABI gate accepted exactly 0x70001405 and 0x70001407, both measured from
    linked binaries where crt objects set NOREORDER. Object files need not:
    libarchive's xxhash.o is 0x70001406, identical ABI, and the gate called it
    wrong. It also read one Flags: line, while readelf -h on an archive prints
    one per member -- 122 for libarchive.a. It now masks the three non-ABI bits
    and checks every header.

  * bin/fetch-assets.sh compared work/.s6/.version against two fields where
    patch.sh wrote three, so the test could never be false and 71MB was
    re-hashed every run. The comment above it described a fast path that had
    never once been taken.

Gate: 127 static tests (29 packaging), and `make packages` from a cold cache
producing four byte-reproducible .ipk files plus the index. The end-to-end
install under qemu has NOT been re-run since opkg went dynamic -- a dynamic
binary needs qemu -L <sysroot>, and the command is written down in
docs/notes/85-packaging.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 15:28:44 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5025aac171 opkg-utils already builds ipks, and opkg is the second package that proves lib.sh
Two corrections to the packaging PoC, both from review.

FIRST: THERE WAS ALREADY A TOOL. bin/mkipk.sh is deleted. opkg-utils ships
opkg-build and opkg-make-index -- 423 lines of POSIX sh and a python indexer,
upstream, maintained -- and this repo now drives them instead of re-deriving
the format. What is left of build-packages.sh is the part that is genuinely
ours: laying out the tree opkg-build packages. The hand-rolled version worked,
and it was still us reimplementing member ORDER, control validation and the tar
flags that make a build reproducible, all of which fail as "a package that
inspects fine and installs nowhere".

opkg-utils is PINNED BY COMMIT, uniquely in this repo, and not by choice: it
publishes no release tarball anywhere. Upstream's cgit has snapshots disabled,
downloads.yoctoproject.org carries opkg but not opkg-utils, and the GitHub
mirror last saw a commit in 2012 with no tags. A git sha is a hash of the whole
tree -- the same guarantee every sha256 here gives -- and fetch-assets.sh
verifies HEAD against it and refuses a dirty checkout. Both were tested.

SECOND: A SECOND PACKAGE, AND IT IS opkg ITSELF. pkg/lib.sh now holds the part
of a cross-build that is the same every time -- the two toolchains, the gcc
wrappers that bake -EL -mnan=2008 into the DRIVER because autotools link lines
do not all forward CFLAGS, the ABI self-test of those wrappers, autotools,
the build cache, and pkg_ship. Both recipes fit on a screen and share exactly
that file.

opkg is the honest test of it: different toolchain (Bootlin musl, not Ingenic
glibc), different libc, different link mode (static, not shared), and a
dependency chain three builds deep (zlib -> libarchive -> opkg) against
libsodium's none. It is static musl for the same reason s6 is -- a standalone
binary that must match nothing -- so no libarchive.so or libz.so ships beside
it. qa/static/test_ipk.py fails any recipe that sources its own toolchain,
spells its own -mnan=2008, or calls ./configure directly; the gate was checked
by breaking a recipe on purpose. zlib is the one named exception, because its
configure has never accepted --host.

THE LOOP IS CLOSED ON THE TARGET. The opkg this repo builds -- 775KB, static,
nan2008/o32/mips32r2 -- runs under qemu-mipsel and installs both our packages,
including itself, then lists their files and removes them leaving nothing
behind. The opkg it installed then runs and reports its version. Its status
database landed at /usr/data/anvil/var/lib/opkg, which is where pkg/ipk-install
writes, because both agree rather than by coincidence.

Three findings paid for by building it, all in the note:

  * opkg BAKES ITS STATE DIRECTORY IN AT COMPILE TIME -- --offline-root does
    not move it. Built with any other prefix it believes nothing is installed
    and reinstalls the world. Third instance of this trap here after s6's
    libexecdir and execline's shebangdir.
  * opkg 0.6.3 does not build against musl: two files call basename() without
    <libgen.h> and get away with it only under glibc. 0.7.0 fixed both, which
    is the difference between a pin bump and carrying a patch.
  * libtool SWALLOWS -static. A program configured LDFLAGS=-static comes out
    dynamic and the only symptom is a NEEDED entry nobody reads. -all-static,
    at make time, is the flag that means it -- hence the explicit check.

Both packages are byte-identical across a full cold rebuild. The musl toolchain
fetch now also triggers on a stale work/.opkg, and its cache key lives in
common.sh so the fetcher and the recipe cannot spell it differently -- the
hazard already written above pypkg_stamp.

Also fixed: two `# shellcheck disable=... -- prose` directives were malformed,
which made shellcheck abandon those files rather than check them. A gate that
examined nothing, in the file arguing against those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 14:15:50 +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 1c89a9cd80 Neither Alpine nor OpenWrt has a binary we can install. That picks opkg.
The evaluation asked for apk vs opkg. The comparison turns on one fact that
makes most of the usual argument irrelevant here: Alpine has no MIPS port at
all, and OpenWrt's mipsel_24kc is musl against this printer's glibc 2.29. So
no upstream repository is reachable either way, every package is cross-built
here regardless, and a package manager buys packaging and installing -- not
software.

That reduces the choice to which format is cheapest to PRODUCE on every build,
and .ipk wins by a distance: ar + two tarballs + a three-byte version file,
no keypair, no Alpine host, no abuild. apk would need the signed
three-concatenated-gzip layout implemented by hand or abuild imported into a
Debian build image, paid per recipe forever, for a format with an in-progress
v2->v3 transition and no users on our architecture. The full table, the two
honest points for apk, and the phased plan are in docs/notes/85-packaging.md.

The proof of concept is libsodium, end to end and measured rather than
described:

  make packages   ->  libsodium_1.0.20-1_mipsel_xburst2.ipk + a feed index

Verified against real opkg 0.6.3 built from source -- offline-root install,
both symlinks preserved as symlinks, list-installed, files, remove leaving
nothing behind. dpkg-deb reads it too; the container format is Debian's.
A full recompile from a cold cache produces a byte-identical package, so a
pin bump is reviewable as a hash diff.

libsodium is now compiled ONCE. patch.sh section 5d's build block moved to
pkg/libsodium/build.sh and patch.sh runs it, so the tarball's copy and the
packaged copy cannot become different libraries wearing one version number --
asserted, not trusted. MODDIR, PY_HOST, PY_TOOLCHAIN_DIR and mips_abi_gate
moved to common.sh for the same reason: a package is a shipping vehicle and
gets ABI-gated like one, on a machine that may never run patch.sh.

Nothing ships. make build is unchanged and still produces one anvil.tar.xz;
make packages needs no stock FlashForge package, which is the point -- a
packaging gate that only runs where the proprietary firmware is stops being a
gate.

Two findings worth more than the code. opkg bakes its state directory in at
COMPILE time, so a phase-2 opkg must be configured --prefix=/usr/data/anvil or
it comes up believing nothing is installed -- the s6 libexecdir trap one layer
up. And opkg 0.6.3 will not configure without libarchive, which is what phase 2
actually costs; the opkg binary itself is 33KB. So pkg/ipk-install walks the ar
headers with tail/head instead: this busybox is 1.31.1 built small, with no
timeout, nc or ionice applet, and betting an installer on an ar nobody has
checked for is how an update fails at the last step.

qa/static/test_ipk.py is 18 tests that build their own package from a synthetic
tree -- no toolchain, no firmware -- so the lane keeps the property
qa/conftest.py insists on and can never be skipped into looking green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 11:45:51 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 bcd38fae28 Six of the ten library paths were never loaded by anything
ANVIL_LIBS handed every mod process ten /usr/prog library directories. Four of
them are load-bearing and six have no consumer at all, which was established
by measurement rather than argument: /proc/PID/maps of the processes the mod
actually runs, plus a DT_NEEDED walk over every ELF in
/usr/prog/{PROGRAM,bin,nginx,mjpg-streamer,klipper,module,modules,wifi},
/usr/bin, /usr/sbin, /bin and /sbin.

That walk found exactly ONE consumer of ffmpeg, x264, opencv, nim and libzip
between them: /usr/prog/PROGRAM/software/firmwareExe -- FlashForge's Qt
binary, which this mod replaces with a shell script. curl is the interesting
one, because it did have a Python consumer: pycurl, in site-packages, which
does not import on this firmware at all. "undefined symbol:
curl_global_sslset", a symbol that arrived in curl 7.56 while /usr/prog ships
7.55.1, and the shipped pycurl links libssl.so.1.1 where this curl links
1.0.0.

The comment that stood here is replaced rather than deleted. It argued the
list should be the union, because which subset a script needs is a property of
the component set in moonraker.conf and the user edits that file. The
measurement empties that defence: no moonraker.conf edit can reach a library
through a module that cannot be imported in the first place. What replaces it
says what was measured, and what would have to be true to put an entry back --
some process the mod runs has to map it, which is a measurement and not an
argument.

Each of the four that stay now names the failure it prevents, and openssl's is
worth having written down: it is NOT for `import ssl`. _ssl and zlib are
BUILTIN on this interpreter, statically linked into libpython, so
libpython3.8.so.1.0 itself carries DT_NEEDED on libssl.so.1.0.0 -- take the
directory away and the interpreter does not start, before it has read a line
of anyone's python. The rootfs has 1.1 only, a different soname that cannot
stand in.

case-libpath.sh is the measurement, kept as a gate and running in both
directions. Each of the four is removed one at a time and required to produce
a real failure -- two of them the interpreter refusing to start, one an
ImportError from ctypes, one an OSError from libnacl, which is the shape that
once made a missing library read as a component crash. And a probe is run
twice: on the four shipped entries, and again on the full old ten-entry path.
The second run is the one that carries the weight. With every directory
available it still maps none of the six, while it does map libffi and
libsodium -- so it was loading libraries, it just never wanted those.

Two of the case's checks SKIP in the replica, honestly and for the same reason
case-moonraker.sh skips the identical one: only the payload directory is
mounted, so FlashForge's 2022 Moonraker tree stands in and has no
moonraker.server. The component-import proof needs a checkout where patch.sh
has staged the mod's own tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 22:07:57 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a430aeefc2 Moonraker is supervised, and ready means the API is answering
The third and last service of this phase, written as a sibling of nginx and
the camera rather than as a new idea: a run script that execs moonraker in
the foreground under $FF_PYTHON with the old start-stop-daemon arguments and
the -b removed, a shipped `down` file because MOD_WEB is read at runtime while
the payload is built once, and S62moonraker kept as a thin wrapper so the
verbs and the vocabulary the gate greps for do not change.

Readiness is :7125 in TCP_LISTEN, with the port parsed out of the [server]
section of moonraker.conf so a user who moved it does not get a healthy
printer reporting itself broken. The two alternatives are argued in the file
and rejected: "forked" is what the pidfile already measured, badly, and
"klippy_state is ready" is wrong -- moonraker reporting klippy_state=error is
moonraker doing its job, so that definition would fail to fire on exactly the
broken printer whose UI you need to reach. The gate proves the distinction
rather than assuming it: s6-svwait -U was still blocking while s6-svstat
already said up, and returned at 46s against a 40s stand-in delay, after
which /server/info answered.

With no scanner moonraker falls back to starting directly, as nginx does and
unlike the camera. MOD_WEB is one switch across both scripts, so nginx up
with moonraker refusing invents a third state nobody chose and is the worst
of the three to debug -- Mainsail loads and blames Klipper. And ff-startup.py
drives the whole boot off moonraker's API, so refusing would turn a failed s6
build into a printer that cannot bring itself up or say why. Starting twice
is not possible: the `down` file plus the run script's sweep of any leftover
moonraker see to that.

The deferred config move is why -d /usr/data is still here, marked as the one
line phase 7 changes. klipperDaemon is FlashForge's script and hardcodes
KLIPPER_CONF=/usr/data/config/printer.cfg, so the directory cannot move until
we own the launch; docs/notes/80-s6-migration.md now says so and carries the
argument for moving it into phase 7a rather than losing it.

Four bugs in the gate itself, found by running it and fixed there, each now
commented. s6-svstat -p hands over the pid at FORK, so a check that read
/proc/PID/cmdline saw `/bin/sh ./run` and not the interpreter -- it waits for
the exec now, and a run script that never execs is a failure rather than a
race. The same fork-is-not-ready gap made a supervised nginx look dead. A
`start | sed` blocked for 43 seconds because the detached readiness reporter
holds the pipe open. And a churning service reports pid -1 at every sample,
so "the pid keeps changing" read as "the pid is stable" -- churn is measured
by maximum age from s6-svstat now, which is what the negative control needed
to mean anything.

gates.moonraker() hands over the real s6 when one exists but DEGRADES rather
than skipping when one does not, which is the opposite of the nginx and
camera gates and deliberate. Those two are about supervision and have nothing
to say without a supervisor. This one is the only gate that runs the shipped
Moonraker at all, so skipping it in a checkout that has not built s6 would
take the component-import checks with it and let a broken pin through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 21:21:34 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8d310f60a4 The plan for s6 and a prefix root, with the constraints that shape it
Seven phases, each independently shippable and each naming the gate that
proves it. The ordering is not taste: it follows from what the printer
actually does.

The install manifest comes first because run-append.sh wipes whole
directories -- $MODDIR/bin among them -- before extracting. That is correct
today and destructive the moment a supervisor or an interpreter lives there,
so it has to change before anything third-party is installed, not after.

Klipper turns out to be further from us than it looks and closer than it
looks at the same time. We do not install it: patch.sh stages a software
component and FlashForge's own run.sh copies it onto /usr/prog/klipper, which
is why it sits on the partition a stock flash overwrites -- inherited, not
chosen. But payload/start.sh already IS /usr/prog/klipper/start.sh, and
klipperDaemon, the thing it calls to actually start klippy, is a shell script
whose entire payload is one start-stop-daemon line. Drop the -b and it is an
s6 run script. What blocks owning Klipper is not Klipper. It is that every
path in anvil-env.sh -- interpreter, openssl, libffi, curl, ffmpeg, opencv --
is on /usr/prog, so klippy could move tomorrow and still run under
FlashForge's Python. Python is phase 6 and Klipper is phase 7 for that reason
alone.

The config move earns its place on the same argument that moved Moonraker.
FlashForge's run.sh does `cp $WORK_DIR/klipper/config/* /usr/data/config/ -rf`
on every flash, so a stock update overwrites our shipped .cfg files where they
live now. Every [include] in that tree is relative and the only absolute path
anywhere is /usr/data/gcodes, so the directory can move to $MODDIR/etc/klipper
without rewriting a single user file. Moonraker moves to s6 after that, so its
run script is written once against the final path rather than twice.

Also recorded: the two findings from the measurements that fail at runtime
rather than at build time, because a phase that forgets either of them fails
on a printer instead of in CI. 32-bit MIPS needs -D_FILE_OFFSET_BITS=64 or
readdir() returns EOVERFLOW and a supervisor cannot see its own service
directory. And s6 bakes its prefix in at compile time, so built for the wrong
prefix it reports status correctly and every waiting verb fails.

The last section says how it gets implemented: phases 1-3 are one worker
because they all touch run-append.sh, patch.sh and anvil-service.sh; phase 4
is a clean per-service fan-out; phase 5 touches user data and gets undivided
attention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:42:08 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8994b461b0 Every service starts the same way, and Moonraker stays on /usr/data
Moonraker is no longer copied to /usr/prog. The payload already extracts
that exact tree to /usr/data/anvil/moonraker, so the installer was making a
second identical copy onto the small firmware partition -- the one install
step that could fail on space, whose failure was "this printer has no
working web UI". Worse, /usr/prog is what a stock flash overwrites while
/usr/data survives, so a firmware update silently reverted Moonraker to
FlashForge's 2022 build and left the rest of the mod in place: exactly the
"which Moonraker is this printer running?" ambiguity the previous commit set
out to end. Neither of the two things thought to pin it there holds -- the
moonraker-env virtualenv is not on sys.path (imports resolve from
Python-3.8.2/lib/python3.8/site-packages, checked on the real image) and
moonrakerDaemon is never invoked. FlashForge's tree is left alone, unused.

S60web became S60nginx and S62moonraker. nginx and moonraker fail for
different reasons and are now stopped, started and diagnosed separately;
MOD_WEB still governs both.

payload/anvil-service.sh is the shape all six services now share. They had
been written one at a time and had arrived at four different answers to "is
it running" and two to "restart", none of them decisions. Two real bugs were
living in that drift, and both are fixed here:

  * busybox start-stop-daemon -K returns before the process is dead and
    never removes the pidfile, so restart raced its own start -- stop
    returned, start found the pidfile still held, and the service ended up
    stopped rather than restarted. The `sleep 2` in front of restart was
    standing in for that wait; svc_stop_daemon does it properly and the
    sleep is gone.
  * klippy does not unlink /tmp/uds on exit. supervise() knew that; stop()
    did not, so status reported a dead klippy as running and restart
    declined to do anything at all.

run-append.sh now clears $MODDIR/init.d before extracting. Without it the
rename above would leave a stale S60web beside the two new scripts on any
printer that already has the mod, and firmwareExe runs every executable
S* in filename order -- starting nginx and moonraker twice, from scripts
disagreeing about where moonraker lives.

bin/patch.sh stages anvil-service.sh. It is sourced, so no chmod +x.

test/integration/printer/case-services.sh is a new replica gate: it runs
every service on the printer's own busybox and checks the contract they
share rather than grepping them for how they are spelled. case-moonraker.sh
now asserts moonraker runs from /usr/data, that nothing under /usr/prog is
executed, that restart yields a genuinely new pid, and that stopping
moonraker leaves nginx up -- the point of the split.

The .modold rollback recovery in docs/how-it-works.md described a code path
removed in the previous commit and is rewritten for what happens now.

Verified in the replica (real rootfs.squashfs, qemu-mipsel): case-moonraker,
case-services, case-mcu-bringup and case-boot-screen all pass; pytest 181
passed, 5 skipped. case-install.sh and case-recovery.sh are syntax-checked
only -- they need a stock FlashForge package, which is not in this checkout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:32:25 +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 d048eec6c2 The nozzle wipe subtracts every layer between it and the machine
clean_wipe_z is the raw eddy frame, and _FF_NOZZLE_WIPE reached it by
subtracting homing_origin.z -- correct back when that one number held the
whole offset. Since the per-tool frame moved into its own move transform it
holds only the operator's babystep, so the line left the tool's ~3.2 mm
nozzle-to-station gap in and parked the nozzle that far above the wipe spot.
Safe direction, silent failure: the ooze string never touches, and the wipe
just quietly stops working.

Subtract all of them -- homing_origin.z, the mounted tool's gcode_z_offset,
and the job term the frame also carries. The job term is zero here, since
the clean runs before TOOLCHANGE_SET_PRINT_OFFSET, but that is not a thing
for a Z expression to assume.

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 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 396651a29c ff-mcu-bringup.py takes the eboard too, and checkEboard stops running
start.sh handed /dev/ttyS5 to FlashForge's checkEboard while ff-mcu-bringup.py
did the other two boards immediately before it. That split was not earning
anything: checkEboard is 9KB containing exactly one function,
_Z23bootSerialMainEboardMcuv, hard wired to /dev/ttyS5, importing nothing but
the tty calls and printf. It flashes no firmware, opens no other device and
writes no files -- verified against the binary rather than assumed, because
"drop the vendor tool" is the kind of change that wants evidence.

What it does do is the OLDER build of the routine ff-mcu-bringup.py already
reimplements, with the flaw 10-hardware.md documented: on ttyS5 any byte at
all sets isReady, so it sends 'A' at an eboard that is already running Klipper
and merely returning garbage at the wrong baud. Here only a banner earns a
write.

Folding ttyS5 into the same pass also fixes something the split caused. A
serial port nobody holds open is shut down in the driver, so every banner
ttyS5 sent while ttyS4 and ttyS7 were being polled went on the floor -- the
same bug that made those two take turns reporting a banner before they were
polled together. All three are now opened up front and polled as one.

The binary stays on the firmware partition. Nothing runs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:13:43 +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 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 212d3a1efd A fork build without a fork is now impossible to ship
v20260824-nova-kakhovka went out without the Klipper fork: release.yml set
KLIPPER_FORK="", bin/patch.sh answered with "(skip) Klipper: keeping stock
tree", and every gate stayed green because every Klipper check was
conditional on files the broken package simply did not contain. On an
already-modded printer the stock 0.12-era overlay half-overwrote the v0.13
tree and klippy died at connect: "'void(*)(struct stepper_kinematics *,
double, double, double)' expects 4 arguments, got 3" -- a v0.12 caller
against the v0.13 cdef of extruder_set_pressure_advance (upstream
c84d78f3f). Note for the next reader of such a traceback: an arg-count
TypeError is a MIXED KLIPPY TREE, python against python; a stale
c_helper.so shows up as AttributeError instead, because cffi resolves
symbols lazily.

The fork now travels like every other asset. versions.env pins the creator5
commit and the Ingenic gcc 7.2.0/glibc 2.29 toolchain (the X2000's own;
Debian's mips crosses are big-endian or legacy-nan), fetch-assets.sh
downloads both by sha256, and patch.sh extracts the pinned tarball whenever
KLIPPER_FORK is not a local checkout -- which is exactly what CI now does,
with the KLIPPER_FORK="" lines gone from both workflows and vendor/ cached
on the pin file. c_helper.so is cross-compiled from the chelper sources of
the very tree being shipped whenever it is missing or older than them, so
the stale-.so class dies too; test/test-chelper.py returns from 3a28fdf's
grave to prove every cdef'd function is exported, and runs both in patch.sh
and in verify.sh against the extracted package. BUILD_KLIPPER=fork with no
tree is a hard build failure now; stock has to be asked for by name.

verify.sh also stops trusting `echo "$LIST" | grep -q`: under pipefail,
grep exiting at an early match SIGPIPEs the echo and the check reports 141
-- a race that stayed invisible until the fork tree tripled the payload
listing, then intermittently declared Mainsail and Moonraker missing from a
package that carried both. Herestrings have no pipe to break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 13:52:20 +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 e5bd171cf8 Correct the docs the code had drifted away from
Applies the audit's sections B, C and D, plus two decisions.

Behaviour (A1): fm_ex1..3 carried stock's `extruder: extruder`, so a clog on
T1..T3 was measured against T0's filament movement. Each motion sensor now
watches its own extruder.

Gate count (A6): "four gates" was the number of make test-* targets, not of
gates run -- run-tests.py emits twelve. The number is dropped rather than
restated, in docs/testing.md and the Makefile help.

B, install instructions that failed as written: STOCK_TGZ_CREATOR5 missing its
.tgz; a pointer to a ./build.sh that does not exist; klippy-extras/ paths that
moved to payload/klipper/extras/ in 0ef9d6b; a calibration recipe both
commands refuse without PLATE_REMOVED=1; a storage table that put dock_x/dock_y
in an include, which SAVE_CONFIG refuses; four files telling the user to add
includes printer.base.cfg already ships; ./pack.sh style paths that do not
resolve from where the scripts leave you.

The MAINSAIL_ZIP / HELIX_TGZ override was documented in three places as "used
as-is, never checksummed". It is checksummed, and fetch-assets.sh overwrites
the user's file with the pinned release -- SKIP re-downloads too. Corrected,
with the way that actually works (match the hash in versions.env).

C, files that contradicted themselves: the -5/-3 ESTOP target, the
t<n>_offset_* storage names, the chamber heater's model split, the LED
premise, the include order comment in test_includes.py, gear_stepper as a
"feed hub" when it is the tool lock, an object list ff-toolchange.cfg says
does not exist, the eddy probe vs the under-bed cylinder, "installs them
without clobbering" against a gate that enforces the clobber, "keeps two
versions" when the installer keeps one, "two changes from stock" when there
are three, and ff_legacy's "nothing here runs during normal operation" when
auto_import runs on every ready.

D, dangling references: OKF/* and firmwareExe-decompiled/* repointed at
docs/notes/*, plus case-boot.sh, sim-image.sh, printer-exec.sh, the
test/printer build context, wait_for_banner, ff_toolchanger_compat and a
nonexistent orca snippet.

Not touched, pending a decision: sections E-H, and the printer.base.cfg
"verbatim" claim (A7), which depends on how the unpack drift check is fixed.

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 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 a7e0b3fd3e Bring the MCU handshake in line with the decompiled stock routine
The level board never left its bootloader, so klippy died on
"mcu 'levelboard': Wait for identify_response". Four separate defects,
three of them in ff-mcu-bringup.py and all of them visible in the
printer's own output once you line the runs up.

The ack. ttyS7 answers 0x01 to 'A', every time, where ttyS4 answers 0x06.
The script sent 'A' once, saw a byte it did not recognise, printed "sent
the banner but acked b'\x01'" and gave up -- leaving the board in its
bootloader. So the three bootSerial* routines were disassembled out of
1.9.7's firmwareExe rather than guessed at any further, and stock does
this:

    send = 'A'
    for (j = 0; j < 0x32; j++) {
        write(fd, &send, 1);
        read(fd, &recv, 1);
        if (recv == 6) { isAck = 1; break; }
    }

Fifty attempts, and the reply is ignored except as a reason to stop
early: isAck is initialised to 1 and never assigned 0 anywhere in the
function, so the ack byte cannot fail the handshake at all. We now do
the same fifty writes and treat a non-0x06 byte as information, not a
verdict.

One port at a time. poke() opened, polled and closed each device in
turn, and a serial port nobody holds open is shut down in the driver --
so every banner ttyS7 sent while ttyS4 was being polled went in the bin.
That is the whole explanation for the two ports taking turns reporting a
banner across consecutive runs. Both are held open for the pass now.

A read count instead of a clock. MAX_READS=50 was documented as a ~5s
ceiling, which holds only when the board says nothing: VTIME=1 bounds an
*idle* read at 0.1s, and a board that is talking returns reads instantly.
Fifty of those can elapse in milliseconds. The pass is on a wall clock
now.

"No banner" reported as "already running". It is not the same thing, and
the printer proved it: ttyS4 was declared already-running on one pass and
sent a bootloader banner on the next. Stock's ready phase is only ~5s and
never waits for a second banner, so it only ever worked when it ran right
after the board powered up; the banner period is longer than that window.
An unknown is reported as an unknown now, and a board that keeps
bannering after fifty 'A' is reported as still in its bootloader.

Separately, start.sh tested -x on the script and printed "missing" while
it sat right there in /usr/data/anvil/bin. It is handed to the
interpreter by path, so the execute bit is irrelevant -- the payload
carries mode 644 in git and only patch.sh and run-append.sh ever chmod
it. Tests -f now.

Verified against ptys standing in for the boards: heat acking 0x06 and
level acking 0x01 both come up; a board that yields 0x06 only on the
eleventh 'A' is caught in 0.2s; a board that keeps bannering is reported
as stuck with a non-zero exit; a silent port reports the real deadline;
missing devices still name both ports and do not raise.

docs/notes/10-hardware.md carries the decompiled handshake, including the
two things the three routines do not share and stock's odd
tcsetattr(fd, TCSETS, ...).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:07:10 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3a28fdf65b Drop test-chelper and its build gate
test-chelper.py compared c_helper.so's exported symbols against the cdef
blocks in the klippy tree shipped beside it, and bin/patch.sh ran it as a hard
build gate with ALLOW_STALE_CHELPER=1 as the escape hatch. Both are gone, along
with the Makefile target, the suite entry and the ALLOW_STALE_CHELPER
passthrough that had nothing left to pass through.

patch.sh still refuses a c_helper.so that is not MIPS32r2/nan2008/o32, so the
ABI failure mode is still caught. The symbol failure mode is not: cffi resolves
lazily, so a .so older than its klippy imports cleanly, passes the ABI check,
installs, boots, and dies at connect with a traceback that names cffi rather
than the stale build. That is a real thing that shipped once, so it is written
down in docs/building.md and docs/notes/20-klipper-fork.md rather than left for
someone to rediscover -- the fork path this guarded is winding down anyway, per
the same note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:38:31 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 607e6595fb Start the camera ourselves: mjpg-streamer was on the disk all along
Correcting the previous commit, which said the :8080 stream could not be
restored without cross-compiling a streamer. Both halves are actually true:

  * firmwareExe really did serve the stream itself -- its own accept loop,
    `GET /?action=stream`, boundarydonotcross, even announcing
    `Server: MJPG-Streamer/0.2` -- so killing it does take the camera down.
  * ...but /usr/prog/mjpg-streamer/ ships the genuine article: mjpg_streamer,
    input_uvc.so, output_http.so, its own libjpeg.so.9 and www/.
    app_startup.sh:159 exports LD_LIBRARY_PATH for it and then never launches
    it. Nothing needs building; the binary just needs starting.

S65camera does that, on the S50wifi pattern -- the wait for /dev/video0 and the
respawn loop run in a background subshell, so a printer with no camera plugged
in never holds up Klipper or the UI. stop() kills the supervisor before the
streamer, or it would come back three seconds later.

Two details are load-bearing and easy to lose: libjpeg.so.9 lives in the
mjpg-streamer directory, so without LD_LIBRARY_PATH the binary dies on a
missing shared object (the same trap moonrakerDaemon has in S60web); and the
plugin .so names resolve against the working directory, hence the cd.

nginx proxies it at /webcam/ with buffering off and no read timeout, since
MJPEG is one endless response, and moonraker.conf's [webcam] entry uses
relative URLs through that proxy -- so one forwarded port carries the stream,
tunnels included. MOD_CAM=0 turns it off; MOD_CAM_RES/FPS/PORT tune it.

The earlier claim is corrected in docs/notes/25 and in the README, which no
longer lists the camera as something you give up.

Verified: parses under the printer's own busybox ash, lint-danger clean. The
full suite was not re-run to completion -- the working tree is being edited
concurrently and a build lane raced it (tar: file changed as we read it).
bin/patch.sh's MOD_CAM line is deliberately left out of this commit for the
same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:06:25 +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 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