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>
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>
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>
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.
OrcaSlicer 2.4.0's Moonraker agent offers to map a project's filaments onto
the printer's slots, and it learns those slots from the `lane_data` namespace
in Moonraker's database. Nothing in the tree said where that data comes from,
so the question "what does this need to work?" had to be answered by reading
three codebases: ff_toolchange's status surface, HelixScreen's AMS backend
and override store, and MoonrakerPrinterAgent.cpp.
The note is that answer as one diagram plus the reasoning around it. Klipper
supplies the slot count and nothing else -- it cannot sense what filament is
in a tool; the operator supplies the identity through HelixScreen; Moonraker's
database is the only durable record; OrcaSlicer reads it once at connect and
never writes back.
It also records the two things that surprised us. The mapping is not applied
at print time -- start_local_print() ignores ams_mapping, so it selects
presets and temperatures at slice time and does not rewrite tool numbers. And
the reason this does not work on our build yet is not a missing feature but a
missing merge: AmsBackendToolChanger was the last AMS backend without a
FilamentSlotOverrideStore, upstream gave it one on 2026-08-24, and our last
catch-up from upstream is four days older than that.
Claude-Session: https://claude.ai/code/session_01PU5nCo85xuFQuub4kJzWt9
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page says a release is a fixed set of these versions, and two rows did not
hold that up. HelixScreen read v0.99.115-creator5.1 while versions.env has
pinned creator5.5 since it shipped in v20260827d, and Klipper read "a current
fork (branch creator5)" -- a branch is not a pin, and the build uses a commit.
versions.env is right as it stands: HELIX_FILE is the release asset's own name
because the URL is .../releases/download/$HELIX_VERSION/$HELIX_FILE, and the
pinned sha256 matches the creator5.5 asset, which is what CI fetches.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
* 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>
* Document automatic pressure-advance calibration
FF_PA_CALIBRATE/FF_PA_PROBE and HelixScreen's calibration-screen
integration shipped without owner/experienced-user documentation --
only the reverse-engineering notes covered it. Adds a table row and
full section to how-calibration-works.md, and links back to it from
the notes page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy
* Add a simple pressure-advance calibration page
Owner-facing procedure alongside XYZ tool calibration and bed mesh --
FF_PA_CALIBRATE with a temp and tool number, what the output means,
and how to save it. how-calibration-works.md's mechanism section now
links here instead of duplicating the command walkthrough.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy
* Point pressure-advance results at the slicer, not printer.cfg
PA is a filament property, not a printer one -- a value baked into
printer.cfg is wrong the moment you switch material. Recommend the
slicer's per-filament Pressure Advance field instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy
* Correct pressure-advance page: there is no factory value
Unlike tool offsets and bed mesh, PA calibration doesn't import a
factory number on first boot -- FF_PA_CALIBRATE only measures and
reports one. Say that instead of implying it's pre-populated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0147AQiPYgtafuy1x5NjqJuy
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* Show the first-boot screen instead of describing it
bin/preview-boot-screen.py already renders every ffscreen.py phase to PNG
on the host, byte-identical to what the printer draws (make boot-screen-sim
proves it against the real replica). Four of those frames -- waiting,
importing, saving, complete -- now sit in installing.md's first-boot
walkthrough instead of leaving the reader to picture a progress bar from
prose alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMV6mRtJ4dCxAj2QAifT8v
* Two big frames beat four small ones
24% width left the boot-screen text unreadable. Down to importing and
complete at 48% -- the same sizing the README already uses for the
HelixScreen pair -- and dropped the two frames no longer referenced.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMV6mRtJ4dCxAj2QAifT8v
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
v0.99.115-creator5.4. The screen was built for a printer with one hotend --
one nozzle reading, a Load button that never said which head it meant -- and
on this machine that was not merely thin: preheat resolved "the nozzle" to the
ACTIVE extruder, so selecting T2 and pressing preheat heated T0. Every nozzle
target now goes to the selected tool's own heater, cooldown builds its heater
name the same way, and where no heater resolves the command is dropped with a
log line rather than sent to the wrong hotend. The panel above it is a row of
tool chips, and the reading, the graph and the presets follow the selection.
HELIX_PKG_VERSION MOVES WITH THE TAG, which is the whole reason the two lines
are neighbours in versions.env: `_p4` for `-creator5.4`. The upstream release
title calls this build 5; the tag's own revision is 4 and that is what a
package version can be ordered by.
Measured: `make packages PKG=helixscreen` builds anvil-helixscreen
0.99.115_p4-r1 against the new tarball, whose sha256 is the one the release's
own SHA256SUMS publishes, and qa/static passes at 319.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rgrDeu9YAUuMM2G7gKTpL
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
_guarded snapshots the pins on entry and _restore puts back what it
found, which is right for every value except one. If a run ever ends
with the latch still on, the next run snapshots 1.00, faithfully
restores 1.00, and the drivers stay dead -- one aborted run turning
into a printer that relatches itself on every subsequent run. Release
the latch instead of reinstating it; only a non-latched value is worth
restoring truthfully.
Also correct an overstatement in the previous commit. A klippy or MCU
shutdown does NOT leave the drivers latched: [output_pin] hands
shutdown_value, which defaults to 0., to setup_start_value, so the MCU
drives PH2/PH3 low by itself. The guard earns its keep on the ordinary
failures -- a command_error, a cancel, an exception out of the sweep --
which unwind through _restore with the machine still running.
The latched and released values are named rather than spelled 1.00 and
0.00 at four call sites, since the comparison in the snapshot now
depends on them agreeing.
SET_PIN PIN=enable_pin_tmc_x VALUE=1.00 disables the X driver outright.
It is not Klipper's stepper enable -- that is !PJ6 and !PJ2 -- but a
second latch on PH2/PH3 that Klipper knows nothing about. So for the
whole sweep every G1 X in the ladder is planned, step-generated and
clocked out against dead drivers, and the gram of filament falls
straight down from wherever the toolhead was standing.
clearNozzleEddy settles it. Just before its jal to paTestMgr it builds
the string resetTmcPa replays: X = 275 + tool_dx, Y = 254 + tool_dy,
Z = probeZ + 2.8, from the constants at 0xdb6b2c/0xdb6b28/0xdb6b24 --
the purge chute, the same pair ff-filament.cfg carries as purge_x and
purge_y, with a -8.0 that is literally our tool_dy[3]. The app parks
over the chute, kills X and Y, dumps into the chute, and then tells
Klipper the carriage is still at the chute, which is only sane because
it never left.
We inherited the latch and the height without the park, so the ladder
piled onto the plate wherever the nozzle happened to be. Park in
_FF_FILAMENT_PREP's own three moves before the latch goes on, replay
SET_KINEMATIC_POSITION after, and shout when the pins will not unlatch
rather than leaving a printer that cannot move.
The replay lands after RESTORE_GCODE_STATE, not before: set_position
fires toolhead:set_position and gcode_move recomputes last_position
from base_position/homing_position, so it has to see the restored
offsets.
The 0.05 mm default was derived from what the guard can resolve rather
than from what the machine actually measures: with four symmetric points
one bad point of error e shows up as only ~e/4 in the residual while
moving the fitted centre by e/2, so 0.05 trips at centre errors of about
0.1 mm. That is inside the ESTOP's own repeatability. A healthy run on a
Creator 5 Pro fits with a worst residual of 0.23, which the guard
rejected -- and rejecting means aborting without saving, so a good
calibration was thrown away.
Default is now 0.5, which trips at centre errors of about 1 mm. A probe
that missed the bore edge or fired on the way in still lands far outside
that; ordinary scatter of a couple of tenths passes. 0 still disables,
and the option is still settable per printer.
The other guards are unchanged: min_radius/max_radius are off by
default, gap_min/gap_max is a 1.5..5 mm window around a nominal 3.2 mm
rise, and plate_z_tolerance is one-sided at 0.8 mm.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DYQe3sx9XDhz59PDHCDNU
The station pass needs an empty carriage, so with a toolchanger it always
parks: PARK=0 only said "I docked it by hand", which the sensor check
right below already decides for itself. Without [ff_toolchange] there is
no TOOLCHANGE_PARK to call and no sensor to ask, so the pass just runs --
where it used to demand PARK=0 before it would.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfsqVZ3aXL3qxr3BFDS35x
It served an operator who does not exist: anyone who wants the axes left
alone simply does not run a calibration command, and nothing in the tree
passes HOME=0. Both commands home when an axis is unhomed, full stop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfsqVZ3aXL3qxr3BFDS35x
Every move both offset commands make is in absolute machine coordinates,
so an unhomed axis is a crash, not a warning -- but refusing with "home
all axes first" just made the operator type the G28 we were about to
need. Both commands now run it themselves; HOME=0 keeps the refusal for
anyone driving the axes by hand.
Homing docks a mounted tool (Z homes on the carriage's eddy sensor), so
the tool pass no longer keys its re-grab off the plate check having run:
it asks the toolchanger what is on the carriage, picks the tool back up
if it is not there, and refuses to measure a carriage it cannot verify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfsqVZ3aXL3qxr3BFDS35x
EXCLUDE_OBJECT installs the ExcludeObject move transform, and inside a
cancelled object _ignore_move updates Python state without ever calling
next_transform.move(). Nothing reaches the toolhead, so the lookahead
buffer that normally blocks gcode.run_script never fills, and
virtual_sdcard.work_handler runs on its own yields alone -- one
reactor.pause per 8192-byte read, about 330 lines apart. The eboard's
soft-PWM refresh misses its deadline and the MCU shuts down with
"Timer too close".
Measured on a Creator 5 Pro: 37 s from "Excluding object" to the
shutdown, with 1 Hz status queries answered in 1.30-1.78 s throughout
while the main MCU sat at mcu_awake=0.000 and gcodein=0.
EXCLUDE_OBJECT_DEFINE is not the cost. The line costs 115 ms through the
live klippy and nothing thereafter; three prints of the same file
without a cancel complete with bytes_retransmit=0. Removing it removes
the object there is to cancel, not the work.
tools/gcode-bench holds the harnesses, including the shlex fast path
that takes that DEFINE from 91.6 ms to 5.9 ms on the printer -- worth
having for large plates, but not this bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AVUMfbNuKUWWV32PBcydwb
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
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
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
how-calibration-works.md is the mechanism page under "For experienced users",
and it already ends with input shaping -- which is exactly the section a reader
would otherwise confuse this with. The touchscreen listed the two next to each
other and they are not the same kind of thing: input shaping changes how moves
are planned, VFA changes the current going into the motor.
NOT docs/calibration.md, which is the XYZ tool offsets procedure and nothing
else, and NOT "Using Reforge": the procedure is console work and needs
SAVE_CONFIG and a macro name to describe at all, which is the line the docs
map draws between the first section and the second.
The section says the three things a reader can act on. That the correction is
applied by the driver rather than by Klipper, so it is live on every move and
is not something printing drifts -- which is why this is the calibration you
are least likely to need. That SAVE_CONFIG is required and is deliberately not
in the macro. And that the commands underneath are FlashForge's: the stock app
ran this from its own flow, so it is not claimed as a gain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr2x7aDaY889qwSxbdLt8
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
The note said where the calibration is stored and left the consumer implied.
It is two layers and only the first is ours.
klippy/extras/mclib.py is fork-only and is not FlashForge's either -- its
header is Copyright (C) 2024 Dongzhi Yu <dongzhi.yu@gigadevice.com>, so mclib
is the GD32 vendor's motor-control library. On the host it is pure transport:
_build_config emits the motor description through add_config_cmd, the block
Klipper replays on every MCU connect, which is what makes SAVE_CONFIG's
td*_amp/phase survive a reboot.
The application is in the GD32H757ZG firmware at 600 MHz, and its command set
says what it is: phase resistance, inductance, torque constant, bus voltage,
current-loop kp/ki, motor identification. That is a closed-loop current-
controlled driver synthesising phase currents, not a step/dir chip -- the 1x/
2x/4x injection is added to the commutation waveform inside that loop.
The fork ships upstream's src/ but no GD32 port and no mclib.c, so we cannot
rebuild mainBoardGD.bin or read the injection maths. It does not matter:
ff_mcu_bringup.py flashes nothing, the printer keeps FlashForge's firmware,
and its dictionary already carries mclib_set_resonance_damp.
Follows from that, and worth having written down: the compensation is applied
by the driver and is live whether or not stepper_resonance_tester.py can
import numpy. mclib.py imports only logging and stepper. A printer with saved
td* values keeps them; only the calibration is blocked.
Also checked while in there -- both phase results are normalised % 2pi before
return, so nothing negative reaches the %u field on the wire.
Documentation only. mkdocs build --strict passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr2x7aDaY889qwSxbdLt8
The stock app's "magic" VFA calibration is not app-side math at all. Traced
from CommMgr::calibration_vfa (0x0075e694), it sends five G-code strings and
the last one is STEPPER_RESONANCE_FACTORY_CALIBRATE -- a macro in FlashForge's
printer.vibration.cfg over klippy/extras/stepper_resonance_tester.py, which
searches amplitude and phase for the motor torque-ripple compensation the
closed-loop driver applies at 1x/2x/4x the electrical frequency. Every piece is
already here: the module is byte-identical in our vendored fork, the mclib
sections are in our printer.base.cfg, and mclib_set_resonance_damp is in the
stock mainBoardGD dictionary we never rebuild.
WHAT THE TRACE TURNED UP ON THE WAY. Two comments in this repo said the numpy
gap was a small item because the module guards its own import. It does not:
line 1 of stepper_resonance_tester.py is a bare `import numpy as np`, and
klippy.py:122 loads EVERY config section through a load_object that does not
catch ImportError -- the default=None argument only covers a missing file.
printer.base.cfg:19 includes printer.vibration.cfg, which declares
[stepper_resonance_tester]. So on the CPython 3.13 klippy has run on since
53292b4 this is not a lost feature, it is a klippy that never reaches ready.
Confirmed by importing the module with numpy blocked at sys.meta_path.
Both comments are corrected rather than deleted, because the claim they made is
the one a reader would otherwise make again.
Documentation only -- no behaviour changes here. The note lays out the chain
address by address and the three ways out (package numpy, drop numpy from the
module, or guard the import), and says which to reach for first. Nothing should
be built on it before a real printer.log confirms the boot failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJr2x7aDaY889qwSxbdLt8
The host half of the touchscreen's automatic pressure advance, ported from
CommMgr::paTestMgr. ff_pa.py drives the recovered sequence verbatim -- the same
seven candidates in the same scrambled order, the same Y ladder, the same
5-sweep / 3-winner / mean-of-3 selection, the same "only verdict 9 passes" --
and prints the result. It saves nothing and applies nothing; the measurement
itself stays where it already works, in the closed eBoard firmware we ship
unchanged, reached through [pa_adjust]'s existing command wrappers.
It borrows those wrappers rather than declaring its own. Declaring our own
would be safe (lookup_query_command builds a fresh wrapper per call and
SerialRetryCommand registers per send), but it would duplicate a closed MCU's
protocol strings and would need a config-time callback on [mcu eboard], letting
a report-only tool block startup on a machine whose eBoard is unplugged.
Deliberate divergences, all commented at the point they matter: the extruder's
own pressure advance is restored on exit, which the app never does because
SET_PA_ADVANCE overrides it and we do not; a run where every line came back with
the same verdict is refused, because "smallest passing wins" then yields the
smallest candidate wearing a measurement's clothes; cold extruder, empty tool
and out-of-range geometry are refused before anything moves rather than failing
mid-line with the eBoard armed; SET_KINEMATIC_POSITION is not replayed, since
our sweep is plain G1 under Klipper's own kinematics.
What is NOT changed is the timing. pa_action is an immediate MCU command and
there is no M400 between the last G1 and ACTION=0; the scorer was tuned against
that, and Klipper's own input throttle is what bounds the window.
FF_PA_PROBE draws one line and prints one verdict. It exists because the first
real question on hardware is whether the eBoard discriminates between candidates
at all, and answering that should not cost a full run.
Checked: pyflakes clean; qa/static 143 passed; both packages build and both
files land in the .ipk; the include set and the shipped ff-*.cfg set match the
way the replica gate checks them. The selection logic was exercised against
stubs -- it picks 0.0150/0.0200/0.0150 -> 0.016667 from a canned verdict table,
refuses an all-9 run, and refuses fewer than three winners. Nothing in the repo
can load klippy off-hardware, so behaviour on a machine is still unproven.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kw2rehvQJ7LSc28hPikfqk
eBoard.bin is a stock Klipper MCU build, so the handlers are reachable without
symbols: eBoard.hex loads at 0x08010000, command_index sits at 0x0801a288 with
16-byte entries indexed by the dictionary's command id, and all 77 ids line up.
pa_action is id 5 (0x080115ec), get_emcu_pa_value id 6 (0x080115d0).
ACTION=11 arms a capture: it zeroes an 8000-byte buffer at 0x200001fc and the
result word at 0x200000a0, then enables TIM1/TIM4/TIM8 and DMA1 ch5. The buffer
is filled by the USART3 idle-line handler at 0x08016734, which validates a fixed
11-byte frame and stores the big-endian uint16 payload, up to 1990 samples --
and only while the capture flag is set, which is what makes it the PA trace.
On stop, a background task at 0x08016c60 runs analyse(buffer, 2000) and parks
the verdict where get_emcu_pa_value reads it.
So the signal is a serial sample stream from a separate module, not the
accelerometer, the eddy coil or an ADC channel -- the earlier guess in this file
was wrong and is removed. Which transducer is on that UART is still open.
The point that matters for the port: nothing ever looks at the printed lines.
They exist only because you cannot make a real pressure transient without really
extruding. The scoring is live, off a signal the toolhead already streams, so
keeping FlashForge's eBoard firmware gets us the same verdicts for free.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kw2rehvQJ7LSc28hPikfqk
firmwareExe runs a real PA calibration at print start, inside the nozzle clean.
CommMgr::paTestMgr @0x791640 prints one slow-fast-slow line per candidate at
Y = 50 + 5*i for seven fixed PA values, asks the eBoard to score each line, keeps
the smallest that scores 9, repeats until three sweeps have produced a winner and
averages those three. All of that is host code and is recovered here in full.
Only the verdict crosses into the closed toolhead MCU, over two commands that
pa_adjust.py already forwards -- and pa_adjust.py is in our tree byte-identical
to FlashForge's, with [pa_adjust] already in printer.base.cfg. So PA_ACTION and
PA_GET should answer on a Reforge machine today; that is step one of the port.
This supersedes "sensor unknown" in 60-background.md and "callable, not
reproducible" in 25-app-vs-klipper-ownership.md. What stays unknown is narrower:
which sensor the eBoard samples, what ACTION=11 and PC=666 select, and what the
non-9 verdicts mean.
Two findings that shape the port. PA_GET only respond_info()s its value, so the
sweep cannot be a G-code macro -- it needs an extra that queries the eBoard
directly. And PA_ACTION is an immediate MCU command, so FlashForge never
synchronises the measurement window with motion; the port must copy that timing
rather than improve it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kw2rehvQJ7LSc28hPikfqk
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
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
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
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
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
Master moved under this branch: patch.sh is payload.sh, run-pre.sh and
run-append.sh are one runFirmwareExe.sh we own, and the install is no
longer spliced into FlashForge's run.sh. Every path and line number in
the note pointed at files that are not there.
The argument survives intact -- the manifest, backup/stock and
config-installed are all still on master, just moved -- so this is a
re-derivation of the citations, not a rethink. One section did change:
.prev-root-hash is resolved. Nothing records or restores the hash any
more; runFirmwareExe.sh:169-171 just deletes it as a secret left by
older installs. So the ordering risk against the password branch is
gone, and the wipe subsumes those three lines too.
backup/stock also reads better now: the script itself explains at
:157-161 why firmwareExe is not in the backup, which is the point the
note was making the long way round.
mkdocs build --strict green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXKk3zz8LAUPUDZkVEZ9fR
Four claims in the new note described code or logic that is not there.
The backup/stock section said make test-recovery "is still referenced"
by hardware-testing.md; that page already says the gate is retired, so
the sentence invented a staleness that does not exist. Property (a) was
illustrated with a renamed init script starting nginx twice -- the
failure that prompted the manifest, but init.d has not shipped since
s6-rc took over, which is why test_upgrade.py deliberately avoids those
names. The cancels section told a future reader to go and mark the two
phase notes, which this branch had already done. And the allowlist
paragraph still called it phase 2's to-do list after cancelling phase 2.
The pointers in 80- and 85- also read as settled when nothing has
landed; both now say proposal. The 80- one leaned on that note's gate
item (c), which names anvil.conf -- removed outright some releases ago.
mkdocs build --strict green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXKk3zz8LAUPUDZkVEZ9fR
Proposal: on update, rm -rf $MODDIR and extract into the empty
directory. No manifest, no selective delete, no compatibility branch.
The audit is the note. Against a full wipe .install-manifest was
protecting backup/stock, .prev-root-hash and config-installed, and
each one falls: nothing restores from backup/stock and the documented
recovery is a stock reflash; .prev-root-hash is removed by the
password branch; config-installed is 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 got included last, and which inverts its own
purpose by keeping the webcam block and API lockdown off any printer
that trips it.
So moonraker.conf joins ff-*.cfg and is simply overwritten, owner-
dropped files under $MODDIR become an accepted loss, and phase 2's
on-printer half in 85-packaging.md is cancelled rather than owed: a
printer that never deletes selectively needs no file database for it.
Phases 1 and 2 in 80-s6-migration.md and 85-packaging.md point here.
No code has moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXKk3zz8LAUPUDZkVEZ9fR