diff --git a/README.md b/README.md index a4a8e7c..b4a0b83 100644 --- a/README.md +++ b/README.md @@ -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= 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` 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 ]` / 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 diff --git a/config/ff-legacy.cfg b/config/ff-legacy.cfg new file mode 100644 index 0000000..aa8d451 --- /dev/null +++ b/config/ff-legacy.cfg @@ -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] diff --git a/config/ff-print-macros.cfg b/config/ff-print-macros.cfg index f604cb5..cc47d86 100644 --- a/config/ff-print-macros.cfg +++ b/config/ff-print-macros.cfg @@ -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] diff --git a/config/ff-tool-offset.cfg b/config/ff-tool-offset.cfg new file mode 100644 index 0000000..10b6a82 --- /dev/null +++ b/config/ff-tool-offset.cfg @@ -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 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_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 / 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 diff --git a/config/ff-toolchange.cfg b/config/ff-toolchange.cfg index 269a212..e0b7a9f 100644 --- a/config/ff-toolchange.cfg +++ b/config/ff-toolchange.cfg @@ -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_offset_x/y minus the base tool's -# offset_z <- zoffset.json z_offset_t (1-BASED: t1 is tool 0) -# plus extruder.json t_offset_z minus the base tool's +# [ff_tool ] 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/y_check_pos. 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_offset_x - t_offset_x -# Y = t_offset_y - t_offset_y -# Z = z_offset_t + (t_offset_z - t_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 diff --git a/docs/notes/40-offsets.md b/docs/notes/40-offsets.md index 0a3a48c..8c6f2ab 100644 --- a/docs/notes/40-offsets.md +++ b/docs/notes/40-offsets.md @@ -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 diff --git a/docs/notes/45-tool-offset-calibration.md b/docs/notes/45-tool-offset-calibration.md new file mode 100644 index 0000000..1273336 --- /dev/null +++ b/docs/notes/45-tool-offset-calibration.md @@ -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 ]` / 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` + `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. diff --git a/docs/notes/46-offset-calibration-recovered.md b/docs/notes/46-offset-calibration-recovered.md new file mode 100644 index 0000000..f8ce54b --- /dev/null +++ b/docs/notes/46-offset-calibration-recovered.md @@ -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` 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= TARGET="; 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 ": : 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_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_offset_x, y_station_pos - t_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- = 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). diff --git a/docs/notes/60-background.md b/docs/notes/60-background.md index 635826f..c73b0ae 100644 --- a/docs/notes/60-background.md +++ b/docs/notes/60-background.md @@ -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`. diff --git a/klippy-extras/ff_legacy.py b/klippy-extras/ff_legacy.py new file mode 100644 index 0000000..734f328 --- /dev/null +++ b/klippy-extras/ff_legacy.py @@ -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_offset_x/y/z +# z_adjust <- zoffset.json z_offset_t (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/y_check_pos +# [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=] [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) diff --git a/klippy-extras/ff_tool.py b/klippy-extras/ff_tool.py new file mode 100644 index 0000000..fb7a7f4 --- /dev/null +++ b/klippy-extras/ff_tool.py @@ -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 '" + % 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) diff --git a/klippy-extras/ff_tool_offset.py b/klippy-extras/ff_tool_offset.py new file mode 100644 index 0000000..182b3a0 --- /dev/null +++ b/klippy-extras/ff_tool_offset.py @@ -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_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 ] 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= 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) diff --git a/klippy-extras/ff_toolchange.py b/klippy-extras/ff_toolchange.py index 2657746..6261c4d 100644 --- a/klippy-extras/ff_toolchange.py +++ b/klippy-extras/ff_toolchange.py @@ -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" (@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; 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 ] 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, ), 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_offset_z is - # the Z at which tool n's NOZZLE touched that same sensor, so - # (t_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_offset_x - t_offset_x - Y = t_offset_y - t_offset_y - Z = z_offset_t + (t_offset_z - t_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=); 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):