Commit Graph
44 Commits
Author SHA1 Message Date
Oleksandr ShyshatskyiandClaude Opus 5 2be2de2bc5 The compile does not drive s6-rc-update: on a printer it empties the scandir
Two commits ago this script called s6-rc-update so a recompiled database
reached a running machine without a reboot. On the printer it does this, from
a live state that had just booted cleanly:

    s6-rc-update: fatal: unable to manage new service directories
                  in /run/s6-rc: Value too large for defined data type

and by then every symlink under $MODDIR/etc/s6 is already gone, while the
s6-supervise processes it started are still running. Nothing looks wrong --
until the next s6-svscan rescan, which finds an empty scandir and takes down
klipper, the UI and wifi together. On a printer reached over that wifi, the
recovery is a power cycle.

So the database is compiled, `current` is moved, and the live state is left
alone. The cost is stated where it is paid: `s6-rc` reports "unable to read
valid state" until the next boot, because /run/s6-rc/state is one byte per
service in the database that booted and /run/s6-rc/compiled resolves through
`current`. Supervision is a separate mechanism and keeps every service up, so
the printer runs normally in the window.

s6-rc-update still ships. It is the right tool for a hand at a prompt, and the
EOVERFLOW wants finding first -- it is not the obvious missing largefile flag,
which skalibs, s6, execline and s6-rc all already pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb
2026-08-31 13:04:42 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 e3d5a62860 The ntp service execs ntpclient, the only name that sets the clock
sntpd and ntpclient are one binary that picks its argument parser from
argv[0], and only one of those parsers can step the clock. The step is behind

    if (!dry && ntpc->set_clock)          sntpd.c:485

and set_clock is raised in exactly one place: `case 's'` in the ntpclient
parser, sntpd.c:1459. Under the sntpd name it is initialised to zero with no
option to raise it -- that parser's -s means "log to syslog" -- and the daemon
disciplines the clock FREQUENCY through adjtimex instead. Right for a clock
that is roughly correct and drifting; useless on a printer with no RTC, which
boots in 1970. Parts per million do not close fifty-six years.

So the service has never worked. It resolved, connected, took valid stratum-1
readings, logged a reading line every poll, and left the clock in 1970 for as
long as the machine was up -- healthy by every signal except the one that
matters.

The recipe asks for --with-ntpclient, which is upstream's own install hook and
a symlink rather than a second copy of the binary (asserted, because a cp that
dereferenced it would ship 32KB twice). The run script execs that name with
-s -l, and its option block is rewritten against the ntpclient parser: the two
disagree about -p, and -n does not exist there at all, so the old arguments
would have been a usage error and a service that exited at once.

Measured on a printer: clock forced back two hours, service restarted, correct
again twenty seconds later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb
2026-08-31 13:04:28 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 dda4eb4025 s6-rc-update ships, and the compile runs it before current moves
Recompiling on a printer is not enough on its own. s6-rc-init writes
/run/s6-rc/state as one byte per service in the database it booted, and
/run/s6-rc/compiled is a symlink to compiled/current -- so moving `current`
under a running system leaves a state file whose size no longer matches, and
every s6-rc command fails:

    s6-rc: fatal: unable to read valid state in /run/s6-rc/state

Supervision survives it, which is what makes it quiet: s6-svscan and
s6-supervise are a separate mechanism, so every service stays up and
s6-svstat keeps answering while s6-rc itself is unusable until the next boot.
Seen on a printer, 2026-08-31, caused by the postinst added two commits ago.

s6-rc-update is the answer and was not shipped, on the reasoning that an
update is a reboot anyway -- true while updates arrived only as a .tgz, and
false now that a postinst compiles a database. It ships.

ORDER MATTERS, and the first version had it wrong: s6-rc-update reads the
database the live directory is on to know what it migrates FROM, and that is
`current`. Flipping first leaves it comparing the new database against
itself. The compile now migrates while the old database is still current,
and moves the symlink afterwards.

A migration failure is reported, not fatal: the database on disk is right
either way and a reboot applies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb
2026-08-31 12:43:22 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 9fcfaa43a4 klipper-config links the configs it ships, rather than hoping anvil-core did
printer.cfg includes the ff-*.cfg beside it, and those are symlinks that
anvil-link-prog.sh makes from whatever it finds in $MODDIR/config. The only
package calling that script was anvil-core -- which does not own those files,
and which opkg installs first. So an upgrade that ADDS an ff-*.cfg linked the
old set and left the new file with nothing pointing at it, and Klipper refused
to start:

    Include file '/usr/data/config/ff-pa.cfg' does not exist

Seen on a printer, 2026-08-31. The package that ships the files now relinks
them. Re-running the script from two postinsts costs nothing: link_one leaves
a link that is already right alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb
2026-08-31 12:26:48 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 15aa6abdda The boot database is compiled on the printer too, not only by the build
s6-rc's database is generated from anvil-core's etc/s6-rc/source, and the
only thing that ran s6-rc-compile was the payload build. So `opkg upgrade
anvil-core` on a printer wrote new service definitions and left the machine
booting the database compiled from the old ones -- the upgrade landed and
changed nothing.

anvil-core ships bin/anvil-s6-rc-compile.sh and its postinst calls it.
anvil-s6-rc calls it too, because s6-rc-compile writes its own execline
shebang and oneshot runner into every database it produces, so one left by
the previous s6-rc is not one this s6-rc should run.

The payload build calls the same script rather than its own copy of the
compile, passing the database name: given one the script prunes every other
database, which is what keeps two builds of one commit byte-identical.
Without a name it timestamps and keeps the database it replaced, so a printer
that will not boot has one to point `current` back at. Nothing live is
touched either way -- a running supervision tree keeps its database until the
next boot, so an upgrade cannot restart services mid-print.

anvil-s6-rc gains PKG_STAMP_EXTRA: its version comes from the tarball and
cannot see control/postinst, and a stamp that cannot see an input reports
"already current" and ships the previous .ipk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb
2026-08-31 12:22:48 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a51b09884d klippy is exec'd directly; start-stop-daemon matched moonraker
klipper/run launched klippy with

    exec start-stop-daemon -S --exec "$FF_PYTHON" -- "$KLIPPY" ...

-S starts the program unless a matching process is found, and --exec matches
on the interpreter rather than on the script. moonraker runs $FF_PYTHON too,
so start-stop-daemon found moonraker, reported the service already up, and
returned without ever exec'ing klippy; s6 saw a longrun exit and respawned it
for as long as the printer was on. One boot log carried 508 copies of
"/usr/data/anvil/bin/python3.13 is already running", with moonraker up,
klipper disconnected, ff-startup giving up with KLIPPER DID NOT FINISH
STARTING, and HelixScreen in front of a machine with no motion and no heaters.

Not intermittent: klipper waits on the mcu-bringup oneshot and moonraker
depends on nothing, so moonraker is always already running by then.

There was nothing for start-stop-daemon to do. s6-supervise is the parent --
it knows the pid, runs one copy of a servicedir at a time, and restarts what
dies -- so a duplicate check in a run script has no true positive left to find
and can only return false ones. The one case s6 does not cover, a klippy that
outlived its supervisor, was already swept by cmdline against klippy.py.

The guard is static rather than a replica test because the replica cannot
reproduce this. Printer binaries run there under qemu-mipsel-static, so a
moonraker process has cmdline[0] and /proc/PID/exe of qemu-mipsel-static, not
$FF_PYTHON; start-stop-daemon matches nothing and starts klippy correctly.
Measured: a replica baked from a package carrying the broken line passes a
behaviour test asserting klippy starts with moonraker up. A behaviour test
there would report this failure as green.

The rest of the diff is comment reduction, 119 lines to 66.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:58:39 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 28eb20f3da Package numpy, because klippy does not start without it
extras/stepper_resonance_tester.py opens with a bare `import numpy as np`;
klippy.py:122 walks EVERY config section through a load_object whose
importlib.import_module is not wrapped in except ImportError; printer.base.cfg
includes FlashForge's printer.vibration.cfg, which declares
[stepper_resonance_tester]. Since klippy moved to our own CPython 3.13 that has
not been a printer missing resonance testing. It has been a klippy that never
reaches ready.

So this is not a scientific extra. anvil-klipper depends on anvil-python-numpy
the way it depends on cffi, except that the others mean "klippy imports this"
and this one means "klippy starts". It also gives back SHAPER_CALIBRATE and
TEST_RESONANCES, dead since the same switch, and the VFA calibration behind
STEPPER_RESONANCE_FACTORY_CALIBRATE that docs/notes/44-vfa-calibration.md
traces end to end.

WHAT WAS NOT OBVIOUS, in the order it bit.

numpy 2.x builds with meson-python, and meson has never heard of
_PYTHON_SYSCONFIGDATA_NAME -- the trick every other python-* recipe here leans
on. pkg_mesoncross writes a real cross file instead, naming the $PKG_XW
wrappers rather than the raw toolchain: the wrappers carry -EL -mnan=2008, and
a cross file that reaches past them builds a tree that passes every gate which
only reads symbols and is refused at exec() on the printer. pkg_pywheel grew a
PKG_PY_PIP_ARGS branch to pass the file as --config-settings, which is the one
thing PEP 517 does standardise.

The cross file's `python =` is the BUILD python, and that is not a mistake:
meson introspects an interpreter by running it and asking sysconfig, and
pkg_pytarget has already made that sysconfig answer for mipsel. The old trick
does reach meson, just not by its own mechanism.

numpy's meson.build hard-errors below gcc 8.4 and ours is 7.2. The gate is the
SciPy Toolchain Roadmap's support statement, not a requirement of the code:
this toolchain compiles if constexpr, structured bindings, std::optional,
std::string_view, and pocketfft_hdronly.h -- 3635 lines of the heaviest
template C++ in the tree, and the half np.fft.rfft needs. Probed before the
floor was lowered, and lowered rather than removed, behind a guard that fails
loudly if upstream moves the check. 19 extension modules, every one at e_flags
0x70001405/0x70001407, 14MB shipped after pruning tests/, f2py/, stubs and
static libs.

meson-python is self-hosting, so pkg_buildpython runs meson and ninja just to
install the backend -- hence ninja-build in the image and PYPKG_HOST_LIST in
dependency order rather than alphabetical. And pip puts meson and cython in
work/.py-host/bin, which nothing had ever looked in: without that on PATH the
backend fails to install with `meson executable "meson" not found` while pip
list shows it right there.

THE GATE THAT WOULD HAVE CAUGHT ALL OF IT is qa/replica/test_klippy_extras_import.py:
walk the real /usr/data/config include graph, map every section to its extra,
import each on the printer's own interpreter. It needs no MCU, because config
load happens before klippy:mcu_identify. Nothing asked that question before,
which is how two comments in this repo came to assert the module guarded its
own import. test_numpy.py asks for numeric answers rather than a module object
-- linalg.lstsq specifically, because -Dallow-noblas selects the bundled
lapack_lite and _r2_sinusoidal_fit calls it every calibration round. And
test_install.py's include check read only ff-*.cfg, so FlashForge's four --
printer.vibration.cfg among them -- were never asked about at all.

ff-toolchange.cfg wraps the two search commands in _FF_SHAPER_PREP, as
SHAPER_CALIBRATE already was. STEPPER_RESONANCE_FACTORY_CALIBRATE is
deliberately NOT wrapped: it is a gcode_macro, not a registered command, and
same-named sections merge under RawConfigParser(strict=False) -- a wrapper
would not wrap it, it would BE it, with rename_existing pointing at itself. It
needs none, since its body is two calls to a command that is wrapped. No
SAVE_CONFIG in any macro either; it restarts klippy and stays the operator's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr2x7aDaY889qwSxbdLt8
2026-08-31 13:42:57 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 6a4067e863 VFA calibration is one import away, and that import stops klippy booting
The stock app's "magic" VFA calibration is not app-side math at all. Traced
from CommMgr::calibration_vfa (0x0075e694), it sends five G-code strings and
the last one is STEPPER_RESONANCE_FACTORY_CALIBRATE -- a macro in FlashForge's
printer.vibration.cfg over klippy/extras/stepper_resonance_tester.py, which
searches amplitude and phase for the motor torque-ripple compensation the
closed-loop driver applies at 1x/2x/4x the electrical frequency. Every piece is
already here: the module is byte-identical in our vendored fork, the mclib
sections are in our printer.base.cfg, and mclib_set_resonance_damp is in the
stock mainBoardGD dictionary we never rebuild.

WHAT THE TRACE TURNED UP ON THE WAY. Two comments in this repo said the numpy
gap was a small item because the module guards its own import. It does not:
line 1 of stepper_resonance_tester.py is a bare `import numpy as np`, and
klippy.py:122 loads EVERY config section through a load_object that does not
catch ImportError -- the default=None argument only covers a missing file.
printer.base.cfg:19 includes printer.vibration.cfg, which declares
[stepper_resonance_tester]. So on the CPython 3.13 klippy has run on since
53292b4 this is not a lost feature, it is a klippy that never reaches ready.
Confirmed by importing the module with numpy blocked at sys.meta_path.

Both comments are corrected rather than deleted, because the claim they made is
the one a reader would otherwise make again.

Documentation only -- no behaviour changes here. The note lays out the chain
address by address and the three ways out (package numpy, drop numpy from the
module, or guard the import), and says which to reach for first. Nothing should
be built on it before a real printer.log confirms the boot failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr2x7aDaY889qwSxbdLt8
2026-08-31 13:42:57 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 bd0df7c28d FF_PA_CALIBRATE runs FlashForge's PA sweep and reports the number
The host half of the touchscreen's automatic pressure advance, ported from
CommMgr::paTestMgr. ff_pa.py drives the recovered sequence verbatim -- the same
seven candidates in the same scrambled order, the same Y ladder, the same
5-sweep / 3-winner / mean-of-3 selection, the same "only verdict 9 passes" --
and prints the result. It saves nothing and applies nothing; the measurement
itself stays where it already works, in the closed eBoard firmware we ship
unchanged, reached through [pa_adjust]'s existing command wrappers.

It borrows those wrappers rather than declaring its own. Declaring our own
would be safe (lookup_query_command builds a fresh wrapper per call and
SerialRetryCommand registers per send), but it would duplicate a closed MCU's
protocol strings and would need a config-time callback on [mcu eboard], letting
a report-only tool block startup on a machine whose eBoard is unplugged.

Deliberate divergences, all commented at the point they matter: the extruder's
own pressure advance is restored on exit, which the app never does because
SET_PA_ADVANCE overrides it and we do not; a run where every line came back with
the same verdict is refused, because "smallest passing wins" then yields the
smallest candidate wearing a measurement's clothes; cold extruder, empty tool
and out-of-range geometry are refused before anything moves rather than failing
mid-line with the eBoard armed; SET_KINEMATIC_POSITION is not replayed, since
our sweep is plain G1 under Klipper's own kinematics.

What is NOT changed is the timing. pa_action is an immediate MCU command and
there is no M400 between the last G1 and ACTION=0; the scorer was tuned against
that, and Klipper's own input throttle is what bounds the window.

FF_PA_PROBE draws one line and prints one verdict. It exists because the first
real question on hardware is whether the eBoard discriminates between candidates
at all, and answering that should not cost a full run.

Checked: pyflakes clean; qa/static 143 passed; both packages build and both
files land in the .ipk; the include set and the shipped ff-*.cfg set match the
way the replica gate checks them. The selection logic was exercised against
stubs -- it picks 0.0150/0.0200/0.0150 -> 0.016667 from a canned verdict table,
refuses an all-9 run, and refuses fewer than three winners. Nothing in the repo
can load klippy off-hardware, so behaviour on a machine is still unproven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kw2rehvQJ7LSc28hPikfqk
2026-08-31 01:00:20 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 9ef21c1fb8 wpa_supplicant is a supervised longrun, not a oneshot that forks
The wifi oneshot backgrounded a subshell and returned at once, so that the
15s wait for wlan0 and the 20s wait to associate did not stall the boot
transition firmwareExe blocks on. Its comment was explicit that nothing
could depend on it, which was true and was not the problem.

The problem is the subshell it left behind: a child of s6rc-oneshot-runner
with its stdout on a pipe s6-rc closes the moment the up script returns, so
a sleep in the wlan0 wait followed by an echo is a write to a closed pipe.

OBSERVED on a printer: a healthy supervision tree, s6-rc reporting wifi up,
no wlan0 configured, no lease, and not one line anywhere saying why --
HelixScreen calling it "wifi system is not ready" because there was no
supplicant holding a control socket. Getting in at all needed a repacked
.tgz that brought wifi up ahead of s6. The subshell was never caught in the
act and could not be: once its pipe is gone it cannot report its own death.
Which is the argument, whatever the exact signal was. A oneshot that forks
and returns cannot be relied on and cannot be debugged when it isn't.

So: type longrun, no -B, exec wpa_supplicant in the foreground. Blocking IS
the job now, s6-supervise holds the process, and a supplicant that dies is
restarted rather than mourned. up/down are gone -- a longrun's down is
silently dropped anyway.

wifi-dhcp is new and is not optional. wpa_supplicant only associates; it
never asks for an address, and running udhcpc was the oneshot's last act.
bin/wifi-action.sh has shipped since the s6 migration for exactly this hook
and was invoked by nothing at all -- `wpa_cli -a` is what invokes it. That
also fixes what the oneshot never covered: it ran udhcpc ONCE after boot, so
an owner joining a network from the HelixScreen panel an hour later was
associated with no address, which the panel reports as a connection timeout.

Its run script re-runs the action script when wpa_state is already COMPLETED.
wpa_cli fires on CHANGES, and it is not established that it emits CONNECTED
on attach to an already-connected supplicant, so without this the lease that
finish drops would not come back on a restart. The action script replaces any
previous client first, so it is idempotent with a real event.

udhcpc keeps -b inside the action script. Different flag, different program:
wpa_cli blocks until the script returns, so a foreground udhcpc would wedge
event processing.

ntp/run reasoned about "the wifi oneshot" and about nothing being allowed to
depend on it; it still takes no wifi dependency, now because wifi being up
means a supplicant is running rather than that anything is routable. The
service tables in how-it-works.md and building.md, and test_s6rc.py -- where
wifi sat in HW_SET as "the oneshot above" and test_execline_shipped_too used
it as its example -- follow. Both wifi and wifi-dhcp stay out of BOOT_SET:
they want a real wlan0, which the replica has not got.

qa-static passes. The database is NOT compiled here -- s6-rc-compile is a
MIPS binary that runs in the replica -- so make qa-replica is still owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPQqLMitZzrRcaehiYLHdx
2026-08-30 22:17:00 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 6bb58a4091 The timelapse pin is main's head, and v0.0.1 is not a newer one
Two comments claimed upstream had never cut a tag. It has: v0.0.1, October
2021. The claim came from checking a --depth 1 clone, which fetches none.

The pin does not move. v0.0.1 is two years OLDER than c7fff11e, which is
still the head of main, so "update to the latest release" would drop
hyperlapse, the configurable delayed_gcode interval and the webcam-database
lookup getWebcamConfig depends on. Both comments now say which release exists
and why the pin is a commit anyway, so the next reader does not re-derive it
and reach the opposite conclusion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSmXJv47Wwq2J7FfgCgpCe
2026-08-30 15:56:32 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3dc50ad187 moonraker-timelapse ships as a package
The Moonraker component and the Klipper macros behind Mainsail's Timelapse
tab, pinned by commit because upstream has never cut a tag.

Upstream's scripts/install.sh is not used: it clones into a home directory,
symlinks into a Moonraker checkout, appends to live config and registers an
update_manager entry that re-clones itself over the network. All of those are
things this repo does differently, so the tarball is a source like any other
and the two files it contributes are placed by hand. timelapse.py lands
inside anvil-moonraker's directory because Moonraker resolves a component
with import_module(".components.<name>", "moonraker") and looks nowhere else.

No BUILD_TIMELAPSE flag, unlike every other optional piece here.
moonraker.conf carries a [timelapse] section and printer.base.cfg an
[include timelapse.cfg]; Moonraker dies on a section with no component behind
it and Klipper on an include it cannot find, so the off position of that flag
would not be a printer without timelapses -- it would be a printer that does
not come up.

The dependency arrow runs one way on purpose. anvil-timelapse does NOT depend
on anvil-moonraker, though it plainly needs it: anvil-moonraker already
depends on this package for the config section above, and declaring both
would be a cycle. Only the direction that is a hard failure is written down.

frame_path is /usr/data/anvil-timelapse rather than the default
/tmp/timelapse/: /tmp here is tmpfs, so a tall print's frames would be
hundreds of megabytes of RAM on the machine running that print.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSmXJv47Wwq2J7FfgCgpCe
2026-08-30 15:47:46 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 58cfd00415 FlashForge's ffmpeg cannot render a rotated webcam, so we build our own
moonraker-timelapse emits -vf 'transpose=1', 'hflip,vflip' and friends
straight from the webcam's rotation and flip settings. The stock ffmpeg is
configured --disable-everything with a narrow enable list, and asking the
binary itself for -filters returns format, null, scale and trim -- so every
one of those renders fails on a printer whose camera is not upright.

So: ffmpeg 4.4.5 and x264, cross-built, static, one 1.8M binary at
$MODDIR/bin/ffmpeg. The enable list is FlashForge's plus the filters they
left out, since flash is scarce and nothing else is wanted.

There is no hardware encoder to use instead, and it is written down in
pkgs/3rdparty/ffmpeg/pkg.conf so nobody re-derives it: the X2600's device
tree declares jpege (a JPEG encoder), jpegd and felix, and felix is an H.264
DECODER -- the die has no H.264 encode block at all. Only felix has a driver
in the stock kernel; jpege and the rotate block have DT nodes and clock gates
and nothing bound to them. Software x264 is the only route to an H.264 mp4
here, which is why moonraker.conf tunes it with -preset ultrafast rather than
leaving the default.

x264 is named anvil-x264-dev outright, as anvil-zlib-dev is: every file in it
is a header or an archive, so a PKG_DEV_FILES split would leave an empty
anvil-x264 beside it that no printer would ever install.

bzip2 joins the build image because x264's only distributed form is a
.tar.bz2 and tar could not unpack it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSmXJv47Wwq2J7FfgCgpCe
2026-08-30 15:47:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 d898d105e0 The Moonraker pin was frozen by a missing _sqlite3 we now build ourselves
MOONRAKER_VERSION was 9d0d09d, the last commit before 80c7620 moved the
database from lmdb to sqlite, because FlashForge's python 3.8.2 has no
_sqlite3 and every release after it died loading the database component.
This repo builds and ships its own CPython 3.13 now, and it has one. The pin
is v0.11.0 -- a release tag, not a sha, so PKG_VERSION is just 0.11.0 and no
longer the Debian-style 0.8.0+git<sha> snapshot string. GitHub strips the
leading "v" from the directory inside /archive/<tag>.tar.gz, which is what
${MOONRAKER_VERSION#v} in the recipe is for.

The dependency set is the part that actually moved. Read against 0.11.0's
CORE_COMPONENTS plus the sections moonraker.conf enables:

  dbus-next became dbus-fast. dbus_manager is a core component and imports
  it at module scope, so this is not optional whatever [machine] provider
  says. Its sdist cythonises nine modules; SKIP_CYTHON=1 ships the python
  they were generated from and keeps the package ARCH all. The wheel still
  comes out platform-tagged -- poetry-core marks the distribution impure
  for declaring a build script at all -- but it holds 29 .py and no .so,
  and the content is what ships.

  importlib-metadata is new, imported at module scope by
  utils/source_info.py, and brings zipp. Both build through setuptools_scm,
  so PYPKG_HOST_LIST grew packaging and setuptools-scm. That list is now
  install order: pkg_buildpython pip-installs each with --no-deps against
  no index, flit-core builds packaging, setuptools_scm imports it.

  pyserial-asyncio is gone. Nothing in 0.11.0 imports serial_asyncio;
  utils/async_serial.py drives pyserial over asyncio itself. pyserial stays
  -- klippy needs it and declares it.

LMDB STAYS, for a reason that is not the old one. 0.11.0 converts an
existing data.mdb to sqlite on first start and imports lmdb to read it. With
the module missing it tries to pip install one, and a printer has no index
to install from -- the old database would be dropped in silence. It is the
upgrade path now, not the database, and the comment on WANTED_MODULES says
so.

inotify-simple STAYS AT 1.3.5 though 0.11.0 pins ==2.0.1: 2.0.1 declares its
licence the PEP 639 way and needs setuptools>=77 to build, against the 75.6.0
pinned here for lmdb's sake. It fails at metadata generation, not at import,
which is the kind of thing that reads as a broken recipe later. 1.3.5 carries
every name 0.11.0 asks of it, and moonraker.conf sets file_system_observer:
none, so the import is all that is asked. jinja2 goes to 3.1.6 to match.

Not taken: paho-mqtt, zeroconf, apprise, ldap3 and python-periphery, whose
components this printer does not enable, and msgspec and uvloop, which
Moonraker imports under contextlib.suppress(ImportError).

Verified: every changed sha256 downloads and matches at the URL
fetch-assets.sh builds; all five affected wheels build offline
(--no-index --no-build-isolation) on a real 3.13 with the host backends
installed in the declared order; qa/static 122 passed, including the gates
that cross-check PYPKG_LIST against the recipes and every PKG_DEPENDS
against the feed. The mipsel cross-build and `make qa-replica` have not run
yet, and versions.env still says a bump is gated on the latter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012oRdgKM7scrEsr6waAByaY
2026-08-30 15:07:38 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 2e9f2214a0 Merge master into the comment trim
Master moved twelve commits while this branch ran: patch.sh became
payload.sh, run-pre.sh and run-append.sh became runFirmwareExe.sh, the
install manifest and anvil.conf are gone, and versions.env was trimmed
harder there than here (53 comment lines against 185).

Resolved so the merge contributes comments and nothing else. Verified
mechanically: every one of the 270 files in the tree now carries
exactly master's code, and no file this branch touched is resurrected.

  - master's deletions accepted: bin/patch.sh, bin/verify.sh,
    installer/run-{pre,append}.sh, anvil.conf.in
  - versions.env: master's, which is the better trim
  - 26 files master only renamed through: this branch's trim kept, with
    patch.sh -> payload.sh applied
  - 14 files master rewrote to describe the new installer: master's
    text taken wholesale, to be re-trimmed separately
  - stale references to the manifest and run-append.sh corrected in
    pkgs/3rdparty/busybox/build.sh and pkgs/moonraker/build.sh

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMMpQTajN7RDJySrhQ2cTE
2026-08-30 16:25:37 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 bf7d0f7e06 The printer has no clock and nothing was setting it
There is no RTC and no battery, so every boot starts at whatever the
kernel was built with and stays there: job timestamps, log ordering and
certificate validity are all wrong on a machine that has been unplugged.
Nothing on the stock rootfs could fix it -- FlashForge's busybox is
v1.31.1 built without the ntpd applet, and its rdate speaks RFC 868.

anvil-sntpd is troglobit's sntpd, a fork of Doolittle's ntpclient: one
small binary, servers and interval on the command line, so there is no
config file, no privsep user and no chroot to place on a read-only
rootfs. It ships $MODDIR/sbin/sntpd and nothing else.

A LONGRUN, NOT A HOOK ON THE WIFI ONESHOT. That oneshot runs once, at
boot, and returns before wlan0 has associated -- so a sync hung off its
end would fire too early, never retry, and never fire at all for the
owner who joins a network from the HelixScreen panel an hour later. And
with no RTC a clock is not set once but maintained. sntpd keeps its
schedule with the network down instead of exiting, which is what lets
this service need no wifi dependency -- just as well, since nothing may
depend on that oneshot.

The poll interval is 300 rather than upstream's 600 because it is also
the ceiling on the down-network retry backoff, and so bounds how long a
printer that joined wifi late keeps the wrong time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149y4iyLpEwYKh2PS4fmf4u
2026-08-30 16:03:55 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5d7754285e Wipe $MODDIR and extract, instead of pruning by a manifest
The installer deletes /usr/data/anvil outright and extracts into the
empty directory. The installed set is the shipped set by construction,
so a renamed file cannot survive beside its replacement and no list has
to be shipped, read or trusted for that to hold.

What that costs is everything else under $MODDIR, and the audit of what
was actually there is docs/notes/86-wipe-and-extract.md. backup/stock
had no restore path in any script and omitted the one file a restore
would need. .prev-root-hash was already dead state. config-installed
was a twelve-file snapshot serving one md5, for a compare whose stated
rationale -- "there is no include-and-override seam" -- stopped being
true when moonraker-custom.conf became the last include, and which
inverted its own purpose by keeping the webcam block and the API
lockdown off any printer that tripped it. So moonraker.conf is
overwritten like every ff-*.cfg.

HelixScreen's /tmp stash is untouched and is now the only preservation
the installer does, so the gate asserts it with a value that could only
have come from the printer.

Gone with it: both delete passes, the forged-path guard, the rmdir
pass, the pre-manifest branch, the unconditional init.d/anvil-service/
anvil.conf removal, the etc/s6 scandir sweep and the $MODDIR __pycache__
sweep -- the last two existed only because a manifest cannot see paths
made at runtime. firmwareExe already makes the scandir itself.

test_upgrade.py rewritten: the negative control is inverted rather than
deleted, and the legacy fixture goes -- there is one path now.

qa/static green (112). The replica lane needs a package in work/out,
which needs config.env with FF_KEY and STOCK_TGZ; not run here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXKk3zz8LAUPUDZkVEZ9fR
2026-08-30 15:49:26 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 7ffb39a730 The last ten files still named run-pre.sh and run-append.sh
Both were deleted three commits ago and the sweep that followed missed these:
the recipes, the s6-rc service scripts, printer.base.cfg's header and three qa
modules all still pointed at them by name. Renamed to runFirmwareExe.sh, which
is the file that actually does each thing described.

Two needed more than a rename.

anvil-link-prog.sh's ORDERING note explained itself in terms of stock run.sh's
line numbers -- "distributes the software component at its lines 107-179,
before the payload is extracted at line 180" -- and none of that is the shape
of an install any more. What the note is really for is why the links cannot
ship as files, which is unchanged, and it now says that instead. The paragraph
about the component no longer carrying firmwareExe is replaced by the reason
that matters: no component ships at all, so the version directory under
/usr/prog/PROGRAM/software stays FlashForge's and app_startup.sh's watchdog
has their binary to fall back on.

test_moddir_paths.py's ALLOWED set excused two paths that no longer exist.
anvil.conf went when the settings it held moved into the services, and
.prev-root-hash is not written any more -- this installer only deletes the
stale ones. Both were dead weight in a list whose whole job is to say "not
shipped, and correctly so": an entry for a file nobody guards on excuses
nothing and hides the next real one.

Gates: 112 static tests, shellcheck clean over the edited payload scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH7Yb4wuPhnppHAB25UX7e
2026-08-30 15:38:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 b98552386b No FlashForge component ships; patch.sh is payload.sh
The software component existed to give the mod one exec hook. It is not needed
for that any more, so a release now carries our installer and our payload and
nothing else: 26MB smaller, 21MB of which was FlashForge's own firmwareExe
travelling back to the partition it was already on, along with their klippy
tree, their wifi module and their configs.

WHAT IT GIVES BACK. /usr/prog/PROGRAM/software is no longer rewritten, so the
version directory there stays FlashForge's with their genuine firmwareExe
inside it -- and app_startup.sh's five-second watchdog, which copies one out of
that directory when nothing by that name is running, has something to copy
again. A printer whose payload fails to install now comes up on the stock UI
instead of a blank screen. That recovery has been broken since we started
deleting their binary out of a component we shipped; docs/how-it-works.md
claimed it worked throughout.

THE ROOT PASSWORD moves with it, because it used to ride in the component's
shadow file. bin/pack.sh bakes ROOT_PW_HASH into the installer, single-quoted
-- an unquoted $6$... would have the printer's shell expand $6 as a positional
parameter and ship a truncated hash -- and the installer writes it to
/usr/prog/etc/shadow. The random-password path asks one question instead of
four: FlashForge ship the same root hash on every machine, so a live shadow
still carrying it means nobody has set a password and it is ours to set. The
.prev-root-hash recording, the shipped-vs-live comparison and the restore-it-
afterwards step were all machinery for surviving a file that nothing overwrites
any more, and are gone. pack.sh checks the hardcoded constant against the stock
package it built from, because getting it wrong is silent and total: every
printer would read as "already set" and none would ever get a password.

bin/patch.sh is bin/payload.sh. It patched nothing any more -- no splice, no
shadow edit, no firmwareExe deletion -- and what is left is the payload: the
feed installed by the printer's own opkg, the seeded config, the manifest. Its
sections are renumbered 1-5 with no gaps, and the five tombstone comments
marking where removed sections used to be are gone with them.

test_recipe_layout.py loses six of its nine tests. Each compared the tree
against a description of the tree -- no fourth directory kind, each kind still
used, 3rdparty carries no files of ours, installer/ holds exactly two named
files -- and none could go red for a reason a printer would notice, while all
six went red the moment the layout legitimately changed. The two that bite are
kept: a payload/ path escaping $MODDIR, and a recipe name at both levels, which
makes pkg_dir silently build the wrong package.

SW_VER is deleted from config.env.example and both workflows: it stamped a
component that is not built.

Gates: 112 static tests pass. The generated installer is verified after
substitution -- all four lines present, and `sh -n` over the result, because a
hash with a stray character in it would otherwise reach a printer as a syntax
error whose only symptom is a package that installs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH7Yb4wuPhnppHAB25UX7e
2026-08-30 15:38:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 9b5b562a22 Comments: keep the contract and the trap, drop the narration
A sweep over every commented file in the tree. What is kept is what a
reader needs to change the code safely: what a thing is, its signature,
the invariants other files depend on, and the traps that fail silently
(find's non-zero exit under set -e, s6's baked-in libexecdir, the -lm
that decides whether _sqlite3 exists, opkg's compile-time prefix).

What goes is the narration around it: incident histories, arguments
against alternatives already rejected, restatements of the code below,
and paragraphs re-explaining a rule three files already state. One
paragraph in pkgs/3rdparty/opkg/build.sh was duplicated outright.

Comment lines 7971 -> 6158. Every change is comment-only: no
non-comment line differs anywhere in the 86 files, checked
mechanically. Commented-out config directives (#fast_feed: 30000 and
friends) are documentation of defaults and are kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMMpQTajN7RDJySrhQ2cTE
2026-08-30 14:54:50 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 57c776ee63 test/ is deleted; qa/ is the only suite
The eight files were already gone from the working tree. What this commit does
is everything that had to follow them, because nothing else had been updated
and three of the four things pointing at test/ were on a build or release path.

WHAT WAS STILL WIRED TO IT

  pkgs/klipper/build.sh   ran test/test-chelper.py under `|| pkg_die`. With the
                          file gone `make packages` would have died on every
                          klipper build.
  bin/verify.sh           ran the same script and read a non-zero exit as
                          "c_helper.so does not match the shipped klippy tree
                          (stale build)" -- a false red on every release verify
                          rather than an error about a missing file. Its
                          ship-boundary check also globbed `find bin test
                          docker`; that is `find bin docker` now.
  pytest.ini              testpaths = test, so a bare `pytest` collected
                          nothing at all. It is `qa` now, which is what the
                          comment above it was already describing.
  qa/static/              PY_GLOBS listed test/*.py and test/integration/*.py,
    test_shell_syntax.py  and _files() makes an empty glob a collection error
                          on purpose -- so the whole static lane failed to
                          collect, taking 93 unrelated tests with it.

Also dropped: the `pytest -- the host-side tree` step in ci.yml, the `test-py`
target with its .PHONY entry and two help lines, and qa/conftest.py's opening
paragraph explaining why qa/ sat beside test/ rather than inside it.

THE CHELPER SYMBOL CHECK IS GONE, NOT MOVED

It compared the .so's dynamic symbols against what klippy cdefs, and it was the
only check for a c_helper.so built from older sources than the klippy tree
beside it -- the failure that shipped once and bricked klippy startup on
hardware, because cffi resolves lazily and a stale .so imports cleanly.
qa/replica/test_install.py covers the ELF arch and nan2008 and nothing else.

What stands in its place is structural rather than a gate: pkgs/klipper
compiles the .so from the chelper sources of the very tree it ships, so the
mismatch is not something the recipe can produce. The comments in
pkgs/klipper/build.sh and docs/building.md now say that is the whole defence.

DOCS

docs/testing.md's "what is left in test/" section is now "test/ is gone", and
it names what went uncovered rather than leaving it to be found: the tool-frame
arithmetic in ff_toolchange, the chamber macros' per-model branching, the stamp
discipline in ff-startup.py, and the verification G-code against the shipped
macros. docs/hardware-testing.md said `make test-py` keeps the two
gcode/creator5-*.gcode files honest; it now says nothing does, and to read them
by hand when a macro is renamed. Paths corrected in docs/building.md,
docs/printer-replica.md and docs/qa-migration.md's intro.

Verified: `pytest` collects 366; qa/static is 136 passed, up from 43 because
the lane could not collect before this. Its 110 errors are this checkout's
missing vendor/opkg-utils, the same cause before and after. bin/verify.sh,
pkgs/klipper/build.sh and the Makefile parse; ci.yml is valid YAML.

Not touched, and stale before this commit: tools/python/README.md and
tools/supervisor/README.md still document test/integration/printer/case-*.sh
and a printer-exec.py that earlier commits removed.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015E7armHFDcnZFZwdAbqkbt
2026-08-30 01:09:02 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a534b4a744 klipperDaemon is a link too; its nice value moves to anvil.conf
The third file in $MODDIR/prog was the only one anvil-link-prog.sh did not
own. It could not be a link because its CONTENT was machine-specific: the
installer seded FlashForge's KLIPPER_NICENESS into it on every flash, off the
file it was replacing. That is the whole reason it was hand-copied, and the
hand-copy is what silently broke -- its guard named a path the file had
stopped shipping at, so no printer got the shim at all.

We do not carry FlashForge's number forward. NICE_KLIPPER is ours now, in
anvil.conf beside NICE_MOONRAKER and NICE_CAM, templated from config.env by
patch.sh section 10 like the other two, and read by etc/s6-rc/source/klipper/run
the way camera/run and moonraker/run read theirs. Default 0 -- normal priority,
which is what stock shipped and almost certainly what you want: klipper_pri.sh
already puts klippy's main thread on SCHED_FIFO 50 and its serial reader thread
calls os.nice(-20) itself, so this knob only matters when the chrt did not take.

With nothing machine-specific left in it, klipperDaemon is one more link_one.
Whether the stock software carries a klipperDaemon does not matter: link_one
replaces whatever it finds, and it runs after the payload has landed. The 14
lines of sed/mv/chmod in run-append.sh are gone, and an `opkg upgrade
anvil-core` now changes what the printer runs for all three files instead of
two.

Gates: the replica symlink test covers all three paths, and a new one RUNS
`klipperDaemon start` -- it must exit 0, say klipper is supervised, and leave no
second klippy behind. The file being in place said nothing about what it does,
which is how the old breakage hid.

Verified: the nice branch over an explicit 0, a real value, and no anvil.conf at
all; and the anvil.conf substitution with NICE_KLIPPER set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01La5MaDKqTW675k5fsXsP6S
2026-08-30 00:58:16 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 7dda2fee0d ABI gate: one check, on the filesystem the kernel loads from
mips_abi_gate answered "did this recipe stage the right ABI", once per
recipe, over a PKG_ROOT. Four implementations of that one rule had grown up
around it -- the function in bin/common.sh, the call at the packaging
boundary, two recipes that re-ran it on their own output, and a
readelf|grep nan2008 in bin/verify.sh reading a single .so -- and between
them they still could not see the largest binary we ship: bin/patch.sh
unpacks HelixScreen into /usr/prog and no gate ever read that tree.
pkgs/helixscreen/pkg.conf had carried a note saying exactly that.

qa/replica/test_abi.py replaces all of it. It walks the installed
filesystem on the printer's own CPython -- one qemu process, ~24000 files,
8s -- and reads the ELF header of everything it finds. That is the only
place where what the recipes staged, what opkg unpacked, what patch.sh
copied and what FlashForge shipped are all visible at once, and it is the
thing the question is actually about: the kernel does not read a PKG_ROOT.

MEASURED, not assumed: 603 ELF objects on a real install. 569 the loader
handles and 568 of those conform. The exemptions are the other two, both
narrow. ET_REL objects (33 stock .ko files, plus a stray python.o in
FlashForge's CPython 3.8 tree) are not asked about the FP ABI, because
insmod and the linker read them rather than the ELF loader and kernel code
has no FP ABI to agree about -- by e_type, so the python.o needs no case of
its own. And /usr/prog/PROGRAM/ffstartup-arm is an ARM binary FlashForge
ships that nothing in the stock tree names; exempt by path, so an ARM
binary anywhere else still fails.

The architecture is settled before anything is classified, and that
ordering came from planting a counterexample rather than from reasoning: a
big-endian object decodes its own e_type big-endian, so ET_DYN reads back
as 768, lands in neither bucket, and an earlier draft looked straight at it
and passed. Every gate here was driven red on purpose before it was kept --
an x86-64 .so in $MODDIR, a real mipsel object with EF_MIPS_NAN2008
cleared, an ARM binary outside the exempt path, a big-endian object, an
x86-64 object among the .ko files, and a truncated sweep.

test_install.py's two chelper header tests go with it: they read one file
out of the several hundred on the same fixture, a strict subset of what now
runs. Whether the package ships a chelper at all stays test_install.py's
question and is not asked twice. test_ipk.py's assertion that the gate
exists becomes an assertion that a second one has not grown back beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NpH87TJJEjDyRhQPu18pgp
2026-08-30 00:47:41 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 965cf67d93 Compile the s6-rc database where the packages are installed
The replica already installs the feed with the printer's own opkg, and
anvil-s6-rc already ships s6-rc-compile -- pkgs/3rdparty/s6-rc/build.sh keeps
it deliberately, "the only way to recover a printer whose database is wrong
without rebuilding a package on a laptop". So the compiler was sitting in the
container the whole time, three lines after the install that puts the source
tree there.

bin/patch.sh section 5b-2 built a second, NATIVE skalibs+execline+s6+s6-rc
instead -- four ./configure && make passes cached in work/.s6-native -- purely
to get an s6-rc-compile that runs on x86. 110 lines, and its entire hazard was
self-inflicted: s6-rc-compile bakes the #! of the execline the COMPILER was
linked against into the oneshot runner, so a native stack configured with a
different --prefix ships a database whose every oneshot ENOENTs on the printer.
Compiling with the compiler we ship cannot get that wrong.

The output is the same object. docs/notes/80-s6-migration.md records the
measurement from 2026-08-28: one source tree, the host compiler and the target
one under qemu, byte-identical db, n and resolve.cdb and identical servicedirs.
That measurement was made to justify compiling on the host; it works just as
well in the other direction.

The rejected alternative in the old comment -- ship s6-rc-compile and compile
at INSTALL time on the printer -- is a different thing and stays rejected.

qa/static/test_s6rc_source.py is dropped. It was the only consumer of the
native compiler, and it hard-failed on any checkout that had not run `make
build`: 14 of the static suite's errors were it saying so. What goes with it is
the timeout and edge assertions, which are now made nowhere; qa/replica/
test_s6rc.py still boots the real thing. bin/verify.sh keeps the half a host
can still check, moved out of patch.sh: the oneshot runner's shebang, and that
compiled/current resolves -- read as text straight out of the shipped payload,
so it needs no mipsel binary.

bin/patch.sh: 278 -> 207 lines. Static suite: 122 passed, errors 124 -> 110.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01La5MaDKqTW675k5fsXsP6S
2026-08-30 00:44:26 +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 39710c0f02 s6-rc needs a finite deadline on a 32-bit time_t
s6-rc-init and s6-rc default to TAIN_INFINITE_RELATIVE. skalibs converts a
deadline to a struct timeval before select(), and infinity does not fit a
32-bit time_t: the call fails immediately with EOVERFLOW -- "Value too large
for defined data type" -- before a single service is touched. Passing -t fixes
it, in firmwareExe, start.sh and klipperDaemon.

WHY IT APPEARED NOW. qa-suite-bridgehead built the supervision stack with the
Bootlin musl toolchain, and musl has a 64-bit time_t on every architecture.
pkgs/lib.sh states the rule this repo actually follows -- "ONE TOOLCHAIN:
everything here cross-compiles with the Ingenic glibc 2.29 / gcc 7.2
toolchain" -- and glibc 2.29 on mips32 does not. So the bug was latent in the
graph and became visible the moment s6 became a recipe like everything else.
Fixing it in the callers keeps one libc, which is the point of that rule.

HOW IT WAS FOUND, because three plausible answers were wrong first. Not the
filesystem: it failed identically with the live dir on /usr/data and on /run.
Not the database: s6-rc-db read it fine, and a database recompiled inside the
replica by the target compiler failed the same way. Not a missing
-D_FILE_OFFSET_BITS=64: both s6-svscan and s6-rc-init reference readdir64 and
never plain readdir -- and s6-svscan supervises a NON-empty scandir on both
filesystems, which is the test that had been missing (it had only ever been
started on an empty one). qemu's own strace then showed no failing syscall at
all, which is what pointed at a deadline computed in userspace.

The boot now reaches the transition: the scanner comes up, s6-rc-init lays
down all seven servicedirs and links them, and s6-rc starts wifi, nginx and
moonraker. qa/replica/test_s6rc.py still fails, for an environmental reason
that is not this bug: the replica creates null, zero, full, random, urandom,
tty and fb0 and no ttyS*, so mcu-bringup cannot reach the toolhead boards,
klipper never starts, and the ok-all bundle never comes up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 19:48:02 +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 5f36cd14e3 Say what the code does, not what it used to
Comment cleanup over the files this branch touched, plus the dead code the
last two commits left:

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 18:42:57 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 26e3388895 The printer picks its own chamber config
anvil-klipper-config carries printer.base.cfg and BOTH chamber configs, under
config/chamber/<Machine>.cfg, and anvil-link-prog.sh symlinks the right one
into /usr/data/config. The model comes from FlashForge's own app_startup.sh,
which carries MACHINE= at its top -- so the machine answers the question about
itself instead of a build being told the answer and having to be right.

THAT REMOVES A PACKAGE PAIR THAT COULD NOT COEXIST. anvil-klipper-creator5-config
and -creator5pro-config each owned config/printer.chamber.cfg, so they
Conflicted, opkg refused the pair with exit 255, and bin/patch.sh had to pick
one from TARGET_MACHINE. The payload was model-specific for the sake of one
file; it is not any more. TARGET_MACHINE still names the output, because
runFirmwareExe.sh gates on it.

THE SOFTWARE COMPONENT'S klipper/config IS NOW BYTE-IDENTICAL TO STOCK --
diffed, not assumed. printer.base.cfg was the last thing patch.sh wrote there.

The seven configs we do NOT modify stay stock and unpackaged, and that works
because Klipper resolves [include] against the directory of the file doing the
including, by the path as opened rather than the resolved target
(configfile.py: dirname = os.path.dirname(source_filename)). So a symlinked
printer.base.cfg still finds printer.filament.cfg and friends beside it in
/usr/data/config.

run-append.sh no longer copies the files the linker links -- copying would only
put a real file in the way of the symlink about to replace it. moonraker.conf
and moonraker-custom.conf keep their branches: those are user-editable, and a
symlink would lose the .mod-new preservation a tuned trusted_clients block
depends on.

anvil-klipper now Depends on anvil-klipper-config, which is where that
dependency always belonged -- it used to arrive only because the per-model
packages pulled it in, and naming it in MOD_ROOTS would answer a missing
dependency in the one place a printer's own `opkg install` never reads.

prog/ is gone as a recipe subtree. Its own test said an empty one was the good
outcome; it emptied, so the rule went with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 18:24:33 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 722804364a The boot path follows the package, not the last .tgz
firmwareExe and start.sh have to sit on /usr/prog because FlashForge's own
scripts look for them by absolute path. They are package files now
(anvil-core, under payload/prog/) and anvil-link-prog.sh replaces the copies
on /usr/prog with symlinks into $MODDIR, so what the printer executes is what
the package owns.

WHAT THAT BUYS: `opkg upgrade anvil-core` on a running printer used to update
$MODDIR and change nothing the boot path reads -- the new firmwareExe sat in
the payload while the old copy kept being run, and only a full .tgz could land
it. The postinst calls the same script, so an upgrade is now enough.

THE LINKS ARE MADE AFTER EXTRACTION, and that is the whole ordering. Stock
run.sh distributes the component at its lines 107-179 and run-append.sh does
not extract anvil.tar.xz until line 180, so the component cannot carry the
links itself: on a first install they would dangle, and on an upgrade they
would resolve to the payload being replaced and silently install the previous
release's files.

The component still carries real copies. That is what keeps a printer
bootable when the payload is not there, and patch.sh now takes them from the
installed payload rather than from a directory beside the recipe -- the same
move it already makes for the klippy fork.

A postinst needed the recipe system to have maintainer scripts at all, so
build-packages.sh copies pkgs/<name>/control/* into CONTROL/. Note what that
exposes: patch.sh installs with a host opkg under --offline-root
--force-postinstall, and that combination DOES run maintainer scripts, with
IPKG_INSTROOT empty, so their absolute paths are taken as written. Inside the
build image that is harmless -- there is no /usr/prog there -- and the
script's guard is what makes it a quiet no-op rather than an error.

pkg_payload_hash covers control/ too, or an edited postinst would read as
"already current". It takes two find calls because `find a b` with b missing
exits non-zero, which under the recipes' set -euo pipefail killed every build
whose recipe has no control/ -- silently, the existing 2>/dev/null swallowing
the one line that said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VpUmBb4xtmnmtTosonqeNi
2026-08-29 17:45:20 +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 ShyshatskyiandClaude Opus 5 09e1e6521f patch.sh was asking the payload questions its recipes had already answered
Four sections went. Three of them were checks: _sqlite3 by name, which
pkgs/3rdparty/python/build.sh already made; libsodium's symlink, which
pkgs/3rdparty/libsodium/build.sh already made; and an ABI walk of the whole
payload, which bin/build-packages.sh makes over every PKG_ROOT on the way into
every .ipk -- from the packaging loop rather than a build.sh, so cached trees
are re-read too.

The fourth was not a check. config.env's BUSYBOX_BIN was copied into the
payload here, which made it the one file no package owned: an allowance in the
"every file is owned" test, an ABI gate of its own because the payload-wide one
ran before the copy, and a file no `opkg remove` could take away. It is
pkgs/3rdparty/busybox now, PKG_WHEN-gated on BUSYBOX_BIN, and it needed a third
source verb -- pkg_prebuilt, beside pkg_unpack and pkg_intree -- because a
recipe names its source exactly once and this one's source is a path someone
typed. Once it was a package, nothing in the payload came from outside the
feed, which is what made the payload-wide ABI gate redundant rather than merely
duplicated.

The "at least twelve extension modules in site-packages" count was the one
check with nowhere to go and no duplicate. It is pkg_pynative in the seven
python-* recipes that ship a .so: an aggregate could say that a native package
had fallen back to a pure-python wheel but never which one, and each of those
recipes can say it about itself.

What that costs is written into the section it left: a gate inside build.sh
does not run on a cache hit, and no python recipe sets PKG_STAMP_EXTRA. That is
pkg_stamp's problem, not an argument for a second copy of every check here.

Measured: `make packages` builds anvil-busybox_1.31.1-1_mipsel_xburst2.ipk,
`make build` assembles a 32-package payload whose bin/busybox is listed in
anvil-busybox.list, and qa-static is 265 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gKRUCqj18TVF1Vz1oMpmC
2026-08-29 15:24:20 +03:00
Oleksandr Shyshatskyi 48a11481bb The nginx gate was holding s6 to a rule it does not follow
case-nginx.sh section 1 asserted that s6-svscan execs s6-supervise BY
NAME off PATH, so a scanner started without $MODDIR/bin on PATH would
supervise nothing and say so. It failed, but not by supervising: the log
came back EMPTY, which is neither outcome the check knew about.

Reproduced in isolation in the replica rather than guessed at. With a
bare PATH the scanner spawns its supervisor and stays silent, because
there is nothing to report -- `strings` finds
/usr/data/anvil/bin/s6-supervise inside s6-svscan. A copy of the scanner
alone in an empty directory works too, so it is not resolving relative
to argv[0] either. Checked across the four binaries, and there are no
bare names anywhere:

    s6-svscan               -> $MODDIR/bin/s6-supervise
    s6-svc                  -> $MODDIR/bin/{s6-svlisten,s6-svwait,s6-svc}
    s6-svwait, s6-svlisten1 -> $MODDIR/libexec/s6-ftrigrd

Every one is compiled in at --prefix time -- the same property
pkg_buildopkg relies on for opkg, and the reason pkgs/3rdparty/s6 builds
with --prefix=$MODDIR.

So the section is inverted, and aimed at the thing that would actually
break quietly: rebuild s6 under a different --prefix, or relocate $MODDIR
without rebuilding, and the scanner goes looking for its supervisor at a
path that is no longer there. It now asserts the bare-PATH spawn
succeeds, that the log stays empty, and that the compiled-in path is in
the binary -- which is what makes the first two true.

anvil-env.sh carried the same false claim, with a quoted error message
to prove it. Corrected. The PATH entry stays, but the honest reason is
people over ssh wanting s6-svstat without the prefix: anvil-service.sh
calls every s6 program as "$SVC_S6_BIN/<name>", so nothing of ours
needed it either.

make test: 24 passed, 0 failed, 0 skipped -- the whole suite, replica
included. It was 15/7/2 when the lane first ran on this machine.
2026-08-29 14:45:08 +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 ShyshatskyiandClaude Opus 5 8f54d87513 Comments that restated the code, and a few that had stopped being true
A pass over what this branch added, on the same rule as the branch merged
before it: a comment earns its place by saying WHY, and the ones here had
grown into essays.

bin/patch.sh 781 -> 555 lines, its comments 523 -> 297. The install section
keeps the three things a reader cannot get from the code -- the set comes from
pkg_recipes so the BUILD_ flags are read once, -dev is filtered by package
name because nine recipes ARE the dev package, --force-postinstall is what
stops the database saying "unpacked" about files that are in place -- and
loses the narration around them. Sections 3 and 4 were mostly an account of
the sections that used to be there; they are four lines now. The CPython and
libsodium headers keep the facts that took measuring (FF_PYTHON's callers, the
missing _sqlite3 that pins Moonraker, libnacl's __file__ arithmetic and its
dependence on "lib" appearing in the prefix) and drop the rest.

Same for pkg_buildopkg, the payload-path block in bin/common.sh, and the four
test docstrings.

Also fixed, found while reading rather than looked for:

  bin/build-packages.sh still called itself "the proof-of-concept half" and
  said bin/patch.sh "still stages libsodium into the payload exactly as it
  did and `make build` is untouched". None of that survived the migration.

  The -maxdepth 1 comment had been wedged between `# shellcheck disable` and
  the command it applies to.

  Eleven "bin/patch.sh stages ..." references across pkgs/, test/ and qa/
  described a script that stages nothing now.

Payload bytes unchanged except S40s6, whose comment was one of the eleven.
qa-static 259; make test 9 gates, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 12:11:23 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 51f524b5cf Merge worktree-comment-cleanup
The branch tightens comments across the tree -- removing the ones that
explained the code's past rather than the code. It branched before both the
pkg/ -> pkgs/ rename and the migration of bin/patch.sh onto the feed, so most
of its conflicts were with text describing code that has since moved or gone.

Resolved by taking its brevity and correcting what it could not have known:

  bin/patch.sh   ours, entirely. Its only non-comment change there deletes a
                 trailing comment from a line this branch rewrote, and every
                 comment it edits annotates one of the eight staging sections
                 that no longer exist.
  bin/fetch-assets.sh  ours. Theirs reinstates a KLIPPER_FORK condition; that
                 knob was deleted with the move to pkgs/ -- a recipe names its
                 source exactly once.
  pkgs/lib.sh    theirs, except the two hunks where our side carries code it
                 never saw (pkg_payload_hash and pkg_dir; the PKG_CC_SHARED
                 branch of pkg_build). Its prose, our functions.
  bin/common.sh  theirs throughout, with pkg/ -> pkgs/ and $MOD_PAYLOAD ->
                 $PAYLOAD_DIR, and minus its claim that the toolchain is used
                 "by bin/patch.sh for Klipper's c_helper.so" -- patch.sh
                 compiles nothing now.
  pkg/anvil-core/build.sh  deleted. The file is pkgs/anvil-core/build.sh and
                 was rewritten as a loop; its comments are superseded.

Two of its deletions were right about things this branch had wrong:

  bzip2 named a pkg_make verb that was retired into pkg_build.

  zlib claimed a ./configure line of its own as "the one sanctioned
  exception". It has not had one since PKG_CONFIGURE_AUTO=0 and CHOST; the
  rule in qa/static/test_ipk.py is absolute, and its docstring says so. I had
  kept that paragraph in an earlier pass of this merge and was wrong to.

Three stale hints in the Makefile went with them: work/pkg is filled by
`make packages`, not by bin/patch.sh, in the two gate targets and one error
message.

Verified: the payload rebuilds to the same 31 packages and 154 ELF objects,
and the six files whose bytes changed are five payload scripts this branch
comment-edited (zero non-comment lines between them) plus the opkg status
file, which records their sizes. qa-static 259; make test 9 gates, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 11:55:49 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 dcd037c250 The build host has no reason to imitate a package manager
pkgs/ipk-install is POSIX sh because the PRINTER has no opkg and no ar --
measured, and its header says so. Neither is true in the build container, and
the imitation is missing exactly the parts that decide what a payload should
contain: it resolves no Depends, enforces no Conflicts, reads no Provides and
handles no conffiles. Assembling a payload with it means arguing that the
result matches what opkg would have done.

pkg_buildopkg builds an x86-64 opkg from the same pinned tarball the mipsel
one is built from, cached at work/.opkg-host, shaped as pkg_buildpython's twin
so that "build a host tool from the pinned source" means one thing here.
ipk-install keeps its job -- repairing a printer by hand -- and comes off the
build path.

Two things this build gets wrong if written the obvious way, both now checked
rather than commented:

  --prefix must be $MODDIR. opkg bakes its state directory in at compile time
  (libopkg/Makefile.am: -DVARDIR="@localstatedir@"), so a host opkg built
  --prefix=/usr/local keeps its database somewhere the printer never reads and
  says nothing about it. The prefix is a literal string in the binary, so the
  question is put to the file.

  --disable-shared is not a size decision. The prefix is baked into the
  LIBRARY too, so a shared build produces a bin/opkg looking for libopkg.so.1
  at a path that exists on the printer and not here.

libarchive-dev is Debian's, deliberately: opkg's configure.ac requires
libarchive, and this opkg is a build tool that never leaves the container. The
same call is already made for the gcc that builds the build-python.

Measured against the built feed: installs into an --offline-root with the
database at $MODDIR/var/lib/opkg, and refuses the two chamber configs together
(exit 255) on the Conflicts field ipk-install cannot read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:13:26 +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