Store calibration in Klipper config; add nozzle-offset calibration

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>
This commit is contained in:
AlexanderandClaude Fable 5 committed 2026-08-21 12:54:10 +03:00
1 parent 8549b86118
commit 065afec2cf
13 files changed
+1659 -465

No files matched your search

+145 -20
View File
@@ -9,13 +9,25 @@ 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. The
> `main` branch 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`](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), `TOOLCHANGE_STATUS`, `TOOLCHANGE_RELOAD`, `TOOLCHANGE_PARK` |
| [`config/ff-toolchange.cfg`](config/ff-toolchange.cfg) | `/usr/data/config/` | Configuration + `SDCARD_PRINT_FILE` wrapper. Carries **no per-unit numbers** — calibration is read live from firmwareExe's own JSON |
| [`config/ff-print-macros.cfg`](config/ff-print-macros.cfg) | `/usr/data/config/` | `START_PRINT` / `END_PRINT` / `PAUSE` / `RESUME` / `CANCEL_PRINT`, reconstructed from the app's sequences |
| [`klippy-extras/ff_toolchange.py`](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`](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`](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`](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`](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`](config/ff-tool-offset.cfg) | `/usr/data/config/` | `[ff_tool_offset]` — probe geometry and guards for the calibration commands |
| [`config/ff-legacy.cfg`](config/ff-legacy.cfg) | `/usr/data/config/` | `[ff_legacy]` — include only for the import step, then remove |
| [`config/ff-print-macros.cfg`](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/`](orca/) | OrcaSlicer printer profile | Machine start/end G-code, change-filament G-code, example project |
| [`docs/notes/`](docs/notes/) | (reference only) | Condensed reverse-engineering notes: what the stock app actually does, with binary addresses |
@@ -39,12 +51,22 @@ and stay next to the machine until you trust it.
start; `START_PRINT` calls `TOOLCHANGE_SET_PRINT_OFFSET` to do the same.
Printing without these macros (or with a stale copy of them) drives the
nozzle into the plate.
* **Never edit the JSON under `/usr/data/firmwareRes/config/`.** It holds
per-unit factory/touchscreen calibration; the app rewrites those files
wholesale. This module only reads them.
* **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 in `ff-toolchange.cfg`. Calibrated
values — `nozzle_x/y/z` per tool, `station_x/y/z`, `z_adjust` — are
written by the calibration commands (`configfile.set`) and persisted with
`SAVE_CONFIG` into the `#*#` block at the end of `printer.cfg`, exactly
like PID or input-shaper results. **Never put those autosaved options
into an included `.cfg`**: this Klipper fork's `SAVE_CONFIG` refuses 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 `ff_toolchange.py`. Keep this
repo and re-run step 1 after every firmware update.
overwrite `/usr/prog/klipper/`, deleting the extras. Keep this repo and
re-run step 1 after every firmware update. The `#*#` block in
`printer.cfg` is on the data partition and survives.
## Install
@@ -53,11 +75,15 @@ get root/ssh access is covered in the community
[Discord](https://discord.gg/tYs3eNEDq)):
```sh
# 1. the klippy extra (firmware partition — may need remount rw)
scp klippy-extras/ff_toolchange.py pwned@PRINTER:/usr/prog/klipper/klippy/extras/
# 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-print-macros.cfg pwned@PRINTER:/usr/data/config/
scp config/ff-toolchange.cfg config/ff-tool-offset.cfg \
config/ff-print-macros.cfg config/ff-legacy.cfg \
pwned@PRINTER:/usr/data/config/
```
3. Append to `/usr/data/config/printer.cfg` (order matters — must come
@@ -66,17 +92,47 @@ scp config/ff-toolchange.cfg config/ff-print-macros.cfg pwned@PRINTER:/usr/data/
```ini
[include ff-toolchange.cfg]
[include /usr/data/config/ff-print-macros.cfg]
[include ff-tool-offset.cfg]
[include ff-print-macros.cfg]
[include ff-legacy.cfg] ; temporary — only for step 5
```
4. Reboot the printer.
4. `RESTART` (or reboot).
5. Import the factory/touchscreen calibration once:
```gcode
FF_IMPORT_FIRMWARE_CONFIG
```
This reads `extruder.json` / `test.json` / `zoffset.json`, stages the
nozzle and station values (and any per-tool Z tune) for `SAVE_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.cfg` ships with the author's dock numbers — paste
the snippet's `dock_x`/`dock_y` into its `[ff_tool 0..3]` sections (and
any feed that differs into `[ff_toolchange]`) before moving anything.
6. `SAVE_CONFIG` — persists the imported values into `printer.cfg`'s `#*#`
block and restarts.
7. `TOOLCHANGE_STATUS` and `TOOL_OFFSET_STATUS` — every tool should show a
nozzle triple and the station `z` must be present; nothing should say
`NOT CALIBRATED` or "unsaved calibration pending".
8. Remove the `[include ff-legacy.cfg]` line and `RESTART`. The import is
one-shot; the touchscreen's JSON is not consulted again.
## Verify
Before moving anything:
* `TOOLCHANGE_STATUS` — every dock coordinate, feedrate and offset with
its provenance; flags disagreement with the app's `now_extruder`.
* `TOOLCHANGE_STATUS` — current tool as derived from the dock sensors, every
sensor's state, each tool's nozzle position and `z_adjust`, `station_z`,
dock coordinates and the derived tool-to-tool offsets. Check the
`dock_x`/`dock_y` rows 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 yet `SAVE_CONFIG`'d.
* Physical Z check (clean bed, nothing on it):
```gcode
@@ -91,7 +147,7 @@ Before moving anything:
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_STATUS`
is clean. Afterwards restore the idle state:
and `TOOL_OFFSET_STATUS` are clean. Afterwards restore the idle state:
```gcode
G1 Z10 F1200
@@ -99,8 +155,66 @@ Before moving anything:
SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=1
```
After a touchscreen recalibration, run `TOOLCHANGE_RELOAD` (or reboot) to
pick up the new JSON.
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`](docs/notes/45-tool-offset-calibration.md)
and [`46-offset-calibration-recovered.md`](docs/notes/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:
```gcode
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
```gcode
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_PRINT` runs
`_FF_REQUIRE_CALIBRATION` first, and the Mainsail entry point (the
`SDCARD_PRINT_FILE` wrapper) checks `printer.ff_toolchange.print_offset_ready`:
if any tool's `nozzle_z` or the `station_z` is 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 in
`ff-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
@@ -166,14 +280,24 @@ module.
cleaner to apply the absolute offset on the *first toolchange* rather
than as a `START_PRINT` step, and that change is being considered (see
Roadmap).
* Calibration is stored the way Klipper's own calibrators store theirs:
absolute `nozzle_x/y/z` per `[ff_tool n]` section (modelled on
`[bed_mesh <profile>]` / klipper-toolchanger's `[tool Tn]`) plus
`[ff_tool_offset] station_x/y/z`, written with `configfile.set()` and
persisted by `SAVE_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 — a macro renders its
polls the grab sensor and retries up to 3 times, and the calibration
drives the fork's `e_stop` probe 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 `G28` while 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
@@ -196,7 +320,8 @@ Fool-proofing, in rough priority order:
Condensed notes from the `firmwareExe` analysis — recovered sequences, binary
addresses, JSON semantics — live in [`docs/notes/`](docs/notes/):
architecture overview, the Klipper-fork delta (including the `Tn` interception),
the verified grab/release sequences, the offset model, and the full print
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
+19
View File
@@ -0,0 +1,19 @@
# By using these files I affirm my respect for Ukraine's sovereignty and
# territorial integrity and my support for UN GA resolution ES-11/1.
# ff-legacy.cfg -- one-shot migration from firmwareExe's JSON.
#
# Include this only when you need it (klippy-extras/ff_legacy.py must be in
# /usr/prog/klipper/klippy/extras/):
#
# [include ff-legacy.cfg]
# RESTART
# FF_IMPORT_FIRMWARE_CONFIG ; reads /usr/data/firmwareRes/config
# SAVE_CONFIG ; persists nozzle_*/station_*/z_adjust
#
# The command also prints an [ff_tool n] / [ff_toolchange] / [ff_tool_offset]
# snippet with dock coordinates, feeds and the station start point -- paste
# that into ff-toolchange.cfg / ff-tool-offset.cfg by hand; those options are
# deliberately not autosaved. Afterwards the include can be removed.
[ff_legacy]
+22
View File
@@ -34,6 +34,23 @@
# does NOT put the nozzle at the bed plane -- without this offset (~+3.2 mm
# on this unit) the first layer is driven into the plate.
# ---------------------------------------------------------------------------
# Refuse BEFORE heating/homing/grabbing if the Z frame cannot be established.
# The eddy-homed Z is ~3.2 mm low; with no nozzle_z/station_z the print-start
# offset cannot be computed and the first layer goes into the plate.
[gcode_macro _FF_REQUIRE_CALIBRATION]
gcode:
{% set tool = params.TOOL|default(-1)|int %}
{% set tc = printer.ff_toolchange %}
{% set ok = (tool >= 0 and tool in tc.calibrated_tools and tc.station_z is not none)
or (tool < 0 and tc.print_offset_ready) %}
{% if not ok and not printer["gcode_macro _FF_JOB"].allow_uncalibrated %}
{ action_raise_error("Not calibrated: station_z=%s, calibrated tools %s. "
"Run STATION_CALIBRATE / TOOL_OFFSET_CALIBRATE (or FF_IMPORT_FIRMWARE_CONFIG) "
"and SAVE_CONFIG. Without nozzle_z/station_z the print Z offset cannot be "
"computed and the nozzle would be driven ~3 mm into the plate."
% (tc.station_z, tc.calibrated_tools)) }
{% endif %}
[gcode_macro START_PRINT]
gcode:
{% set bed = params.BED|default(0)|float %}
@@ -41,6 +58,7 @@ gcode:
{% set level = params.LEVEL|default(0)|int %}
{% set nozzle = params.NOZZLE|default(220)|float %}
{% set layer = params.LAYER|default(0)|float %}
_FF_REQUIRE_CALIBRATION TOOL={tool}
G90
M82
BED_MESH_CLEAR
@@ -140,6 +158,10 @@ gcode:
# SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=mainsail VALUE=0 first.
[gcode_macro _FF_JOB]
variable_mainsail: 0
# Escape hatch for the calibration gate (START_PRINT and the Mainsail entry
# point both refuse on an uncalibrated machine). Only for deliberate tests:
# SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=allow_uncalibrated VALUE=1
variable_allow_uncalibrated: 0
gcode:
[gcode_macro CANCEL_PRINT]
+94
View File
@@ -0,0 +1,94 @@
# By using these files I affirm my respect for Ukraine's sovereignty and
# territorial integrity and my support for UN GA resolution ES-11/1.
# ff-tool-offset.cfg -- nozzle XY/Z offset calibration, Klipper-native.
#
# Implementation: klippy-extras/ff_tool_offset.py (copy next to
# ff_toolchange.py in /usr/prog/klipper/klippy/extras/).
# Recovered sequence: firmwareExe-decompiled/recovered/offset-calibration.md
#
# What it does
# Both calibration commands REQUIRE PLATE_REMOVED=1: the station sits below
# the bed plane and the Z probe cannot tell a build plate from air.
#
# TOOL_OFFSET_CALIBRATE TOOL=<0..3|ALL> per tool: T<n> pickup, zero the
# G-code offset, go to (cylinder_x - 12.5, cylinder_y), ESTOP Z to -5,
# four ESTOP probes outward (+X +Y -X -Y, 14 mm) at Z+0.6, circle fit,
# second pass centred on that fit with Z re-probed, store the second
# fit as t<n>_offset_x/y/z. Identical to the touchscreen's
# "Four-point" (default) calibration, constants included.
# STATION_CALIBRATE same with an EMPTY carriage from
# (cylinder_x, cylinder_y); second Z probe to -3; stores
# x/y/z_station_pos. z_station_pos feeds TOOLCHANGE_SET_PRINT_OFFSET,
# so re-run this whenever the station or bed is disturbed.
# TOOL_OFFSET_STATUS saved vs firmware values side by side.
#
# Where results go
# The SAVE_CONFIG block of printer.cfg, like PID or input-shaper results:
# #*# [ff_tool 1]
# #*# nozzle_x = 16.407789 nozzle_y = 211.986710 nozzle_z = 1.426736
# #*# [ff_tool_offset]
# #*# station_x = 28.791826 station_y = 212.639328 station_z = -1.678819
# Values are applied live immediately and persisted by SAVE_CONFIG (restart).
# Absolute station-frame numbers are stored, so calibrating only T2 leaves
# T0/T1/T3 valid; ff_toolchange derives the tool-to-tool differences.
# firmwareExe's extruder.json is never read or written (the app rewrites it
# from memory on every grab). FF_IMPORT_FIRMWARE_CONFIG (ff-legacy.cfg)
# imports the factory numbers once.
#
# Prerequisites
# * [ff_tool 0..3] sections (ff-toolchange.cfg) and [ff_toolchange] for
# T<n> / TOOLCHANGE_PARK -- or mount/park the tool by hand and pass
# GRAB=0 RELEASE=0 / PARK=0.
# * home all axes first; the extra refuses otherwise.
# * the PEI sheet removed as for the touchscreen calibration -- the
# station sits below the bed plane.
# * do not write nozzle_*/station_* in an include: SAVE_CONFIG refuses to
# autosave an option an include already sets.
#
# Safety notes (all divergences from the app, which accepts ANY numbers)
# * PLATE_REMOVED=1 is mandatory on both commands.
# * z_target defaults to -3 (the app's own station pass-2 value) instead of
# the app's -5: 2 mm less crash depth if the plate is on after all.
# * Once a trigger height is known (nozzle_z / station_z calibrated, or
# station_z alone for a first tool pass) the Z probe stops z_margin
# below it instead of descending to z_target.
# * max_residual 0.05: a point that far off the fitted circle is a
# mis-trigger; abort without saving. (Four symmetric points dilute a
# single bad point's error to ~1/4 in the residual but move the centre
# by half of it, hence the tight default.)
# * gap_min/gap_max: nozzle_z must sit 1.5..5 mm above station_z (3.19 on
# this unit). A bogus nozzle_z is what drives the first layer into the
# plate, so an implausible one is not saved.
# * START_PRINT and the Mainsail entry point refuse to print while
# nozzle_z/station_z are missing (see ff-print-macros.cfg).
[ff_tool_offset]
# Start point (the app's test.json cylinder_x/cylinder_y; 28.5 / 214.5 on a
# stock unit, same as its compiled-in defaults).
#cylinder_x: 28.5
#cylinder_y: 214.5
# The tool pass starts nozzle_x_shift to -X of the station (app: 12.5).
#nozzle_x_shift: 12.5
# ESTOP travel from the centre (app: 14).
#probe_travel: 14
# ESTOP Z targets (app: -5 / station second pass -3; our default -3 / -3).
#z_target: -3
#z_target_station_2: -3
# Stop this far below a known trigger height instead of at z_target.
#z_margin: 2.0
# Probe height above the Z trigger, lift between passes, start/exit heights.
#z_clear: 0.6
#z_lift: 3
#z_start: 10
#z_final: 15
# Accel during ESTOP moves (app: 100) and what to restore afterwards
# (unset = the limit live before the run; the app forces 20000).
#probe_accel: 100
#accel_restore: 20000
# Plausibility guards -- 0 disables (app behaviour).
#max_residual: 0.05
#min_radius: 0
#max_radius: 0
#gap_min: 1.5
#gap_max: 5.0
+75 -54
View File
@@ -3,7 +3,7 @@
# ff-toolchange.cfg -- configuration for the native Creator 5 Pro toolchanger.
#
# The implementation lives in klippy-extras/ff_toolchange.py (copy it to
# Implementation: klippy-extras/ff_tool.py + ff_toolchange.py (copy both to
# /usr/prog/klipper/klippy/extras/).
#
# It is a Python extra rather than a set of gcode_macros because firmwareExe's
@@ -14,31 +14,39 @@
# See OKF/31-recovered-toolchange-sequences.md.
#
# ---------------------------------------------------------------------------
# THIS FILE CARRIES NO PER-UNIT NUMBERS ON PURPOSE
# Where the per-unit numbers live
#
# The dock coordinates, grab feedrates and per-tool offsets are factory
# calibration that already exists on this printer, in firmwareExe's own JSON
# under /usr/data/firmwareRes/config/. The module reads those files directly:
# Everything is ordinary Klipper config -- firmwareExe's JSON is not read.
#
# dock_x <- extruder.json x_check_pos, x_check_pos1..3
# dock_y <- extruder.json y_check_pos, y_check_pos1..3
# x_correction <- test.json grabOffset
# fast_feed <- test.json grabSpeed x60 (mm/s -> mm/min)
# slow_feed <- test.json grabSpeedSlow x60
# offset_x/y <- extruder.json t<n>_offset_x/y minus the base tool's
# offset_z <- zoffset.json z_offset_t<n+1> (1-BASED: t1 is tool 0)
# plus extruder.json t<n>_offset_z minus the base tool's
# [ff_tool <n>] dock_x / dock_y hand-written below (per unit)
# nozzle_x / nozzle_y / nozzle_z
# written by TOOL_OFFSET_CALIBRATE into
# printer.cfg's SAVE_CONFIG block
# z_adjust per-tool persistent Z correction,
# set with TOOL_Z_ADJUST (autosaved too)
# [ff_tool_offset] station_x/y/z written by STATION_CALIBRATE
# [ff_toolchange] feeds etc. below
#
# Copying those into this file would mean re-editing it every time the machine
# is recalibrated from the touchscreen, and silently printing with stale
# geometry when someone forgets. Anything you set below OVERRIDES the factory
# value; leaving it unset is the normal case.
# Do NOT put nozzle_*/station_* in this or any included file -- SAVE_CONFIG
# refuses to autosave an option an include already sets. The calibrated
# values belong in the "#*#" block at the end of printer.cfg, like pid_* do.
#
# Precedence, highest first: this file > firmwareRes JSON > app fallback.
# First install on a machine that was calibrated from the touchscreen:
# include ff-legacy.cfg, run FF_IMPORT_FIRMWARE_CONFIG, SAVE_CONFIG -- it
# stages the factory nozzle/station numbers and prints the dock/feed snippet
# for this file. Check with TOOLCHANGE_STATUS before moving anything.
#
# TOOLCHANGE_STATUS prints every resolved value with its provenance, and flags a
# disagreement with the app's own now_extruder. TOOLCHANGE_RELOAD re-reads the JSON
# after a touchscreen recalibration without a Klipper restart.
# Tool-to-tool offsets are DIFFERENCES of nozzle_* against offset_base (T0),
# exactly as CommMgr::setGrabGcodeOffsetMgr computes them:
# X = nozzle_x[tool] - nozzle_x[base]
# Y = nozzle_y[tool] - nozzle_y[base]
# Z = nozzle_z[tool] - nozzle_z[base] + z_adjust[tool]
# z_adjust is the per-tool babystep Klipper lacks (SET_GCODE_OFFSET Z_ADJUST
# is global): TOOL_Z_ADJUST TOOL=1 ADJUST=-0.02, then SAVE_CONFIG.
# applied after a successful pickup as the app does, in two commands:
# SET_GCODE_OFFSET X=.. Y=.. MOVE=1 MOVE_SPEED=100
# SET_GCODE_OFFSET Z=.. MOVE=1 MOVE_SPEED=40
# A tool with no nozzle_* gets zero offset and a warning at startup.
#
# ---------------------------------------------------------------------------
# How a tool line reaches this code
@@ -63,7 +71,8 @@
#
# ---------------------------------------------------------------------------
# INSTALL
# 1. cp klippy-extras/ff_toolchange.py /usr/prog/klipper/klippy/extras/
# 1. cp klippy-extras/ff_tool.py klippy-extras/ff_toolchange.py \
# /usr/prog/klipper/klippy/extras/
# (Klipper lives on the firmware partition -- klipper_pri.sh runs
# /usr/prog/klipper/klippy/klippy.py. It may be mounted read-only, and an
# OTA will overwrite it, so keep a copy and a re-apply script.)
@@ -71,40 +80,45 @@
# It MUST come after [virtual_sdcard] -- the SDCARD_PRINT_FILE wrapper
# below uses rename_existing. Do NOT put it in printer.override.cfg, which
# is included first, before virtual_sdcard exists.
# 3. Run TOOLCHANGE_STATUS and confirm the sensor names AND the loaded geometry
# 3. Fill in dock_x/dock_y below (FF_IMPORT_FIRMWARE_CONFIG prints them).
# 4. Run TOOLCHANGE_STATUS and confirm the sensor names AND the geometry
# before moving anything.
# ---------------------------------------------------------------------------
[ff_toolchange]
# Where firmwareExe keeps its per-unit calibration. The default is correct for
# a stock machine; point it elsewhere only to test against a pulled copy.
#firmware_config_dir: /usr/data/firmwareRes/config
# Dock coordinates: carriage X at which the dock engages, and the dock's Y.
# The app's x_check_pos<n>/y_check_pos<n>. The values below are THIS unit's
# factory calibration (SN MUPF9513454); replace with yours.
[ff_tool 0]
dock_x: 296.557861
dock_y: 56.235977
# --- overrides (all optional; omit to use the factory value) ---------------
#dock_x: 296.557861, 296.538940, 296.532623, 296.952393
#dock_y: 56.235977, 106.336197, 155.631531, 205.320541
[ff_tool 1]
dock_x: 296.538940
dock_y: 106.336197
[ff_tool 2]
dock_x: 296.532623
dock_y: 155.631531
[ff_tool 3]
dock_x: 296.952393
dock_y: 205.320541
[ff_toolchange]
# X added to every dock_x (the app's grabOffset; 0 on this unit).
#x_correction: 0.0
# Feeds, mm/min. The app stores mm/s (grabSpeed 500, grabSpeedSlow 90 here)
# and multiplies by 60.
#fast_feed: 30000
#slow_feed: 5400
#release_slow_feed: 5400
#offset_z: 0, 0, 0, 0
# Per-tool G-code offsets come from the firmware JSON too -- see the table
# above plus `offset_x`/`offset_y` below. They are DIFFERENCES against a base
# tool, exactly as CommMgr::setGrabGcodeOffsetMgr computes them:
# X = t<tool>_offset_x - t<base>_offset_x
# Y = t<tool>_offset_y - t<base>_offset_y
# Z = z_offset_t<tool+1> + (t<tool>_offset_z - t<base>_offset_z)
# and are applied after a successful pickup as the app does, in two commands:
# SET_GCODE_OFFSET X=.. Y=.. MOVE=1 MOVE_SPEED=100
# SET_GCODE_OFFSET Z=.. MOVE=1 MOVE_SPEED=40
#
# The base tool is CommMgr+0x59c, zeroed by the constructor, so T0. The
# touchscreen can rebase it via setBaseExtruder() when a print does not start
# on T0; we do not follow that -- set offset_base here if you need to.
# degC-to-mm thermal term of the print-start Z offset:
# (nozzle_temp - 120) * temp_offset. The app's tempOffset.
#temp_offset: 0.00045
# Base tool for the offset differences. The touchscreen rebases to the
# print's first tool via setBaseExtruder(); we keep a fixed base -- the
# choice cancels out, see _apply_tool_diff_offsets.
#offset_base: 0
#offset_x: 0, 0, 0, 0
#offset_y: 0, 0, 0, 0
# Homing safety. firmwareExe auto-homes before a grab (doGrabExtruderLatest
# calls homeManager when not homed) -- but on the touchscreen someone is
@@ -125,9 +139,6 @@ accel_move: 8000
# below 20000 does not come out of a toolchange faster than its [printer]
# max_accel. Set this only to force the app's literal behaviour.
#accel_restore: 20000
# The release-side mate feed. Same grabSpeedSlow JSON key as slow_feed, but
# the app's fallback on the release path is 5400, not 6000.
#release_slow_feed: 5400
# Sensor names. These are microswitches, exposed to Klipper as [gcode_button]
# sections in printer.base.cfg -- but what they ARE is the app's sensors:
@@ -158,7 +169,7 @@ release_macro: MOTOR_RELEASE
stop_macro: MOTOR_STOP
# NO PERSISTENT STATE -- there is deliberately no [save_variables] here.
# NO PERSISTENT TOOL STATE.
#
# The mounted tool is DERIVED FROM THE DOCK SENSORS every time it is needed: the
# tool whose dock is empty is the one on the carriage. Nothing is stored, so
@@ -169,11 +180,9 @@ stop_macro: MOTOR_STOP
# ignores the `tool` argument and just ORs the four grab bytes, so the app only
# learns THAT something is held, never WHICH. That is why it keeps an imperative
# index (extruderConfig()+0x5c / now_extruder): written on grab, reset to -1 on
# release and on every home (homeManager stores -1 at 0x76c408/0x76c510/0x76c5a8),
# persisted by syncExtruderConfig, and read in ~30 places. checkInstallExtruder
# release and on every home, persisted by syncExtruderConfig. checkInstallExtruder
# @0x781a34 shows how the two combine:
# installed = dock_sensor[tool] || (anyGrabbed && now_extruder == tool)
# Sensors first; the stored index only to put a name to the held tool.
#
# extruder_pos1..4 are per-tool, so they carry that name directly, and they also
# distinguish "tool is on the carriage" from "tool has been taken out of the
@@ -222,9 +231,21 @@ stop_macro: MOTOR_STOP
# * set_velocity_limit needs no line here: SDCARD_SET_CHANNEL clears it (:259)
# and ff_toolchange issues that on every change.
# ---------------------------------------------------------------------------
#
# It also refuses to start at all while the machine is uncalibrated: a file
# with its own start G-code would otherwise print in the raw eddy Z frame,
# ~3.2 mm into the plate. _FF_JOB.allow_uncalibrated=1 overrides for tests.
# ---------------------------------------------------------------------------
[gcode_macro SDCARD_PRINT_FILE]
rename_existing: _FF_SDCARD_PRINT_FILE
gcode:
{% set tc = printer.ff_toolchange %}
{% if not tc.print_offset_ready and not printer["gcode_macro _FF_JOB"].allow_uncalibrated %}
{ action_raise_error("Refusing to print: station_z=%s, calibrated tools %s. "
"Run STATION_CALIBRATE / TOOL_OFFSET_CALIBRATE (or FF_IMPORT_FIRMWARE_CONFIG) "
"and SAVE_CONFIG first -- without them the nozzle would be driven ~3 mm into the plate."
% (tc.station_z, tc.calibrated_tools)) }
{% endif %}
SDCARD_CLEAR_REFUELLING
SDCARD_ENABLE_FFM ENABLE=0
SDCARD_SET_NEED_CHECK_EX CHECK=0
+5 -4
View File
@@ -25,10 +25,11 @@ Key files:
`Config::syncExtruderConfig` @0x651388 rewrites the whole file from the app's
in-memory struct on every toolchange — anything written externally is clobbered, and
a partial write could revert unrelated per-unit calibration. `ff_toolchange.py` only
READS them. Precedence per option: `printer.cfg` > firmwareRes JSON > app fallback;
`TOOLCHANGE_STATUS` shows each value's provenance; `TOOLCHANGE_RELOAD` re-reads after
a touchscreen recalibration.
a partial write could revert unrelated per-unit calibration. On the `klipper-vanilla`
branch these files are not read at runtime at all: `ff_legacy.py`'s
`FF_IMPORT_FIRMWARE_CONFIG` imports them once into `[ff_tool n]` / `[ff_tool_offset]`
(`SAVE_CONFIG` block of `printer.cfg`), and calibration is redone from Klipper with
`STATION_CALIBRATE` / `TOOL_OFFSET_CALIBRATE` (see `45-tool-offset-calibration.md`).
## Per-tool offsets — `setGrabGcodeOffsetMgr` @0x77f1dc
+107
View File
@@ -0,0 +1,107 @@
# Nozzle XY/Z offset calibration — Klipper port
Source of truth for the sequence: [`46-offset-calibration-recovered.md`](46-offset-calibration-recovered.md),
recovered 2026-08-21 from MIPS machine code with a custom disassembler (annotated `.asm` listing not in repo).
Design decision (2026-08-21): firmwareExe's JSON is no longer a runtime source. The stock UI
cannot use Klipper-side results anyway and HelixScreen is the target, so calibration is stored
the way Klipper's own calibrators store theirs — `configfile.set()` + `SAVE_CONFIG` — in
per-tool sections, modelled on `[bed_mesh <profile>]` / klipper-toolchanger's `[tool Tn]`.
## Files
| file | role |
|---|---|
| `klippy-extras/ff_tool.py` | `[ff_tool n]`: `dock_x/dock_y` hand-written; `nozzle_x/y/z` (measured) and `z_adjust` (user per-tool Z correction, `TOOL_Z_ADJUST`) autosaved |
| `klippy-extras/ff_tool_offset.py` | `TOOL_OFFSET_CALIBRATE`, `STATION_CALIBRATE`, `TOOL_OFFSET_STATUS`; `[ff_tool_offset]` holds `station_x/y/z` (autosaved) + probe geometry |
| `klippy-extras/ff_toolchange.py` | takes docks/offsets from the `ff_tool` objects; `refresh_offsets()`; `_station_z()`; JSON reader and `TOOLCHANGE_RELOAD` removed |
| `klippy-extras/ff_legacy.py` | `FF_IMPORT_FIRMWARE_CONFIG [DIR=] [APPLY=1]` — one-shot import of extruder/test/zoffset.json |
| `config/ff-toolchange.cfg` | `[ff_tool 0..3]` with this unit's docks + `[ff_toolchange]` |
| `config/ff-tool-offset.cfg`, `config/ff-legacy.cfg` | sections for the other two extras |
## Storage layout
```
# hand-written (ff-toolchange.cfg) # SAVE_CONFIG block of printer.cfg
[ff_tool 1] #*# [ff_tool 1]
dock_x: 296.538940 #*# nozzle_x = 16.407789
dock_y: 106.336197 #*# nozzle_y = 211.986710
#*# nozzle_z = 1.426736
[ff_tool_offset] #*# [ff_tool_offset]
#cylinder_x: 28.5 #*# station_x = 28.791826
#*# station_y = 212.639328
#*# station_z = -1.678819
```
- Raw station-frame absolutes per tool, not T0-relative diffs: recalibrating one tool leaves
the others valid; diffs are derived at load (`ff_toolchange._derive_offsets`).
- `nozzle_z − station_z` is the ~3.19 mm nozzle-to-eddy-trigger gap the print-start Z offset
uses (`TOOLCHANGE_SET_PRINT_OFFSET`).
- Fork constraint (`configfile.py:385`): `SAVE_CONFIG` refuses to autosave an option an
*included* file already sets — so `nozzle_*`/`station_*` must never be written into the
`.cfg` includes. Autosaving new options into a section that an include defines is fine.
- Values are applied live at once; `SAVE_CONFIG` persists and restarts.
- Klipper's babystep is global; `TOOL_Z_ADJUST TOOL=n ADJUST=±mm|VALUE=mm` edits the per-tool
`z_adjust`, re-applies immediately if that tool is mounted, and stages it for `SAVE_CONFIG`.
- `[save_variables]` is not used (Klipper core never uses it for calibration).
## Install / first run
```
cp klippy-extras/ff_tool.py ff_toolchange.py ff_tool_offset.py ff_legacy.py /usr/prog/klipper/klippy/extras/
# printer.cfg, after [virtual_sdcard]:
# [include ff-toolchange.cfg] [include ff-tool-offset.cfg] [include ff-legacy.cfg]
RESTART
FF_IMPORT_FIRMWARE_CONFIG ; stages factory nozzle/station; prints dock/feed snippet
SAVE_CONFIG
TOOLCHANGE_STATUS ; all four tools calibrated, station_z present
# later, PEI off, homed:
STATION_CALIBRATE
TOOL_OFFSET_CALIBRATE TOOL=ALL
SAVE_CONFIG
```
Remove `ff-legacy.cfg` afterwards; it is only needed once.
## Safety on an uncalibrated machine (added 2026-08-21, user request)
- `START_PRINT` calls `_FF_REQUIRE_CALIBRATION` first and the Mainsail entry point
(`SDCARD_PRINT_FILE` wrapper) checks `printer.ff_toolchange.print_offset_ready`: no
`nozzle_z`/`station_z` → refuse before heating/homing/grabbing, with the fix spelled out.
Escape hatch for tests: `SET_GCODE_VARIABLE MACRO=_FF_JOB VARIABLE=allow_uncalibrated VALUE=1`.
- `TOOL_OFFSET_CALIBRATE` / `STATION_CALIBRATE` require `PLATE_REMOVED=1`; default `z_target`
is −3 (the app's station pass-2 value) not −5; once a trigger height is known (calibrated
`nozzle_z`/`station_z`, or `station_z + ~3.25` for a first tool pass) the Z probe stops
`z_margin` (2 mm) below it.
- Guards on by default: `max_residual 0.05` (4 symmetric points dilute one bad point's error to
~1/4 in the residual while moving the centre by half), `gap_min/gap_max 1.5..5 mm` for
`nozzle_z − station_z` (3.19 here). A failed guard saves nothing.
- `TOOL_Z_ADJUST TOOL=n ADJUST=±mm|VALUE=mm` is the per-tool babystep (Klipper's is global).
## Fidelity of the probe sequence
Identical to `testEddyExtruderOffsetForwardTwoCheck`: start `(cylinder_x-12.5, cylinder_y)`,
`ESTOP Z TARGET=-5`, probes `+X +Y -X -Y` by 14 mm at `zP+0.6` returning through the centre,
LS circle fit, lift `zP+3`, `G1 Y; G1 X F2400` to the fit, re-probe Z, second 4-point pass,
store pass 2 `(cx, cy, zP2)` raw. Station: start `(cylinder_x, cylinder_y)`, second Z to -3.
`M104 S0 T<n>` + `G1 Z15` on exit. ESTOP is driven by calling the fork's `e_stop` objects'
`run_probe()` directly (3 samples, `error_v` spread check, `back_v` retract — all the fork's).
Deliberate divergences (documented in the cfg): accel stays 100 for the whole run and is
restored to the pre-run limit; optional `max_residual`/`min_radius`/`max_radius` guards; pass-2
points use the 3-decimal-rounded centre actually commanded; `fit_circle` is the centroid-shifted
`fitCircleStable` (identical result for 4 axis-aligned points); homing is required, not done.
## Verified (mock harness, not in repo)
Emitted G-code order matches the recovered list; fitted centre = virtual centre; autosave
receives exactly `nozzle_x/y/z` for the calibrated tool and `station_x/y/z`; live offsets
update without restart; `TOOLCHANGE_SET_PRINT_OFFSET` gives 3.240 for T0/220 °C/80 °C/0.25 mm
(OKF/62's value) and refuses without `station_z`; `TOOL_Z_ADJUST` applies live and stages;
PLATE_REMOVED gate, bounded Z target, gap and residual guards all fire; `FF_IMPORT_FIRMWARE_CONFIG` against
`live/firmwareRes-config` stages 15 values and reproduces the OKF/33 offset table.
**Not yet run on the printer.**
## Not recoverable / open
`fitCircleByLeastSquares` body not traced line by line; `TypeManager=6` in the station pass
unnamed; what feature of the empty carriage the station detects (12.4 mm +X of the nozzle,
3.2 mm lower) is inferred from the numbers only.
@@ -0,0 +1,117 @@
# Nozzle XY/Z offset calibration -- recovered from firmwareExe (MIPS32 LE, -O0)
Source: a custom MIPS disassembler (float/double constant resolution, COP1 fallback for odd-numbered
double regs that capstone rejects, skipdata). Annotated `.asm` listing and tool are not in this repo.
Klipper port: [`45-tool-offset-calibration.md`](45-tool-offset-calibration.md).
Ghidra bodies were useless past the first `ErrorBak` ctor; everything below is from machine code.
## 1. Which variant the touchscreen runs
`CalibrationDialog::doExtruderOffset` (button for the selected T0..T3, `this[0]` = TypeManager) snapshots the
check-boxes into `this[0x1d..0x24]` and spawns `pthreadExtruderOffset` @0x7fcfe4:
```
if Config::testConfig()->generalFirmware (byte +0x10) -> testExtruderOffsetChangeDirection (default 0)
elif this[0x21] "Four-point" (+0x7c) -> testEddyExtruderOffsetForwardTwoCheck @0x806490 <== SHIPPED
elif this[0x22] "maker-point" (+0x80):
this[0x23] "maker-movex" -> ...TwoCheckMakerMoveX ; else this[0x24] -> ...TwoCheckMakerMoveY
```
`initCheckBox` (@0x7ec008, line 331 of CalibrationDialog.c) does `lv_obj_add_state(+0x7c, CHECKED)` -> "Four-point"
is on by default and `doTestWithFF` clears the maker boxes when it is on. `testConfig` comes from
`/usr/data/config/test.json` (keys `grabSpeed, grabSpeedSlow, grabOffset, tempOffset, generalFirmware, cylinder_x,
cylinder_y, impact_loop_count, ...`); `initTestConfig` defaults: generalFirmware=0, cylinder_x=28.5, cylinder_y=214.5.
`testEddyExtruderOffsetForwardTest` / `...Forward` (no TwoCheck) are **not reachable** from the thread.
`testAllExtruderOffset` @0x82fe64 ("All" button, `pthreadAllExtruderOffset`) just loops `for t in 0..3: this[0]=t;`
and calls the same variant selection, then `G1 Z15 F1200; M400`; it sets `this[0x1c]` ("All-T" log prefix).
Other check-boxes: "Loop" (+0x74 -> `this[0x1d]`, 1000 iterations instead of 1), "Release" (+0x70 -> `this[0x1e]`,
release tool after each run), "Clear Ex" (+0x78 -> `this[0x1f]`, heats to `this+0x114` (temp +/- buttons) and purges via
`clearNozzleEddy` instead of a plain tool grab). All default off.
## 2. Exact sequence of testEddyExtruderOffsetForwardTwoCheck (per selected tool `ext`)
No heating anywhere in the default path (only `M104 S0 T<ext>` via `heatManager(ext,0)` on exit). Constants are
IEEE values resolved from .rodata (`@dfdb8c -5.0f, @dfdba8 12.5f, @dfdb90 0.6, @dfdb98 7.0, @dfdba0 3.0f, @dfdbac 10.0f`).
Helper semantics: `estopManager(axis, from, target, &result)` @0x788738 = `setEstopStatus(1); usleep(20ms);
setEddyZero(); SET_VELOCITY_LIMIT ACCEL=100; "ESTOP AXES=<X|Y|Z> TARGET=<target %.3f>"; poll getEstopStatus()
every 100 ms until the `{"module":"estop"}` reply clears it; result = reply "data"; if not ok: G1 Z10 F1200; M400;
SET_VELOCITY_LIMIT ACCEL=20000`. **The `from` argument is never read** -- the caller positions the carriage itself,
and the ESTOP move starts from wherever the nozzle is.
```
0 releaseFourExtruder(ext, true, false) park every tool (G28 X / G28 Y / homeManager as needed)
1 changeExtruderManager(ext, true, false) -> doGrabExtruderMgr(ext): pick up tool ext
(setGrabGcodeOffsetMgr applies SET_GCODE_OFFSET X= Y= MOVE=1 for it)
2 moveCylinderPos(&zP, ext, isStation=0, probeZ=1, useStationEx=0, zTarget=-5.0) @0x789a1c :
x0 = cylinder_x - 12.5 ; y0 = cylinder_y (28.5-12.5 = 16.0 , 214.5 by default)
SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=0 MOVE_SPEED=600 ; M400 ; G1 Z10 F1200
G1 X{x0} Y{y0} F12000 ; SET_VELOCITY_LIMIT ACCEL=100 ; M400 ; G1 Z10 F1200
estopManager(Z, 10.0, -5.0, &zP) => zP = Z where the station fires under the nozzle ("Z Probe pos")
G1 F6000 ; SET_VELOCITY_LIMIT ACCEL=20000
3 G1 X{x0} Y{y0} F1200 ; G1 Z{zP + 0.6} F1200 ; M400 (nozzle stays at this Z for all 4 points)
4 ESTOP X TARGET = x0 + 14 -> px1 Point1 = (px1, y0)
G1 X{x0} Y{y0} F1200 ; M400 (straight back through the centre, same Z)
ESTOP Y TARGET = y0 + 14 -> py2 Point2 = (x0, py2)
G1 X{x0} Y{y0} F1200 ; M400
ESTOP X TARGET = x0 - 14 -> px3 Point3 = (px3, y0)
G1 X{x0} Y{y0} F1200 ; M400 ; M400
ESTOP Y TARGET = y0 - 14 -> py4 Point4 = (x0, py4)
5 (cx, cy, r) = fitCircleByLeastSquares({P1..P4}) @0x645b10 (plain algebraic LS; fitCircleStable is NOT used)
log "<ext>: <loop>: Center (cx, cy, zP) radius: r" -- no tolerance / retry check at all
6 x0 = cx ; y0 = cy
SET_VELOCITY_LIMIT ACCEL=100 ; G1 Z{zP + 3} F1200 ; G1 Y{cy} F2400 ; G1 X{cx} F2400 ; M400
[generalFirmware only: checkProbeZValue -> cmdProbeCalibration, needs max-min <= 0.1]
ESTOP Z TARGET = -5.0 (from Z = zP+3) -> zP2 "double Z Probe pos" (Z re-probed at fitted centre)
G1 Z{zP2 + 0.6} F1200 ; M400
7 second ("check") pass, same 4 directions, +/-14, around (cx,cy); between points the return is
G1 Y{cy} F2400 ; G1 X{cx} F2400 ; M400 (Y first, then X)
8 G1 Y{cy} F2400 ; G1 X{cx} F2400 ; M400 ; G1 Z15 F1200 ; M400
(cx2, cy2, r2) = fitCircleByLeastSquares({check P1..P4}); log "check Center (...)"
9 this+4 = cx2 ; this+8 = cy2 ; this+0xc = zP2 ; doExtruderOffsetSave()
10 optional: "Release" -> doReleaseExtruderMgr(ext, false, false); PNG via drawCalibrationImg; loop
11 exit: heatManager(ext, 0) ; G1 Z15 F1200 ; M400 ; SET_VELOCITY_LIMIT ACCEL=20000
```
Summary of numbers: 4 points per circle, probe radius/travel 14 mm (target = centre +/- 14, i.e. `7.0+7.0`),
approach always from the centre outward (one side per axis, not both), probe Z = `zProbe + 0.6`, lift between
passes `zProbe + 3`, final lift Z15, XY approach feed F1200 (pass 1) / F2400 (pass 2), positioning F12000,
ESTOP runs at ACCEL=100 (restored to 20000). Two passes: pass 2 is centred on the pass-1 fit and re-probes Z
there; pass-2 result is what is stored. No residual check, no retries; any ESTOP error aborts the whole run.
## 3. What is stored
`doExtruderOffsetSave` @0x7fbef0: `OffsetMgr::setXyzOffsetValue(ext, {cx2, cy2, zP2})` and
`extruderConfig()[ext*3 + {0,1,2}] = cx2, cy2, zP2` (Config+0x14c.. = `t<ext>_offset_x/y/z`), then
`syncExtruderConfig()` -> `/usr/data/config/extruder.json`. **Raw machine coordinates of the fitted circle centre
with gcode offset zeroed; nothing is subtracted** (no probe x_offset/y_offset, no station position).
`TS` / `x,y,z_station_pos` come from `testStationPosFourPointTwoCheck` @0x7edf10 (`pthreadStationPos`, the
"station" button): `releaseFourExtruder` (carriage EMPTY), then
`moveCylinderPos(&z, TypeManager=6, isStation=1, probeZ=1, useStationEx=0, -5.0)` -> carriage to
`(cylinder_x, cylinder_y)` (no -12.5), ESTOP Z 10 -> -5; identical 4-point/+-14/F1200 pass at `z+0.6`, LS fit;
then `G1 Z{z+3}; G1 X{cx} Y{cy} F1200; moveCylinderPos(&z2, cx, cy, probeZ=1, -3.0)` (@0x78a610, ESTOP Z to -3),
check pass at `z2+0.6`, LS fit -> `x_station_pos = cx2 (+0x50), y_station_pos = cy2 (+0x54), z_station_pos = z2 (+0x58)`.
So TS is the station-detected feature of the *empty carriage* (on this unit 12.4 mm +X of the nozzle, trigger Z
3.2 mm lower), not an eddy measurement. `CommMgr::getStationExOffset(ext)` @0x79a508 returns
`(x_station_pos - t<ext>_offset_x, y_station_pos - t<ext>_offset_y)` and is only used by `moveCylinderPos(useStationEx=1)`
(other variants) to pre-position a tool over the station. The CSV `calibration-mgr-test-*.txt` lines are exactly
these stored triples (TS = station pass, T<n>-<loop> = tool pass).
## 4. Tool pickup / gcode offset
Pickup is `CommMgr::changeExtruderManager(ext, true, false)` @0x782600 (home check -> `homeManager`, release current
tool via `doReleaseExtruderMgr` if different, `doGrabExtruderMgr(ext, ..)` @0x77ed0c which wraps
`doGrabExtruderLatest(ext,..)` @0x7a8190 and then `setGrabGcodeOffsetMgr`), called once per run before any probing
(step 1). The grab applies the tool's `SET_GCODE_OFFSET X= Y= MOVE=1 MOVE_SPEED=100`;
`moveCylinderPos` then sends `SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=0 MOVE_SPEED=600` **before** probing, so all
ESTOP results are raw machine coordinates. The tool is left attached afterwards unless "Release" is checked.
## 5. Not determined / caveats
- `fitCircleByLeastSquares` @0x645b10 body (372 B) was not traced instruction-by-instruction; it is a 3+-point
algebraic LS fit returning centre+radius (size<3 -> returns 0). With 4 symmetric points it is effectively
`cx=(px1+px3)/2, cy=(py2+py4)/2`.
- `estopManager` local `0x30` (1.0 for X/Y, 3.0 for Z @0x788898/0x788930) is computed but never used.
- `TypeManager` value 6 passed by the station variant was not resolved to its enum name.
- What physical sensor/feature fires for the empty carriage (TS) is inferred from the numbers only.
- `test.json` contents on this unit unknown; defaults (cylinder_x/y = 28.5/214.5) assumed. If present it
overrides the start point only -- the stored result does not depend on it beyond convergence.
- `releaseFourExtruder`, `changeExtruderManager`, `clearNozzleEddy` were only skimmed (call/string skeleton).
+4 -2
View File
@@ -24,8 +24,10 @@ handles all of this. Kept as reference for future work.
- **Nozzle offset calibration** (`testEddyExtruderOffsetForward*`): heats and cleans
each nozzle, probes the fixed under-bed sensor with the eddy, then touches it with
each nozzle; circular probing + least-squares circle fit for XY; results →
`tN_offset_x/y/z`, `z_station_pos` in extruder.json. This is what
`TOOLCHANGE_RELOAD` picks up after a touchscreen recalibration.
`tN_offset_x/y/z`, `z_station_pos` in extruder.json. Reimplemented in Klipper as
`STATION_CALIBRATE` / `TOOL_OFFSET_CALIBRATE` (`45-tool-offset-calibration.md`,
`46-offset-calibration-recovered.md`); the JSON is imported once with
`FF_IMPORT_FIRMWARE_CONFIG` and not consulted afterwards.
- **Input shaper**: `STEPPER_RESONANCE_FACTORY_CALIBRATE` (fork) / `SHAPER_CALIBRATE`.
- **Auto PA**: prints slow-fast-slow line patterns per PA candidate, scores via
`PA_ACTION`/`PA_GET` (sensor unknown), stores per-tool table via `SET_PA_ADVANCE`.
+219
View File
@@ -0,0 +1,219 @@
# One-shot migration helpers from firmwareExe's per-unit JSON to Klipper config.
#
# Nothing here runs during normal operation. Load it with an empty
# [ff_legacy] section when you need it, run the command once, SAVE_CONFIG,
# and you can remove the section again.
#
# FF_IMPORT_FIRMWARE_CONFIG [DIR=/usr/data/firmwareRes/config] [APPLY=1]
#
# reads extruder.json / test.json / zoffset.json and
# * stages the CALIBRATION data for SAVE_CONFIG (configfile.set), exactly
# as TOOL_OFFSET_CALIBRATE / STATION_CALIBRATE would:
# [ff_tool n] nozzle_x/y/z <- t<n>_offset_x/y/z
# z_adjust <- zoffset.json z_offset_t<n+1> (if != 0)
# [ff_tool_offset] station_x/y/z <- x/y/z_station_pos
# * PRINTS a ready-to-paste snippet for the settings that belong in the
# hand-written part of the config and must not be autosaved (SAVE_CONFIG
# refuses to autosave an option an include already sets):
# [ff_tool n] dock_x/dock_y <- x_check_pos<n>/y_check_pos<n>
# [ff_toolchange] x_correction <- grabOffset
# fast_feed <- grabSpeed * 60
# slow_feed, release_slow_feed <- grabSpeedSlow * 60
# temp_offset <- tempOffset
# [ff_tool_offset] cylinder_x/y <- cylinder_x/y
# APPLY=0 only prints everything and stages nothing.
#
# Key names and the struct layout were read off the binary
# (Config::loadExtruderConfig @0x64f7f8, loadTestConfig @0x654380,
# loadZOffsetConfig @0x65621c); see OKF/32-config-provenance.md. The files
# are NOT strict JSON: each ends with a trailing C comment after the closing
# brace, so we use raw_decode() and ignore the tail.
import json
import logging
import math
import os
EXTRUDER_COUNT = 4
FIRMWARE_CONFIG_DIR = '/usr/data/firmwareRes/config'
class FFFirmwareConfig:
"""Read-only view of firmwareExe's per-unit JSON configuration."""
def __init__(self, directory):
self.dir = directory
self.files = {}
self.errors = []
for name in ('extruder', 'test', 'zoffset'):
path = os.path.join(self.dir, name + '.json')
try:
with open(path, 'r') as fh:
text = fh.read()
except IOError as e:
self.errors.append("%s: %s" % (path, e))
continue
try:
obj, _ = json.JSONDecoder().raw_decode(text.lstrip())
except ValueError as e:
self.errors.append("%s: not valid JSON (%s)" % (path, e))
continue
if not isinstance(obj, dict):
self.errors.append("%s: expected a JSON object" % path)
continue
self.files[name] = obj
def num(self, fname, key):
obj = self.files.get(fname)
if obj is None:
return None
val = obj.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return None
if not math.isfinite(val):
return None
return float(val)
def tool_offsets(self):
rows = []
for n in range(EXTRUDER_COUNT):
v = [self.num('extruder', 't%d_offset_%s' % (n, a)) for a in 'xyz']
rows.append(None if None in v else tuple(v))
return rows
def docks(self):
rows = []
for n in range(EXTRUDER_COUNT):
sfx = '' if n == 0 else '%d' % n
x = self.num('extruder', 'x_check_pos' + sfx)
y = self.num('extruder', 'y_check_pos' + sfx)
rows.append(None if None in (x, y) else (x, y))
return rows
def station(self):
v = [self.num('extruder', '%s_station_pos' % a) for a in 'xyz']
return None if None in v else tuple(v)
def z_adjust(self):
# zoffset.json is 1-BASED: z_offset_t1 is tool 0.
return [self.num('zoffset', 'z_offset_t%d' % (n + 1))
for n in range(EXTRUDER_COUNT)]
def speed(self, key):
# mm/s in the file; the app multiplies by 60 and ignores <= 0.
v = self.num('test', key)
if v is None or v <= 0:
return None
return int(round(v * 60.0))
class FFLegacy:
def __init__(self, config):
self.printer = config.get_printer()
self.name = config.get_name()
self.gcode = self.printer.lookup_object('gcode')
self.gcode.register_command(
'FF_IMPORT_FIRMWARE_CONFIG', self.cmd_FF_IMPORT_FIRMWARE_CONFIG,
desc=self.cmd_FF_IMPORT_FIRMWARE_CONFIG_help)
cmd_FF_IMPORT_FIRMWARE_CONFIG_help = (
"Import firmwareExe's extruder/test/zoffset.json into [ff_tool n] /"
" [ff_tool_offset] ([DIR=<path>] [APPLY=1])")
def cmd_FF_IMPORT_FIRMWARE_CONFIG(self, gcmd):
directory = gcmd.get('DIR', FIRMWARE_CONFIG_DIR)
apply = gcmd.get_int('APPLY', 1, minval=0, maxval=1)
fw = FFFirmwareConfig(directory)
out = ["import from %s" % directory]
out += [" ! %s" % e for e in fw.errors]
if 'extruder' not in fw.files:
raise gcmd.error("%s: extruder.json could not be read (%s)"
% (self.name, "; ".join(fw.errors)))
configfile = self.printer.lookup_object('configfile')
staged = []
def stage(section, option, value):
if apply:
configfile.set(section, option, "%.6f" % value)
staged.append(" [%s] %s = %.6f" % (section, option, value))
# -- calibration data -> autosave --------------------------------
tools = fw.tool_offsets()
zadj = fw.z_adjust()
for n in range(EXTRUDER_COUNT):
sec = 'ff_tool %d' % n
if tools[n] is not None:
stage(sec, 'nozzle_x', tools[n][0])
stage(sec, 'nozzle_y', tools[n][1])
stage(sec, 'nozzle_z', tools[n][2])
obj = self.printer.lookup_object(sec, None)
if apply and obj is not None:
obj.nozzle = tools[n]
else:
out.append(" ! t%d_offset_x/y/z missing -- T%d not imported"
% (n, n))
if zadj[n]:
stage(sec, 'z_adjust', zadj[n])
obj = self.printer.lookup_object(sec, None)
if apply and obj is not None:
obj.z_adjust = zadj[n]
st = fw.station()
if st is not None:
stage('ff_tool_offset', 'station_x', st[0])
stage('ff_tool_offset', 'station_y', st[1])
stage('ff_tool_offset', 'station_z', st[2])
obj = self.printer.lookup_object('ff_tool_offset', None)
if apply and obj is not None:
obj.station = st
else:
out.append(" ! x/y/z_station_pos missing -- station not imported")
tc = self.printer.lookup_object('ff_toolchange', None)
if apply and tc is not None and hasattr(tc, 'refresh_offsets'):
tc.refresh_offsets()
out.append("%s for SAVE_CONFIG:" % ("staged" if apply else "would stage"))
out += staged or [" (nothing)"]
# -- hand-written settings -> snippet ----------------------------
snippet = []
docks = fw.docks()
for n in range(EXTRUDER_COUNT):
snippet.append("[ff_tool %d]" % n)
if docks[n] is not None:
snippet.append("dock_x: %.6f" % docks[n][0])
snippet.append("dock_y: %.6f" % docks[n][1])
else:
snippet.append("# x/y_check_pos%s missing in extruder.json"
% ('' if n == 0 else n))
snippet.append("")
snippet.append("[ff_toolchange]")
for opt, key in (('x_correction', 'grabOffset'),
('temp_offset', 'tempOffset')):
v = fw.num('test', key)
if v is not None:
snippet.append("%s: %.6g" % (opt, v))
fast = fw.speed('grabSpeed')
slow = fw.speed('grabSpeedSlow')
if fast is not None:
snippet.append("fast_feed: %d" % fast)
if slow is not None:
snippet.append("slow_feed: %d" % slow)
snippet.append("release_slow_feed: %d" % slow)
snippet.append("")
snippet.append("[ff_tool_offset]")
for opt in ('cylinder_x', 'cylinder_y'):
v = fw.num('test', opt)
if v is not None:
snippet.append("%s: %.6g" % (opt, v))
out.append("paste into the hand-written config (NOT autosaved):")
out += [" " + l for l in snippet]
if apply and staged:
out.append("Then run SAVE_CONFIG to persist the staged values.")
gcmd.respond_info("\n".join(out))
logging.info("%s: %s", self.name, "\n".join(out))
def load_config(config):
return FFLegacy(config)
+91
View File
@@ -0,0 +1,91 @@
# Per-tool configuration for the FlashForge Creator 5 Pro toolchanger.
#
# Hand-written (ff-toolchange.cfg):
# [ff_tool 0]
# dock_x: 296.558 # carriage X at which this tool's dock is engaged
# dock_y: 56.236 # Y of the dock
#
# Autosaved into printer.cfg's SAVE_CONFIG block -- never write these in an
# included file (SAVE_CONFIG refuses: "conflicts with included value"):
# #*# [ff_tool 0]
# #*# nozzle_x = 16.416023 MEASUREMENT. The station bore axis as probed
# #*# nozzle_y = 212.585861 with this tool's nozzle, raw machine coords,
# #*# nozzle_z = 1.515972 G-code offset zeroed. Written as a triple by
# TOOL_OFFSET_CALIBRATE; all three or none.
# #*# z_adjust = -0.020 USER CORRECTION. A persistent per-tool Z tweak
# added on top of the measured difference at
# every grab (the app's zoffset.json). Klipper's
# own babystep (SET_GCODE_OFFSET Z_ADJUST) is
# global, so this is the only per-tool one.
# Set with TOOL_Z_ADJUST, persisted by SAVE_CONFIG.
#
# Offsets applied on a grab are DIFFERENCES against a base tool (ff_toolchange):
# X = nozzle_x[tool] - nozzle_x[base]
# Y = nozzle_y[tool] - nozzle_y[base]
# Z = nozzle_z[tool] - nozzle_z[base] + z_adjust[tool]
# Absolute values are stored, so recalibrating one tool leaves the others
# valid. nozzle_z - station_z ([ff_tool_offset]) is the nozzle-to-eddy-trigger
# gap the print-start Z offset is built from.
import logging
class FFTool:
def __init__(self, config):
self.printer = config.get_printer()
self.name = config.get_name()
try:
self.index = int(self.name.split()[-1])
except ValueError:
raise config.error("%s: section name must be 'ff_tool <n>'"
% self.name)
if self.index < 0:
raise config.error("%s: tool index must be >= 0" % self.name)
self.dock_x = config.getfloat('dock_x')
self.dock_y = config.getfloat('dock_y')
# Klipper's own naming: tool 0 is [extruder], tool n is [extruderN].
self.extruder_name = ('extruder' if self.index == 0
else 'extruder%d' % self.index)
# Measured (TOOL_OFFSET_CALIBRATE) -- one probe run yields all three.
nozzle = [config.getfloat('nozzle_' + a, None) for a in 'xyz']
if None in nozzle and nozzle != [None, None, None]:
raise config.error("%s: nozzle_x, nozzle_y and nozzle_z must be"
" set together" % self.name)
self.nozzle = None if nozzle[0] is None else tuple(nozzle)
# User's persistent per-tool Z correction (TOOL_Z_ADJUST).
self.z_adjust = config.getfloat('z_adjust', 0.0)
self.printer.register_event_handler('klippy:connect',
self._handle_connect)
def _handle_connect(self):
if self.printer.lookup_object(self.extruder_name, None) is None:
raise self.printer.config_error(
"%s: extruder '%s' not found" % (self.name, self.extruder_name))
def calibrated(self):
return self.nozzle is not None
def set_nozzle(self, x, y, z):
"""Adopt a new measurement live and stage it for SAVE_CONFIG."""
self.nozzle = (float(x), float(y), float(z))
configfile = self.printer.lookup_object('configfile')
configfile.set(self.name, 'nozzle_x', "%.6f" % x)
configfile.set(self.name, 'nozzle_y', "%.6f" % y)
configfile.set(self.name, 'nozzle_z', "%.6f" % z)
logging.info("%s: nozzle = (%.6f, %.6f, %.6f)", self.name, x, y, z)
def set_z_adjust(self, z):
self.z_adjust = float(z)
configfile = self.printer.lookup_object('configfile')
configfile.set(self.name, 'z_adjust', "%.6f" % z)
def get_status(self, eventtime):
nx, ny, nz = self.nozzle if self.nozzle else (None, None, None)
return {'index': self.index, 'dock_x': self.dock_x,
'dock_y': self.dock_y, 'extruder': self.extruder_name,
'z_adjust': self.z_adjust, 'calibrated': self.nozzle is not None,
'nozzle_x': nx, 'nozzle_y': ny, 'nozzle_z': nz}
def load_config_prefix(config):
return FFTool(config)
+583
View File
@@ -0,0 +1,583 @@
# Nozzle XY/Z offset calibration for the FlashForge Creator 5 Pro.
#
# A port of the touchscreen's "Extruder offset" calibration, recovered from
# the firmwareExe binary (MIPS machine code -- Ghidra truncated these bodies):
# CalibrationDialog::testEddyExtruderOffsetForwardTwoCheck @0x806490 (tools)
# CalibrationDialog::testStationPosFourPointTwoCheck @0x7edf10 (station)
# CommMgr::moveCylinderPos @0x789a1c / @0x78a610
# CommMgr::estopManager @0x788738
# BaseFunction::fitCircleStable / fitCircleByLeastSquares @0x6467f8 / @0x645b10
# Full walkthrough: firmwareExe-decompiled/recovered/offset-calibration.md
#
# The physical setup: a fixed inductive "cylinder" station under the bed
# (levelboard PD0, exposed to Klipper as [e_stop X]/[e_stop Y]/[e_stop Z] --
# one pin, three axis wrappers). The nozzle is lowered into its bore until the
# sensor fires (Z), then driven outward from the bore centre along +X, +Y, -X,
# -Y until the bore wall fires it. Four boundary points -> least-squares circle
# -> the bore axis in this tool's nozzle frame. Done twice (the second pass is
# centred on the first fit and re-probes Z there); the second result is stored
# as t<n>_offset_x/y/z -- RAW machine coordinates with the G-code offset
# zeroed, nothing subtracted. Tool-to-tool differences of those are what the
# toolchanger applies (ff_toolchange._derive_offsets).
#
# The same pass with an EMPTY carriage measures x/y/z_station_pos ("TS" in the
# app's CSV). z_station_pos is the eddy-frame reference the print-start Z
# offset is computed against (TOOLCHANGE_SET_PRINT_OFFSET), so it must be
# measured with the same station and the same mechanics as the tools.
#
# Results are stored the way Klipper's own calibrators store theirs
# (PID_CALIBRATE, SHAPER_CALIBRATE, PROBE_CALIBRATE): configfile.set() into
# the SAVE_CONFIG block of printer.cfg --
# [ff_tool <n>] nozzle_x / nozzle_y / nozzle_z (ff_tool.py)
# [ff_tool_offset] station_x / station_y / station_z
# applied live at once and persisted by SAVE_CONFIG (which restarts).
# firmwareExe's extruder.json is not touched; ff_legacy.py can import it once.
#
# This is a Python extra rather than a macro because each ESTOP result feeds
# the next move (the second pass is centred on the first fit) and a gcode_macro
# renders its whole template before anything executes.
import logging
import math
EXTRUDER_COUNT = 4
# Constants read from the binary's .rodata (offset-calibration.md section 2):
NOZZLE_X_SHIFT = 12.5 # @0xdfdba8 tool pass starts at cylinder_x - 12.5
PROBE_TRAVEL = 14.0 # 7.0 + 7.0 (@0xdfdb98) ESTOP target = centre +/- 14
Z_TARGET_APP = -5.0 # @0xdfdb8c the app's ESTOP Z target (tools, station pass 1)
Z_TARGET = -3.0 # our default: the app's own station pass-2 value;
# 1.3 mm below the deepest expected trigger, 2 mm
# less crash depth than -5 if the plate is still on
Z_TARGET_STATION_2 = -3.0 # station second Z probe (moveCylinderPos @0x78a610)
Z_CLEAR = 0.6 # @0xdfdb90 probing height above the Z trigger
Z_LIFT = 3.0 # @0xdfdba0 lift between the two passes
Z_START = 10.0 # @0xdfdbac "G1 Z10" before the Z probe
Z_FINAL = 15.0 # "G1 Z15 F1200" on exit
FEED_POSITION = 12000 # G1 X Y F12000 to the start point
FEED_PASS1 = 1200 # returns to centre in pass 1, and all Z moves
FEED_PASS2 = 2400 # returns to centre in pass 2
PROBE_ACCEL = 100.0 # SET_VELOCITY_LIMIT ACCEL=100 while probing
APP_ACCEL_RESTORE = 20000.0 # the app's literal restore value
# test.json defaults (Config::initTestConfig) for the station start point.
CYLINDER_X_DEFAULT = 28.5
CYLINDER_Y_DEFAULT = 214.5
# ---------------------------------------------------------------------------
# Circle fit
#
# Port of BaseFunction::fitCircleStable @0x6467f8 (centroid-shifted algebraic
# least squares, cv::solve DECOMP_LU on the 3x3 normal equations):
# A = [2x', 2y', 1], b = x'^2 + y'^2, (A^T A) p = A^T b
# centre = (p0 + xbar, p1 + ybar), r = sqrt(p2 + p0^2 + p1^2)
# The shipped variant calls fitCircleByLeastSquares @0x645b10, the same
# algebraic fit without the centroid shift; for the 4 symmetric points the
# sequence produces both reduce to cx = (px1+px3)/2, cy = (py2+py4)/2. We
# keep the shifted form for numerical hygiene and accept >= 3 points.
# Pure Python on purpose: numpy is not guaranteed on the X2000 rootfs.
# ---------------------------------------------------------------------------
def fit_circle(points):
"""Return (cx, cy, r, residuals); raise ValueError if unfittable."""
n = len(points)
if n < 3:
raise ValueError("circle fit needs at least 3 points, got %d" % n)
xbar = sum(p[0] for p in points) / n
ybar = sum(p[1] for p in points) / n
m = [[0.0] * 3 for _ in range(3)]
v = [0.0] * 3
for x, y in points:
xs, ys = x - xbar, y - ybar
row = (2.0 * xs, 2.0 * ys, 1.0)
b = xs * xs + ys * ys
for i in range(3):
v[i] += row[i] * b
for j in range(3):
m[i][j] += row[i] * row[j]
p = _solve3(m, v)
if p is None:
raise ValueError("circle fit is singular (points collinear?)")
cx, cy = p[0] + xbar, p[1] + ybar
rr = p[2] + p[0] * p[0] + p[1] * p[1]
if rr <= 0.0:
raise ValueError("circle fit gave radius^2 = %.6f" % rr)
r = math.sqrt(rr)
resid = [math.hypot(x - cx, y - cy) - r for x, y in points]
return cx, cy, r, resid
def _solve3(m, v):
a = [m[i][:] + [v[i]] for i in range(3)]
for c in range(3):
piv = max(range(c, 3), key=lambda r: abs(a[r][c]))
if abs(a[piv][c]) < 1e-12:
return None
a[c], a[piv] = a[piv], a[c]
for r in range(3):
if r != c:
f = a[r][c] / a[c][c]
for k in range(c, 4):
a[r][k] -= f * a[c][k]
return [a[i][3] / a[i][i] for i in range(3)]
class FFToolOffsetError(Exception):
pass
class FFToolOffset:
def __init__(self, config):
self.printer = config.get_printer()
self.reactor = self.printer.get_reactor()
self.gcode = self.printer.lookup_object('gcode')
self.name = config.get_name()
# Start point. The app reads cylinder_x/y from test.json (28.5 /
# 214.5 on a stock unit, equal to its compiled-in defaults).
self.cfg_cylinder_x = config.getfloat('cylinder_x', None)
self.cfg_cylinder_y = config.getfloat('cylinder_y', None)
self.nozzle_x_shift = config.getfloat('nozzle_x_shift', NOZZLE_X_SHIFT)
self.probe_travel = config.getfloat('probe_travel', PROBE_TRAVEL,
above=0.)
self.z_target = config.getfloat('z_target', Z_TARGET)
# Once a trigger height is known (nozzle_z / station_z already
# calibrated) the Z probe stops this far below it instead of going
# all the way to z_target.
self.z_margin = config.getfloat('z_margin', 2.0, above=0.)
self.z_target_station_2 = config.getfloat('z_target_station_2',
Z_TARGET_STATION_2)
self.z_clear = config.getfloat('z_clear', Z_CLEAR, above=0.)
self.z_lift = config.getfloat('z_lift', Z_LIFT, above=0.)
self.z_start = config.getfloat('z_start', Z_START)
self.z_final = config.getfloat('z_final', Z_FINAL)
self.probe_accel = config.getfloat('probe_accel', PROBE_ACCEL, above=0.)
# None = restore the accel limit that was live when we started
# (same policy as ff_toolchange); set to force the app's 20000.
self.accel_restore = config.getfloat('accel_restore', None)
# Residual guard. The app has NONE -- any four numbers are accepted.
# A point off the fitted circle is what a mis-trigger looks like;
# abort without saving. With four symmetric points one bad point of
# error e shows up as a residual of only ~e/4 while moving the centre
# by e/2, so 0.05 here catches centre errors of ~0.1 mm. 0 disables.
self.max_residual = config.getfloat('max_residual', 0.05, minval=0.)
# Sanity window for the fitted bore radius (0 disables).
self.min_radius = config.getfloat('min_radius', 0.0, minval=0.)
self.max_radius = config.getfloat('max_radius', 0.0, minval=0.)
# Plausibility of nozzle_z against station_z: the nozzle fires the
# station ~3.2 mm above where the empty carriage does (this unit
# 3.19). A value far outside that means a bad probe, and a bad
# nozzle_z is exactly what drives the first layer into the plate.
# Checked only when station_z is known. 0 disables.
self.gap_min = config.getfloat('gap_min', 1.5)
self.gap_max = config.getfloat('gap_max', 5.0)
# Station position measured with the empty carriage
# (STATION_CALIBRATE), autosaved here as station_x/y/z.
sx = config.getfloat('station_x', None)
sy = config.getfloat('station_y', None)
sz = config.getfloat('station_z', None)
if (sx, sy, sz) != (None, None, None) and None in (sx, sy, sz):
raise config.error("%s: station_x, station_y and station_z must"
" be set together" % self.name)
self.station = None if sx is None else (sx, sy, sz)
self.estop = {}
self.tools = []
for i in range(EXTRUDER_COUNT):
self.tools.append(self.printer.load_object(config, 'ff_tool %d' % i))
self.toolchange = None
self.last = {}
self.gcode.register_command(
'TOOL_OFFSET_CALIBRATE', self.cmd_TOOL_OFFSET_CALIBRATE,
desc=self.cmd_TOOL_OFFSET_CALIBRATE_help)
self.gcode.register_command(
'STATION_CALIBRATE', self.cmd_STATION_CALIBRATE,
desc=self.cmd_STATION_CALIBRATE_help)
self.gcode.register_command(
'TOOL_OFFSET_STATUS', self.cmd_TOOL_OFFSET_STATUS,
desc=self.cmd_TOOL_OFFSET_STATUS_help)
self.printer.register_event_handler('klippy:connect',
self._handle_connect)
# ---------------- plumbing ----------------
def _handle_connect(self):
missing = []
for axis in 'XYZ':
obj = self.printer.lookup_object('e_stop %s' % axis, None)
if obj is None or not hasattr(obj, 'run_probe'):
missing.append('e_stop %s' % axis)
self.estop[axis] = obj
if missing:
raise self.printer.config_error(
"%s: required sections not found: %s (the station sensor is"
" exposed through the fork's [e_stop X|Y|Z])"
% (self.name, ", ".join(missing)))
self.toolchange = self.printer.lookup_object('ff_toolchange', None)
def _run(self, script):
self.gcode.run_script_from_command(script)
def _wait_moves(self):
self.printer.lookup_object('toolhead').wait_moves()
def _cylinder(self):
x, y = self.cfg_cylinder_x, self.cfg_cylinder_y
if x is None:
x = CYLINDER_X_DEFAULT
if y is None:
y = CYLINDER_Y_DEFAULT
return x, y
def _check_homed(self, gcmd):
th = self.printer.lookup_object('toolhead')
homed = th.get_status(self.reactor.monotonic())['homed_axes']
if not all(a in homed for a in 'xyz'):
raise gcmd.error("%s: home all axes first (homed: '%s')"
% (self.name, homed))
def _current_accel(self):
th = self.printer.lookup_object('toolhead')
return th.get_status(self.reactor.monotonic())['max_accel']
def _estop(self, gcmd, axis, target):
"""One ESTOP AXES=<axis> TARGET=<target>, calling the fork's e_stop
object directly instead of parsing its JSON reply. Same code path as
the command: run_probe() takes sub_cycle_cnt samples (3 in
printer.base.cfg) each retracting back_v, rejects a spread above
error_v, and raises on no-trigger / triggered-before-move."""
obj = self.estop[axis]
obj.position_offset = target
try:
pos = obj.run_probe(gcmd)
except self.printer.command_error as e:
raise FFToolOffsetError("ESTOP %s TARGET=%.3f failed: %s"
% (axis, target, e))
if pos is None:
raise FFToolOffsetError("ESTOP %s returned no position" % axis)
return float(pos)
# ---------------- the recovered sequence ----------------
def _four_points(self, gcmd, x0, y0, return_feed, pass2):
"""One 4-point pass around (x0, y0) at the current Z.
Order and return moves are the app's: +X, +Y, -X, -Y, always from the
centre outward, straight back through the centre between points (pass
1: one G1 X Y F1200; pass 2: G1 Y then G1 X at F2400). No Z lift."""
def back():
if pass2:
self._run('G1 Y%.3f F%d' % (y0, return_feed))
self._run('G1 X%.3f F%d' % (x0, return_feed))
else:
self._run('G1 X%.3f Y%.3f F%d' % (x0, y0, return_feed))
self._run('M400')
d = self.probe_travel
px1 = self._estop(gcmd, 'X', x0 + d)
back()
py2 = self._estop(gcmd, 'Y', y0 + d)
back()
px3 = self._estop(gcmd, 'X', x0 - d)
back()
py4 = self._estop(gcmd, 'Y', y0 - d)
back()
pts = [(px1, y0), (x0, py2), (px3, y0), (x0, py4)]
for i, p in enumerate(pts):
gcmd.respond_info(" Point%d: %.4f, %.4f" % (i + 1, p[0], p[1]))
try:
cx, cy, r, resid = fit_circle(pts)
except ValueError as e:
raise FFToolOffsetError("circle fit failed: %s" % e)
worst = max(abs(e) for e in resid)
gcmd.respond_info(" centre %.4f, %.4f radius %.4f max residual %.4f"
% (cx, cy, r, worst))
if self.max_residual and worst > self.max_residual:
raise FFToolOffsetError(
"fit residual %.4f exceeds max_residual %.4f -- a probe"
" mis-triggered; nothing saved" % (worst, self.max_residual))
if self.min_radius and r < self.min_radius:
raise FFToolOffsetError("fitted radius %.4f below min_radius" % r)
if self.max_radius and r > self.max_radius:
raise FFToolOffsetError("fitted radius %.4f above max_radius" % r)
return cx, cy, r
def _z_target_for(self, nominal, expected):
"""Stop z_margin below a known trigger height instead of going all
the way to the nominal target."""
if expected is None:
return nominal
return max(nominal, expected - self.z_margin)
def _require_plate_removed(self, gcmd):
if not gcmd.get_int('PLATE_REMOVED', 0, minval=0, maxval=1):
raise gcmd.error(
"%s: the station sits BELOW the bed plane and the Z probe"
" cannot tell a build plate from thin air. Take the PEI sheet"
" off, then repeat with PLATE_REMOVED=1." % self.name)
def _two_pass(self, gcmd, x0, y0, z_target_2, expected_z=None):
"""moveCylinderPos + both passes. Returns (cx2, cy2, zP2).
Caller has already put the right thing on the carriage (tool or
nothing) and ensured we are homed. expected_z, when known, bounds
both Z probes to expected_z - z_margin."""
z_t1 = self._z_target_for(self.z_target, expected_z)
z_t2 = self._z_target_for(z_target_2, expected_z)
# moveCylinderPos: zero the G-code offset (the grab applied this
# tool's SET_GCODE_OFFSET), lift, travel, Z probe.
self._run('SET_GCODE_OFFSET X=0 Y=0 Z=0 MOVE=0 MOVE_SPEED=600')
self._run('M400')
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
self._run('G1 X%.3f Y%.3f F%d' % (x0, y0, FEED_POSITION))
self._run('SET_VELOCITY_LIMIT ACCEL=%.0f' % self.probe_accel)
self._run('M400')
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
gcmd.respond_info(" Z probe to %.3f" % z_t1)
zp = self._estop(gcmd, 'Z', z_t1)
gcmd.respond_info(" Z probe pos: %.4f" % zp)
# pass 1
self._run('G1 X%.3f Y%.3f F%d' % (x0, y0, FEED_PASS1))
self._run('G1 Z%.3f F%d' % (zp + self.z_clear, FEED_PASS1))
self._run('M400')
gcmd.respond_info(" pass 1 around %.3f, %.3f at Z %.3f"
% (x0, y0, zp + self.z_clear))
cx, cy, _r = self._four_points(gcmd, x0, y0, FEED_PASS1, False)
# G-code is formatted to 3 decimals (the app's float_to_string(v, 3)),
# so the centre we actually return to is the rounded one; use that
# for the pass-2 point coordinates too.
cx, cy = round(cx, 3), round(cy, 3)
# pass 2: centred on the fit, Z re-probed there
self._run('G1 Z%.3f F%d' % (zp + self.z_lift, FEED_PASS1))
self._run('G1 Y%.3f F%d' % (cy, FEED_PASS2))
self._run('G1 X%.3f F%d' % (cx, FEED_PASS2))
self._run('M400')
zp2 = self._estop(gcmd, 'Z', self._z_target_for(z_t2, zp))
gcmd.respond_info(" double Z probe pos: %.4f" % zp2)
self._run('G1 Z%.3f F%d' % (zp2 + self.z_clear, FEED_PASS1))
self._run('M400')
gcmd.respond_info(" pass 2 around %.3f, %.3f at Z %.3f"
% (cx, cy, zp2 + self.z_clear))
cx2, cy2, _r2 = self._four_points(gcmd, cx, cy, FEED_PASS2, True)
self._run('G1 Z%.3f F%d' % (self.z_final, FEED_PASS1))
self._run('M400')
return cx2, cy2, zp2
def _with_accel_guard(self, gcmd, body):
"""Run body() with the app's exit block guaranteed: lift, restore
accel. The app does G1 Z10 on an ESTOP failure and Z15 on success."""
restore = self.accel_restore
if restore is None:
restore = self._current_accel()
try:
return body()
except FFToolOffsetError as e:
try:
self._run('G1 Z%.3f F%d' % (self.z_start, FEED_PASS1))
self._run('M400')
except self.printer.command_error:
pass
raise gcmd.error("%s: %s" % (self.name, e))
finally:
try:
self._run('SET_VELOCITY_LIMIT ACCEL=%.0f' % restore)
except self.printer.command_error:
pass
# ---------------- persistence ----------------
def set_station(self, x, y, z):
self.station = (float(x), float(y), float(z))
configfile = self.printer.lookup_object('configfile')
configfile.set(self.name, 'station_x', "%.6f" % x)
configfile.set(self.name, 'station_y', "%.6f" % y)
configfile.set(self.name, 'station_z', "%.6f" % z)
def _refresh_toolchange(self, gcmd):
if self.toolchange is not None \
and hasattr(self.toolchange, 'refresh_offsets'):
self.toolchange.refresh_offsets(gcmd)
# ---------------- commands ----------------
cmd_TOOL_OFFSET_CALIBRATE_help = (
"Measure a tool's nozzle position against the station "
"(TOOL=<0..3|ALL> PLATE_REMOVED=1 [GRAB=1] [RELEASE=0] [SAVE=1])")
def cmd_TOOL_OFFSET_CALIBRATE(self, gcmd):
raw = gcmd.get('TOOL')
if raw.upper() == 'ALL':
tools = list(range(EXTRUDER_COUNT))
else:
try:
t = int(raw)
except ValueError:
raise gcmd.error("TOOL must be 0..%d or ALL"
% (EXTRUDER_COUNT - 1))
if not 0 <= t < EXTRUDER_COUNT:
raise gcmd.error("TOOL must be 0..%d or ALL"
% (EXTRUDER_COUNT - 1))
tools = [t]
grab = gcmd.get_int('GRAB', 1, minval=0, maxval=1)
release = gcmd.get_int('RELEASE', 0, minval=0, maxval=1)
save = gcmd.get_int('SAVE', 1, minval=0, maxval=1)
if (grab or release) and self.toolchange is None:
raise gcmd.error("%s: [ff_toolchange] not loaded -- cannot pick"
" up tools. Mount the tool by hand and pass"
" GRAB=0 RELEASE=0." % self.name)
self._require_plate_removed(gcmd)
self._check_homed(gcmd)
cyl_x, cyl_y = self._cylinder()
x0, y0 = cyl_x - self.nozzle_x_shift, cyl_y
results = {}
for tool in tools:
gcmd.respond_info("T%d: offset calibration, start %.3f, %.3f"
% (tool, x0, y0))
if grab:
# changeExtruderManager(ext, true, false): releases whatever
# is mounted, grabs `tool`, applies its G-code offsets
# (zeroed again by moveCylinderPos below).
self._run('T%d' % tool)
self._wait_moves()
t = self.tools[tool]
expected_z = t.nozzle[2] if t.calibrated() else None
if expected_z is None and self.station is not None:
# nozzle fires ~3.2 mm above the empty-carriage trigger
expected_z = self.station[2] + 0.5 * (self.gap_min
+ self.gap_max)
def body():
return self._two_pass(gcmd, x0, y0, self.z_target,
expected_z)
cx, cy, zp = self._with_accel_guard(gcmd, body)
if self.station is not None and (self.gap_min or self.gap_max):
gap = zp - self.station[2]
if gap < self.gap_min or gap > self.gap_max:
raise gcmd.error(
"%s: T%d nozzle_z %.3f is %.3f above station_z %.3f,"
" outside gap_min/gap_max [%.2f, %.2f] -- probe"
" mis-trigger suspected, nothing saved"
% (self.name, tool, zp, gap, self.station[2],
self.gap_min, self.gap_max))
results[tool] = (cx, cy, zp)
self.last['t%d' % tool] = (cx, cy, zp)
gcmd.respond_info("T%d: offset = (%.4f, %.4f, %.4f)"
% (tool, cx, cy, zp))
if save:
self.tools[tool].set_nozzle(cx, cy, zp)
if release:
self._run('TOOLCHANGE_PARK')
self._wait_moves()
# the app's exit block: heater off for the tool(s), Z15
for tool in tools:
self._run('M104 S0 T%d' % tool)
self._run('G1 Z%.3f F%d' % (self.z_final, FEED_PASS1))
self._run('M400')
if save:
self._refresh_toolchange(gcmd)
gcmd.respond_info(
"The SAVE_CONFIG command will update the printer config file"
" with the new nozzle position(s) and restart the printer.")
self._report_diffs(gcmd, results)
cmd_STATION_CALIBRATE_help = (
"Measure the station position with an EMPTY carriage "
"(station_x/y/z) (PLATE_REMOVED=1 [PARK=1] [SAVE=1])")
def cmd_STATION_CALIBRATE(self, gcmd):
park = gcmd.get_int('PARK', 1, minval=0, maxval=1)
save = gcmd.get_int('SAVE', 1, minval=0, maxval=1)
self._require_plate_removed(gcmd)
self._check_homed(gcmd)
if self.toolchange is not None:
if park:
# releaseFourExtruder: the carriage must be empty for TS.
self._run('TOOLCHANGE_PARK')
self._wait_moves()
cur = self.toolchange.get_status(self.reactor.monotonic())
if cur.get('current_tool', -1) != -1 or not cur.get('state_ok'):
raise gcmd.error(
"%s: carriage is not verifiably empty (%s) -- the station"
" pass must run with no tool mounted"
% (self.name, cur.get('state_reason')))
elif park:
raise gcmd.error("%s: [ff_toolchange] not loaded -- park the tool"
" by hand and pass PARK=0." % self.name)
cyl_x, cyl_y = self._cylinder()
gcmd.respond_info("station calibration, start %.3f, %.3f"
% (cyl_x, cyl_y))
expected_z = self.station[2] if self.station is not None else None
def body():
return self._two_pass(gcmd, cyl_x, cyl_y, self.z_target_station_2,
expected_z)
cx, cy, zp = self._with_accel_guard(gcmd, body)
self.last['station'] = (cx, cy, zp)
gcmd.respond_info("station = (%.4f, %.4f, %.4f)" % (cx, cy, zp))
if save:
self.set_station(cx, cy, zp)
self._refresh_toolchange(gcmd)
gcmd.respond_info(
"The SAVE_CONFIG command will update the printer config file"
" with the new station position and restart the printer.")
cmd_TOOL_OFFSET_STATUS_help = "Show the configured nozzle/station positions"
def cmd_TOOL_OFFSET_STATUS(self, gcmd):
cyl_x, cyl_y = self._cylinder()
lines = ["station start (cylinder_x/y): %.3f, %.3f" % (cyl_x, cyl_y)]
for t in self.tools:
if t.calibrated():
lines.append("[ff_tool %d] nozzle %.4f, %.4f, %.4f"
% (t.index, t.nozzle[0], t.nozzle[1],
t.nozzle[2]))
else:
lines.append("[ff_tool %d] nozzle NOT CALIBRATED" % t.index)
if self.station is not None:
lines.append("[%s] station %.4f, %.4f, %.4f"
% ((self.name,) + self.station))
else:
lines.append("[%s] station NOT CALIBRATED" % self.name)
configfile = self.printer.lookup_object('configfile')
st = configfile.get_status(self.reactor.monotonic())
if st.get('save_config_pending'):
lines.append("! unsaved calibration pending -- run SAVE_CONFIG")
gcmd.respond_info("\n".join(lines))
def _report_diffs(self, gcmd, results):
"""Tool-to-tool differences against T0, the quantity the toolchanger
applies (ff_toolchange._derive_offsets, base T0)."""
base = None
if 0 in results:
base = results[0]
elif self.tools[0].calibrated():
base = self.tools[0].nozzle
if base is None:
return
lines = ["differences vs T0 (what a toolchange applies):"]
for tool, (cx, cy, zp) in sorted(results.items()):
lines.append(" T%d: dX %+.4f dY %+.4f dZ %+.4f"
% (tool, cx - base[0], cy - base[1], zp - base[2]))
gcmd.respond_info("\n".join(lines))
def get_status(self, eventtime):
out = {}
for k, v in self.last.items():
out[k] = {'x': v[0], 'y': v[1], 'z': v[2]}
sx, sy, sz = self.station if self.station else (None, None, None)
return {'last': out, 'station_x': sx, 'station_y': sy,
'station_z': sz}
def load_config(config):
return FFToolOffset(config)
+178 -385
View File
@@ -16,20 +16,15 @@
# switch state; E0145 "Lock motor current abnormal" is raised Klipper/MCU-side.
# Driving the same MOTOR_* macros therefore inherits that behaviour unchanged.
#
# Install: copy to /usr/prog/klipper/klippy/extras/ff_toolchange.py, then in
# /usr/data/config/printer.cfg:
# [ff_toolchange]
# dock_x: ...
# Install: copy ff_tool.py and ff_toolchange.py to
# /usr/prog/klipper/klippy/extras/, then see config/ff-toolchange.cfg.
#
# Klipper calls Tn only for lines that reach the gcode engine; a bare "Tn" in a
# printing file is still swallowed by the FlashForge virtual_sdcard fork, so
# touchscreen jobs are unaffected.
import contextlib
import json
import logging
import math
import os
EXTRUDER_COUNT = 4
@@ -49,12 +44,6 @@ RELEASE_RETRIES = 3 # MOTOR_RELEASE sends per attempt (fail on 3rd)
RELEASE_STAGE_BACKOFF = 10.0 # release staging "G1 X<dock-10>" (@0xdb6af8)
PULLBACK_FEED = 4800 # grab pullback's literal " F4800" (@0xdb4d40)
# Feed fallbacks (mm/min) the app uses when testConfig() has no usable value.
FAST_FEED_DEFAULT = 24000.
SLOW_FEED_DEFAULT = 6000. # grab side
RELEASE_SLOW_FEED_DEFAULT = 5400. # release reads the same grabSpeedSlow but
# substitutes 5400 (0x1518), not 6000
# Firmware error codes. Per-tool families report E<base + tool>; both grab
# and release map through the LANG_SRC table built at 0x689838.
ERR_NOT_IN_DOCK_BASE = 127 # E0127+t tool not detected in its dock (grab)
@@ -65,197 +54,19 @@ ERR_RELEASE_STATE = 144 # E0144 state error after release verify
# ---------------------------------------------------------------------------
# FlashForge's own per-unit configuration.
# Where the numbers come from
#
# The values this module needs already exist on the printer, written by the
# factory calibration and maintained by firmwareExe. Duplicating them into a
# Klipper .cfg guarantees the two drift apart the moment anything recalibrates,
# so we read the originals and treat the .cfg purely as an override layer.
#
# Config::extruderConfig() / testConfig() / zOffsetConfig() are backed by these
# files. Note they are NOT strict JSON: each ends with a trailing C-style
# comment after the closing brace ("/* Printer Extruder Offset Config */"),
# which makes json.loads() raise "Extra data". raw_decode() stops at the end of
# the first value and ignores the tail.
#
# There is exactly ONE config directory. initManagers() @0x412efc checks for
# /usr/data/config/test.json and, if present, FileManager::copy()s it into
# /usr/data/firmwareRes/config/ and then FileManager::remove()s the original --
# it is a one-shot drop-in install hook, not a second search path. Every reader
# then calls Config::load("/usr/data/firmwareRes/config") exactly once.
#
# Key names and their order were read off the binary's symbol table and the
# load functions themselves (Config::loadExtruderConfig @0x64f7f8,
# loadTestConfig @0x654380, loadZOffsetConfig @0x65621c), so the struct offsets
# quoted below are the app's real layout:
# t0..t3_offset_x/y/z +0x00..+0x2c
# x_check_pos ,y_check_pos +0x30/+0x34 (X and Y interleaved per tool)
# x_check_pos1,y_check_pos1 +0x38/+0x3c
# x_check_pos2,y_check_pos2 +0x40/+0x44
# x_check_pos3,y_check_pos3 +0x48/+0x4c
# x/y/z_station_pos +0x50/+0x54/+0x58
# now_extruder +0x5c
# test.json: grabSpeed +0, grabSpeedSlow +4, grabOffset +8
#
# We never WRITE these files. Config::syncExtruderConfig @0x651388 rewrites the
# whole of extruder.json from the app's in-memory struct, so anything we wrote
# would be silently clobbered (and could revert unrelated keys). We do not
# mirror now_extruder either -- the mounted tool is derived from the dock
# sensors on demand and nothing is stored. See OKF/34-mounted-tool-state.md.
# Everything per-unit lives in Klipper's own config:
# [ff_tool <n>] dock_x, dock_y, z_adjust (ff_tool.py)
# nozzle_x/y/z <- TOOL_OFFSET_CALIBRATE via SAVE_CONFIG
# [ff_tool_offset] station_x/y/z <- STATION_CALIBRATE via SAVE_CONFIG
# [ff_toolchange] feeds, x_correction, temp_offset, staging positions
# firmwareExe's JSON (extruder.json / test.json / zoffset.json) is no longer
# read at runtime; ff_legacy.py's FF_IMPORT_FIRMWARE_CONFIG copies the
# factory numbers into the layout above once.
# ---------------------------------------------------------------------------
FIRMWARE_CONFIG_DIR = '/usr/data/firmwareRes/config'
class FFFirmwareConfig:
"""Read-only view of firmwareExe's per-unit JSON configuration."""
def __init__(self, directory):
self.dir = directory
self.files = {}
self.errors = []
self.load()
def load(self):
self.files = {}
self.errors = []
for name in ('extruder', 'test', 'zoffset'):
path = os.path.join(self.dir, name + '.json')
try:
with open(path, 'r') as fh:
text = fh.read()
except IOError as e:
self.errors.append("%s: %s" % (path, e))
continue
try:
# raw_decode, not loads -- see the note above.
obj, _ = json.JSONDecoder().raw_decode(text.lstrip())
except ValueError as e:
self.errors.append("%s: not valid JSON (%s)" % (path, e))
continue
if not isinstance(obj, dict):
self.errors.append("%s: expected a JSON object" % path)
continue
self.files[name] = obj
logging.info("ff_toolchange: loaded %s (%d keys)",
path, len(obj))
for msg in self.errors:
logging.warning("ff_toolchange: %s", msg)
# -- primitives ---------------------------------------------------------
def _num(self, fname, key):
obj = self.files.get(fname)
if obj is None:
return None
val = obj.get(key)
if isinstance(val, bool) or not isinstance(val, (int, float)):
return None
if not math.isfinite(val):
logging.warning("ff_toolchange: %s.json: %s is not finite (%r),"
" ignoring", fname, key, val)
return None
return float(val)
def _series(self, fname, keys):
"""All-or-nothing -- a DELIBERATE divergence from the app.
Config::loadExtruderConfig reads each key as
Json::Value::get(key, <current member>), so a missing key silently
falls back to the app's compiled-in init default (initExtruderConfig
@0x65119c: x_check_pos 298.219, x_check_pos1 298.605, ...). Those are
generic values, ~1.7 mm from this unit's calibration, and substituting
one into a dock approach would drive the carriage to the wrong place
with no indication anything was wrong.
We would rather fail loudly, so a series is used only if every key of
it is present."""
vals = [self._num(fname, k) for k in keys]
if any(v is None for v in vals):
return None
return vals
# -- the quantities the toolchanger needs -------------------------------
def dock_x(self):
# extruderConfig() +0x30/+0x38/+0x40/+0x48
return self._series('extruder', ['x_check_pos', 'x_check_pos1',
'x_check_pos2', 'x_check_pos3'])
def dock_y(self):
# extruderConfig() +0x34/+0x3c/+0x44/+0x4c
return self._series('extruder', ['y_check_pos', 'y_check_pos1',
'y_check_pos2', 'y_check_pos3'])
def x_correction(self):
# testConfig()+8
return self._num('test', 'grabOffset')
def _speed(self, key, fallback):
# The app reads a mm/s word and multiplies by 60, falling back to a
# constant when the stored value is <= 0.
val = self._num('test', key)
if val is None:
return None
val *= 60.0
return val if val > 0 else float(fallback)
def fast_feed(self):
# testConfig()+0
return self._speed('grabSpeed', FAST_FEED_DEFAULT)
def slow_feed(self):
# testConfig()+4
return self._speed('grabSpeedSlow', SLOW_FEED_DEFAULT)
def slow_feed_release(self):
# doReleaseExtruderLatest reads the same testConfig()+4 word but its
# <= 0 fallback is 5400, not the grab side's 6000.
return self._speed('grabSpeedSlow', RELEASE_SLOW_FEED_DEFAULT)
def offset_z(self):
# zoffset.json is 1-BASED: z_offset_t1 is tool 0.
return self._series('zoffset',
['z_offset_t%d' % (i + 1) for i in range(4)])
def tool_offsets(self):
"""t0..t3_offset_x/y/z, exactly as OffsetMgr::rebootInitValue @0x6593e8
loads them into its map: map[Tn] = extruderConfig()+{0,4,8} + 0xc*n.
No transformation is applied there and none is applied here."""
rows = []
for n in range(EXTRUDER_COUNT):
v = [self._num('extruder', 't%d_offset_%s' % (n, a))
for a in ('x', 'y', 'z')]
if any(x is None for x in v):
return None
rows.append(v)
return rows
def z_station_pos(self):
# extruderConfig()+0x58 -- the Z at which the EDDY probe triggered
# over the fixed nozzle-touch sensor during calibration (the sensor
# sits below the bed plane; negative on this unit). t<n>_offset_z is
# the Z at which tool n's NOZZLE touched that same sensor, so
# (t<n>_offset_z - z_station_pos) is the absolute gap between the
# eddy trigger plane and tool n's nozzle plane -- the core of the
# print-start Z offset (BuildPage::startPrint @0x9fc148).
return self._num('extruder', 'z_station_pos')
def temp_offset(self):
# testConfig()+0xc -- degC-to-mm thermal compensation factor in the
# print-start Z offset: (nozzle_temp - 120) * tempOffset.
return self._num('test', 'tempOffset')
def now_extruder(self):
# extruderConfig()+0x5c -- the app's own active-tool index.
val = self._num('extruder', 'now_extruder')
# _num already rejects non-finite values, but guard int() anyway.
if val is None or not math.isfinite(val):
return None
return int(val)
class FFToolchangeError(Exception):
"""A toolchange step failed or the sensors report an unusable state."""
@@ -267,67 +78,33 @@ class FFToolchange:
self.gcode = self.printer.lookup_object('gcode')
self.name = config.get_name()
# --- where the numbers come from -----------------------------------
# Precedence, highest first:
# 1. an option explicitly set in this printer.cfg section
# 2. firmwareExe's own per-unit JSON under firmware_config_dir
# 3. the app's hardcoded fallback
# So a stock machine needs no geometry in the .cfg at all, and anything
# you do write here is an intentional override of the factory value.
self.fw_dir = config.get('firmware_config_dir', FIRMWARE_CONFIG_DIR)
self.fw = FFFirmwareConfig(self.fw_dir)
self.sources = {}
def _resolve(option, fw_value, fallback, getter):
"""Return (value, provenance) honouring the precedence above."""
cfg_value = getter(option, None)
if cfg_value is not None:
self.sources[option] = 'printer.cfg'
return cfg_value
if fw_value is not None:
self.sources[option] = 'firmware'
return fw_value
self.sources[option] = 'default'
return fallback
def scalar(option, fw_value, fallback):
return _resolve(option, fw_value, fallback, config.getfloat)
def series(option, fw_value, fallback):
def getter(opt, _default):
raw = config.get(opt, None)
if raw is None:
return None
vals = [float(v.strip()) for v in raw.split(',')]
if len(vals) != EXTRUDER_COUNT:
raise config.error(
"%s: %s needs %d comma-separated values"
% (self.name, opt, EXTRUDER_COUNT))
return vals
return _resolve(option, fw_value, fallback, getter)
# Per-unit dock geometry -- extruderConfig() +0x30..+0x4c.
self.dock_x = series('dock_x', self.fw.dock_x(), None)
self.dock_y = series('dock_y', self.fw.dock_y(), None)
if self.dock_x is None or self.dock_y is None:
raise config.error(
"%s: dock coordinates unavailable -- %s could not be read (%s)."
" Set dock_x/dock_y explicitly, or point firmware_config_dir at"
" a copy of the printer's firmwareRes/config directory."
% (self.name, self.fw_dir,
"; ".join(self.fw.errors) or "keys missing"))
# testConfig()+8.
self.x_correction = scalar('x_correction', self.fw.x_correction(), 0.0)
# Per-tool sections. load_object() resolves them regardless of the
# order they appear in printer.cfg.
self.tools = []
for i in range(EXTRUDER_COUNT):
try:
self.tools.append(
self.printer.load_object(config, 'ff_tool %d' % i))
except Exception:
raise config.error(
"%s: section [ff_tool %d] is required (dock_x/dock_y);"
" run FF_IMPORT_FIRMWARE_CONFIG from ff_legacy.py to"
" generate it from the factory JSON" % (self.name, i))
self.dock_x = [t.dock_x for t in self.tools]
self.dock_y = [t.dock_y for t in self.tools]
# testConfig()+8 grabOffset in the app.
self.x_correction = config.getfloat('x_correction', 0.0)
# degC-to-mm thermal term of the print-start Z offset
# ((nozzle_temp - 120) * temp_offset); testConfig()+0xc in the app.
self.temp_offset = config.getfloat('temp_offset', 0.00045)
# Per-tool G-code offsets -- differences against a base tool, exactly
# as CommMgr::setGrabGcodeOffsetMgr computes them. See _derive_offsets
# and OKF/33-per-tool-offsets.md.
self.offset_base = config.getint('offset_base', 0,
minval=0, maxval=EXTRUDER_COUNT - 1)
fx, fy, fz = self._derive_offsets(self.offset_base)
self.off_x = series('offset_x', fx, [0.0] * EXTRUDER_COUNT)
self.off_y = series('offset_y', fy, [0.0] * EXTRUDER_COUNT)
self.off_z = series('offset_z', fz, [0.0] * EXTRUDER_COUNT)
self.off_x = self.off_y = self.off_z = [0.0] * EXTRUDER_COUNT
self.refresh_offsets()
# The tool-derived part of the Z offset we last applied. The remainder
# of Klipper's gcode Z offset is the user's babystep, which the app
# keeps separately at CommMgr+0x130 and re-adds on every change so a
@@ -339,15 +116,14 @@ class FFToolchange:
self.x_approach = config.getfloat('x_approach', 280.0)
self.grab_pullback = config.getfloat('grab_pullback', 20.0)
# Feeds. testConfig()+0 and +4 are mm/s and get multiplied by 60, with
# the app falling back to 24000/6000 when the stored value is <= 0.
self.fast_feed = int(scalar('fast_feed', self.fw.fast_feed(),
FAST_FEED_DEFAULT))
self.slow_feed = int(scalar('slow_feed', self.fw.slow_feed(),
SLOW_FEED_DEFAULT))
self.release_slow_feed = int(scalar(
'release_slow_feed', self.fw.slow_feed_release(),
RELEASE_SLOW_FEED_DEFAULT))
# Feeds (mm/min). The app reads grabSpeed/grabSpeedSlow (mm/s) from
# test.json and multiplies by 60 -- 500/90 on this unit, i.e.
# 30000/5400 -- falling back to 24000/6000 (5400 on release) when the
# stored value is <= 0. Defaults here are the factory numbers.
self.fast_feed = config.getint('fast_feed', 30000, minval=1)
self.slow_feed = config.getint('slow_feed', 5400, minval=1)
self.release_slow_feed = config.getint('release_slow_feed', 5400,
minval=1)
self.grab_retreat_feed = config.getint('grab_retreat_feed', 1500)
self.release_retreat_feed = config.getint('release_retreat_feed', 4800)
self.accel_move = config.getint('accel_move', 8000)
@@ -398,12 +174,12 @@ class FFToolchange:
self.gcode.register_command(
'TOOLCHANGE_PARK', self.cmd_TOOLCHANGE_PARK,
desc=self.cmd_TOOLCHANGE_PARK_help)
self.gcode.register_command(
'TOOLCHANGE_RELOAD', self.cmd_TOOLCHANGE_RELOAD,
desc=self.cmd_TOOLCHANGE_RELOAD_help)
self.gcode.register_command(
'TOOLCHANGE_SET_PRINT_OFFSET', self.cmd_TOOLCHANGE_SET_PRINT_OFFSET,
desc=self.cmd_TOOLCHANGE_SET_PRINT_OFFSET_help)
self.gcode.register_command(
'TOOL_Z_ADJUST', self.cmd_TOOL_Z_ADJUST,
desc=self.cmd_TOOL_Z_ADJUST_help)
self.printer.register_event_handler('klippy:connect',
self._handle_connect)
@@ -412,41 +188,59 @@ class FFToolchange:
def _derive_offsets(self, base):
"""Per-tool G-code offsets, as CommMgr::setGrabGcodeOffsetMgr @0x77f1dc
computes them.
computes them:
X = t<tool>_offset_x - t<base>_offset_x
Y = t<tool>_offset_y - t<base>_offset_y
Z = z_offset_t<tool+1> + (t<tool>_offset_z - t<base>_offset_z)
X = nozzle_x[tool] - nozzle_x[base]
Y = nozzle_y[tool] - nozzle_y[base]
Z = z_adjust[tool] + (nozzle_z[tool] - nozzle_z[base])
i.e. DIFFERENCES against a base tool, not absolute positions. The base
is CommMgr+0x59c, zeroed by the constructor (so T0) and changed only by
setBaseExtruder(), which the touchscreen calls from serialPrint and
BuildPage::compareExtruderFromMf.
DIFFERENCES against a base tool (T0 by default), not absolute
positions. nozzle_* are the station-bore centre measured with each
tool's nozzle ([ff_tool n], written by TOOL_OFFSET_CALIBRATE);
z_adjust is the user's per-tool Z tune (the app's zoffset.json).
The values come from OffsetMgr, which loads extruderConfig() verbatim,
and the z term's zoffset part is CommMgr::getAdjustOffset @0x77efd4 =
zOffsetConfig()[tool].
Do NOT confuse this with CommMgr::getStationExOffset @0x79a508, which
computes (x_station_pos - tN_offset_x) ~= 12 mm. That one is only used
by the calibration dialogs and moveCylinderPos -- it is not on the
print-offset path.
Returns (xs, ys, zs), or (None, None, None) if the data is unavailable.
A tool without a calibration contributes zero offset (and is listed
by TOOLCHANGE_STATUS / warned about at ready). If the BASE tool is
uncalibrated every tool is treated as zero, since nothing can be
measured against it.
"""
tools = self.fw.tool_offsets()
zoff = self.fw.offset_z()
if tools is None:
return None, None, None
bx, by, bz = tools[base]
xs = [t[0] - bx for t in tools]
ys = [t[1] - by for t in tools]
if zoff is None:
zs = None
else:
zs = [zoff[i] + (tools[i][2] - bz) for i in range(EXTRUDER_COUNT)]
tools = self.tools
if not tools[base].calibrated():
return ([0.0] * EXTRUDER_COUNT, [0.0] * EXTRUDER_COUNT,
[t.z_adjust for t in tools])
bx, by, bz = tools[base].nozzle
xs, ys, zs = [], [], []
for t in tools:
if t.calibrated():
xs.append(t.nozzle[0] - bx)
ys.append(t.nozzle[1] - by)
zs.append(t.z_adjust + (t.nozzle[2] - bz))
else:
xs.append(0.0)
ys.append(0.0)
zs.append(t.z_adjust)
return xs, ys, zs
def refresh_offsets(self, gcmd=None):
"""Re-derive after a calibration changed an [ff_tool] live."""
fx, fy, fz = self._derive_offsets(self.offset_base)
changed = (fx, fy, fz) != (self.off_x, self.off_y, self.off_z)
self.off_x, self.off_y, self.off_z = fx, fy, fz
if gcmd is not None:
gcmd.respond_info("ff_toolchange: offsets %s"
% ("updated" if changed else "unchanged"))
return changed
def uncalibrated_tools(self):
return [t.index for t in self.tools if not t.calibrated()]
def _station_z(self):
"""station_z from [ff_tool_offset] (STATION_CALIBRATE), or None."""
st = self.printer.lookup_object('ff_tool_offset', None)
if st is None or st.station is None:
return None
return st.station[2]
# ---------------- plumbing ----------------
def _handle_connect(self):
@@ -487,12 +281,13 @@ class FFToolchange:
% (self.name, ", ".join(missing)))
def _handle_ready(self):
if any(self.sources.get(k) == 'default'
for k in ('offset_x', 'offset_y', 'offset_z')):
missing = self.uncalibrated_tools()
if missing:
self.gcode.respond_info(
"ff_toolchange: WARNING: per-tool offsets not found in"
" firmware JSON and not set in printer.cfg -- all tools"
" assume zero offset. Check %s." % self.fw_dir)
"ff_toolchange: WARNING: no nozzle calibration for %s --"
" those tools get ZERO X/Y/Z offset. Run"
" TOOL_OFFSET_CALIBRATE (or FF_IMPORT_FIRMWARE_CONFIG once)"
" and SAVE_CONFIG." % ", ".join("T%d" % i for i in missing))
def _make_tn(self, index):
def handler(gcmd):
@@ -612,7 +407,7 @@ class FFToolchange:
return self.dock_x[tool] + self.x_correction, self.dock_y[tool]
def _extruder_name(self, tool):
return 'extruder' if tool == 0 else 'extruder%d' % tool
return self.tools[tool].extruder_name
def _current_max_accel(self):
toolhead = self.printer.lookup_object('toolhead')
@@ -993,17 +788,18 @@ class FFToolchange:
current, _why = self._current_tool_or_none()
tool = current if current is not None and current >= 0 else 0
tools = self.fw.tool_offsets()
z_station = self.fw.z_station_pos()
temp_coeff = self.fw.temp_offset()
if tools is None or z_station is None or temp_coeff is None:
z_station = self._station_z()
temp_coeff = self.temp_offset
if not self.tools[tool].calibrated() or z_station is None:
raise gcmd.error(
"TOOLCHANGE_SET_PRINT_OFFSET: t*_offset_z / z_station_pos /"
" tempOffset unavailable from firmware JSON (%s) -- cannot"
"TOOLCHANGE_SET_PRINT_OFFSET: T%d nozzle_z and/or"
" [ff_tool_offset] station_z are not calibrated -- cannot"
" compute the print Z offset. Without it the eddy-homed Z is"
" several mm too low; NOT printing is the safe choice."
% self.fw_dir)
zoff = self.fw.offset_z() or [0.0] * EXTRUDER_COUNT
" several mm too low; NOT printing is the safe choice. Run"
" STATION_CALIBRATE / TOOL_OFFSET_CALIBRATE (or"
" FF_IMPORT_FIRMWARE_CONFIG) and SAVE_CONFIG." % tool)
tools = [t.nozzle or (0.0, 0.0, 0.0) for t in self.tools]
zoff = [t.z_adjust for t in self.tools]
z = tools[tool][2] - z_station + (nozzle - 120.0) * temp_coeff
if bed >= 100.0:
z += 0.08
@@ -1022,6 +818,43 @@ class FFToolchange:
' MOVE=1 MOVE_SPEED=100'
% (self.off_x[tool], self.off_y[tool], z))
cmd_TOOL_Z_ADJUST_help = (
"Per-tool persistent Z correction: TOOL_Z_ADJUST TOOL=<0..3> "
"(ADJUST=<+/-mm> | VALUE=<mm>); SAVE_CONFIG to persist")
def cmd_TOOL_Z_ADJUST(self, gcmd):
"""The per-tool counterpart of SET_GCODE_OFFSET Z_ADJUST.
Klipper's babystep is one global number; this edits [ff_tool n]
z_adjust instead, which _apply_tool_diff_offsets adds on every grab of
that tool only. If the tool is mounted right now the new value is
applied immediately through the same path (so the live babystep is
preserved), and the change is staged for SAVE_CONFIG."""
tool = gcmd.get_int('TOOL', minval=0, maxval=EXTRUDER_COUNT - 1)
adjust = gcmd.get_float('ADJUST', None)
value = gcmd.get_float('VALUE', None)
if (adjust is None) == (value is None):
raise gcmd.error("TOOL_Z_ADJUST: give exactly one of ADJUST= or"
" VALUE=")
t = self.tools[tool]
old = t.z_adjust
new = value if value is not None else old + adjust
t.set_z_adjust(new)
self.refresh_offsets()
applied = ""
try:
self._wait_moves()
current, _why = self._current_tool_or_none()
except FFToolchangeError:
current = None
if current == tool:
self._apply_tool_diff_offsets(tool)
applied = ", applied now"
gcmd.respond_info(
"T%d z_adjust %.3f -> %.3f%s. The SAVE_CONFIG command will update"
" the printer config file and restart the printer."
% (tool, old, new, applied))
cmd_TOOLCHANGE_STATUS_help = "Report toolchanger sensor state"
def cmd_TOOLCHANGE_STATUS(self, gcmd):
@@ -1044,14 +877,22 @@ class FFToolchange:
except FFToolchangeError as e:
lines.append(" grab (%s): %s" % (n, e))
lines.append("firmware config: %s" % self.fw_dir)
for msg in self.fw.errors:
lines.append(" ! %s" % msg)
lines.append("geometry ([ff_tool n] / [ff_toolchange]):")
for t in self.tools:
if t.calibrated():
lines.append(" T%d nozzle (%.4f, %.4f, %.4f) z_adjust %+.3f"
% (t.index, t.nozzle[0], t.nozzle[1],
t.nozzle[2], t.z_adjust))
else:
lines.append(" T%d nozzle NOT CALIBRATED (zero offset)"
% t.index)
sz = self._station_z()
lines.append(" station_z %s"
% ("%.4f" % sz if sz is not None else "NOT CALIBRATED"))
def series_line(option, values):
lines.append(" %-14s[%s] (%s)"
% (option, ", ".join("%.4f" % v for v in values),
self.sources.get(option)))
lines.append(" %-14s[%s]"
% (option, ", ".join("%.4f" % v for v in values)))
series_line('dock_x', self.dock_x)
series_line('dock_y', self.dock_y)
@@ -1059,27 +900,11 @@ class FFToolchange:
series_line('offset_x', self.off_x)
series_line('offset_y', self.off_y)
lines.append(" offset_base T%d" % self.offset_base)
lines.append(" x_correction %.4f (%s)"
% (self.x_correction, self.sources.get('x_correction')))
lines.append(" fast_feed %d (%s)"
% (self.fast_feed, self.sources.get('fast_feed')))
lines.append(" slow_feed %d (%s)"
% (self.slow_feed, self.sources.get('slow_feed')))
lines.append(" release_slow_feed %d (%s)"
% (self.release_slow_feed,
self.sources.get('release_slow_feed')))
# firmwareExe keeps its own active-tool index (extruderConfig()+0x5c,
# persisted as now_extruder). We do not write it -- the app holds the
# struct in memory and rewrites the whole file, so our write would be
# clobbered. Surfacing a disagreement is the useful part.
now = self.fw.now_extruder()
if now is not None:
# Informational only. The app resets its index to -1 on every
# home, so the two legitimately differ much of the time.
flag = "" if now == tool else " (differs from ours)"
lines.append(" now_extruder %d (firmwareExe's own index)%s"
% (now, flag))
lines.append(" x_correction %.4f" % self.x_correction)
lines.append(" fast_feed %d" % self.fast_feed)
lines.append(" slow_feed %d" % self.slow_feed)
lines.append(" release_slow_feed %d" % self.release_slow_feed)
lines.append(" temp_offset %.6f" % self.temp_offset)
gcmd.respond_info("\n".join(lines))
cmd_TOOLCHANGE_PARK_help = "Dock whatever tool is currently mounted"
@@ -1099,58 +924,26 @@ class FFToolchange:
except FFToolchangeError as e:
raise gcmd.error(str(e))
cmd_TOOLCHANGE_RELOAD_help = ("Re-read firmwareExe's per-unit JSON config "
"(dock coordinates, feeds, Z offsets)")
def cmd_TOOLCHANGE_RELOAD(self, gcmd):
"""Pick up recalibration done from the touchscreen without a restart.
Options explicitly set in printer.cfg still win -- reloading cannot
silently override a deliberate local override."""
self.fw.load()
changed = []
def adopt(option, attr, fw_value, fmt):
if self.sources.get(option) == 'printer.cfg' or fw_value is None:
return
if getattr(self, attr) != fw_value:
changed.append("%s: %s -> %s" % (option, fmt(getattr(self, attr)),
fmt(fw_value)))
setattr(self, attr, fw_value)
self.sources[option] = 'firmware'
def fmt_series(v):
return "[%s]" % ", ".join("%.4f" % x for x in v)
def fmt_num(v):
return "%.4f" % v
fx, fy, fz = self._derive_offsets(self.offset_base)
adopt('offset_x', 'off_x', fx, fmt_series)
adopt('offset_y', 'off_y', fy, fmt_series)
adopt('offset_z', 'off_z', fz, fmt_series)
adopt('dock_x', 'dock_x', self.fw.dock_x(), fmt_series)
adopt('dock_y', 'dock_y', self.fw.dock_y(), fmt_series)
adopt('x_correction', 'x_correction', self.fw.x_correction(), fmt_num)
ff, sf = self.fw.fast_feed(), self.fw.slow_feed()
adopt('fast_feed', 'fast_feed', None if ff is None else int(ff),
lambda v: "%d" % v)
adopt('slow_feed', 'slow_feed', None if sf is None else int(sf),
lambda v: "%d" % v)
rsf = self.fw.slow_feed_release()
adopt('release_slow_feed', 'release_slow_feed',
None if rsf is None else int(rsf), lambda v: "%d" % v)
out = ["reloaded %s" % self.fw_dir]
out += [" ! %s" % m for m in self.fw.errors]
out += [" %s" % c for c in changed] or [" no changes"]
gcmd.respond_info("\n".join(out))
def print_offset_ready(self, tool=None):
"""Can TOOLCHANGE_SET_PRINT_OFFSET succeed? Needs station_z and
nozzle_z of the tool (all tools when tool is None)."""
if self._station_z() is None:
return False
if tool is None:
return not self.uncalibrated_tools()
return self.tools[tool].calibrated()
def get_status(self, eventtime):
tool, why = self._current_tool_or_none(eventtime)
return {'current_tool': -1 if tool is None else tool,
'state_ok': tool is not None,
'state_reason': why}
'state_reason': why,
'calibrated_tools': [t.index for t in self.tools
if t.calibrated()],
'station_z': self._station_z(),
# True when every tool and the station are calibrated, i.e.
# a print's Z frame can be established for any tool.
'print_offset_ready': self.print_offset_ready()}
def load_config(config):