Commit Graph
47 Commits
Author SHA1 Message Date
Oleksandr ShyshatskyiandClaude Opus 5 68afe65485 Fix two features that silently did nothing, and give Moonraker a user seam
Both bugs were found by auditing a40bc86, which the first pass never saw.

1. The plain Creator 5 could not print at all. ff-filament.cfg subscripted
   printer["heater_generic chamber_heater"] unguarded, and that object exists
   only on the Pro. Klipper renders a macro's whole template before executing
   any of it, so _FF_FILAMENT_PREP/_FF_FILAMENT_FINISH raised on a non-Pro --
   taking LOAD_FILAMENT, UNLOAD_FILAMENT, PURGE, _FF_NOZZLE_CLEAN and
   therefore START_PRINT and every Moonraker print with them. Guarded with the
   `in printer` idiom ff-chamber.cfg already used.

   test_chamber.py gains a static gate over every ff-*.cfg macro. Proven to
   catch it: reverted the guards and the test fails naming both macros.

2. The shipped moonraker.conf could never install. /usr/data/config/
   moonraker.conf is ON the factory image -- verified by probing the replica --
   so the compare-and-.mod-new rule saw an existing file with no snapshot to
   compare it against and parked ours as .mod-new, on the first install and on
   every one after. The [webcam] entry therefore never reached the printer and
   Mainsail still showed no camera, which is the entire reason a40bc86 exists.
   The API lockdown never applied either.

   /usr/prog/klipper/runConfig/moonraker.conf is FlashForge's pristine
   template and is byte-identical to the live copy on an untouched machine
   (both 371 bytes, md5 a49bd5ea...), so that is now the test for "nobody has
   edited this": matching it means the file is ours to write.

   case-install.sh asserts the LIVE file has a [webcam] block, not that a
   .mod-new appeared. It reproduced the bug before the fix.

3. moonraker.conf is mod-owned and overwritten every update, so it needed the
   seam printer.cfg gives the Klipper configs. New moonraker-custom.conf:
   created once, never written again, and included at the END of
   moonraker.conf -- Moonraker applies options in read order, so the user's
   win. Moonraker treats an include matching no file as fatal, so the file is
   guaranteed to exist and both it and its header say not to delete it.

   case-install.sh writes a trusted_clients block into it before boot 2 and
   asserts it is byte-identical afterwards, so "survives an update" is tested
   rather than asserted.

Verified on the printer replica through all three boots: the config lands, the
seam is created and included, a user edit survives the re-install, and
Moonraker still starts -- which is what proves the include parses.

53 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:44 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 b5982b09bb Fix eleven defects the first pass introduced
An adversarial re-read of master..HEAD, checking the new text rather than
trusting it. The substantive work held up -- all eight dead-code removals prove
out, SAFE-MODE->MOD_UI is complete, A3's reordering is correct -- but eleven
claims were wrong, and one made a document worse than it found it:

* docs/notes/10-hardware.md said `temperature_sensor motor_value` on
  eboard:PA1 is "not in anything this repo ships". It is:
  printer.motor.cfg:37 declares it and that file is in the package, which is
  also why ff-toolchange.cfg can name MOTOR_STOP. The real nuance is that
  printer.base.cfg does not include printer.motor.cfg. A correct claim in
  25-app-vs-klipper-ownership.md was "corrected" into a false denial.
* docs/toolchange.md pointed at a worked example that reaches 0.125 mm. The
  only example is NOZZLE=220 BED=80, which gives 0.045; the 0.08 bed term
  needs a bed at 100 or above. Both numbers now stated, no forward reference.
* docs/testing.md said 48 tests; pytest collects 53.
* test_config_ownership.py cited bin/patch.sh:80/:88/:128 -- correct on
  master, shifted by two when this branch deleted MARK_BEGIN/MARK_END. The
  line numbers are dropped rather than re-pinned, so they cannot rot again.
* test_includes.py invented a reason for ff-legacy being last ("so its import
  sees every [ff_tool] already declared"). ff_legacy resolves tools at
  klippy:ready; order does not matter.
* fetch-assets.sh's replacement usage text omitted BUILD_MOONRAKER and was
  less accurate than the vague line it replaced.
* 49-runout's sensor table still claimed `extruder: extruderN` after the A1
  fix made that true only of our override, not of the stock file it describes.
* firmwareExe kept "newest version directory" after the same claim was
  corrected in how-it-works.md.
* 25-app inverted the preflight call order and omitted that an M23 print gets
  neither call.
* creator5-safe-moves.gcode called the prepare's G1 Z10 "the one move" below
  Z50; the G28 and offset move before it also go below.
* DOC-DRIFT-AUDIT.md asserted both "Every finding has been applied" and
  "Nothing in this file has been applied", and cited five commits after a
  hash the rebase had rewritten.

53 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:44 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 63a2669577 Re-check the audit against the Moonraker work it was blind to
The audit branch was cut from origin/master and so was missing a40bc86
("Ship our own Moonraker") and 3e997e5 (HelixScreen v0.99.115-creator5.1).
Merging it as-is would have reverted both. Rebased onto master; three
conflicts resolved by keeping master's Moonraker code and applying the audit's
comment corrections around it (bin/common.sh, bin/verify.sh,
test/integration/printer/case-install.sh).

Two findings were stale against that commit and are re-checked here:

* docs/building.md listed four BUILD_* flags; there are five. BUILD_MOONRAKER
  was added by a40bc86 and never reached the doc -- the same class of drift
  this branch exists to remove, caught only because the rebase surfaced it.
* the default package size is ~63MB, not the ~62MB measured before Moonraker
  rode along. Measured again on a real build: 65,720,344 bytes.

Also folded in: the three-asset wording for MAINSAIL_ZIP / HELIX_TGZ /
MOONRAKER_TGZ keeps master's list and the audit's correction that an explicit
path IS checksummed and overwritten.

Full suite on the rebased tree: 12 passed, 0 failed, 0 skipped -- including
all five moonraker assertions, so the feature survived the merge intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:44 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 d05565284d Sections E, F, G: wrong values, stale claims, overclaims
E -- values and counts. The START_PRINT prologue has no M400 and an idle
timeout of 864000, not 1800000. The nozzle-clean temperature comes from the
material table FIRST, so NOZZLE=240 with no per-tool temp cleans at 220 --
documented backwards before. TOOLS is the paired 0:220,2:240 form. The wipe Z
is the raw eddy frame, not the homed one. The runout M117 strings are
"T<n> clog" / "T<n> out of filament", not E-codes. Grab-verify raises
E0051+tool, not E0143. Docks are at X~297; 250/280 are staging waypoints. The
grab sequence is documented in the order _grab actually emits it. ff_print
exposes seven status fields, not nine, and never emits TOOLS=. Package sizes
reconciled against the real artifacts (62MB built, ~93MB stock, ~370MB of CI
assets, ~60s not 90s). testing.md's sample output block matched no header the
code prints, so it is replaced with the real ones; its pytest row listed two
of six suites.

F -- stale claims. _FF_PREFLIGHT is not "missing". SDCARD_SET_CHANNEL is not
still issued (764af2a). The app and Moonraker do not coexist; the app is gone.
TOOLCHANGE_STATUS has nothing to compare against since the JSON reader was
removed. The JSON is re-imported every boot until a nozzle is saved, not once.
The 23-check render harness was deleted in 91604c2. PURGE now wipes by
default. 50-print-lifecycle.md gained the section on how the sequences are
actually entered now -- [ff_print], not the slicer. The dead "profiles"
concept is gone from three files, one of which printed it on every replica run.

G -- overclaims. The README no longer says a stock reflash puts the printer
back "exactly as it was": it says what is restored and what survives inert,
which is what test-recovery actually asserts. The random ssh password is
conditional on ROOT_PW_HASH being unset. verify.sh no longer warns "ssh will
not start" on every build for a dropbear we deliberately do not ship -- the
check now reads the way round it means. case-install.sh's read-only block says
it asserts nothing. An uncalibrated tool contributes z_adjust, not zero, in
three operator-facing places. offset_base does not cancel out in X/Y. The
safe-moves file's Z50 promise is scoped to its own lines, with the implicit
prepare's G1 Z10 spelled out.

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:43 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 ce06b7e88d Take initial_WHITE back out of printer.base.cfg; the plate check is one-sided
A7: f10d002 put initial_WHITE: 1.0 in printer.base.cfg as well as
ff-chamber.cfg, and recorded the cost -- bin/unpack.sh diffs our copy against
each stock package to notice FlashForge changing a pin map or a stepper
current under us, and initial_WHITE is a real option line, so the warning
fired on every unpack. A warning that always fires is not read.

The line is removed instead of filtered: ff-chamber.cfg is included after
printer.base.cfg and already sets it, sections merge under
RawConfigParser(strict=False), so the light still comes up lit and the base
file is FlashForge's again -- which is what its own header, and
ff-chamber.cfg's, have been claiming all along. Restored byte for byte,
trailing whitespace included, and checked through unpack.sh's own strip()
filter: strip()-equal to the pre-f10d002 file, so the check is quiet.

A4: the plate-removed guard is `zp > expected + plate_z_tolerance` -- an
upper bound, not the +/-0.8 mm band three places described. That is right: a
build plate can only hold the probe HIGH, so a low reading is not a plate.
Documented as one-sided in docs/toolchange.md, ff-tool-offset.cfg and
ff_tool_offset.py rather than made symmetric.

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:42 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 b2c651b11f Fix the runout insert path and TOOL=ALL staging; drop SAFE-MODE for MOD_UI
A2: _FF_INSERT's body was entirely inside {% if kind == 'switch' %}, yet
insert_gcode is wired on all four fm_ex* with KIND=motion. A motion insert --
which is what clearing a clog looks like, filament moving again -- produced no
output at all, on exactly the path where the operator most needs the next
step. Adds the motion branch the comment already described.

A3: TOOL_OFFSET_CALIBRATE staged each tool with set_nozzle() inside the loop,
so a gap-check failure on a later tool left earlier tools pending for
SAVE_CONFIG -- while raising an error that says "nothing saved". True for
TOOL=<n>, false for TOOL=ALL, and told to the operator at the moment they
decide whether to save. Staging now happens after every tool has passed.

A8: SAFE-MODE is removed rather than repaired. It never worked: the settle
check cleared the counter 90s after every boot on a condition
(`[ -s $CHOICE ]`) that cannot be false, since $CHOICE is written two lines
earlier and "none" is not empty. Nothing checked whether helix-screen was
alive, so the latch only fired on three reboots inside 90 seconds -- not the
crash-loop it was written for.

In its place, MOD_UI in anvil.conf, alongside MOD_WEB/MOD_CAM/MOD_SSH/MOD_WIFI
and templated by patch.sh the same way. It stops the UI being started; it does
not pretend to repair one. The docs said `touch SAFE-MODE` was how you recover
a blank screen. Nothing recovers a blank screen on the machine: HelixScreen is
the only UI, and getting one back means flashing a package. Said plainly now,
in the README, the recovery section and the troubleshooting table -- along with
what is genuinely unaffected, which is ssh, Mainsail, Klipper and printing.

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:42 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 e5bd171cf8 Correct the docs the code had drifted away from
Applies the audit's sections B, C and D, plus two decisions.

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

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

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

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

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

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

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

48 passed, 5 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 19:48:42 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 4460488ea9 Moonraker: pin the last lmdb build -- this python has no sqlite3
v0.9.3 does not start on the printer. It was built, flashed by hand and
tried, and it died on:

    ModuleNotFoundError: No module named '_sqlite3'

FlashForge built python 3.8.2 without that module -- there is no
_sqlite3*.so in lib-dynload and no libsqlite3 anywhere on the image,
only the pure-python sqlite3/ wrapper that cannot work without it.
Moonraker moved its database from lmdb to sqlite in v0.9.0, so every
release from there on gets as far as loading the database component and
exits. The last release still on lmdb is v0.8.0, which predates the
webcam "enabled" flag this whole change exists to get.

No release has both, so the pin is now the newest COMMIT that does:
9d0d09d, 2023-12-05, the one before "database: migrate from lmdb to
sqlite". It has the webcam field and reads the printer's existing lmdb
store in place, so nothing is converted and going back to stock is a
clean round trip. Verified on python 3.8 against the printer's exact
library versions with _sqlite3 removed from the interpreter to match.

Two things follow from having shipped a build that could not start.

run-append.sh now asks the printer's OWN python to import the tree
before it moves anything, and keeps the stock server if it cannot. A bad
pin refuses to install instead of leaving the machine with no web UI --
confirmed on the replica, where the v0.9.3 payload is now declined and
the stock Moonraker keeps running.

And the test that passed this build is fixed. It asked only whether a
moonraker.py process ever appeared, which a build that dies on an import
satisfies for a second or two on its way down. It now requires the
process to still be there after a settle, and the log to be free of
import errors. Re-pinned to v0.9.3 it fails, as it should have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:49:12 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a40bc861dd Ship our own Moonraker: the stock 2022 build hides the camera
Mainsail has shown no webcam panel on this printer no matter how the
stream is configured, and the stream was never the problem: nginx serves
/webcam/ correctly and mjpg-streamer answers it.

FlashForge's Moonraker is a 2022 build (API 1.0.5). Moonraker only grew
the webcam "enabled" flag in April 2023, and Mainsail filters its webcam
list on exactly that field -- so every [webcam] entry is discarded and
the panel is removed from the dashboard entirely. Nothing on either side
of that can be configured around; the server has to be newer.

So the mod now ships Moonraker the way it ships Mainsail: pinned in
versions.env, fetched to vendor/ against a sha256, installed over the
stock tree. v0.9.3 is the newest release that runs on the printer's
existing python environment untouched -- v0.10.0 wants dbus-fast, a
compiled module that is not there and would have to be cross-compiled
for mipsel. Only the python package is replaced; the interpreter, the
moonraker-env beside it and moonrakerDaemon stay FlashForge's.

It rides in the mod payload rather than the software component because
the stock run.sh does not extract that component over /usr/prog -- it
copies a hand-written list of paths out of it, and a moonraker/ dropped
in beside them would never be looked at. run-append.sh installs it,
moving the old tree aside and putting it back if the copy fails: this is
the one thing the mod writes to /usr/prog that the stock installer does
not, and a printer whose Moonraker did not survive has no web UI at all.

The database migrates itself -- v0.9.x moved from lmdb to sqlite, lmdb
1.3.0 is installed, so Mainsail's settings survive the upgrade.

Covered end to end on the printer replica: that the swap happened, that
the installed webcam.py has the enabled field, and that the shipped tree
really runs on the printer's own python3.8 under qemu-mipsel. With
BUILD_MOONRAKER=0 those skip and the stock server is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 17:42:34 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 401c2ecf35 The ff-*.cfg are ours: overwrite them, and say so at the top
Editing a shipped config was the wrong seam, and the installer was
letting people do it. An edited ff-*.cfg was KEPT and the new version
left beside it as .mod-new, so a machine could sit for releases on a
macro set nobody else was running -- the failure mode being a bug report
about code that had been replaced twice since.

Klipper already offers the better seam. It parses with
RawConfigParser(strict=False): same-named sections MERGE, the last value
of an option wins, and a redefined [gcode_macro] replaces the original.
So an override in printer.cfg restates only what it changes, keeps every
shipped value it does not mention, and survives every flash. printer.cfg
is the one config nothing ever writes.

So run-append.sh now overwrites ff-*.cfg unconditionally, logging a line
when it clobbered something so the log explains where an edit went, and
each of those files opens with a header saying it will be overwritten
and where to put changes instead.

printer.base.cfg needed no installer rule: FlashForge's own run.sh:135
does `cp $WORK_DIR/klipper/config/* /usr/data/config/ -rf` on every
flash, no comparison and no .mod-new, so it has always worked this way.
It gets the header anyway, because "why did my edit vanish" is the same
question there -- and the stock-drift comparison in bin/unpack.sh strips
comment lines, so the banner does not trip that warning.

moonraker.conf deliberately keeps the .mod-new dance. It is not a
Klipper config and has no include-and-override seam, so the argument
above does not apply to it, and a printer reached through a tuned
trusted_clients or cors_domains block would lose that access on an
update.

test_config_ownership.py holds the parts together: every mod-owned
config must carry the banner near the top, the banner must name
printer.cfg, moonraker.conf must NOT carry it, and run-append.sh must
still contain the overwrite branch the banner promises. A banner that
outlives the behaviour is worse than none -- the user trusts it, puts
nothing in printer.cfg, and their edit quietly wins.

Rebased onto 9b1f944, which ships the includes inside printer.base.cfg.
Two things followed from it: the banner there now cites run.sh rather
than the software component, since that commit establishes the live copy
in /usr/data/config as the one that matters; and the paragraph in
docs/hardware-testing.md telling the operator to add seven [include]
lines by hand was left stale by it and is rewritten here.

make test: 12 passed, 0 failed, 0 skipped, replica gates included --
boot -> install -> re-install -> boot exercises the overwrite path, and
both "user printer.cfg preserved through both flashes" and
"printer.base.cfg restored" still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:59:41 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 9b1f944293 Ship the ff-*.cfg includes in printer.base.cfg
The package installed every file the mod needs and then relied on the user
to hand-edit printer.cfg with seven [include] lines. That was the one
install step nobody could automate, because printer.cfg is the user's file
and run-append.sh may not write it -- so a freshly flashed printer had all
seven configs sitting in /usr/data/config/ and not one of them loaded, and
came up as a plain printer with no toolchanger and nothing saying so.

printer.base.cfg is ours, and the stock run.sh force-copies it to
/usr/data/config/ on every flash -- the same directory as the ff-*.cfg, so
relative includes resolve. Putting them at its end wires the mod up at
flash time and makes the undo button complete: flashing the stock
FlashForge package restores its own printer.base.cfg and the includes go
with it, leaving the ff-*.cfg as inert files rather than as a config
referencing extras that are no longer in /usr/prog.

It also retires the trap in the old instructions. "Append to printer.cfg"
is wrong for any printer that has run SAVE_CONFIG: an [include] below the
"#*#" marker makes Klipper read the whole autosave block as corruption
("modifications after header") and discard every calibrated value -- PID,
mesh, shaper, nozzle and dock positions.

Most of the documented ordering rules turn out not to exist. Nothing needs
to follow [virtual_sdcard]: virtual_sdcard registers SDCARD_PRINT_FILE and
M23 in __init__, ff_print takes them over at klippy:connect, and by then
every section is loaded. That was true of the old gcode_macro wrapper and
stayed in the docs after the move to ff_print.py. gcode_macro's
rename_existing defers to klippy:connect too, so the G28 and
SHAPER_CALIBRATE wrappers do not need their targets declared first either,
and ff-print-macros.cfg reads _FF_FILAMENT through a runtime template
lookup rather than at parse time. What survives is override order:
ff-runout.cfg after printer.filament.cfg's sensors, ff-chamber.cfg after
printer.chamber.cfg. End-of-file satisfies both.

ff_print.py's error message said "must be loaded after [virtual_sdcard]",
which pointed at the wrong problem; it can only fire when there is no
[virtual_sdcard] at all.

Both halves are checked on the replica now: case-install.sh asserts the
includes and files are present after a flash, case-recovery.sh asserts a
stock flash takes the includes away again. test_includes.py covers the
wiring in the repo -- every shipped ff-*.cfg included, every include
resolving, and the block after everything it overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:48:35 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 424ef059a7 Stop telling the operator to persist prepare=0
The feature print's header and docs/hardware-testing.md both said to set
FF_BEFORE_PRINT_START.prepare to 0 and offered "persist it by editing
variable_prepare" as the way to make it stick. Following that breaks
every other print on this machine.

The Orca profile here deliberately carries no START_PRINT: [ff_print]
wraps SDCARD_PRINT_FILE and the implicit prepare is what homes and heats
the bed. FlashForge's start block has no G28 and no M190 of its own, so
with prepare persisted at 0 an ordinary file's first move -- G1 Z5 F2400
-- runs on unhomed axes, and nothing has heated the bed.

The feature print is the one exception, because it carries its own
START_PRINT with the TOOLS= list the automatic path cannot derive
(nothing in a sliced file names the tool set without reading the
slicer's config block). So it is a per-print toggle, and both files now
say to put it back afterwards -- or just RESTART, which does it anyway.

Comments and docs only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:27:54 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 f70369155b Run the brick-safety gates in CI, and stop skipping quietly
The replica gates -- the only ones that decide whether a package bricks a
printer -- had never run in CI. They were gated on a STOCK_URL_CREATOR5PRO
secret that was never set, so printer-sim rendered as a grey dash beside
two green ticks and read as "CI passed".

The packages it needs are already public. ghzserg/FF publishes both stock
packages and the factory image, and this repo already points at that
release in docs/printer-replica.md. All three are now pinned in ci.yml by
sha256 and verified byte-for-byte against the copies used locally:

  Creator5Pro-1.9.7-1.2.9-20260810.tgz  1f3252fe...  93MB
  Creator5-1.9.7-1.2.9-20260810.tgz     72009be1...  93MB
  Creator5Pro-factory.tar.xz            45657924...  190MB

Nothing proprietary is redistributed from here; the workflow fetches what
is published elsewhere, cached on their checksums so an unchanged pin
costs that archive nothing. Secrets still override for a fork with its own
firmware -- but a URL override without a matching checksum now refuses to
run rather than downloading something nobody verified. have-firmware is
gone with the `if:` it fed.

Rehearsing that job locally found the reason it would have failed anyway.
The replica gates call ./bin/patch.sh directly, and patch.sh expects
vendor/ already populated -- bin/build.sh is what fetches Mainsail and
HelixScreen, and the gates do not go through build.sh. No local run ever
noticed, because vendor/ was always already there. Both workflows now
fetch assets before the suite; in release.yml that step has to precede the
gates, since `make release` would otherwise fetch them one step too late.

Second half: a skip must be legible.

ALLOW_SKIP=1 silenced the explanation as well as the exit code -- the
summary printed "2 skipped" and then nothing about what or why. That is
the disease this harness exists to treat, reintroduced by the flag meant
to manage it. Now:

  - Every skip is listed again in the summary with its reason and whether
    it was accepted, whatever ALLOW_SKIP says, and becomes a GitHub
    annotation. Nobody reads a green job's log.
  - ALLOW_SKIP takes names: ALLOW_SKIP="pytest,the printer replica". `1`
    still accepts everything, but it is a standing promise never to notice
    a skip again, so CI uses the list and a third skip fails there.
  - The pytest gate names the tests that skipped instead of counting them.
    That immediately showed the exec-bit test skipping wherever .git is
    absent -- a bare "6 skipped" would not have.

VERIFIED:

  - printer-sim rehearsed end to end on the actual published bytes, real
    git checkout, REQUIRE_PRINTER_SIM=1 and no ALLOW_SKIP: 12 passed,
    0 failed, 0 skipped, exit 0, in about four minutes. Before the vendor
    step it failed at patch.sh, which is what CI would have done.
  - The four ALLOW_SKIP cases, in the build image with no firmware: none
    set -> exit 1 naming both gaps; "pytest" only -> exit 1, replica still
    NOT ACCEPTED; both named -> exit 0, both still printed; and under
    GITHUB_ACTIONS the ::warning:: annotations appear.
  - pytest 30 passed. Both workflows parse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:17:57 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 30b21262ed Ship the two verification files, not the thing that wrote one
The generator was a build step for an artifact that is written once and
then read by a person standing at a printer. Keeping it meant the file
you send to the machine did not exist in the repo, and the header of the
file you did get told you to re-run a script instead of telling you what
to watch. gcode/ now holds the G-code itself and bin/gen-test-gcode.py
is gone.

Added alongside it: creator5-safe-moves.gcode, the feature print with
the heat and the filament taken out. Once homed, nothing in it goes
below Z50, nothing extrudes, no heater is ever given a target. It homes,
laps the bed slow then fast, runs the diagonals a failing corexy motor
turns into an L, changes tools, sweeps the fans and checks the chamber
gate -- so that a dock that does not latch or a Z offset that is wrong
is found 50 mm up rather than in the plate.

It names its tools with SELECT_TOOL rather than a bare `Tn`, and carries
no M104 or M140. ff_print scans a file's head for exactly those to
decide what to prepare, so this one leaves it nothing to find and is
safe whether FF_BEFORE_PRINT_START.prepare is 0 or 1. The feature print
still wants it at 0.

test_gcode.py reads the committed files now instead of running a
generator, and covers both. The safe file gets the two checks that are
its whole reason to exist -- never below Z50, and never a heater target
or an E word -- because those are exactly the properties a later edit
would break while leaving a file that still runs and still looks safe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:05:01 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 959fbd356a Generate the print that proves the macros, not just the machine
The on-hardware checklist ended at "a single-tool test print" and "a
multi-tool test print" without saying which print. Any cube proves the
motion system; none of them prove START_PRINT's preflight gate, the
per-tool purge, the print Z offset, the M106 fan map or the M141 model
gate, which are the parts of this firmware that have never run on a
printer.

bin/gen-test-gcode.py writes one file that drives all of them in the
order a real print drives them, and lays down nested squares -- one ring
per tool, a fixed gap apart -- so the tool offsets can be measured with
calipers afterwards instead of eyeballed.

It calibrates nothing: TOOL_OFFSET_CALIBRATE and STATION_CALIBRATE need
the build plate off, which a print does not have, so the file only reads
the geometry into the log. PAUSE is left out for the same kind of
reason -- a resume that fails should not be buried inside the print the
offsets are being measured from, so the header tells the operator to
press Pause in Mainsail instead.

Four things the first draft got wrong on the machine and not on paper:
T<n> ships with restore_axis unset, so a change restores no position at
all and every move after one has to re-state itself; descending to layer
height before the cross-bed travel dragged the nozzle back over the work
from the dock; START_PRINT's clean leaves each tool retracted by
purge_retract and the start block primes only the tool it starts on, so
a tool's first ring was the one recovering those 5 mm; and parked tools
sat at full temperature oozing into their docks for the whole print.

test_gcode.py is the gate that keeps the file honest: every command in it
must be one the shipped configs or extras define, TOOLS= must list every
tool the file uses, and every coordinate must be inside the configured
axis limits. Renaming a macro now breaks the suite rather than the print.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 15:05:01 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 e0dad3513c Rewrite the host half of the harness in Python, so a broken gate fails
The shell suite decided "this gate did not run" by grepping the child's
stdout:

    if "$@" >"$out" 2>&1; then
        if grep -qE '^[[:space:]]*SKIP:|[0-9]+ skipped' "$out"
        then skip "$name"; else pass "$name"; fi

Text on stdout cannot tell you what happened inside a process. A launcher that
had ALREADY failed reached that same line by another road: the file that
sources config.env failed to load, so STOCK_TGZ_* was never set, so the
launcher concluded there was nothing to test, printed those five characters
and exited 0 -- on a machine with docker and the firmware sitting right there.
That is how five broken launchers shipped and stayed broken.

So the host half is Python and a skip is an exception. Output cannot imitate
one, and a gate that fails cannot claim it was skipped.

  test/ffsim/            config loading, docker plumbing, gate reporting
  test/run-tests.py      the orchestrator
  test/integration/      sim-install.py, sim-roundtrip.py, printer-exec.py,
                         extract-rootfs.py

test/integration/printer/*.sh and the whole payload stay shell. They are
executed by the printer's own busybox ash under qemu, and surviving that is a
large part of what the suite proves; rewriting them would test a Python the
printer does not have. build-printer-image.sh and make-stock-fixture.sh stay
too: both are `set -euo pipefail`, neither is a gate, and one is a release
tool run by hand.

Four things that used to report as skips are now failures -- a config.env that
does not parse, a missing case script, a missing package, a missing baseline.
A genuinely absent precondition is still a skip, because that is still true.

Three more bug classes are gone by construction rather than by being tested
for. The repo root is found by searching upward for bin/common.sh instead of
counting `..` from $0, so a launcher can move to any depth. Sourcing a config
with a syntax error is checked -- `.` returns non-zero and does NOT stop the
shell, so the old code carried on with half a file applied, which is exactly
the broken-looks-absent confusion again. And pytest counts come from the JUnit
XML rather than from grepping "N skipped".

test_script_roots.py becomes test_harness.py: the `..` check now has two
remaining shell targets rather than seven, and it gains a byte-compile of the
whole harness. That last one matters -- sim-install.py and friends are only
executed by `make test-install`, which needs docker and the firmware, so
without it a typo would wait quietly until release day. It also pins the
property that replaced the `..` counting, from four different depths.

VERIFIED, and the verification is the point, because this machine has no
docker daemon and these are exactly the scripts that cannot run here:

  - Stubbed the docker binary and diffed the recorded `docker run` argv,
    shell against Python, across five paths: install with a prebuilt image,
    install with a local build, local build plus PROG_DUMP, roundtrip, and
    printer-exec. IDENTICAL in all five.
  - Ran the old orchestrator and the new one in the same tree: both give
    6 passed, 1 failed, 2 skipped, exit 1, with the same gates in the same
    order. (The failure is shellcheck, which lives in the build image.)
  - pytest: 24 passed / 5 skipped bare, 29 passed against a real extracted
    rootfs.
  - Compared failure modes directly. The four cases above go from "exit 0,
    reported skipped" to "exit 1, named". Case 2 is the sharpest: with a
    missing case script the shell launcher exited 0 in silence, having cp'd
    nothing and mounted a directory over /case.sh.

Still unproven here: the gates that need a live daemon. `make test-install`,
`test-mcu` and `test-recovery` want one run on a docker-capable machine. The
argv diff says they will be handed the same command as before; it cannot say
what docker does with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:28:47 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5bf57cb75a ff_toolchange: RESTORE_AXIS, and three more upstream commands
Stage 3 -- RESTORE_AXIS on the change itself. We registered SELECT_TOOL
and UNSELECT_TOOL but never read the parameter, and Klipper does not
error on unknown parameters, so a config ported from klipper-toolchanger
silently lost its position restore.

Resolved inside _toolchange so T<n> and TOOLCHANGE -- what a file
actually issues -- honour it too, defaulting to [ff_toolchange]
restore_axis, itself empty: nothing is restored unless asked, which is
how this machine has always behaved.

A G-code position is captured and replayed, not a machine one, so it
reads back through the offsets in force after the change and the new
tool's nozzle lands where the old one was. X/Y first, Z last, so the
nozzle is never dragged across the part. Z after a park is the sharp
edge and is documented as such: parking zeroes the tool offsets, so the
same G-code Z is ~3.2 mm of machine Z away.

Stage 4 -- SET_TOOL_TEMPERATURE, VERIFY_TOOL_DETECTED and
SELECT_TOOL_ERROR, which UIs send and we answered with "Unknown
command". Upstream's docking-mode and tool-parameter commands stay
absent: calibration here is TOOL_OFFSET_CALIBRATE / STATION_CALIBRATE /
TOOL_Z_ADJUST, already tied to the factory numbers.

Untested on hardware: RESTORE_AXIS moves the machine and is off by
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:11:58 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 71044d5867 Drop runout_distance and its watcher
The deferred pause was the one thing here Klipper cannot express natively,
and it cost a delayed_gcode re-arming itself every second for the length of a
print, three scratch variables living in the middle of the policy knobs, and
a fourth branch in _FF_RUNOUT -- all to save filament the app also wasted, and
only once someone measured their own switch-to-gear path and set the number.
It ran on nobody's machine at the default of 0.

_FF_RUNOUT_CFG is now two toggles that nothing writes to, so "edit this to
configure" and "the macros scribble here" are no longer the same object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:02:15 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 1d8f3091dd Say what happened instead of quoting the app's error codes
E0162 / E0163 are FlashForge's numbers, meaningful to someone reading the
stock firmware's tables and to nobody standing at the printer. The pause
messages now say what happened and what to do, and the two cases stop sharing
one string: a clog does not want "LOAD_FILAMENT and RESUME", it wants the jam
cleared. The codes stay in the comments and notes that record what the app
did -- that is provenance, not user-facing text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:02:15 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 15ef06dcef Ship [ff_print], fold _FF_PRINT_END into END_PRINT, drop the origin flag
Three things the ff_print move left behind.

[ff_print] was declared nowhere. The module shipped (bin/patch.sh copies
ff_*.py) and the docs promised a stock Orca profile would work, but the
section that instantiates it existed only as a commented-out snippet in a
header and was missing from the required-includes list in docs/toolchange.md.
Following the docs got you the module on disk and nothing wrapping
SDCARD_PRINT_FILE -- so a stock profile printed from its own start block,
whose first motion is G1 Z5 F2400 on an unhomed machine. Declared in
ff-print-macros.cfg, which is already required and already included after
[virtual_sdcard].

_FF_PRINT_END existed only so END_PRINT and CANCEL_PRINT could share a body.
END_PRINT is the public exit macro, symmetric with START_PRINT, so it holds
the body and everything calls it.

_FF_JOB.mainsail told CANCEL_PRINT whether we started the job, because
FlashForge's touchscreen sends CANCEL_PRINT itself and then runs its own
motion cleanup that ours must not double. HelixScreen starts prints through
Moonraker exactly as Mainsail does, and the stock UI is not one this mod
ships, so the flag guarded a caller that no longer exists -- along with a
documented corner case where an abnormal end left it stuck at 1.

Plus the text those moves invalidated: the orphaned "WHY THIS WRAPPER EXISTS"
block in ff-toolchange.cfg (its FIXME described four SDCARD_* lines deleted in
848db02), _FF_REQUIRE_CALIBRATION / _FF_REQUIRE_TOOLS in six doc sites (they
merged into _FF_PREFLIGHT), the doc rows for both cfg files, and a missing
table row for ff_print.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 14:02:15 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 43baf4054e Drop the unit lane; one suite, in test/integration
The split existed so a plain pull request had something to run. This repo has
one maintainer who always has the firmware to hand, so it was a boundary kept
in sync for a contributor who never arrives. One directory now.

The tests moved rather than went away. test_chamber.py is the only check in
the suite that can catch a printer heating something it has no element for,
and the replica cannot cover it: case-install.sh touches .cfg files at exactly
two lines, both a grep for a sentinel proving the user config survived, and it
asserts klippy.py exists and c_helper.so has the right ABI without ever
starting klippy. Declaring [heater_generic chamber_heater] on the plain
Creator 5 fails four of these tests, including M191 waiting on a target the
machine can never reach; that same package installs, boots, and passes every
replica gate. make-stock-fixture.sh came too, since the packaging pipeline is
built on it.

Ordering changed, and that is the part to look at. With two lanes, pytest ran
twice -- once per lane, each with its own -m expression or path. With one it
runs once, so it has to run at the point where it can see the most: the stock
package is now resolved and the rootfs extracted BEFORE pytest rather than
after it. On a configured machine that is 28 tests in one pass; on a bare tree
the five that read the rootfs skip.

config.env is read in a subshell while doing that. It is the BUILD config, and
it is now consulted upstream of the fixture build, where the entire point is
that the fixture config wins -- sourcing it into this shell would put MOD_NAME
and TARGET_MACHINE where that build could see them. Verified by pointing a
config.env at MOD_NAME=LEAKED and confirming the fixture still builds
Creator5Pro-anvil-ci.tgz.

One thing gets worse, and it is inherent to having a single lane: on a tree
with no rootfs, pytest reports 5 skips, so sub() marks the whole gate SKIP
rather than ok, and the summary reads 6 passed instead of 7. Those 23 passes
are real and the line above the SKIP says so. This is the existing policy
working as designed -- a gate that did not fully run must not read as success
-- but the summary is coarser than it was when a lane existed that never
skipped.

Verified: 28 passed with a real extracted rootfs, 23 passed / 5 skipped
without; the full suite gives 7 passed / 1 failed / 1 skipped with a rootfs
present and 6 / 1 / 2 without, the failure being shellcheck, which lives in
the build image; no test/unit reference is left outside the passages that
explain why it is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:37:58 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 2ae9736e97 Name the two lanes unit and integration, and fix what the move broke
test/repo -> test/unit, test/replica -> test/integration. The old names said
where the input comes from; these say what the tests are, which is the thing
you already have a word for.

The rename is also how the bug below surfaced, because "integration" is one
character-count away from nothing and running the launchers by hand is not.

Every launcher that moved out of test/ into a subdirectory opened with

    ROOT="$(cd "$(dirname "$0")/.." && pwd)"

and one `..` was correct at test/ and wrong a directory deeper. $ROOT became
test/, so the first thing each of them did -- source $ROOT/test/integration/
sim-image.sh -- went looking for test/test/integration/. Five scripts:
printer-exec.sh, sim-install.sh, sim-roundtrip.sh, build-printer-image.sh and
extract-rootfs.sh, which reached bin/common.sh the same way.

The previous commit shipped this. The path-existence sweep that was supposed
to catch it only checked paths written as literals; these are assembled at
runtime from $0. Nothing else caught it either, and that is the part worth
naming: the integration half cannot run without docker and the stock package,
so on any machine missing either -- this one, and a fork's CI without the
secrets -- five broken scripts still reported a clean SKIP.

So test/unit/test_script_roots.py now checks it statically: for every .sh
under test/, count the `..` after `dirname "$0"` and compare it against how
deep the file actually sits. It needs no docker, no firmware and no shell, so
it runs in the lane that always runs, which is the whole point -- the failure
it pins only appears in the lane that often does not.

Verified: the guard fails with "climbs 1, is 2 deep" against a reintroduced
bug and passes once it is reverted; all four launchers now reach their own
preconditions (docker daemon, config.env) instead of dying on a missing file;
21 in test/unit and 7 in test/integration against a real extracted rootfs;
run-tests.sh reports 7 passed / 1 failed / 1 skipped, the same as before the
rename (shellcheck lives in the build image, and no stock package is
configured in this worktree); no test/repo or test/replica reference is left
anywhere in the tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:23:18 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8337359a68 Split test/ into the two lanes it already had
The suite has run in two halves for a long time, and the rest of the repo is
built around that: RUN vs RUNSIM in the Makefile, `test` vs `printer-sim` in
CI, two halves in run-tests.sh. Only test/ itself was flat, so which half a
file belonged to was something you learned by reading its header.

    test/repo/      needs nothing but this checkout
    test/replica/   needs the printer's real rootfs
    test/           run-tests.sh, and the fixtures both lanes share

The axis is the precondition, not the runner. test/replica is everything that
cannot run without the proprietary package -- the qemu'd sim launchers and the
replica image, but also test_paths.py, which only reads the extracted rootfs
as files and needs no docker at all. Grouping by "does it use docker" would
have put that one in the wrong place; grouping by what has to exist first puts
every file where the question "can this run on a public checkout?" is answered
by the directory name.

The `rootfs` pytest marker goes with it. It existed to let run-tests.sh pass
`-m 'not rootfs'` in the PR lane and `-m rootfs` in the replica lane; the
directories say the same thing without anyone having to remember to tag a new
test, and a file in the wrong lane now shows up in `git status`. The two
lanes are `pytest ./test/repo` and `pytest ./test/replica`.

pytest.ini is new and is load-bearing: without a pinned rootdir, naming a lane
on the command line makes pytest take that lane as the root and never load
test/conftest.py, so the fixtures both lanes share would vanish depending on
which one you asked for.

Renames only, plus the path references: test/fixtures/make-stock-fixture.sh ->
test/repo/, the sim launchers and test/printer/ -> test/replica/, and
test_applets.py -> test/replica/test_paths.py, which is what it has actually
checked since the command-word half was dropped.

Verified: 19 in test/repo and 7 in test/replica against a real extracted
rootfs, the same 26 as before the split; run-tests.sh reports 7 passed /
1 failed / 1 skipped, identical to the pre-split run on this host (shellcheck
is absent here and lives in the build image, and no stock package is
configured in this worktree); every referenced path in the repo resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:13:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 801c772c21 ff_toolchange: align the compat views with klipper-toolchanger
Four field-level differences against viesturz/klipper-toolchanger:

  - detect_state said 'present'; upstream's DETECT_PRESENT is "mounted".
    A stock toolchanger UI read every tool as unavailable.
  - toolchanger lacked name, detected_tool, detected_tool_number and
    has_detection. We keep no commanded tool state, so detected_* is
    the same derived value as tool* -- reported anyway, because a UI
    that only reads detected_* must still see a tool.
  - tool lacked toolchanger, heater and extruder_stepper.
  - the header comment claimed detect_state was omitted on purpose;
    it has been emitted from the grab sensor all along.

No behaviour change: the views are read-only and every other addition
is a new key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 13:10:22 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a7e0b3fd3e Bring the MCU handshake in line with the decompiled stock routine
The level board never left its bootloader, so klippy died on
"mcu 'levelboard': Wait for identify_response". Four separate defects,
three of them in ff-mcu-bringup.py and all of them visible in the
printer's own output once you line the runs up.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:38:31 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 71cea577c5 Drop test-printer-db and test-env
test-printer-db.py is gone entirely. It started as a re-implementation of
PrinterDetector's scoring formula and was later narrowed to invariants that do
not model anyone's arithmetic; neither version earned the place it took up.
The Makefile target, the suite entry and the docs note go with it.

test-env.sh was a file whose whole job was to load two other files. Its
contents move into sim-image.sh, which every replica launcher already sources
or is one exec away from, so the config and the docker plumbing now arrive
together instead of as two sources the caller had to remember in order.
sim-install.sh and sim-roundtrip.sh source sim-image.sh directly now, which
also means a missing docker is reported before a package is staged rather than
after. The environment still wins over both config files -- verified in both
directions, an explicit PRINTER_IMAGE beats test.env and an unset one falls
through to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:33:50 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 2ad7304397 Drop assets/orca and the last four fork-only calls
The Orca profile is left stock, so there is nothing to ship for it: assets/orca
held copies of FlashForge's own start/end blocks, which a user already has and
is told not to touch. Removed, along with the two docs/toolchange.md references.

The four remaining fork-only calls in _FF_PRINT_END are gone too. Each undid a
line-rewriting override that only FlashForge's virtual_sdcard ever performed,
and that nothing in this tree switches on:

  SET_FAN_M106[P2] ADJUSTED= FACTOR=  added FACTOR*255/100 to every M106 the
                                      fork passed through (fork lines 498-504)
  SET_PA_ADVANCE T0..T3 ENABLE=       replaced the file's own
                                      SET_PRESSURE_ADVANCE with a per-tool
                                      value (fork line 479)
  MUTE_MODE_DISABLE                   never a command at all, here or in the
                                      fork: mute was M204 S61166 (0xEEEE), a
                                      sentinel accel read in toolhead.py that
                                      clamped max_accel

Upstream rewrites nothing, so the file's own M106 and SET_PRESSURE_ADVANCE
apply as written and there is no override to clear. Nothing in payload/ ever
set ENABLE=1 or ADJUSTED=1, and HelixScreen does not send any of them, so no
behaviour is lost. Should a UI want live fan, PA or quiet overrides they are
new features against upstream Klipper, not end-of-print cleanup -- noted where
the calls used to be.

That is the last of them: no SDCARD_*, SET_PA_ADVANCE, SET_FAN_M106[P2] or
MUTE_MODE call remains in payload/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:07:21 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 35aa61fccb Keep the Orca profile stock; prepare from the file by default
The decision is that a FlashForge Orca profile is not edited at all: no
START_PRINT pasted into Machine start G-code, and files sliced before the mod
keep printing. So FF_BEFORE_PRINT_START.prepare is now 1, and [ff_print] runs
the whole of START_PRINT before the file's first line.

The stock block needs exactly that. It prepares nothing: M140 and M104
without waiting, fans, M191, then `G1 Z5 F2400` as its first motion on a
machine nothing has homed, then T<n>, M109 and the prime line. No G28, so
nothing homes; no M190, so nothing waits for the bed. The touchscreen app did
both before handing the file over. Its end block is one move -- no heaters
off, no M84, no park -- so FF_AFTER_PRINT_END is what ends the job.

assets/orca/*.txt now hold the stock blocks themselves, as the reference for
what the mod expects rather than something to paste. The previous contents
were a modified start block carrying a START_PRINT call, which is no longer
what a user should have.

docs/toolchange.md said to paste both files into the profile; it now says to
leave the profile alone, and documents prepare=0 plus an explicit START_PRINT
as the way to drive the sequence from the slicer instead. Its TOOLS=/TEMPS=
paragraph is updated for the paired TOOLS=0:220,2:240 form and for clean
temperatures now coming from _FF_FILAMENT.tool_material.

The two prepare=0 cases stay documented on the variable: a profile that calls
START_PRINT itself, and one that homes for itself -- a generic Orca block runs
G28 after M140/M104, and the G28 wrapper docks a mounted tool before homing Z.

Untested on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 23:03:18 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3b7da4489b Rename the printer-database drop-in: it covers both Creator 5 models
The file gained the non-Pro entry, so flashforge_creator5_pro.json no longer
says what is in it. The Pro's entry keeps its id -- ids are what HelixScreen
merges on, and renaming one would orphan any saved printer type.

Nothing resolves this file by name: PrinterDetector scans
printer_database.d/*.json and merges by id, and bin/patch.sh installs the
directory with a glob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cBeTWFQunbuEEg51ifhhZ
2026-08-23 22:20:14 +03:00
Oleksandr Shyshatskyi 12bbd1e7dc Retire the probe profile: there is one package now
The probe profile was a bring-up aid. It set BUILD_APPLY=0, reinstalled
the stock component byte-for-byte and wrote a report to the stick, so you
could prove the update chain worked on your own machine -- the filename
glob, the DES3 key, the md5 gate, run.sh execution -- before flashing
anything that changed a file. That rehearsal has served its purpose.

With it gone there was exactly one profile left, so the whole PROFILE
indirection goes too: profiles/ is deleted and its six BUILD_* flags are
plain defaults in bin/common.sh, which also means config.env now
overrides them instead of being silently overridden. make default folds
into make build; all-profiles and RECOVERY_PROFILE are gone with it.

What is NOT gone is the undo button: flashing the stock FlashForge
package still restores every file the mod touches, and case-recovery.sh
still proves it. What is gone is the dry run.

Note the loss: PROG_MB/DATA_MB used to come out of the probe report.
The docs now point at df -h over ssh, which you only have after a
successful flash.
2026-08-23 22:15:26 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 3369a5ee1b Creator 5: ship a chamber config with no heater, not a disabled one
The plain Creator 5 (PID 0028) has a chamber thermistor but no heating
element. That is the ONLY functional difference between the two stock
firmwareExe builds: the Pro's carries "SET_HEATER_TEMPERATURE
HEATER=chamber_heater" in CommMgr::heatManager case 5 and this model's does
not. Everything else that differs between the two binaries is branding.

Both models ship the same Klipper tree, so printer.base.cfg declared
[heater_generic chamber_heater] either way and Mainsail would happily let
someone set a target on a machine that cannot heat -- ~15 min later
verify_heater calls invoke_shutdown, mid-print. Declaring the heater anyway
and refusing the command only covered the commands we wrap: PID_CALIBRATE
HEATER=chamber_heater went straight past it.

Klipper config can override an option but cannot un-declare a section, so a
heater that does not exist has to be absent from the file. Ship our own
printer.base.cfg -- FlashForge's with the chamber block replaced by
[include printer.chamber.cfg] -- plus one small file per model:

  printer.chamber.cfg.creator5pro   their [heater_generic] + [verify_heater]
  printer.chamber.cfg.creator5      [temperature_sensor chamber], same pin

Nothing is edited at build time. Anything that differs between models exists
once per model with a .creator5 / .creator5pro suffix and is installed under
its real name; the suffixed names deliberately do not match the ff-*.cfg
glob, so a variant cannot leak into the wrong package.

Carrying a copy of someone else's 377-line pin map is only safe if something
proves it is still their file, so test-base-cfg.py reconstructs the stock one
from ours plus the Pro's chamber file and compares section for section. A
FlashForge change to printer.base.cfg now fails the build instead of being
silently overridden by a stale copy. (It is unchanged between 1.9.7 and
1.9.8.)

ff-model.cfg is gone: GetStatusWrapper implements __contains__, so the macros
ask Klipper whether the object exists rather than being told by a flag --
nothing left to keep in sync. ff-chamber.cfg still earns its place with the
heater absent: a typed command should fail on an unknown heater, but an M141
in a sliced file must not abort the print, so it warns and continues.

Known: the stock UI on a non-Pro subscribes to "heater_generic
chamber_heater" and will now read 0 for chamber temperature. Harmless --
webhooks._do_query substitutes {} for missing objects -- and Mainsail still
reads it through temperature_sensor chamber.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 22:06:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 5bdf78109a Retire firmwareExe: own the web stack, and say what goes with it
The mod replaces FlashForge's application outright, so the reasoning that
still framed the web stack as coexisting with it was wrong on its own terms.

* nginx and moonraker are documented as the only web stack on the printer.
  MOD_WEB=0 is a debugging switch that leaves ssh and the touchscreen, not a
  way back to stock -- that is a stock FlashForge package. The own-prefix rule
  stays, for the reason that is actually true: /usr/prog is the firmware
  partition and every FlashForge package reinstalls it, so a config written
  there is undone by the next stock flash.

* Configs the mod ships now update when you have not edited them. A live file
  still byte-identical to the one the last package wrote is replaced; one you
  changed is kept and the new version lands beside it as .mod-new.
  $MODDIR/config-installed holds that last-written copy. Without this a config
  we own could only ever be written once: it exists on the second flash, so it
  would land as .mod-new forever and updates would never reach the printer.

* Dropped the three dead /server/files/ alias blocks from nginx.conf. The regex
  location above them matches /server/ too and nginx gives regex locations
  precedence, so they never fired -- and had they fired, serving those paths
  from disk would 405 the DELETE and POST that Mainsail sends to the very same
  URLs.

* Recorded what dies with firmwareExe, in docs/notes/25 and in the README: the
  :8898 REST API (FlashPrint, Orca's FlashForge profile, the mobile app) and
  the :8080 camera stream. The camera is the one that looks restorable and is
  not -- the MJPEG server is compiled into firmwareExe (its own accept loop,
  `GET /?action=stream`, boundarydonotcross) rather than being mjpg-streamer,
  so there is no daemon left to point a [webcam] at. assets/moonraker.conf
  carries the block, commented, for when a streamer is built for this board.

Also in this change: the MOD_UI switch and the stock-UI path are gone, the
chamber-heater model gate arrives with ff-model.cfg/ff-chamber.cfg, and the
tests follow.

make test: 32 passed, 1 failed. The failure is the probe end-to-end, which
dies at "mount: failed to setup loop device for /stick.img" -- leaked loop
devices on this build host, not the package. Probe installs nothing, and the
default profile's install -> re-install -> boot passes in the same run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 21:50:32 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 8df9baf179 Do the MCU handshake in Python, from start.sh; ff-*.cfg are mandatory
Replaces the shell handshake with payload/bin/ff-mcu-bringup.py and moves
the call into start.sh, next to the bring-up for the other two boards:

    /dev/ttyS2  mcu           cmd_mcu bootup
    /dev/ttyS4  eheaterboard  ff-mcu-bringup.py   <- was nobody's job
    /dev/ttyS5  eboard        checkEboard
    /dev/ttyS7  levelboard    ff-mcu-bringup.py   <- was nobody's job

start.sh is the right home: it is the file whose job is starting Klipper,
it already exports the Python environment, and it runs again on every
restart S70klipper issues -- so a board that missed its bootloader window
gets another chance without any extra plumbing. S70klipper keeps only the
supervision loop.

The interpreter is not a new dependency: klippy is Python, so if that
interpreter cannot run there is no Klipper either.

Verified under qemu-mipsel against the printer's own Python 3.8.2 with a
pty standing in for the board: "Ready" -> 'A' -> 0x06, a silent board gets
nothing written to its port at all, and a missing device is skipped.
Python reports termios.B115200 == 0x1002 and VMIN/VTIME == 4/5 on MIPS,
matching the constants in firmwareExe's bootSerial* routines exactly.

Also: the ff-*.cfg set is mandatory, not a menu of optional drop-ins --
nothing works without them. Says so now, in the configs and in
docs/toolchange.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013cBeTWFQunbuEEg51ifhhZ
2026-08-23 20:52:21 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 801cd4e897 Drop the implicit start-G-code layer; keep START_PRINT and END_PRINT
Reverts the ff-orca-compat.cfg approach from 3fa8d33. The idea was to let
FlashForge's stock slicer blocks run untouched by hooking homing onto
SDCARD_PRINT_FILE and the print Z offset onto M109. It worked for the
offset but not for the job as a whole.

What killed it: START_PRINT is not mostly about the Z offset. T<n>
already applies the ~3.2 mm gap on every grab, absolutely, so the offset
command only adds three small job terms -- thermal (+0.045 mm at 220 C),
hot bed (+0.08 at >=100 C), thin layer (-0.06 at <=0.10 mm). What
START_PRINT really contributes is the sequence around them: G28, the bed
mesh, M190 to wait for the bed, the re-home of Z once the bed is hot, the
tool-presence gate and the nozzle clean.

The stock block has no M190 at all -- the app waited for the bed before
handing the file over -- so the implicit layer would have started
printing on a cold bed. That is a first-layer adhesion failure with no
error message, which is a worse outcome than the pasted macro call it was
trying to save. Reproducing the rest of the sequence at hook points that
do not line up with it would have meant reimplementing START_PRINT badly.

So: one path, explicit. The Orca blocks call START_PRINT and END_PRINT,
which do the work in the right order and in one readable place.

Kept from the reverted work, all independent of which path is taken:
test/test-macros.py and jinja2 in the build image, and a homed-axes guard
on TOOLCHANGE_PARK in _FF_PRINT_END -- reached from CANCEL_PRINT after an
error the axes may be unhomed, and raising there would abort the rest of
the cleanup (heaters, fans, offset reset). Skipping the park is safe: the
G28 wrapper docks a mounted tool before homing Z on the next job.

Suite is 30 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 18:44:32 +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 ShyshatskyiandClaude Opus 5 44d45f2c46 Repoint the toolchange docs at their new paths
The imported README and notes still described a standalone checkout:
relative links to klippy-extras/ and config/ that no longer resolve, an
scp recipe naming the old directories, and a header about the
klipper-vanilla and main branches, neither of which exists here.

Paths now point at payload/klipper/ and assets/orca/, and the header
keeps the not-yet-run-on-hardware warning without the branch
bookkeeping. Every relative link in docs/ resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 17:20:04 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a9299397f4 Build the toolchanger from this repo instead of a TOOLCHANGE= path
bin/patch.sh now copies the ff_*.py extras, the ff-*.cfg configs and the
HelixScreen printer-database entry out of payload/, the way it already
handled report.sh and the nginx/moonraker assets.

Both call sites also lose a silent-skip guard. They tested for the files
under $TOOLCHANGE and quietly shipped a package without the toolchanger
if the path was wrong or unset -- worth having when the source was a
checkout that might not be there, wrong now that the files are committed
next to the script. A missing file here is a broken checkout, and cp
should say so.

CI and the test fixture had TOOLCHANGE="" set precisely to trigger that
skip, so drop the line; both build the probe profile, which never
reaches this step anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 16:50:44 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 0ef9d6b86b Move the imported toolchange files into this repo's layout
Pure renames, no content changes, so `git log --follow` walks straight
back into the toolchange history imported by the previous commit.

  klippy-extras/ff_*.py -> payload/klipper/extras/
  config/ff-*.cfg       -> payload/klipper/config/
  helixscreen/*.json    -> payload/helixscreen/printer_database.d/
  orca/                 -> assets/orca/
  docs/notes/           -> docs/notes/
  README.md             -> docs/toolchange.md

payload/ is the right home for the first three: they are files that
ship to the printer, which is what everything else under payload/ is.
The Orca snippets go to assets/ instead -- they are pasted into a
slicer on the desktop and never reach the machine.

bin/patch.sh still reads them from $TOOLCHANGE; the next commit points
it here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 16:48:25 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 22d16f984e Run the printer's own boot script for the update test, and cut the suite to 5m
The install test used to re-implement app_startup.sh: it pulled the glob,
MACHINE and PID out of it with sed, then called the installer itself. A
mistake in our reading of the boot script could never be caught that way.

The package now sits on a real FAT filesystem on a loop device exposed as
/dev/sda1, and app_startup.sh runs verbatim through three boots -- stick in,
stick still in, stick pulled. The last one asserts what actually matters:
nginx and moonraker up, klipper launched, the UI in the foreground where
app_startup.sh's own 5-second ps watchdog can see it.

One substitution, declared in .SIMULATED: the printer's kernel has nls_cp936
and a container kernel does not, so /bin/mount retries without codepage= if
the real options are rejected.

Speed. The image carried the partitions but not the stock install, and
test-env.sh sourced the config files after the environment -- so PRINTER_IMAGE
on the command line was overwritten by the empty value in test.env and the
published image was never used. Measured, per replica case: 22s to unpack the
factory image, 37s to install the stock package, 0.7s with the image. The
stock install is now baked in at image build time (bake.sh -- it needs binfmt
and chroot, so it runs privileged and the result is committed) and recorded in
/usr/prog/.BASELINE; a run asking for a different package reinstalls.

make test: ~20m -> 5m26s. Every section header now carries elapsed seconds.

Dropped four checks that could not fail:

  * test-abi's execution half was `qemu "$f" -h || [ $? -lt 126 ]`, which
    accepts nearly every exit status
  * test-ash's md5sum -s probe printed "inconclusive" on every path
  * run-tests.sh's per-file `sh -n` "POSIX" loop ran bash, and its bashism
    grep flagged `local`, which busybox ash supports
  * CI's shellcheck ended in `|| true`

And one that mattered more: the release workflow's final sim-install loop
ignored every exit status, and sim-install.sh exits 0 on a skip -- so the last
gate before publishing could not fail. It now stops on the first failure and
runs with REQUIRE_PRINTER_SIM=1, which turns skips into failures throughout.

Also: the profile lists in run-tests.sh, RECOVERY_PROFILE and release.yml
still said ssh/web/full/helix and would have failed the release job; work/.sim
was shared, so two suites at once truncated each other's packages; and the
fixture half left a 380KB package in work/out where make test-install picks it
up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgNhSa1Qvwvxb2nQXT6b1Y
2026-08-23 16:36:45 +03:00
Oleksandr ShyshatskyiandClaude Opus 5 a5300bde12 Split the repo into ship / build / test lanes, and add the printer replica
Two strands, both landing here because they had grown into each other in the
working tree.

The printer replica: the printer's real rootfs.squashfs chrooted under
qemu-mipsel, with /usr/prog and /usr/data from the factory image, so a package
is installed by FlashForge's own updater running on the printer's own busybox,
tar, md5sum and unTar. There are no stubs -- if the real prog partition is
missing the harness refuses to run rather than test against mocks. A release
workflow is blocked on it.

The lanes: a file's directory now says whether it can ever reach a printer.

  payload/, assets/   ship
  bin/, profiles/     build the package
  test/, docs/        neither, ever

  * the replica's own infrastructure moved out of bin/ and docker/ and in
    beside the harness it belongs to (test/extract-rootfs.sh,
    test/build-printer-image.sh, test/printer/Dockerfile{,.full})
  * the Makefile has two runners: only RUNSIM gets the docker socket and the
    replica's variables, so a build cannot reach the docker daemon at all
  * replica settings moved to test.env, which nothing in a package reads
  * build-time flags renamed BUILD_* (APPLY, REPORT, KLIPPER, TOOLCHANGE,
    MAINSAIL, HELIX). MOD_* now means only the runtime switches written into
    anvil.conf and re-read by the printer at every boot
  * verify.sh fails if any file in a package is byte-identical to one in
    bin/, test/, docker/ or profiles/ -- by content, because a name blacklist
    would trip over whatever Mainsail happens to call common.sh

Fixed while mapping it:

  * BUILD_REPORT was read only inside the BUILD_APPLY=0 branch, so four
    profiles declared it and got nothing. It is honoured on both paths now,
    and off outside probe: report.sh copies /etc, passwd and shadow onto the
    USB stick, which is a diagnostic, not something to hand out.
  * three checks in the brick lint had been quietly dead. Two were guarded on
    a payload/boot.sh replaced by the firmwareExe wrapper long ago; the third
    was the file glob itself, which matched neither firmwareExe nor the
    init.d/S* scripts -- the UI wrapper and every service went unscanned. A
    missing file is now a failure rather than a silent pass.
  * run-tests.sh overwrote config.env and copied it back, leaving the config
    you edited one crashed run away from being a fixture. It uses CONFIG_ENV.
  * bin/make-uninstall.sh does not exist and never did; README, run-pre.sh and
    full.env all pointed at it. Recovery is the stock package, which is what
    test-recovery actually proves.

README is now for someone who wants to flash their printer: what it is, which
file, how to flash, what to do when it goes wrong. The rest moved to
docs/how-it-works.md, docs/building.md and docs/testing.md.

make test: 89 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 14:30:09 +03:00
Oleksandr Shyshatskyi 855bb52573 Build a separate package per model; add the model-gate test
The Creator5Pro stock package turned up, and comparing it with the non-Pro
one settled the question: they are different files carrying DIFFERENT
firmwareExe binaries (f666ff60... vs bf505905...), each with its own
MACHINE=/PID= gate.

That exposed a real bug: pack.sh emitted BOTH filenames from a single build,
copying one file to the other model's name. It was modelled on FlashForge's
factory package, where doing so is legitimate because that package is an
installer-only wrapper with no model-specific binaries. For a full firmware
package it would hand one model the other's firmware.

pack.sh now names the output from the gate inside the package it was built
from, and refuses if that disagrees with TARGET_MACHINE. config.env takes one
stock package per model (STOCK_TGZ_CREATOR5 / STOCK_TGZ_CREATOR5PRO) and
MODEL=/TARGET_MACHINE selects between them. `make release` builds both into
dist/.

test/test-model-gate.sh locks it in: filename prefix matches the gate, the PID
matches the model, and the two packages do not share a firmwareExe.

Also fixed: the CI fixture now carries a real model gate (and is generated
into work/ rather than /tmp, because sibling-container mounts are resolved by
the host daemon, so a build-container /tmp path mounts empty).

77 checks pass. Both models build and verify from the real firmware.
2026-08-22 22:27:55 +03:00
Oleksandr Shyshatskyi 3a70b678f4 Recovery is the stock package; add the model gate check
Drop bin/make-uninstall.sh. Diffing its output against FlashForge's own
software component showed it differed in exactly two files -- run.sh (22
appended lines) and the md5sum.list that changed because of it. Everything
doing the actual reverting was FlashForge's. So recovery is simply "flash the
stock package you already have"; the leftover /usr/data/mod payload is inert
once firmwareExe is the genuine binary again.

test/sim-roundtrip.sh now proves that path instead: install the mod, flash the
stock package, assert firmwareExe matches the stock binary byte-for-byte, the
boot scripts are restored, and no stock script references the payload.

That test immediately surfaced a real problem. Packages carry a model gate --
runFirmwareExe.sh has MACHINE=/PID= baked in and refuses to install when they
do not match what app_startup.sh passes from the installed firmware:

    Creator5    / 0028
    Creator5Pro / 0029

The stock package in use here is the non-Pro build (MACHINE=Creator5), while
the target printer reports "Creator 5 Pro" in general.json. Anything built
from it would be refused by the printer, and it is not a valid recovery image
for a Pro either.

unpack.sh now reports the gate, verify.sh fails on a mismatch against
TARGET_MACHINE in config.env, and the ladder doc leads with checking it.
2026-08-22 22:11:53 +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