79 Commits
Author SHA1 Message Date
Oleksandr ShyshatskyiandClaude Sonnet 5.5 df57d2795e Remove the shared-stepper adapter
ff_extruder.py was taken from #29 but never enabled. The fork already accepts
the four [extruderN] sections' repeated pins through duplicate_pin_override,
each extruder keeps its own stepper and pressure advance, and no failure on
a stock config has been shown. It can come back if one is.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 18:07:59 +03:00
308d97ef21 Add optional Z-hop and in-dock retract to the return after a tool change
With restore_axis naming X or Y, a real tool change can now raise the old
tool before it crosses the part to its dock, retract the new tool while it
is still seated in its dock, travel back raised, descend, and give back part
of the retract at the target. The options are restore_z_hop, restore_retract
and restore_unretract with their feeds; all are zero as shipped.

From Niels Sedat's #29 without the prime-tower registration, the model-skip
rule, the shared-stepper sync or the stats hooks.

Co-authored-by: Niels Sedat <nlssdt@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:30:49 +03:00
Oleksandr ShyshatskyiandClaude Sonnet 5.5 7e8ea9dbf4 Drop the next-tool preheat from ADAPTIVE_MESH
ADAPTIVE_MESH no longer starts heating the file's second tool, and ff_print
no longer reports next_tool and next_nozzle. Orca's own preheat M104 stays
the only thing that heats a tool ahead of its change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 17:28:18 +03:00
bb84772a75 Take the mesh, print-start and shared-stepper pieces of #29
From Niels Sedat's Creator 5 branch, the parts that stand without the rest
of it:

- ff_bed_mesh: mesh probing lifts 2 mm above the last trigger instead of to
  one fixed height. Active through [ff_bed_mesh] in ff-print-macros.cfg.
- ADAPTIVE_MESH, ADAPTIVE_MESH_TOGGLE and DEFINE_PRIME_TOWER_OBJECT, with
  ff_print reading the active plate's prime tower. Used only by a profile
  that calls them and sets FF_BEFORE_PRINT_START.defer_mesh.
- START_PRINT heats the bed before the first homing. ADAPTIVE_MESH preheats
  the second tool when its first M109 is near the start of the file.
- ff_extruder, the shared-stepper adapter. Shipped inert: no [ff_extruder]
  section is configured.

Left out on purpose: the used-tools list and the whole-file scans that fed
it, the ff_stats hooks, the plate and material offsets, and the toolchange
travel changes. The next-tool search reads only the head of the file, so a
second colour far into a print is not heated from the start of the job.

Co-authored-by: Niels Sedat <nlssdt@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:48:20 +03:00
3d9f19391e Clear active bed mesh before parking with Z unhomed (#32)
* klipper: clear active mesh before parking with Z unhomed

* Make the park status and mesh tests agree

The unhomed-Z mesh guard reads printer and reactor, which the status
test's park stub lacks, and the status handling reads `changing`, which the
mesh test's stub lacks. Give each stub what the other change needs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Oleksandr Shyshatskyi <shalal545@gmail.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 11:27:45 +03:00
steven-o-cymru 01892ff6f1 Fix toolchanger status while parking (#31) 2026-10-03 11:14:16 +03:00
Oleksandr Shyshatskyi 80a4d64ffd Prepare next firmware release
Add Fluidd and custom boot scripts, migrate DC24V control, update the web UIs and filament macros, improve startup status reporting, and expand upgrade and replica coverage.
2026-09-24 11:37:28 +03:00
MonstrofilandClaude Opus 5 f095075985 Put FlashForge's chelper back where our Klipper replaced it (#22)
chelper/__init__.py and c_helper.so are one unit: the first declares the cffi
cdefs, the second is what they describe. FlashForge's 1.9.7 declares

    void extruder_set_pressure_advance(struct stepper_kinematics *sk
        , double pressure_advance, double smooth_time);

and ours takes a fourth argument. A stock flash restores c_helper.so -- it is
in the chelper.tar their software component carries -- and restores nothing
else in that directory. Their klippy.py, toolhead.py and mcu.py come back too;
gcode.py, bed_mesh.py and chelper/__init__.py do not, because their overlay is
thirty files against the three hundred and twelve in the factory tree.

So a printer that took v20260827 or v20260827b, whose software component
carried our klippy, comes back from a stock flash with our cdefs against their
library and their extruder.py calling it their way: "expects 4 arguments, got
3", at the first extruder, on a machine that has already been flashed back to
stock and has nothing left to flash.

$MODDIR/prog/stock-chelper is FlashForge's directory, off a 1.9.7 factory
/usr/prog, and anvil-link-prog.sh restores it over ours. The whole directory
rather than the two files that disagree: their __init__.py is upstream's,
check_build_code and all, so a .c newer than c_helper.so sends klippy to
mips-linux-gnu-gcc, which no printer has. c_helper.so is touched last for the
same reason.

ONLY UP TO 1.9.7. That is the firmware this copy came off; a newer FlashForge
release may pair a different __init__.py with a different library, and
overwriting it would be this bug pointed the other way. An unreadable version
is left alone too. The version is theirs to state -- no package of ours ships
a software component, so /usr/prog/PROGRAM/software still names FlashForge's.

It rides in anvil-klipper rather than anvil-core, which qa/static caught: a
MIPS c_helper.so in a package declaring Architecture: all installs on anything.
That recipe gets a postinst of its own for the same reason anvil-klipper-config
has one -- anvil-core's pass runs before this package's payload has landed.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 21:07:25 +03:00
MonstrofilandClaude Opus 5 5f237eff62 Version the pinned recipes on the release, not on memory (#21)
apk decides whether to upgrade from the version string alone. Three recipes
pin an upstream version -- anvil-moonraker on ${MOONRAKER_VERSION#v},
anvil-klipper on the pinned commit, anvil-helixscreen on its tag -- and each
ships a payload/ of ours beside that upstream tree. Our half changes without
the string moving, so the feed serves new bytes under the name every printer
already holds and `apk upgrade` finds nothing to do. #16 did exactly that to
moonraker.conf, the file that moves Moonraker's config to
/usr/data/anvil-data/config.

PKG_RELEASE is that string's other half, and it is the release date now:
pkg_release_stamp, monotone, with the same-day letter as a trailing digit so
20260827b sorts between 20260827 and 20260828. Nobody has to notice that a
payload changed, which is the only version of this that holds.

PKG_STAMP_EXTRA="$(pkg_payload_hash)" is not this and reads as though it were.
It hashes payload/ so the BUILD rebuilds; it says nothing to a printer. All
three recipes had it, and all three would have shipped stale.

test_recipe_layout.py makes it the rule for recipes added later: a pinned
recipe with a payload/ must take pkg_release_stamp, and the failure names the
recipe and the line to write.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:56:59 +03:00
MonstrofilandClaude Opus 5 5316694795 Put FlashForge's klipperDaemon back on a printer that lost it (#20)
A stock package restores every path the mod points at itself except
/usr/prog/klipper/klipperDaemon, which it does not carry: not in the software
component, not in its md5sum.list, no line in run.sh. Whatever sits there
survives every stock flash and stock's start.sh calls it on each boot, so a
printer whose copy was replaced cannot be returned to stock at all -- the file
FlashForge shipped it with is gone, and no package of theirs will put one back.

anvil-core ships that copy now, and anvil-link-prog.sh restores it whenever it
finds a link into $MODDIR at that path -- on a flash and on an `apk upgrade`
alike. One update is enough, and it costs 845 bytes.

The population this is for is the one that keeps up to date: they took a
release that owned the file, they upgraded, and the link is still there --
dangling, since the payload no longer carries what it pointed at. Under the
mod nothing calls it and nothing looks wrong; the bill arrives when they flash
stock and get a UI with no Klipper. Owners who have already rolled back cannot
be reached from here at all and are served by the recovery package.

The shipped copy is FlashForge's, byte for byte, so it is in the parse list
and out of the shellcheck list: they write `mkdir -p $(dirname $KLIPPER_LOG)`
and a lint fix would put a script on printers that no FlashForge machine has
ever run. The replica asserts its md5 after the restore for the same reason.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 12:53:54 +03:00
MonstrofilandClaude Opus 5 1afd5dd2d2 Leave FlashForge's klipperDaemon alone (#19)
It is the one file a stock flash cannot undo, and replacing it is what left
owners with a printer that would not print. A stock package restores the other
two paths the mod points at itself -- its run.sh copies its own firmwareExe and
start.sh over them, and busybox `cp -f` unlinks a symlink rather than writing
through it. It carries no klipperDaemon at all: not in the software component,
not in its md5sum.list, no line in run.sh. So the link outlived the mod, and
stock's start.sh, whose last line is

    /usr/prog/klipper/klipperDaemon start

ran our shim, whose `start` does nothing on purpose. The UI came up, klippy did
not, and flashing stock again changed nothing because there was nothing in it
to change.

The shim guarded that same line: klippy is an s6-rc longrun here, and stock's
klipperDaemon forks a second, unsupervised one that fights it for /dev/ttyS4
and /tmp/uds. But no caller is left on a modded machine. Our own start.sh
replaces stock's and asks s6-rc; FlashForge's firmwareExe execs
/usr/prog/klipper/start.sh and names no other script (`strings` on their
binary); Moonraker runs with `provider: none`, so a restart from Mainsail goes
over the API rather than through a service script; ff-startup and S70klipper
drove it once and neither exists now. What remains is a person typing
`klipperDaemon start` over ssh, who gets FlashForge's behaviour and a second
klippy -- s6-rc and s6-svc are the supported way, and that trade buys back a
printer that can be returned to stock.

test_install.py asks the machine the inverted question now: klipperDaemon is
not a link into $MODDIR, it exists, and it is FlashForge's own script.

Printers that already took an earlier release still carry the link and cannot
be repaired from here -- nothing of ours runs during a stock flash. The
package at Klipper4FlashForge/stock-recovery puts their copy back.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 11:33:55 +03:00
MonstrofilandClaude Opus 5 e20ed98dac Refuse to install on FlashForge firmware older than 1.9.6 (#17)
A printer still on 1.9.4 does not come up on this package: the board firmware
that release pairs with differs from the one klippy is built to talk to, the
MCU never connects, and the failure is at the bottom of the stack -- no
Klipper, so no screen and no Mainsail. From the outside it is indistinguishable
from a bad flash, and the only way out is flashing stock again.

So it is refused before anything is written, with the fix in the message:
install FlashForge's own current firmware first, then this. That costs one
more flash and leaves a working printer either way.

WHAT IS READ is /usr/prog/PROGRAM/software, which holds one directory named
for the version of FlashForge's software component -- install_component wipes
the previous one before renaming the new one in. No package of ours ever ships
a software component, so it goes on describing the printer rather than the
mod. The highest is taken when a machine somehow holds two, so a leftover from
an interrupted install cannot refuse a printer that should go ahead, and the
comparison is field-by-field numeric because a string compare makes 1.9.10
older than 1.9.6.

AN UNREADABLE VERSION IS INSTALLED ON. The two failures are not the same size:
letting one through costs a printer that has to be flashed back to stock,
while refusing on one would break the install path on every machine whose
layout is not the one being read.

THE REFUSAL HAS TO BE VISIBLE. It happens before the panel is switched to
start.img and app_startup.sh goes on booting the printer, so from the outside
the flash would just have done nothing. installer/stock-too-old.img is the
frame it paints instead -- the same raw 480x800@32 framebuffer dump start.img
is, rendered by the same ffscreen.py, so it needs nothing on the printer but
`cat > /dev/fb0`. The version in it is DRAWN, so bin/pack.sh carries the
command to re-render it when MIN_STOCK_VER changes. anvil-NOT-INSTALLED.txt on
the USB stick is the copy that survives the power cycle.

Three tests, on the replica. The machine there is a real one in the way that
matters -- its /usr/prog/PROGRAM/software holds FlashForge's own 1.9.7 -- so
"current firmware installs" needs no setup, the refusal swaps that directory
for one holding 1.9.4 and restores it, and the third asserts that a stale
1.9.4 left BESIDE the real version does not refuse. The control matters more
than the refusal: a gate that refused everything would pass the refusal test
and brick every flash.

qa/replica/test_upgrade.py is 19/19; the lane's 10 other failures are the ones
master has on this bake.


Claude-Session: https://claude.ai/code/session_01Fn1k6kaHWhUrsmBfpxjUcG

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:39:37 +03:00
MonstrofilandClaude Opus 5 0747bc500b Keep the config in a directory of our own (#16)
* Keep the config in a directory of our own

Flashing a stock FlashForge package back over the mod left a printer that
would not start. /usr/data is the DATA partition: a stock flash rewrites
/usr/prog and does not touch it, so the symlinked printer.base.cfg and the
ff-*.cfg beside it outlived the mod, pointing into a /usr/data/anvil that was
gone -- and Klipper treats an [include] it cannot resolve as fatal. Nothing of
ours runs during a stock flash, so there is no undo to write from inside the
mod. Not writing there is the only version of this that works.

The live config is /usr/data/anvil-data/config now, seeded once by
anvil-link-prog.sh with a copy of everything in /usr/data/config: printer.cfg
with the SAVE_CONFIG block of calibrated values at the end of it, and the stock
includes the mod neither ships nor parses. The seed test is printer.cfg, which
is copied last and through a temp name, so a machine that never finished seeds
again on the next boot rather than staying half-configured, and no update ever
re-seeds over a tuned file. It is outside $MODDIR, so the installer's
wipe-and-extract cannot reach it and nothing has to be stashed on /tmp.

Moonraker moved with it, and the lever is the data path rather than -c:
moonraker registers its config root as <data path>/config and nothing
configures that separately, so -c alone would move the file it READS while
file_manager kept registering FlashForge's directory. With -d
/usr/data/anvil-data its config directory is klippy's, which is also what
Mainsail's config editor lists. gcodes and logs stay where the machine keeps
them -- /usr/data/gcodes is klippy's [virtual_sdcard] path -- as symlinks
inside the data path, which works because register_directory() realpaths a
root that is a link. The database is carried across once, by the service,
below the stray-killer: the one point where moonraker is provably not running.

What an older release left in /usr/data/config STAYS there. A sweep could
delete the links but could not put stock's own printer.base.cfg back, because
the release that replaced it kept no copy -- and stock's run.sh force-copies
its own onto every flash, which repairs that directory whether or not anything
was swept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn1k6kaHWhUrsmBfpxjUcG

* Ask the config-directory test about this installer, not the bake

test_flashforges_config_directory_is_never_written asserted that the mod's
filenames are absent from /usr/data/config. On a replica baked from a release
that still WROTE them, that is a question about the bake rather than about the
installer under test, and it fails for a reason the change cannot fix.

Remove them, run the installer over the top, then ask. The property is "an
install writes nothing there", and that is now what is measured.

Verified on the replica: qa/replica/test_upgrade.py is 18/18. The five
test_install.py assertions about the seeded directory still need a machine
baked from a package built with this branch -- work/out holds one from
2026-08-30, which symlinks into /usr/data/config as the old code did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn1k6kaHWhUrsmBfpxjUcG

* Ask the config directory of a machine that has one

The five assertions about /usr/data/anvil-data/config could only pass on a
replica baked from a package built with this branch, and the bake installs
work/out/*.tgz -- whatever was built last. So they failed for a reason the
change cannot fix, and the gate could only go green after a full build, which
is exactly when nobody runs it.

Two different fixes, because the questions are two different kinds.

WHAT THE INSTALLED PACKAGE PRODUCED is still asked of the bake, but the
directory is now read off the machine instead of written down: PRINTER_CFG in
the installed etc/s6-rc/source/klipper/run is the command line klippy is
exec'd with, and therefore the only thing that decides which directory is
live. test_install.py and test_klippy_extras_import.py follow it, so the
include set, the graph walk and the user's printer.cfg are asked about
whichever side of the move the installed package was built on.

WHAT THIS CODE DOES is a new module, test_config_directory.py, which stages
anvil-link-prog.sh from the checkout and runs it -- the way test_upgrade.py
stages the installer. Only the timing is mocked: it seeds from a real
printer.cfg with FlashForge's real includes beside it, links the ff-*.cfg the
installed package really shipped, and reads MACHINE= out of the real
app_startup.sh. It asserts the seeding carries the machine's own marker, the
links point into $MODDIR, the chamber config matches the model, every include
resolves, a second run does not re-seed over an owner's edit, and the run adds
NOTHING to /usr/data/config.

That last one is a DELTA, not an absolute. A machine that has been through a
release which symlinked into FlashForge's directory still holds those links
and nothing can restore what they replaced. What this code owes is that it
adds nothing.

Verified on the replica: 135 passed, and the 10 failures are the same ones
master has on this bake -- ffmpeg, numpy, the timelapse component and the
hardware services, all missing from the 2026-08-30 package in work/out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fn1k6kaHWhUrsmBfpxjUcG

* Link every .cfg the packages ship, not a list of names

test_every_include_resolves_in_the_new_directory failed on CI:
printer.base.cfg has [include timelapse.cfg] and no timelapse.cfg was in
/usr/data/anvil-data/config, which Klipper treats as fatal.

anvil-link-prog.sh linked printer.base.cfg and the ff-*.cfg by name.
timelapse.cfg is anvil-timelapse's and matched neither, so it reached the
live directory only through the installer's copy of $MODDIR/config -- and
never at all on the `apk upgrade` path, where the link script is the only
thing that runs. A named list is a file that resolves on a fresh install
and goes stale the moment a package adds one.

So the rule is the glob: every .cfg in $MODDIR/config is linked, and the
installer skips every .cfg rather than the four names it knew. The .conf
beside them stay the installer's -- moonraker-custom.conf is the owner's
file to edit, so it is copied once and never linked to a package's copy.

test_the_mods_configs_are_links_into_moddir asks the same way now, so a
package whose config nothing includes is covered too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YULWHVHyxKm2HE9kiPfEjP

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 10:05:36 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 9973019ec8 A printer ships knowing where its updates come from
Phase 3 of docs/notes/85-packaging.md was three things: signing, a URL the
printer asks, and a tree at that URL. The migration landed the first. This is
the second, and the tooling for the third.

THE POINTER HAS TO SHIP BEFORE THE HOST EXISTS, which is why this lands with
nothing serving yet. A feed cannot tell a printer where the feed is: a machine
installed without etc/apk/repositories needs a USB stick before it can ever
fetch anything, which is the thing packages were adopted to stop. So the file
travels in the .tgz, naming a host that does not answer yet, and every printer
installed from today starts fetching the day one does.

The cost of that is bounded, and was read out of apk's source rather than
assumed: an unreachable repository fails `apk upgrade` and nothing else --
apk_db_repository_check is called from app_upgrade.c and not from app_add.c,
so `apk add ./file.apk` does not consult a repository at all.

    http://reforge.8941973.xyz/apk/mipsel_xburst2/anvil.adb

bin/common.sh's FEED_URL is the base, config.env can override it for a fork,
and pkgs/anvil-core generates the file from it beside the public key it
already shipped. Three things about that URL:

  * IT NAMES THE INDEX FILE, not a directory. src/repoparser.c reads a line
    ending .adb as a v3 index in place, with the packages as siblings named by
    the pkgname-spec mkndx wrote into it; anything else sends apk looking for
    <url>/<arch>/APKINDEX.tar.gz. That is the layout this repo already builds
    and the stick already uses, so the network feed is the same shape --
    exercised by every payload build rather than only in production. The v3
    directory form would work too, but only with
    `mkndx --pkgname-spec '${arch}/${name}-${version}.apk'`.

  * THE ARCH IS A DIRECTORY. Package file names carry no architecture, so two
    architectures served from one directory would be two files with one name.

  * PLAIN HTTP. libfetch defaults to SSL_VERIFY_PEER and this printer has no
    certificate bundle -- the same gap tools/python/README.md records for
    CPython. Every package is signed, and the signature rather than the
    transport is what decides whether a printer installs one.

bin/publish-feed.sh writes the matching tree and optionally rsyncs it.
Packages go first and the index last, because the index names every package by
sha256 and a mirror carrying a new index with old packages hands printers a
checksum for a file that 404s. Nothing is deleted at the far end. It refuses
an unsigned feed unless ALLOW_UNSIGNED=1: a printer verifies against the key
that shipped in its own anvil-core, so an unsigned feed is one no printer
installs from. The release workflow attaches the same tree as
reforge-apk-feed.tar.gz, so a host can be filled from any release -- including
ones tagged before the host existed -- without rebuilding.

THE REPLICA STOPPED WRITING THE FILE IT USED TO DELETE.
case-build-payload.sh wrote its own etc/apk/repositories naming /mnt and
removed it before tarring; it now passes --repositories-file with a path on
the stick, which apk reads INSTEAD of the default (database.c consults the
default file and repositories.d only when no file is named). So what ships is
whatever anvil-core installed and the build never reads it. The `apk info`
line takes the same flag: without it that one command reads the shipped file
and reaches for the network, which a build's output must not depend on.

Measured: 47 packages built from a cold cache, the payload assembled in the
replica twice, qa/static at 319 passed. Two of those are new -- the default
URL in common.sh (read from the file, not resolved, so a fork setting
FEED_URL in config.env still passes) and the built payload carrying a
package-owned entry that ends /mipsel_xburst2/anvil.adb.

What is left is operational: a host answering at $FEED_URL, and who runs
publish-feed.sh -- a release step with a deploy secret, or a person with the
tarball.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rgrDeu9YAUuMM2G7gKTpL
2026-09-01 14:09:34 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 074ffb9799 Merge PR #9: the chamber heater's fan follows the heater
Brought onto this branch rather than onto master so one CI run covers
the combination. #9's own green run predates the apk migration: it was
proved against an opkg feed, and the klipper-config payload it edits is
now built and installed by a different package manager.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 15:46:00 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 f11b63b6c1 Give apk a version it can parse in CI, and say so when it cannot
ci.yml writes MOD_VER=ci into config.env, and MOD_VER is anvil-core's
PKG_VERSION. apk's version grammar wants a digit first, so mkpkg
refused it -- with "info field 'version' has invalid value", naming
neither the package nor the value nor where it came from. A free-form
version was fine for .ipk, which is why the line survived the
migration unread.

MOD_VER=0 now. It is pinned rather than left to the date default so
two runs of one tree produce the same bytes, and 0 says "not a
release" as plainly as the word did while ordering below every real
version.

emit_apk checks the version before calling mkpkg and names the
package, the offending value and the config.env setting to fix. The
grammar test covers the recipes, and could not have caught this: the
value comes from config.env, so anvil-core answers with whatever the
machine running the test happens to say -- a date, locally, always
valid. The new gate reads MOD_VER out of the workflow files
themselves, which is the only place that value is written down, with
a companion asserting a workflow still pins one at all so the check
cannot pass by having nothing to check.

busybox's pkg.conf expanded $BUSYBOX_BIN bare and let a failed
sha256sum through a pipeline. Sourced under `set -euo pipefail` it
therefore failed -- correctly skipping the recipe, since pkg_recipes
evaluates PKG_WHEN in a subshell, but printing "BUSYBOX_BIN: unbound
variable" twice into every CI log immediately above the first real
failure, where it reads like the cause. With that fixed every recipe
reads under the build's own flags, so the unsigned-recipe gate now
covers all of them rather than only the ones a build would make: a
recipe that cannot be read is dropped by that subshell rather than
reported, so the failure mode is a package silently leaving the feed.

Verified with the production key: 290 static, 135 replica, 47 signed
packages and a payload. MOD_VER=ci reproduced locally against the new
message, and MOD_VER=0 built and pruned clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 15:34:02 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 734a04db45 Make the apk feed buildable by CI, and signable by a release
PR #10 was green on this machine and red on both CI jobs. Three
separate faults, none of which a local signed build could show.

apk-tools is cloned from gitlab.alpinelinux.org, which answers a
GitHub runner with HTTP 418 or with nothing until the connection
times out. It clones from the GitHub mirror now. Which host serves
it cannot change what is fetched: the pin is a commit sha, and git
names a commit by the hash of its content, so a mirror answering
with this sha is serving this tree or is not answering. get_git also
sets the remote url on every run, so a checkout made before this
commit does not keep fetching from the host that refuses.

anvil-core's cache key hashed the feed's public key as

    sha256sum "${APK_SIGN_KEY:-/dev/null}.pub" 2>/dev/null | cut ...

With no key that file is absent, sha256sum is non-zero, pipefail
makes the pipeline non-zero, and build-packages.sh -- which sources
every pkg.conf under `set -euo pipefail` -- died before emitting a
single package. The 2>/dev/null ate the reason, so `make packages`
exited 1 having said only that it had sealed a cache entry. Every
signed build passed. Every CI build is unsigned. It is now
pkg_signkey_hash, which answers `unsigned` and succeeds.

The build container mounts the checkout and nothing else, so a key
at the documented ~/.anvil/anvil.rsa was visible in the developer's
shell and invisible to apk. The Makefile reads APK_SIGN_KEY out of
$(CONFIG_ENV) and bind-mounts its directory read-only. This survived
review because the key used while writing the migration sat under
work/, inside the one directory already mounted.

Gates for all three, each verified against its own bug by reverting
the fix under it. The recipe-reads-unsigned test runs pkg_conf under
the same flags build-packages.sh uses -- the existing _conf helper
does not, and would have stayed green throughout. Its companion
asserts the recipe SET does not change with the key, because
pkg_recipes evaluates PKG_WHEN in a subshell and a recipe that fails
to source is dropped rather than reported: broken only when
unsigned, a package would leave the feed with nothing turning red.
Both are scoped to what pkg_recipes lists, so busybox -- gated off
whenever there is no stock firmware to take one from -- is not
asked a question it cannot answer.

release.yml takes the private key PEM from secrets.APK_SIGN_KEY,
writes it to $RUNNER_TEMP outside the checkout where no later step
can archive it into a release, and derives the public half rather
than storing a second secret that could drift out of step with the
first. Unset, the release builds unsigned with a warning: a fork has
no such secret, and firmware nobody else can build is worse than a
feed untrusted on a stick the printer was handed by hand.

Verified from a cold work/host and work/packages, three ways: no key
(the CI case), a key inside the tree, and a key outside it at the
documented location. Each gives 47 packages plus the index, a
payload, 286 static and 135 replica tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 15:15:29 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 f8b925e48f Replace opkg with apk, and sign the feed
docs/notes/85-packaging.md chose opkg in 2026-08 on one argument: every package
is cross-built here anyway, so the winner is whichever format is cheapest to
PRODUCE -- and .ipk had an upstream tool where .apk meant abuild, a keypair and
an Alpine host. Two lines of that comparison were wrong.

apk mkpkg is an applet of apk itself: -I key:value metadata, -F directory, no
keypair, no abuild, no Alpine host. The "making a package" row described
apk-tools 2. And the on-device client was counted a wash when it is better than
one -- openssl and zlib were already cross-built for CPython, and libarchive
existed in this tree only for opkg, so adopting apk REMOVES a cross-build.

What made it possible at all is that apk resolves its database relative to
--root and uses the same root as the file destination, while / here is a
read-only squashfs -- measured: neither /lib/apk nor /etc/apk can be created.
pkgs/3rdparty/apk-tools/prefix.patch splits the two. Upstream already carries
root_fd and dest_fd separately and merely aliases them in context.c, so the
patch defaults the database root to $MODDIR and leaves files extracting at /.
That is opkg's compile-time VARDIR plus `dest root /`, in apk's spelling.

THE FEED IS SIGNED NOW, which it never was: opkg signed the index and
bin/build-packages.sh signed nothing, so nothing was verified on a printer.
apk verifies with raw PEM public keys, not X.509 -- measured on the machine,
where every certificate directory is empty. ./bin/apk-keygen.sh makes an RSA
pair, APK_SIGN_KEY in config.env points at the private half, and the public
half both seeds the USB stick (so the first install can verify) and ships in
anvil-core (so the printer keeps trusting). An empty APK_SIGN_KEY still builds
an unsigned feed, deliberately: `make packages` has to run on a bare checkout
or the packaging lane stops being a CI gate.

WHAT IT COST is a carried patch, against an internal with one consumer, on a
branch mid v2->v3 transition, on an architecture with no upstream apk port to
share the rebasing. That is the standing risk and it does not go away.

Four bugs this shook out, all silent, all found by a gate rather than by
reading:

  * The prune deleted seventeen packages it had just built. The all -> noarch
    mapping went into the emitter and not into pkg_pkgfile, so the prune looked
    for a name nothing wrote. `make packages` exited 0.
  * Whitespace-separated Depends dropped nineteen packages. opkg's pkg_parse.c
    splits that field on ',' and nothing else, so the whole list became one
    dependency whose name contained spaces and resolved to nothing -- and opkg
    neither warned nor failed. `make build` shipped a payload with no Tornado
    in it. test_the_payload_is_dependency_closed is what catches that now.
  * Ownership was recorded as `nobody`. apk resolves an owner to a NAME against
    <--root>/etc/passwd and the build lane runs --user with no passwd entry, so
    every file in every package would have installed as uid 65534, with no
    error at build or install time. build-packages.sh writes a two-line
    synthetic passwd naming the build user root and points --root at it.
  * Four recipes carried versions apk cannot parse -- two spelled a commit
    +git<sha> where the grammar's hash slot is ~, x264 had a snapshot name
    whose -2245 reads as a malformed release, and helixscreen had
    0.99.115.creator5.3 where apk allows one trailing letter and then only a
    suffix from a closed set of nine words. All four were valid opkg versions,
    which is why nothing had noticed. pkg_version_ok gates every recipe now.

A v3 .apk begins with ADBd magic and is not a tar stream, so there is no apk
equivalent of the `ar -p | gunzip | tar` that hand-unpacked anvil-opkg: you
cannot open a package without a package manager. bin/build-payload.py puts the
cross-built binary on the stick at /mnt/apk instead. It is the same build
anvil-apk-tools contains, that package installs normally, and the bootstrap
copy never lands.

Two gates retired rather than moved. apk's installed database is plain text
(F: sets a directory, R: names a file in it), so the payload-ownership check
reads one file where it used to glob info/*.list. And it carries no clock at
all -- apk records no install time and mkpkg sets no build-time -- so the
Installed-Time normalisation case-build-payload.sh did on every build is gone,
and the test asserts the ABSENCE of a t: line instead. Clock-free by
construction rather than by repair.

Also removed: PKG_SECTION, which 45 recipes declared and nothing read once
apk's schema had no field for it, and the `make apk-host` target -- the
packager is built on demand by bin/build-packages.sh and stamped on the pin,
the way pkg_toolchain and pkg_buildpython already work.

Verified from a cold work/host and work/packages: 47 packages built and signed,
`make build` installs 35 and verifies them against the shipped public key,
`make qa-static` 195 passed, `make qa-replica` 135 passed -- the printer
installing off a simulated USB stick and booting. The installed set was checked
against the declared closure of the eleven roots: 35 expected, 35 installed,
nothing extra and nothing missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rbKEyLXrzSE6wm3iNApVp
2026-08-31 14:32:07 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 d1ef791745 The chamber heater's fan follows the heater, not the touchscreen
firmwareExe was the only thing that knew the chamber element needed air.
CommMgr::heatManager case 5 fired SET_FAN_SPEED FAN=chamber_heat_fan SPEED=0.9
and chamber_loop_fan SPEED=0.3 immediately before every SET_HEATER_TEMPERATURE
HEATER=chamber_heater, on a bare "target != 0" test evaluated once, at the
instant the target was written -- no state machine, no delay, no run-on, and on
the way down it stopped the fans a moment BEFORE dropping the target. Nothing on
the printer knew about the pairing: set a chamber target from a sliced M141,
from Mainsail or from Moonraker and the element ran with its fans stopped. This
mod replaces that binary, and it added M141/M191, so the coupling had to move
somewhere every client passes through.

chamber_heat_fan becomes a [heater_fan] bound to chamber_heater. That follows
the TARGET rather than the heater pin, which matters: PE13 is a PID output on
Klipper's default 0.1 s pwm_cycle_time, so a fan slaved to the pin would chop at
10 Hz and never reach speed. It also runs below G-code -- a pause, a cancel or a
klippy fault cannot strand a hot element behind a stopped fan -- and
fan.Fan(default_shutdown_speed=1.) drives PD12 to full on an MCU shutdown, the
one case where a macro is already dead. There is no tach on any chamber fan, so
nothing here detects a stall; this is not stall protection and does not claim to
be.

The section had to MOVE out of printer.base.cfg. [heater_fan] resolves its
heater at klippy:ready and lookup_heater() raises on the plain Creator 5, which
has no element -- and a pin may be claimed only once, so the plain model keeps
FlashForge's [fan_generic] and the Pro declares the [heater_fan], in the
per-model chamber/<model>.cfg that already exists for the heater itself.

chamber_loop_fan deliberately does NOT go the same way: HelixScreen publishes it
by object name as the user's aux fan ("aux": "fan_generic chamber_loop_fan"),
and a [heater_fan] is neither that object nor settable, so converting it would
take the aux slider away. It stays a [fan_generic] driven from the
SET_HEATER_TEMPERATURE wrapper -- where the app drove it, except this reaches
every client rather than only the touchscreen. ff-filament drops its
chamber_heat_fan blackout for the same reason it can no longer set it, and
because a filament change over a hot element is the last moment to stop
ventilating it.

SEPARATELY, A BUG THIS FOUND. SET_HEATER_TEMPERATURE is a mux command keyed on
HEATER: _cmd_mux raises "The value 'chamber_heater' is not valid for HEATER" for
a heater that was never registered, and a command error aborts a running print.
The gate in ff-chamber.cfg only blocked TARGET > 0, so TARGET=0 went straight to
the base command and threw -- and M141 S0 is what slicers put in their END
G-code. On a plain Creator 5 a chamber-aware profile ran the whole print and
died at the last line. The file's own header claimed "chamber off behaves
exactly as before"; it did not. The gate now covers every chamber command on a
model without the heater, warning only when a real request is dropped and
staying silent for an off that has nothing to turn off.

bin/unpack.sh's drift guard was already broken and is fixed here, because this
change would otherwise have deepened it. strip() dropped the moved sections'
HEADER lines but left their option lines on the stock side with nothing to match
them, and did not know about [include timelapse.cfg], so it printed "printer.
base.cfg DIFFERS" on every single unpack and had been silently useless. It
compares whole blocks now, and reports a match.

qa/static/test_klipper_config.py is new: there was no config coverage at all,
and test_ipk checks that the chamber configs are PACKAGED, never that they
PARSE. It asserts pin uniqueness, that every macro compiles, and -- the half
that earned its place -- it EXECUTES M141/M191 on both models, because reading
the macro did not show the mux bug and running it did. It reproduces klippy's
real parsers rather than approximating them: Environment('{%','%}','{','}') with
single-brace delimiters, RawConfigParser(inline_comment_prefixes=(';','#')) so a
';' inside a string literal survives as configparser leaves it, and both of
gcode.py's disagreeing parameter parsers -- traditional args_r.split(line.
upper()) for M141, shlex with keys-only uppercasing for the extended form, which
is what makes the macro's |lower load-bearing. Both fixes were mutation-tested:
re-adding the duplicate section and restoring the target > 0 gate each fail the
tests written for them.

qa-static green in the container: 171 passed, 3 skipped, including the ipk
packaging tests and shellcheck. make packages PKG=klipper-config builds clean
and the packaged tree is correct -- heater_fan on the Pro, fan_generic on the
plain model, and printer.base.cfg declaring chamber_heat_fan zero times.

qa-replica has NOT been run: this worktree has no work/ tree, so the lane needs
a full make build. Nothing has yet had the real klippy load this config, which
is the check that matters most here, since the failure mode is a printer that
will not boot klippy after a flash. The heater_temp 50 over-run and the 0.9 fan
speed also still want one real chamber heat-up; no test settles those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDJmG2hGGqNEYwc6Hj5P12
2026-08-31 13:50:53 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5ccfc85558 Make the import gate assert the module it exists for, and prove it bites
test_every_named_extra_imports skips a section whose module is absent --
deliberately, because that is what klippy's load_object does. But it means the
test could go green on a machine where the klippy tree had lost
stepper_resonance_tester, having never asked the question it was written for.
So it now names that module and asserts it actually imported.

Checked in both directions before being trusted. On the baked replica, with the
real shipped tree under qemu: the module imports with numpy present, and with
site-packages/numpy moved aside it raises ModuleNotFoundError at line 1 -- an
ImportError, which is the one load_object does not catch. That is the original
diagnosis reproduced on a machine rather than argued from source.

All three lanes green: qa-static 148, qa-replica 135, mkdocs --strict.

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 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 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 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 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 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 b47ffd600d The boot fixture waited for a set the replica cannot reach
`booted` polled for ok-all, and the comment beneath the poll explained at
length why ok-all is unreachable on a replica -- no /dev/ttyS4,5,7 for
mcu-bringup, no /dev/video* for camera. So the loop could only expire, and
all eight tests behind the fixture had been erroring at 120s each.

What the replica CAN prove is the machinery: the scanner answers, s6-rc-init
lays the live servicedirs down, a transition runs, s6 puts a killed daemon
back. The fixture now waits for the longruns that need nothing but the
rootfs -- nginx, moonraker, ntp -- and asks s6-svstat rather than s6-rc,
because ff-startup holds the s6-rc lock for its whole 300000ms timeout
waiting on a klipper with no MCU, so `s6-rc -a list` answers "unable to
take locks" rather than with a list.

camera, klipper, mcu-bringup, ff-startup, ui and wifi are asked of the
compiled database instead, which is still where a missing or misnamed
service shows. ui is in that set because it depends on ff-startup, and
wifi because it is a oneshot with no supervised servicedir to stat.

Two tests change with it. test_the_ui_runs_under_s6_supervise is gone:
80-s6-migration.md calls it the blocking unknown, but ui sits behind
mcu-bringup and no replica can answer it -- that needs the simulated-MCU
lane. And test_the_scanner_logged_nothing demanded an empty s6.log, which
it only ever got because the fixture timed out first; moonraker's run
script sends its early stderr there ON PURPOSE, so the emptiness was never
the property. It asks for the scanner's own failure now, which is the
EOVERFLOW readdir it was written for.

qa: 261 passed, both lanes, 84s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S64uRMdZbj9xPMns2dqMG8
2026-08-30 14:21:29 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 b96390b579 Eleven gates read our own scripts as text; none of them read a package
Each one grepped a file in this repo and asserted a spelling. That is
green when the behaviour is broken and red when nothing is, and both
halves happened here:

  test_the_archives_are_built_by_upstream wanted the literal
  "opkg-make-index" in bin/build-packages.sh. The script calls
  $OPKG_INDEX_BIN, which bin/common.sh points at exactly that tool, so
  the gate went red for a variable it approves of.

  test_the_klipper_service_runs_the_payload_tree_on_our_python asserted
  "/usr/prog/klipper/klippy" was absent from the klipper run script. The
  command line is $MODDIR/klipper/klippy/klippy.py and has been for some
  time; the match was on the header paragraph explaining that the tree
  USED to be read from /usr/prog. The gate read its own warning as the
  thing it warns about.

test_klippy_imports_resolve_on_our_python goes for a different reason: it
sourced anvil-env.sh without MODDIR, against that file's stated contract,
so FF_PYTHON came out /bin/python3.13 and four imports failed on a missing
interpreter. What it meant to ask is answered by klipper starting at all.

The rest are the same technique: payload.sh, lib.sh, build-packages.sh and
bin/*.sh grepped for a spelling, one gate grepped ANOTHER TEST FILE for the
words nan2008/o32/mips32r2, and two linted every recipe for forbidden
constructs. What survives asks the built artifact or the replica.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S64uRMdZbj9xPMns2dqMG8
2026-08-30 14:21:16 +03:00
Oleksandr Shyshatskyi 1b89782ac9 Merge remote-tracking branch 'origin/master' 2026-08-30 16:34:32 +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 c3a64e0394 The stock firmware is a pinned download like everything else
config.env.example opened with DESKTOP="$HOME/Desktop", and four settings
hung off it. Nothing in bin/, pkgs/, qa/ or installer/ ever read it: three
of the four were commented-out overrides for files `make vendor` already
fetches, and the fourth, BUSYBOX_BIN, names a binary with no source at all.
What DESKTOP really did was carry the stock FlashForge .tgz -- the one input
a person had to go and find before they could build anything.

It did not have to be. .github/workflows/ci.yml has been fetching that exact
file for as long as printer-sim has run, from ghzserg/FF, pinned by sha256,
with its own get(), its own cache and its own secrets -- a second spelling of
"download the firmware and check the hash" that no human path shared. The
pins here are those pins, verified byte-for-byte against a fresh download of
both assets.

So versions.env pins the stock package the way it pins everything else,
bin/fetch-assets.sh downloads it into vendor/, and bin/common.sh defaults
STOCK_TGZ_* there. A cold checkout is now `cp config.env.example config.env
&& make build`, with nothing to edit.

--stock is a separate flag from --all, not folded into it. The packaging lane
wants no FlashForge package -- that is most of the point of it being runnable
in CI on a bare checkout -- so `--all` stays "every payload piece, no
firmware", `make build` asks for the one model it is building, and `make
vendor` (--all --stock) takes both.

The one download that will not overwrite an override. HELIX_TGZ and friends
name a public release anyone can re-fetch in a minute; STOCK_TGZ_* names 93MB
of proprietary firmware someone downloaded by hand, and clobbering it on a
hash mismatch would destroy the only copy on the machine. It is skipped
instead, and unchecked -- their file, their problem.

CI loses its duplicate fetcher, its stock/ cache and the STOCK_URL_* secrets.
A fork testing its own firmware re-pins versions.env, which works locally too;
the secrets only ever worked in CI.

qa/lib/config.py reads versions.env for the pinned filenames so stock_for()
looks where the download actually lands. An explicit STOCK_TGZ_* still wins.

An existing config.env keeps working untouched: its paths are now overrides,
so the build skips the download and uses the file it always used.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPU6RR4UJH2Qf9DR5hb2gg
2026-08-30 16:01:47 +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 2a6331a831 We own runFirmwareExe.sh; the install is no longer spliced into run.sh
app_startup.sh runs whatever it finds under that name in the package it just
decrypted. That is the entire contract -- plus the exit code, where 0 means
"flashed, power-cycle me" and non-zero means "carry on booting" -- so owning
the name is all it takes to own the install. We were not using it. We shipped
FlashForge's copy verbatim and reached the printer through the one exec hook
their file offers: the software component's run.sh, into which bin/patch.sh
spliced two marked blocks with 40 lines of regex.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CH7Yb4wuPhnppHAB25UX7e
2026-08-30 15:34:19 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 56b5b6c334 The printer image is the only replica; make rootfs was a second way nothing took
`make rootfs` unsquashed rootfs.squashfs out of the stock package into
work/rootfs, and a thin container bind-mounted it with PROG_DUMP supplying
/usr/prog from the factory image. Every caller set PRINTER_IMAGE anyway --
both workflows wrote it into test.env BEFORE running `make rootfs`, so CI
unpacked a 93MB package, unsquashed a rootfs and downloaded a 182MB factory
image on every push and read none of it. build-printer-image.sh never used
work/rootfs either: Dockerfile.full does its own extraction from the same
public firmware and needs no stock package at all.

So the fallback went, and PROG_DUMP with it -- it was only ever read when the
replica was built locally, and both implementations already passed it empty
otherwise. Gone: the target, extract-rootfs.py, gates.extract_rootfs and its
two tar helpers, the thin printer/Dockerfile, the `prebuilt` flag threaded
through both Replica implementations, and Replica.start's want_output, whose
only message was that the local build was about to be slow.

PRINTER_IMAGE is pinned to 1.9.7-1.2.9-20260810 rather than :latest. It is
now the entire substrate the brick-safety gates run on and the only input
nothing checksums, so a floating tag would let it change under a green suite
-- the drift the sha256 pins in both workflows exist to prevent.

THREE THINGS THAT COULD NOT HAVE PASSED, found while doing it:

  * Neither workflow built the feed. bin/patch.sh has refused to start
    without one since a1b4ea6, 45 commits after ci.yml was last touched.
    Both ran unpack/patch/pack straight into that guard.

  * `make release` ran bin/build.sh on $(RUN), which has no docker socket,
    while patch.sh assembles the payload inside the replica. `build` has
    used RUNBUILD all along; `release` was left behind. It also built no
    feed. It now builds one, once, outside the per-model loop -- nothing in
    it is model-specific.

  * ci.yml's shellcheck gate named pkgs/*/payload/init.d/S* and
    pkgs/*/prog/*.sh, neither of which exists any more; shellcheck exits 2
    on a path it cannot open. It is a `find` with an asserted count now,
    because the opposite failure is worse: a glob that quietly matches
    nothing checks nothing and still reports green.

Both workflows now go through `make` instead of hand-rolling `docker run`.
Hand-rolling meant CI exercised entry points no human uses, which is exactly
why the `release` runner bug could sit there unseen; it also ran as root and
left a work/ that the later `make release` could not write to.

ci.yml's test job builds the feed before the static lane. A good part of that
lane asks what is inside an .ipk and those questions do not fail without one
-- they return, and the lane reports a pass having never opened a package.
build-packages.sh needs no stock firmware, which is what makes that possible.

Docs, and the packages skill, which still documented pkg_buildopkg and
work/.opkg-host as how the payload is assembled -- the host opkg went in
dcd037c and the printer's own installs the feed now -- and still claimed
qa/static holds an ABI gate over every ELF in every package. It does not:
qa/replica/test_abi.py is the only one, and qa/static asserts a seventh does
not grow back.

Verified: qa/static is 122 passed / 110 errors here, identical to master --
every error is `vendor/opkg-utils is missing` in a checkout that has not run
`make vendor`. mkdocs build --strict passes. The new shellcheck gate was run
as written. The workflows themselves were not executed.

tools/replica/printer/*.sh live inside the published image, so those edits
take effect on the next `make printer-image`; behaviour is unchanged either
way, because nothing sets PROG_DUMP any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrhjKabxDihvVjNQGGT4MK
2026-08-30 14:56:26 +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 fbb3182e68 bin/verify.sh is retired; four of its forty checks move to qa/replica
verify.sh described itself as "simulate every check the printer performs,
against a built package". The word carrying that sentence is SIMULATE, and it
is why thirty-six of the forty are deleted rather than moved.

THE REPLICA LANE DOES NOT SIMULATE THE INSTALL. install-package.sh puts the
package on a genuine FAT filesystem at /dev/sda1 and runs the machine's own
/usr/prog/app_startup.sh over it, verbatim, under qemu. So these are
PRECONDITIONS of the lane now rather than assertions in it -- break any and
installed_image() raises with the install log and the whole lane goes red:

  * the decrypt with the firmware key (the printer's unTar does it)
  * runFirmwareExe.sh present, components plain tar not xz (bare `tar -xvf`)
  * md5sum.list verifying (the installer aborts and deletes the payload)
  * the MACHINE= gate (runFirmwareExe.sh refuses a foreign package)
  * the /mnt/<Model>-*.tgz glob (app_startup.sh finds the file by it)

A host-side re-reading of those rules is a second implementation, and a second
implementation can agree with itself while disagreeing with the printer. That
is the defect qa/replica/conftest.py records this repo learning twice about
hand-placed payloads.

A second block was asking what the suite already answers, of an installed
filesystem rather than of a tar listing: the klippy tree and c_helper.so
(test_install, test_abi), the s6-rc database and the oneshot runner's execline
(test_s6rc), s6 and s6-ftrigrd (test_supervisor), the .install-manifest
(test_upgrade), libsodium's bare .so symlink and the chamber configs
(static/test_ipk), and every shipped script's syntax
(static/test_shell_syntax).

THE FOUR THAT MOVED are the ones nothing else asks, and each is stronger for
the move -- a `tar -t` listing cannot tell you that a shared object loads, or
what a name resolves to on a PATH:

  * the interpreter IMPORTS sqlite3, lmdb and _cffi_backend, rather than a
    grep finding three .so files. This is what catches a cross build that
    resolved to an x86_64 manylinux wheel: present on disk, raises on import.
  * nothing in $MODDIR/bin shadows FlashForge's python3 -- asked with
    `command -v` after sourcing anvil-env.sh, which is the file that PREPENDS
    $MODDIR/bin to PATH and so creates the hazard in the first place.
  * CPython's dev half (headers, pkgconfig, config-3.13-*) did not ship.
  * the ship boundary: no file on the installed printer is byte-identical to
    one under bin/ or docker/. Asked of the machine rather than of the .tgz,
    so it also sees whatever a maintainer script wrote as root at install
    time. The sweep filters on exact size before hashing, which is what keeps
    a walk of /usr/prog through qemu cheap.

WHAT GENUINELY LEFT, recorded in docs/qa-migration.md rather than only here:
there is no longer a check you can run on the build host, in seconds and
without docker, against a package you are about to flash. Nothing warns you
before you copy the file to a stick that it was built from the wrong model's
stock package. The printer still refuses it and `make qa-replica` still
catches it, but the fast host-side warning the hardware checklist used is
gone. `make verify` is removed and `make release` no longer calls it.

VERIFIED. All four ran against a real replica (the cached installed image,
PRINTER_IMAGE=monstrofil/creator5-printer:latest): 4 passed in 17s. Then each
was DRIVEN RED on purpose with a throwaway module, which is this repo's own
standard from 4ce928f -- bin/common.sh copied byte-for-byte into $MODDIR, a
python3 symlink in $MODDIR/bin, an include/python3.13 directory, and a module
name that does not exist. Each was caught, each named the planted file in its
message, and each went green again once it was removed. The leak sweep walks
21132 files under $MODDIR and /usr/prog, so its floor of 100 is not load
bearing. The static lane goes 123 -> 122, which is exactly the `bash -n` test
that test_shell_syntax.py generated per bin/*.sh file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GAQKdBfZxbnPagQyidFk6Y
2026-08-30 14:19:15 +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 4ce928f160 The last two artefact ABI gates go; the toolchain check stays
Follow-on to 7dda2fe, which left the gates in tools/ alone pending a call on
whether they were the same rule. Two of the four were.

REMOVED, because qa/replica/test_abi.py now answers exactly this:

  * tools/python/build.sh walked every ELF in the install stage
  * tools/python-packages/build.sh walked every .so in site-packages
  * tools/python-packages/build-libsodium.sh gated the object that ships

Both walkers pinned the e_flags word EXACTLY -- 0x70001405 for an EXEC,
0x70001407 for a DYN -- which made them the strictest of the six and strict
enough to be wrong: the low three bits are NOREORDER/PIC/CPIC and vary
between objects of identical ABI, so libarchive's 0x70001406 is refused by
that rule and loaded happily by the kernel. The replica gate masks them off.
libsodium keeps its readelf output; a build log that says what it produced
is worth having, it just no longer decides anything.

KEPT, because it is a different question about a different object: the
compiler wrapper check in pkgs/lib.sh (and the copies in the two standalone
tools/ trees). It compiles a hello-world and reads THAT header, so it judges
the toolchain rather than any artefact -- something a filesystem sweep
structurally cannot do, since it cannot see a compiler. It also fails in a
second, before a feed is built on a wrapper that quietly lost -mnan=2008,
where the replica gate would report several hundred files minutes later.
test_the_toolchain_wrapper_is_still_checked_before_anything_is_built now
holds it in place from that side.

AND THE GUARD HAD A HOLE, found while doing this. The version in 7dda2fe
grepped the build scripts for nan2008 and mips_abi_gate, so it never saw
pkg_toolchain's check in pkgs/lib.sh -- which is written in raw hex and says
neither word. A gate can be written entirely in hex, so the guard now looks
for 0x7000140 as well, and permits it only in the four files allowed the
wrapper check. It does NOT look for mips32r2: that is also an ISA compiler
flag, and pkgs/3rdparty/openssl/build.sh legitimately passes -mips32r2, so
guarding on it reports the flag -- a false alarm, and then a guard nobody
reads.

Both guards were driven red on purpose: an artefact gate returned to a
recipe, a hex-only gate returned to bin/common.sh (the form the old one
missed), and the wrapper check deleted from pkgs/lib.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NpH87TJJEjDyRhQPu18pgp
2026-08-30 01:02:17 +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 9aece5b890 klipperDaemon was never replaced on any printer
installer/run-append.sh guards the klipperDaemon replacement with

    if [ -f $MODDIR/bin/klipperDaemon ] && [ -d /usr/prog/klipper ]; then

and the file ships at $MODDIR/prog/klipperDaemon. It moved there in 057a3a1
("The boot was seven scripts in filename order. It is one compiled graph now")
and this guard did not, so the test has been false on every printer since: the
whole block was skipped in silence, and every release kept FlashForge's own
klipperDaemon.

What that means on a machine: stock `klipperDaemon start` forks a second,
UNSUPERVISED klippy beside the s6-supervised one -- two processes fighting for
/dev/ttyS4 and /tmp/uds. Our shim exists to make that `start` a no-op, and it
has not been installed. The UI's restart-firmware path and a habit over ssh both
call it.

Nothing could have caught it. sh -n parses it, shellcheck likes it, and both
lanes stay green because the branch never runs. The niceness grep in
etc/s6-rc/source/klipper/run kept working by accident: it reads
/usr/prog/klipper/klipperDaemon, which is still FlashForge's, and FlashForge's
number is what it wanted.

So the fix comes with the gate it needed. qa/static/test_moddir_paths.py asserts
that every literal `[ -f $MODDIR/... ]` existence guard in installer/ and in the
payload scripts names a path some recipe actually ships -- the shell half of
test_no_undefined_names, and the same class of failure: a name that resolves
nowhere, on a path that only runs on a printer. Three exemptions, each with a
reason: anvil.conf (templated, user state), .prev-root-hash (written at
runtime), and helix-launcher.sh (inside anvil-helixscreen's upstream tarball,
shipped but not committed).

Verified by reintroducing the bug: the test fails with
"installer/run-append.sh:218  $MODDIR/bin/klipperDaemon".

Found while confirming that firmwareExe and start.sh are anvil-core's and get
symlinked by anvil-link-prog.sh -- they are, since 214a4e5. klipperDaemon is the
third file in payload/prog/ and the only one still hand-copied by the installer
rather than linked, which is why it could drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01La5MaDKqTW675k5fsXsP6S
2026-08-30 00:50:43 +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 c185ef3285 The replica is a build tool; test/ is 28 unit tests
Two changes that turned out to be one.

MOVED, NOT DELETED. `test/ffsim/` and `test/integration/printer/` are
`tools/replica/`. They were never tests: bin/patch.sh calls
bin/build-payload.py, which starts a replica and assembles the payload by
running the PRINTER'S OWN opkg against / inside it -- so the "test harness"
was on the build path, and deleting it as asked would have broken `make
build`. qa/lib/replica.py drives the same directory unmodified. No content
changed; what changed is qa/lib/replica.py's build directory, three launcher
sys.path lines, the Makefile targets and the CI shellcheck glob.
tools/replica/README.md says who drives it and why it is not under test/.

CUT TO 28. The five surviving modules were 136 tests; they are 28, chosen on
one question: would this failure be SILENT? What is kept is the mistakes that
run perfectly and are wrong --

  test_startup       8  no stamp unless the values are verifiably saved (a
                        stamp written early is a printer that has lost its
                        factory calibration for good), and the handover order
  test_tool_transform 7 every one is a move to the wrong place: a sign, a
                        frame left applied while probing, a stale gcode_move
                        cache, a config a calibrated printer cannot parse
  test_ffscreen      5  the four ways it can do harm rather than look wrong
  test_chamber       4  the chamber mistakes that cost a print before anyone
                        sees them, plus the parse gate they rest on
  test_gcode         4  a renamed macro, and the safe file being cold

What went: the panel narration, the geometry probe and parse_geometry, the
z_adjust and per-job-Z bookkeeping, the config layout checks, the G-code
bounds checks. Each is either a wrong number shown to a person, or a failure
the printer reports loudly the first time -- and qa/replica/test_boot_screen.py
already renders the real frames on the real machine.

The rewrite is not a delete: assertions were folded so a kept test asks its
question in every direction it can be got wrong (both models, both rotations,
all four ways the migration can fail), and each file's header now says what it
dropped and why, where the next person will open it.

Also gone: conftest's `rootfs` fixture, orphaned two commits ago. Nothing under
test/ needs the firmware, docker, or a network any more.

28 host-side tests pass in 0.15s. qa/static unchanged at 218. `make rootfs`
re-verified end to end from its new path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzLebCrvc8ssWR5JuEsha4
2026-08-29 23:27:34 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 465c9902f1 test/: drop the seven modules whose subject was the fake
Eight files, 46 tests and make-stock-fixture.sh. Each of the seven test
modules built a stand-in -- a klippy stub, an argparse Namespace, a config
tree -- elaborate enough that what they pinned down was the stand-in's
behaviour rather than the printer's. What is left under test/ is the Klipper
config gate and the three modules whose subject is our own Python: ff-startup,
ffscreen, and the tool transform.

make-stock-fixture.sh goes with them. docs/qa-migration.md argued for keeping
it as the input a future build-path test would need, but it fed a gate that no
longer exists, and a fixture generator with no consumer rots exactly as
silently as a test that asserts nothing. What it had learnt is recorded in the
doc so whoever ports the build path does not have to find it twice.

The coverage this gives up is written down rather than glossed: a new section
in docs/qa-migration.md names every dropped file, what it asserted, and what
now asks the question instead -- for four of them, nothing. The one worth
watching is the shebang-executable check, which qa/ makes only of
qa/replica/actions/*.sh.

Falling out of it: the rootfs fixture in test/conftest.py had no users left,
and nothing under test/ needs the proprietary firmware any more. Comments in
qa/static/test_probes.py, qa/replica/test_mcu_bringup.py, bin/patch.sh and
ci.yml that pointed at the deleted files say what is true now; the tree
listing in docs/building.md was stale from the run-tests.py removal and is
rewritten.

136 host-side tests pass. qa/static is unchanged at 218.

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

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

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

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

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

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

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

TWO REAL BUGS FOUND ON THE WAY.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013BTAXgjtF8GdhqSxvdXCFW
2026-08-29 22:51:50 +03:00