Replaces the runtime reader of firmwareExe's JSON with Klipper-native storage. Per-unit numbers now live in ordinary config: hand-written [ff_tool 0..3] dock_x/dock_y and [ff_toolchange] feeds in ff-toolchange.cfg; calibrated nozzle_x/y/z per tool, station_x/y/z and the per-tool z_adjust are written via configfile.set and persisted with SAVE_CONFIG into printer.cfg's #*# block, like PID / input shaper. They must never be placed in an included .cfg (the fork's SAVE_CONFIG refuses "conflicts with included value"). New extras and commands: - ff_tool.py: [ff_tool n] per-tool sections. - ff_tool_offset.py: TOOL_OFFSET_CALIBRATE / STATION_CALIBRATE / TOOL_OFFSET_STATUS -- the touchscreen's nozzle XY/Z offset calibration (testEddyExtruderOffsetForwardTwoCheck), recovered from the binary and reimplemented on the fork's e_stop probes. - ff_legacy.py: FF_IMPORT_FIRMWARE_CONFIG, one-shot import of the factory extruder/test/zoffset.json; include ff-legacy.cfg only for that step. - ff_toolchange.py: takes docks/offsets from the ff_tool objects, adds TOOL_Z_ADJUST (per-tool persistent babystep), drops the JSON reader and TOOLCHANGE_RELOAD. - config: ff-tool-offset.cfg, ff-legacy.cfg; updated ff-toolchange.cfg and ff-print-macros.cfg. Safety gates: START_PRINT (_FF_REQUIRE_CALIBRATION) and the SDCARD_PRINT_FILE wrapper refuse to start while ff_toolchange.print_offset_ready is false; calibration requires PLATE_REMOVED=1, probes to Z -3 by default and stops 2 mm below a known trigger height; residual (0.05 mm) and nozzle-to-station gap (1.5-5 mm) guards refuse to save implausible results. Docs: README rewritten for the new install/calibration flow; notes 45 (Klipper port) and 46 (recovered sequence) added; notes 40/60 no longer refer to TOOLCHANGE_RELOAD. Untested on hardware -- mock harness only. main keeps the JSON-reading design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
FlashForge Creator 5 Pro — native toolchanger for Klipper/Mainsail
Makes the Creator 5 Pro's 4-tool toolchanger and print lifecycle work for
prints started from Mainsail/Moonraker (e.g. sliced in OrcaSlicer), without
the stock touchscreen app in the loop. Based on reverse-engineering the
stock firmwareExe binary (addresses referenced in the source comments),
with some behaviour improved along the way — deliberate divergences are
documented in the file headers.
Branch
klipper-vanilla— not yet run on hardware. Everything on this branch has been exercised against a mock Klipper harness only. Themainbranch keeps the previous design, which reads calibration live from firmwareExe's JSON and has been used on a real printer. Here all per-unit numbers live in ordinary Klipper config, and the touchscreen's nozzle-offset calibration is reimplemented as Klipper commands.
What's in here
| File | Goes to (on the printer) | What it does |
|---|---|---|
klippy-extras/ff_toolchange.py |
/usr/prog/klipper/klippy/extras/ |
The toolchanger: T0..T3, dock/grab state machine with sensor polling and retries, per-tool G-code offsets, TOOLCHANGE_SET_PRINT_OFFSET (the absolute print-start Z offset), TOOL_Z_ADJUST (per-tool persistent babystep), TOOLCHANGE_STATUS, TOOLCHANGE_PARK |
klippy-extras/ff_tool.py |
/usr/prog/klipper/klippy/extras/ |
[ff_tool n] — one section per tool: hand-written dock_x/dock_y, autosaved nozzle_x/y/z and z_adjust |
klippy-extras/ff_tool_offset.py |
/usr/prog/klipper/klippy/extras/ |
TOOL_OFFSET_CALIBRATE / STATION_CALIBRATE / TOOL_OFFSET_STATUS — the touchscreen's nozzle XY/Z offset calibration, recovered from the binary and reimplemented in Klipper |
klippy-extras/ff_legacy.py |
/usr/prog/klipper/klippy/extras/ |
FF_IMPORT_FIRMWARE_CONFIG — one-shot import of the factory/touchscreen JSON into Klipper config. Needed once, at install |
config/ff-toolchange.cfg |
/usr/data/config/ |
[ff_tool 0..3] dock coordinates (per unit — ships with the author's numbers), [ff_toolchange] feeds/geometry, SDCARD_PRINT_FILE wrapper |
config/ff-tool-offset.cfg |
/usr/data/config/ |
[ff_tool_offset] — probe geometry and guards for the calibration commands |
config/ff-legacy.cfg |
/usr/data/config/ |
[ff_legacy] — include only for the import step, then remove |
config/ff-print-macros.cfg |
/usr/data/config/ |
START_PRINT / END_PRINT / PAUSE / RESUME / CANCEL_PRINT, reconstructed from the app's sequences, plus the calibration gate |
orca/ |
OrcaSlicer printer profile | Machine start/end G-code, change-filament G-code, example project |
docs/notes/ |
(reference only) | Condensed reverse-engineering notes: what the stock app actually does, with binary addresses |
⚠️ Use at your own risk
This is unofficial, reverse-engineered firmware modification. It is not endorsed by FlashForge, it voids whatever warranty you had, and you alone are responsible for what happens to your printer. Mistakes here are not hypothetical — a wrong Z offset drives the nozzle through the build plate, and a failed grab or missing check can end like this:
Read the warnings below, run the Verify section before the first print, and stay next to the machine until you trust it.
⚠️ Before you start
- The eddy probe's Z home is ~3.2 mm below the real bed plane. The stock
app compensates with an absolute
SET_GCODE_OFFSET Z=+3.2xxat print start;START_PRINTcallsTOOLCHANGE_SET_PRINT_OFFSETto do the same. Printing without these macros (or with a stale copy of them) drives the nozzle into the plate. - Where the numbers live. Nothing is read from firmwareExe's JSON at
runtime any more. Dock positions (
[ff_tool n] dock_x/dock_y) and feeds ([ff_toolchange]) are hand-written inff-toolchange.cfg. Calibrated values —nozzle_x/y/zper tool,station_x/y/z,z_adjust— are written by the calibration commands (configfile.set) and persisted withSAVE_CONFIGinto the#*#block at the end ofprinter.cfg, exactly like PID or input-shaper results. Never put those autosaved options into an included.cfg: this Klipper fork'sSAVE_CONFIGrefuses with "conflicts with included value" and the calibration cannot be saved. - Still never edit the JSON under
/usr/data/firmwareRes/config/. This module no longer reads it, but the touchscreen app still does and rewrites those files wholesale — leave it alone. - Klipper lives on the firmware partition. A FlashForge OTA update will
overwrite
/usr/prog/klipper/, deleting the extras. Keep this repo and re-run step 1 after every firmware update. The#*#block inprinter.cfgis on the data partition and survives.
Install
On the printer (ssh as pwned — this assumes a jailbroken printer; how to
get root/ssh access is covered in the community
Discord):
# 1. the klippy extras (firmware partition — may need remount rw)
scp klippy-extras/ff_tool.py klippy-extras/ff_toolchange.py \
klippy-extras/ff_tool_offset.py klippy-extras/ff_legacy.py \
pwned@PRINTER:/usr/prog/klipper/klippy/extras/
# 2. the config files (data partition — survives OTA)
scp config/ff-toolchange.cfg config/ff-tool-offset.cfg \
config/ff-print-macros.cfg config/ff-legacy.cfg \
pwned@PRINTER:/usr/data/config/
- Append to
/usr/data/config/printer.cfg(order matters — must come AFTER[virtual_sdcard]is defined; do not put these inprinter.override.cfg, which is included first):
[include ff-toolchange.cfg]
[include ff-tool-offset.cfg]
[include ff-print-macros.cfg]
[include ff-legacy.cfg] ; temporary — only for step 5
-
RESTART(or reboot). -
Import the factory/touchscreen calibration once:
FF_IMPORT_FIRMWARE_CONFIGThis reads
extruder.json/test.json/zoffset.json, stages the nozzle and station values (and any per-tool Z tune) forSAVE_CONFIG, and prints an[ff_tool n]/[ff_toolchange]/[ff_tool_offset]snippet with your unit's dock coordinates, feeds and station start point.ff-toolchange.cfgships with the author's dock numbers — paste the snippet'sdock_x/dock_yinto its[ff_tool 0..3]sections (and any feed that differs into[ff_toolchange]) before moving anything. -
SAVE_CONFIG— persists the imported values intoprinter.cfg's#*#block and restarts. -
TOOLCHANGE_STATUSandTOOL_OFFSET_STATUS— every tool should show a nozzle triple and the stationzmust be present; nothing should sayNOT CALIBRATEDor "unsaved calibration pending". -
Remove the
[include ff-legacy.cfg]line andRESTART. The import is one-shot; the touchscreen's JSON is not consulted again.
Verify
Before moving anything:
-
TOOLCHANGE_STATUS— current tool as derived from the dock sensors, every sensor's state, each tool's nozzle position andz_adjust,station_z, dock coordinates and the derived tool-to-tool offsets. Check thedock_x/dock_yrows are your unit's values, not the shipped ones. -
TOOL_OFFSET_STATUS— saved calibration per tool and for the station, and a warning if anything is staged but not yetSAVE_CONFIG'd. -
Physical Z check (clean bed, nothing on it):
G28 T0 TOOLCHANGE_SET_PRINT_OFFSET NOZZLE=220 BED=80 LAYER=0.25 TOOL=0 G1 X150 Y150 F6000 G1 Z0.1 F600The offset command reports a Z around +3.2 with a term-by-term breakdown, and the nozzle should end up a paper-thickness above the bed at the center. If it presses into the plate instead, STOP — the offset is not being applied; do not print until
TOOLCHANGE_STATUSandTOOL_OFFSET_STATUSare clean. Afterwards restore the idle state:G1 Z10 F1200 TOOLCHANGE_PARK SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=1
A touchscreen recalibration no longer affects this module. Recalibrate from Klipper instead (next section), or repeat the import step if you really want the touchscreen's numbers.
Calibration
The nozzle XY/Z offset calibration is the touchscreen's own sequence
(testEddyExtruderOffsetForwardTwoCheck, recovered from the binary — see
docs/notes/45-tool-offset-calibration.md
and 46-offset-calibration-recovered.md),
constants included. Take the PEI sheet off first — the calibration
station sits below the bed plane and the Z probe cannot tell a build plate
from air. Home, then:
STATION_CALIBRATE PLATE_REMOVED=1 ; empty carriage, no tool mounted
TOOL_OFFSET_CALIBRATE TOOL=ALL PLATE_REMOVED=1 ; or TOOL=<0..3> for one tool
SAVE_CONFIG
STATION_CALIBRATE probes the fixed under-bed station with the empty
carriage (eddy frame) and stores station_x/y/z; TOOL_OFFSET_CALIBRATE
then picks up each tool, touches the station's cylinder in four directions
with the nozzle, fits a circle, repeats the pass centred on that fit and
stores the nozzle's absolute nozzle_x/y/z. Results are station-frame
absolutes per tool, so recalibrating one tool leaves the others valid;
nozzle_z − station_z is the ~3.2 mm gap the print-start Z offset uses.
Re-run STATION_CALIBRATE whenever the station or bed is disturbed.
Per-tool first-layer tuning: Klipper's SET_GCODE_OFFSET Z_ADJUST= babystep
is one global number. Use
TOOL_Z_ADJUST TOOL=2 ADJUST=-0.02 ; relative, or VALUE=<mm> absolute
SAVE_CONFIG ; when you are happy with it
instead: it edits [ff_tool 2] z_adjust, re-applies immediately if that
tool is mounted, is added on every later grab of that tool, and persists.
Safety
- Prints refuse to start while uncalibrated.
START_PRINTruns_FF_REQUIRE_CALIBRATIONfirst, and the Mainsail entry point (theSDCARD_PRINT_FILEwrapper) checksprinter.ff_toolchange.print_offset_ready: if any tool'snozzle_zor thestation_zis missing, the job is refused before anything heats, homes or grabs, with the fix spelled out. Override only for bench tests:SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=allow_uncalibrated VALUE=1. - Calibration moves are bounded. Both calibration commands require
PLATE_REMOVED=1; the Z probe targets −3 by default (the app's own station value) and, once a trigger height is known, stops 2 mm below it instead of driving on. - Implausible results are not saved. A circle-fit residual above
0.05 mm, or a nozzle-to-station gap (
nozzle_z − station_z) outside 1.5–5 mm, aborts the command with nothing staged. Thresholds are inff-tool-offset.cfg. - A
T<n>on an unhomed machine aborts instead of homing silently (auto_home: False) — a remote G28 with a tool mounted rams the nozzle into whatever is on the bed.
OrcaSlicer setup
In the printer profile:
- Machine start G-code: contents of
orca/machine-start-gcode.txt - Machine end G-code: contents of
orca/machine-end-gcode.txt - Change filament G-code: contents of
orca/change-filament-gcode.txt—T[next_extruder] ; ff-toolchange, where the trailing comment is load-bearing (see below)
Or skip the copy-pasting: open
orca/creator-mainsail.3mf in OrcaSlicer —
an example project with the "Mainsail - Flashforge Creator 5 Pro 0.4
nozzle" printer profile already carrying all three G-code blocks above.
Re-slice anything sliced with older start G-code — old files won't call
TOOLCHANGE_SET_PRINT_OFFSET and will print ~3.2 mm low.
START_PRINT options: LEVEL=1 probes a fresh mesh (recommended for the
first print), SOAK=<seconds> overrides the bed heat soak (default 30,
SOAK=0 skips; progress is reported in the console).
Why T[next_extruder] ; ff-toolchange and not just T[next_extruder]
The comment satisfies two different parsers at once:
- OrcaSlicer reads the change-filament block with a real G-code parser.
It sees an actual
T<n>command in there, decides the custom G-code already changes the tool (custom_gcode_changes_tool()), and stops emitting its own bareTnafter the block — so the tool change happens exactly once. - FlashForge's Klipper fork traps toolchange lines before they reach
the G-code interpreter:
virtual_sdcard.py(line 566) checksline.startswith("T") and line in VALID_GCODE_T, wherelinecomes straight fromdata.split('\n')and is never stripped or normalized. A bareT2matches the exact string inVALID_GCODE_T, gets swallowed by the fork, and is handed to the touchscreen app, which then blocks the print on its owndoingChangeEx/refuellingstate — state that only the touchscreen UI advances.T2 ; ff-toolchangeis not the exact stringT2, so the trap does not fire; the line falls through togcode.run_script(), whereff_toolchange.py'sT2handler services it.
This is also why touchscreen-started prints are untouched: FF-sliced files
carry bare Tn lines, which the fork still intercepts and the app still
services exactly as before. Only lines with the marker comment reach this
module.
Design notes
- Per-tool offsets are differences vs a base tool (T0), applied on each
grab the way
CommMgr::setGrabGcodeOffsetMgrcomputes them; the once-per-print absolute Z base (BuildPage::startPrint) is set byTOOLCHANGE_SET_PRINT_OFFSETafter the first grab and carried through toolchanges. The base-tool term cancels algebraically, so a print may start on any tool. Babysteps made mid-print are carried across toolchanges, as in the app. Note this split (absolute base at print start + differences on grab) is a rewrite of how the originalfirmwareExestructures it, kept for fidelity — it would arguably be cleaner to apply the absolute offset on the first toolchange rather than as aSTART_PRINTstep, and that change is being considered (see Roadmap). - Calibration is stored the way Klipper's own calibrators store theirs:
absolute
nozzle_x/y/zper[ff_tool n]section (modelled on[bed_mesh <profile>]/ klipper-toolchanger's[tool Tn]) plus[ff_tool_offset] station_x/y/z, written withconfigfile.set()and persisted bySAVE_CONFIG. The T0-relative differences are derived at load.[save_variables]is deliberately not used. The stock touchscreen cannot see Klipper-side results — it keeps using its own JSON. - Everything is a Python extra (not gcode_macro) because the grab sequence
polls the grab sensor and retries up to 3 times, and the calibration
drives the fork's
e_stopprobe objects directly — a macro renders its whole template before executing and cannot poll.
Roadmap
Fool-proofing, in rough priority order:
- Run this branch on hardware. Calibration sequence, import and the safety gates are mock-tested only.
- Forbid
G28while a tool is mounted. Homing Z runs on the eddy probe's frame; with a tool in the head the nozzle rams into the plate. The module should refuse (or auto-park first) instead of trusting the operator. - Z-height / clearance check after picking a tool, before leaving the dock area — catch a bad grab early instead of dragging or breaking the tool on the way out.
- Move the absolute print Z offset from
START_PRINTto the first toolchange of a job, removing the ordering dependency between the macro and the slicer's start G-code. - Block the stock touchscreen UI during Mainsail prints. The app doesn't know a print is running (it only tracks jobs it started itself), so the screen stays fully live — a stray tap can home, move the carriage, start a filament load, or fire its own toolchange into the middle of a running job. The module should lock the UI out (or at least its motion commands) while a Mainsail-started print is active.
Reverse-engineering notes
Condensed notes from the firmwareExe analysis — recovered sequences, binary
addresses, JSON semantics — live in docs/notes/:
architecture overview, the Klipper-fork delta (including the Tn interception),
the verified grab/release sequences, the offset model, the recovered
nozzle-offset calibration and its Klipper port, and the full print
lifecycle with the deliberate divergences listed.
Support
If this saved your build plate (or your sanity), you can
buy me a coffee new hotend.