Commit Graph
13 Commits
Author SHA1 Message Date
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 58cfd00415 FlashForge's ffmpeg cannot render a rotated webcam, so we build our own
moonraker-timelapse emits -vf 'transpose=1', 'hflip,vflip' and friends
straight from the webcam's rotation and flip settings. The stock ffmpeg is
configured --disable-everything with a narrow enable list, and asking the
binary itself for -filters returns format, null, scale and trim -- so every
one of those renders fails on a printer whose camera is not upright.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:13:26 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 16d00be525 case-services.sh is 360 lines and one exit code. That contract is 44 tests now.
test/ has two test frameworks. run-tests.py + ffsim is 1,657 lines
reimplementing what pytest already does -- skip accounting, gating,
reporting, then parsing pytest's own JUnit XML back out -- and it calls
pytest as gate #5 of about twenty. Underneath it, 6,280 lines of
case-*.sh execute inside the qemu chroot and report 13 booleans between
them; case-moonraker.sh is 1,059 lines that collapse to one pass/fail.
Nothing runs in parallel and nothing can be selected: each replica gate
is its own `docker run --privileged`, re-registering binfmt and
re-assembling the mount layout, thirteen of them in sequence.

All three follow from one decision -- THE REPLICA CONTAINER IS TREATED
AS A TEST RUNNER INSTEAD OF AS A FIXTURE. That is what pushes assertions
into ash, which yields one bit per container, which then needs a bespoke
framework above pytest to aggregate the bits.

qa/ is the replacement, built beside test/ rather than inside it, with
two lanes and one framework. Both suites run in CI; test/ is still the
release gate; nothing has been deleted. docs/qa-migration.md has the
order the rest moves in, and the rule: a case script is deleted only
once its replacement here has been green, so there is never a window
where coverage drops.

THE MACHINE IS UNCHANGED. qa/ reuses test/integration/printer/'s
Dockerfile, entrypoint.sh, assemble.sh, binfmt.sh and seed-prog.sh as
they are, and its `docker run` argv is deliberately ffsim's, minus --rm
and plus -d. While both suites run they have to be testing the same
machine or neither proves anything about the other. The only new trick
is how the container is held open: entrypoint.sh ends by running the
case script it was given, and the one it is given here touches a marker
and sleeps, so setup completes exactly as it always has and the
container simply does not exit. The marker is also the readiness
signal, because setup ranges from under a second to over a minute and a
constant would either waste the fast case or fail the slow one.

TESTS RUN AGAINST A MACHINE THE REAL INSTALLER PRODUCED. The `printer`
fixture is a replica with the package installed by the printer's own
app_startup.sh, off a genuine FAT filesystem on /dev/sda1. The first
version of this tree did what case-services.sh does -- copy payload/*.sh
and payload/init.d/S* into $MODDIR by hand -- and two things were wrong
with it. It is a second implementation of the install, so everything
downstream asserted against a layout the harness built rather than one
the installer produced, and the real installer could have broken without
a single test noticing. And it can only place what is in payload/, so
everything the BUILD produces was missing: s6 is a build output that
bin/patch.sh stages into the package, so a hand-placed payload has no
supervisor at all, which is why the tests had to carry a stand-in
scanner and gate one assertion on whether it was in use.

Installing for real fixes both and deletes the stand-in. Hand-placed,
/usr/data/anvil held anvil-env.sh, anvil-service.sh, anvil.conf, init.d
and etc/s6. Really installed it holds VERSION, backup, bin, config,
config-installed, helixscreen, lib, libexec, moonraker, nginx and www as
well -- bin/lib/libexec being the cross-built s6 and CPython 3.13. The
file is SHORTER than the case script it replaces while reporting 44
results instead of one. case-install.sh's header records this same
lesson from the first time somebody learned it: "An earlier version of
this file replayed app_startup.sh by hand -- which meant a bug in our
reading of it could never be caught."

The install is the machine's own shell under qemu, so it is baked into
an image once per package and cached under a tag derived from the
package's md5 -- build-printer-image.sh's trick for the stock baseline.
Rebuild the package and the tag changes, so the next run bakes again
instead of testing yesterday's build; deliberately not a hand-written
staleness check, because those are the ones that eventually get it
wrong.

A MISSING TOOL OR RESOURCE FAILS, and there is no flag. This tree
briefly had --strict-skip=<lane|all>, which was ALLOW_SKIP inverted --
and ALLOW_SKIP's defect was never the direction it named things in, it
was that the decision lived in the CI invocation. An accept-list has to
be edited whenever a gate moves; a strict-list has to be remembered
whenever a job is added; both degrade silently when the command line is
wrong, which is the same disease as the printer-sim job that was gated
on an unset secret and never ran once in its existence. So the verdict
lives with the knowledge: no shellcheck, no pyflakes, no docker, no
daemon, no replica image, no built package -- each fails where it is
needed, with the fix in the message. There are no skips in this suite.

MEASURED, on monstrofil/creator5-printer:latest, both suites agreeing on
the same machine: 131 tests in 40s, all green; qa/static alone 1.3s with
no docker; one assertion selected by node id 2.9s, against the whole
360-line case being the smallest unit test/ can run. Granularity checked
by injecting the regression case-services.sh exists to catch -- deleting
SVC_EXTRA_VERBS="|force-start" from S70klipper -- which failed one named
test, printed the usage line that came back, left the other 41
reporting, and took 4s to re-run alone.

Two bugs the port found in its own harness, both only visible by
running it. bake.sh shipped without its executable bit, so the lane died
at container init with 43 errors all saying "could not install the
package" -- a file mode wearing an installer failure's clothes;
test_action_scripts_are_executable now guards it, the same check the
payload's own s6 run scripts already have. And an ELF magic assertion
used `od -An`, which the printer's busybox od does not have -- the
replica catching host-vs-printer drift in the test code itself, which is
the entire reason it exists.

Also here: qa/static/test_probes.py, which has no counterpart in test/.
Printer.ps() and Printer.listening() are parsers, and a parser is where
a harness goes quietly wrong -- a listening() that never matches makes
every readiness assertion pass by the absence of contradiction. Neither
failure looks like a failure; they look like a green suite. So they are
tested against captured output, in the static lane, with no docker
involved.

Two things found while surveying, neither fixed here.
case-pyext.sh is 567 lines with no gate in gates.py, no Makefile target
and no CI step: it has never run. And case-mcu-bringup.sh:4 says the
handshake is "tested elsewhere against a pty" -- there is no pty
anywhere in the repo, and every documented past bug in
ff_mcu_bringup.py lived in the code that comment excuses from testing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:35:14 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a8a46e21d2 The build makes the packages Moonraker runs on, from hashed sources
Section 5c grows a second half and section 5d appears. The 18 packages and
libsodium that two spikes proved are now built by bin/patch.sh from pins in
versions.env, and land in the payload at lib/python3.13/site-packages and
lib/libsodium.so.

WHERE THE PACKAGE BUILD HAD TO GO, and it is not obvious: inside 5c, against
the UNTRIMMED interpreter stage, before the trim runs. The headers INCLUDEPY
names, the static dependency libraries pillow and cffi link against, and the
x86-64 build-python all exist only at that moment -- the trim deletes
include/ and config-3.13-* and then removes the staging tree entirely. The
alternative was caching a second untrimmed ~200MB tree. The price of not
doing that is that either stamp going stale rebuilds both halves, which is
written down at the stamp rather than left to be discovered.

The build no longer touches PyPI. pip comes from --with-ensurepip=install on
the build-python, out of the same pinned CPython tarball, and every pip wheel
runs --no-index --no-build-isolation --no-binary :all: against hashed sdists
in vendor/. That last flag is not caution: during the spike pip silently
downloaded x86-64 manylinux wheels for cffi, greenlet and tornado, three
wrong-architecture objects sailed through the build, and only the ABI gate
caught them.

Which is why the gate now reaches the STAGED PAYLOAD rather than the build
cache. It walked $PY_BUILD/bin and $PY_BUILD/lib, so libsodium and the twelve
extension modules staged straight into $MOD_PAYLOAD would have gone
unexamined. 69 ELF objects now, plus libsodium's resolved .so. Deliberately
not all of $MOD_PAYLOAD: s6 is musl-built and correctly reads e_flags=0x1007,
mips1 and legacy-NaN, which is right for a static binary that does no floating
point and is measured working on the printer. It has its own gate in 5b. That
distinction is recorded in patch.sh, because a sweep that assumes every object
must be nan2008 flags all thirteen s6 binaries and looks like a disaster.

Two pins carry reasons that cost someone an afternoon: pillow is 11.0.0
because 10.3.0 cannot be built by ANY 3.13 -- its setup.py execs and then
reads locals()["__version__"], which PEP 667 broke -- and streaming-form-data
is 1.19.1 because Moonraker's own pin ships pregenerated Cython calling the
four-argument _PyLong_AsByteArray, which grew a fifth parameter in 3.13.

docker/Dockerfile.build needs zlib1g-dev and patch, and the image must be
rebuilt. The first is a genuinely nasty one: a build-python without zlib
cannot unpack a wheel, or install pip, and ensurepip fails INSIDE a `make
install` that reports success. patch.sh now asserts zlib and pip on the
build-python rather than failing later and further away.

The install manifest goes from 1872 paths to 2940 and case-upgrade.sh still
passes, which is the check that the deletion loop scales. Note that the "70 to
728" in the commit that landed the interpreter was wrong -- 728 predates the
interpreter itself; HEAD's own patch.sh produces 1872 here, measured.
anvil.tar.xz grows 1.6MB packed, about 15MB on the printer.

FF_PYTHON still points at FlashForge's 3.8.2 and /usr/prog/libsodium/lib is
still in ANVIL_LIBS. Both comments now say what changed -- the packages exist
-- and what has not: nothing has run through S62moonraker under s6 on 3.13.
The two lines move together, in the commit that has that gate behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:29:52 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 813973620c The payload carries a CPython 3.13 with a working sqlite3
Nothing runs on it. FF_PYTHON still points at FlashForge's 3.8.2, and the
comment beside it now says at length why changing that line is a one-line way
to break the whole userspace: klippy, Moonraker and ff-startup.py import
third-party C extensions -- tornado, lmdb, cffi, greenlet, pillow, libnacl --
that exist on this printer only as mipsel objects built against 3.8. None has
been cross-built for 3.13 yet. The interpreter ships ahead of the switch for
the same reason s6 did: it puts the artefact on real printers where it can be
measured, and it leaves the switch a one-file change rather than a build
change and a boot change at once.

Why it exists at all: the Moonraker pin is frozen at commit 9d0d09d, December
2023, for exactly one reason -- this firmware's Python has no _sqlite3 and
there is no libsqlite3 anywhere on the box, while Moonraker v0.9.0 moved its
database off lmdb onto sqlite. That is a cost paid at every release. The gate
proves the premise rather than assuming it: the stock 3.8.2, given the library
path it needs to start, imports json and zlib fine and cannot import sqlite3.

Built by bin/patch.sh from eight pinned sources with the Ingenic toolchain
that already builds c_helper.so -- gcc 7.2, glibc 2.29, cross to
mipsel-linux-gnu. Not musl, and this is not a preference: a musl interpreter
cannot dlopen a glibc c_helper.so, which is exactly how klippy loads it.

Three things about the build are load-bearing and all three were found the
hard way:

  * -EL -mnan=2008 must reach the LINK lines, not only the compile lines.
    They are baked into PATH wrapper scripts around the gcc driver rather than
    passed as CFLAGS, because trusting eight separate build systems to forward
    flags to their linkers is how you get legacy-NaN objects that the printer
    refuses to load.
  * _sqlite3 disappears silently without LIBS="-latomic -lm". A static
    libsqlite3.a carries no DT_NEEDED on libm, so configure's
    sqlite3_bind_double probe fails to link and CPython records the module as
    "missing" -- a probe failure, so nothing in the build output says why. The
    build now hard-fails when _sqlite3 is absent rather than shipping an
    interpreter whose whole purpose is gone.
  * OpenSSL's linux-mips32 target hardcodes -mips2, which collides with this
    toolchain's -mfp64. -mips32r2 as a user cflag lands after the target's and
    wins.

Built --disable-shared, unlike FlashForge's own 3.8.2. Static libpython and
static dependencies mean the shipped tree contains no shared object of ours at
all: nothing to version-skew, nothing that can resolve to a /usr/prog copy by
accident, and no new mandatory LD_LIBRARY_PATH entry -- which matters here
specifically, because removing /usr/prog version strings from anvil-env.sh is
the point. The usual objection to static, that C extensions cannot find the
interpreter's symbols, was tested rather than assumed: an out-of-tree
extension cross-built against the staged include/ imports and runs on the
printer with no libpython mapped.

It installs into $MODDIR itself, --prefix=/usr/data/anvil, the same prefix
root s6 uses: bin/python3.13 beside bin/s6-svscan, stdlib in lib/python3.13
beside lib/. One install, not two trees.

That creates a hazard worth naming, because anvil-env.sh PREPENDS $MODDIR/bin
to PATH so s6-svscan can find s6-supervise. `make install` also writes
bin/python3, and a shipped bin/python3 would then sit in front of
FlashForge's for every process that sources anvil-env.sh -- silently handing
klippy and Moonraker an interpreter with none of their extensions. So the
build deletes it and asserts bin/ holds exactly python3.13, verify.sh fails a
package carrying bin/python3, and the gate asks the shell what `python3`
resolves to on anvil-env.sh's own PATH. It answers
/usr/prog/Python-3.8.2/bin/python3, while python3.13 answers ours.

About 28MB and 600-odd files, which takes the install manifest from 70 entries
to 728 -- case-upgrade.sh still passes, which is the check that the manifest
deletion loop scales.

The build image grows a host gcc, which looks redundant beside two
cross-toolchains and is not: cross-building CPython needs a build-python of
exactly the same version to freeze modules and byte-compile the stdlib, so
patch.sh builds an x86-64 3.13 from the same tarball first.

tools/python/ keeps the measurement build and what it cost, as
tools/supervisor/ does for s6. Two gaps recorded there rather than lost: the
printer has NO CA certificates -- /etc/ssl/certs exists and is empty, so TLS
negotiates and verifies nothing, which is true of the stock interpreter too --
and libatomic.so.1 is a new runtime dependency, present on the rootfs,
measured, and removable by linking it statically if that is ever wanted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 23:15:40 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 b1850bb514 A name that is never bound fails the suite, not the printer
The shell lane has bash -n; the Python lane had nothing between "it
imports" and "it ran on hardware". py_compile catches a typo in syntax
and nothing caught a typo in a name, so a rename that renamed an
assignment and missed one of its uses stayed green through both lanes
and waited for the line to execute. That is exactly how _plate_check
came to reach for z_trigger (fixed in the previous commit), on the path
that gates both calibration commands.

pyflakes over bin/, payload/ and test/, filtered to the findings that
can actually fail at runtime -- undefined names, use before assignment,
syntax. Unused imports and the like are untidy but harmless, and a gate
that goes red for those is a gate somebody switches off.

Checked with find_spec rather than shutil.which, the shape the
shellcheck gate uses: the debian package ships the module without
putting a pyflakes executable on PATH, so which() reports it missing on
the very image that has it. Caught by running the gate in the image.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:00:12 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3a968d4219 Land the ffcfg refactor that was sitting in the working tree
Carried in from the uncommitted working tree, not authored here. test/ffcfg.py
gives the Python checks one parser and one set of PASS/FAIL helpers, so
test-chamber, test-base-cfg and test-printer-db stop re-implementing the
reporting and stop reaching test-macros' parser through importlib (the dash in
that filename made a plain import impossible). test/sim-image.sh does the same
for the docker plumbing the replica launchers duplicated.

Both files were untracked, and run-tests.sh already imports them: the suite
could not run from a clean clone until they were committed.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 18:25:08 +03:00
Oleksandr Shyshatskyi ff7204f7d5 Run everything in Docker; test against the printer's real busybox
The stock kernel-*.tar.xz turned out to contain rootfs.squashfs -- the
printer's genuine root filesystem. Unpacking it answered several questions
that were previously guesswork:

- init is busybox init -> /etc/init.d/rcS -> S99factory_test_shell ->
  /usr/prog/app_startup.sh. There is no systemd, and rcS runs from the
  read-only squashfs before /usr/prog/etc is bind-mounted over /etc, so a
  new S-script cannot be injected into the stock boot sequence.
- /usr/sbin/dropbear and an ENABLED /etc/init.d/S50dropbear already ship.
  Port 22 is open on a stock printer; only the root password is missing.
  The dropbear cross-build is therefore deleted -- it was never needed.

New: test/test-ash-conformance.sh parses every on-printer script with the
printer's own busybox 1.31.1 ash under qemu-mipsel. A parse error in the
firmwareExe wrapper would mean a blank screen, and neither bash -n nor dash
-n proves busybox accepts it.

Makefile now runs every target inside the pinned build image, with the docker
socket mounted so test targets can start sibling containers. The repo is
mounted at its real host path rather than /src, because sibling mounts are
resolved by the host daemon.

78 checks pass.
2026-08-22 22:02:11 +03:00
Oleksandr Shyshatskyi 4415905fb3 Initial commit: USB-installable custom firmware builder for Creator 5
Builds a modified FlashForge update package that the printer installs by
itself from a USB stick, reusing FlashForge's own updater rather than a
parallel one.

Integration is a single file: /usr/prog/PROGRAM/software/firmwareExe is
replaced with a shell script. Everything funnels through that binary, so
app_startup.sh, rcS and the init chain stay completely stock. The genuine
binary is kept as firmwareExe.stock and remains the default UI.

Key findings from the stock firmware, each enforced by a test:
- components named *.tar.xz are PLAIN tar; the installer runs bare `tar -xvf`
- the .tgz is DES3-encrypted plain tar, not gzip
- nothing in init starts Klipper -- firmwareExe runs start.sh, so replacing
  the UI without S70klipper leaves a printer that cannot move
- the installer wipes the software dir before run.sh, so an uninstall package
  must ship the genuine firmwareExe rather than back one up
- ssh already works: stock ships dropbear + an enabled S50dropbear, only the
  root password is missing

Brick-safety is designed in, not bolted on: a five-rung profile ladder
starting with a probe that changes nothing, a container-based simulation that
actually runs the installer and asserts the printer would still boot, an
uninstall round-trip test, and a SAFE-MODE latch for UI crash loops.

75 checks pass. Not yet run on hardware.
2026-08-22 21:48:56 +03:00