mirror of
https://github.com/Klipper4FlashForge/firmware.git
synced 2026-10-22 05:12:16 -05:00
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:
1 parent
8549b86118
commit
065afec2cf
13 files changed
+1659
-465
No files matched your search
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user