Files
klipper-macros/macros/pressure_advance/bd_pa_spoolman.cfg
T
mangavalkandClaude Opus 5.5 93ae8795b8 PA calibration reads Spoolman first; guard resets a sensor left in PA mode
- LANE_CALIBRATE_PA: when a calibration will run, assign the lane's spool
  again (SET_SPOOL_ID) so AFC reads the Spoolman temperature now; AFC
  only reads it at spool assignment and Klipper start (TPU calibrated at
  210C after Spoolman was set to 230C). The calibration moved to
  _LANE_CALIBRATE_PA_RUN, rendered after the refresh.
- bdp_guard: an emergency stop during PA_CALIBRATE leaves the sensor in
  PA mode (FIRMWARE_RESTART does not reset it). When arming, read the
  mode register and switch the sensor back to probe mode with a fresh
  baseline before watching it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:04:40 +02:00

725 lines
38 KiB
INI

########################################################################
# BD Pressure Advance x AFC BoxTurtle x Spoolman Integration
# ---------------------------------------------------------------
# Built and verified using ONLY documented/source-confirmed APIs:
#
# bd_pressure (https://github.com/markniu/bd_pressure):
# PA_CALIBRATE NOZZLE_TEMP=<t> MAX_VOLUMETRIC=<v> ACC_WALL=<a>
# TRAVEL_SPEED=<s> ACC_TO_DECEL_FACTOR=<pct>
# - filament must already be loaded into the nozzle before calling
# - the command itself homes, heats, calibrates, and calls
# SET_PRESSURE_ADVANCE -- no separate heating step is needed
# Source: https://pandapi3d.cn/bdpressure/home
#
# AFC-Klipper-Add-On (https://github.com/ArmoredTurtle/AFC-Klipper-Add-On):
# printer.AFC.current_load -- a live status field (confirmed in
# klippy/extras/AFC.py get_status(): str['current_load'] = self.current)
# that always reflects whichever lane is currently loaded in the
# toolhead, regardless of how it got there (T0-T4, CHANGE_TOOL,
# BT_CHANGE_TOOL, or AFC's own infinite-spool runout remapping).
# Confirmed working live on this printer via a test macro.
# SET_SPOOL_ID LANE=.. SPOOL_ID=.. -- assigns a spool, AFC then pulls
# material/color/weight/temps from Spoolman.
# Source: https://www.armoredturtle.xyz/docs/afc-klipper-add-on/
# and confirmed directly against installed AFC source.
#
# Klipper core (https://www.klipper3d.org/Command_Templates.html):
# [save_variables] + SAVE_VARIABLE / printer.save_variables.variables
# -- persists the PA cache and lane->spool_id tracking across
# restarts. No shell_command/curl/Moonraker REST calls needed.
# [delayed_gcode] + UPDATE_DELAYED_GCODE -- used to poll
# printer.AFC.current_load every 2 seconds and detect tool changes
# (see "WHY POLLING" below for why this is the mechanism, instead
# of intercepting a tool-change command directly).
#
# WHY POLLING INSTEAD OF INTERCEPTING A TOOL-CHANGE COMMAND
# ---------------------------------------------------------------------
# This was not the first design tried. Three other approaches were
# attempted and ruled out, each one verified directly against this
# printer's installed AFC Python source (not just AFC's docs, which
# turned out to describe behavior that didn't match the installed
# version in two separate cases):
#
# 1. AFC_POST_PREP (an AFC-documented "fires after every lane load"
# macro hook): verified against source (AFC_lane.py) that this is
# tied to a lane's prep+load SENSOR transition (filament freshly
# inserted into the physical lane) and to an unset-by-default
# `post_prep_macro` config option -- not to a tool change moving
# filament into the toolhead. An empirical test on a real tool
# change confirmed it never fires for that case.
#
# 2. Wrapping CHANGE_TOOL with rename_existing: CHANGE_TOOL the gcode
# command is real (registered via register_command in AFC.py) and
# the wrapper DOES work when CHANGE_TOOL is called by that exact
# name -- confirmed working live. But T0-T4 (what slicers and
# normal use actually send) do NOT route through it: verified in
# AFC_functions.py that T(n) commands are registered with
# `self.afc.gcode.register_command(cur_lane.map, self.afc.cmd_CHANGE_TOOL, ...)`
# -- a direct reference to the original Python method captured at
# registration time. Renaming the "CHANGE_TOOL" dispatcher key has
# zero effect on these separately-registered "T0".."T4" keys.
# Confirmed empirically: CHANGE_TOOL LANE=lane1 typed directly
# applied cached PA correctly; T1 typed directly did not.
#
# 3. Wrapping T0-T4 directly with rename_existing: ruled out for two
# reasons. First, T(n) commands are registered dynamically at PREP
# time (a runtime event), not at config-load time, so
# rename_existing (config-load-time only) can't reliably target
# them. Second, even if that were solved, AFC's SET_MAP /
# RESET_AFC_MAPPING / infinite-spool-runout features rely on which
# lane a given T(n) points to being mutable at runtime -- so
# statically pinning T0-T4 via `map:` in lane config would fight
# that real, separate AFC feature.
#
# Given all of that, polling printer.AFC.current_load (a plain status
# read with no dependency on which command triggered the change) is the
# most robust mechanism available: it works identically regardless of
# whether a tool change was triggered by T0-T4, CHANGE_TOOL,
# BT_CHANGE_TOOL, or AFC's internal runout remapping, and needs no
# load-order considerations at all. The cost is a small polling delay
# (up to ~2 seconds) between a tool change finishing and PA being
# applied, instead of it happening in the same instant.
#
# OTHER PAST MISTAKES, FOR REFERENCE
# ---------------------------------------------------------------------
# - "BD_SENSOR_CALIBRATE_PA" is not a real command. Real: PA_CALIBRATE,
# with required parameters (see above).
# - "printer.afc.lanes[lane].spool_id" is not a real, readable Klipper
# status path. AFC's per-lane spool data partly lives in Moonraker's
# database (namespace "lane_data"), which a Klipper gcode_macro
# cannot reach directly (no HTTP from Jinja2 templates). This file
# tracks lane->spool_id itself instead (see _PA_TRACK_LANE_SPOOL).
# - SET_SPOOL_ID is NOT wrapped with rename_existing, because it is
# registered via register_command (confirmed against installed AFC
# source) and wrapping it failed with "Existing command 'SET_SPOOL_ID'
# not found in gcode_macro rename" when this file's [include] line
# in printer.cfg appeared BEFORE AFC's own includes. The
# companion-macro approach (_PA_TRACK_LANE_SPOOL, called manually
# right after SET_SPOOL_ID) has no such load-order dependency, at
# the small cost of one extra line each time you assign a spool.
# - Calibration nozzle temp comes from printer.extruder.target (a
# documented Klipper status field) at calibration time. AFC already
# sets this from Spoolman's nozzle_temp field when a lane is loaded
# (AFC's own documented "Setting extruder temp" feature), so this
# answers "can the calibration temp come from Spoolman" with a real
# yes, via a path that needs no new plumbing.
#
# KNOWN LIMITATIONS / THINGS TO VERIFY ON YOUR PRINTER
# ---------------------------------------------------------------------
# 1) You must remember to call _PA_TRACK_LANE_SPOOL LANE=.. SPOOL_ID=..
# every time you assign a spool to a lane (right after SET_SPOOL_ID,
# or after assigning via the Mainsail/Fluidd spool UI). If you skip
# this, LANE_CALIBRATE_PA and the auto-apply poller will report
# "no spool_id recorded yet" for that lane. PA_STATUS shows you which
# lanes are missing this.
#
# 2) PA is applied up to ~2 seconds after a tool change completes, not
# instantly, because of the polling interval. If you need it tighter,
# lower the DURATION values in _PA_POLL_LANE_CHANGE below (the
# initial_duration and both UPDATE_DELAYED_GCODE DURATION values),
# at the cost of slightly more frequent background checks.
#
# 3) If printer.AFC ever doesn't exist (AFC failed to load, or a future
# AFC version renames its status object), _PA_POLL_LANE_CHANGE will
# print one warning message and then go silent -- it will NOT spam
# the console, and it will NOT apply PA automatically until this is
# resolved. PA_STATUS and manual LANE_CALIBRATE_PA calls still work
# fine in that case; only the automatic apply-on-tool-change part is
# affected.
# REQUIRED SETUP
# ---------------------------------------------------------------------
# 1) [save_variables] is declared once for the whole repo in
# save_variables.cfg (klipper-macros.cfg includes it). It stores the
# variables of all macros (this file and Blobifier) in one file.
#
# 2) Make sure moonraker.conf has [spoolman] configured so AFC can pull
# nozzle_temp/material from Spoolman when a lane is loaded:
# [spoolman]
# server: http://<spoolman_ip>:7912
# sync_rate: 5
#
# 3) Add to printer.cfg:
# [include bd_pa_afc_spoolman.cfg]
# (Include order relative to AFC no longer matters for this version
# -- the polling approach has no load-order dependency, unlike the
# CHANGE_TOOL/SET_SPOOL_ID wrapper attempts described above.)
#
# 4) Whenever you assign a spool to a lane -- via SET_SPOOL_ID, or via
# the Mainsail/Fluidd spool-assignment UI -- also call
# _PA_TRACK_LANE_SPOOL with the same lane and spool_id, e.g.:
# SET_SPOOL_ID LANE=lane1 SPOOL_ID=12345
# _PA_TRACK_LANE_SPOOL LANE=lane1 SPOOL_ID=12345
# This is what lets the PA cache know which spool_id is in which
# lane (see KNOWN LIMITATIONS #1 above for why this is manual).
#
# USAGE
# ---------------------------------------------------------------------
# After assigning spools to lanes (step 4 above), in PRINT_START,
# after your PREP call, run:
# PA_CALIBRATE_ALL_LANES LANES="lane1,lane2,lane3,lane4,lane5"
# This calibrates (or restores from cache) PA for every lane listed
# that currently has a spool_id recorded.
#
# Per-lane PA is then applied automatically within ~2 seconds of every
# tool change, via the _PA_POLL_LANE_CHANGE background poller -- no
# action needed on your part once it's running.
#
# To force a re-calibration of one lane:
# LANE_RECAL_PA LANE=lane2
#
# To see what's cached, and which lanes are missing a tracked
# spool_id:
# PA_STATUS
########################################################################
# [save_variables] is defined once in ../save_variables.cfg (shared with Blobifier)
# The file above does not need to exist yet -- Klipper creates it on
# first SAVE_VARIABLE call and reloads it on every restart.
# ---------------------------------------------------------------------------
# USER CONFIGURATION -- edit these to match your printer
# ---------------------------------------------------------------------------
[gcode_macro _PA_CFG]
description: Central config -- edit values here
variable_lane_names: "lane1,lane2,lane3,lane4,lane5"
# Fallback nozzle temp (C) used ONLY if the extruder's target temp reads 0
# at calibration time (e.g. you called this macro before AFC set a temp)
variable_fallback_nozzle_temp: 260
# bd_pressure PA_CALIBRATE parameters -- tune to your printer/filament.
# See https://pandapi3d.cn/bdpressure/home for guidance on these values.
variable_max_volumetric: 25
variable_acc_wall: 5000
variable_travel_speed: 300
variable_acc_to_decel_factor: 50
# Minimum PA delta before we bother re-saving the cached value
variable_write_threshold: 0.002
# A calibration is rejected (nothing cached or written to Spoolman, the
# previous PA is kept) when:
# - its result is the last PA_CALIBRATE test value: bdpressure.py found no
# result and left the extruder at the end of its scan (0..49 x 0.002),
# - the printer paused during the test (e.g. an AFC filament fault),
# - bd_pressure gave fewer than min_points readings or fewer than
# min_distinct different ones (sensor silent or hung; needs
# bdpressure_check.py, otherwise this check is skipped).
variable_sweep_end_pa: 0.098
variable_min_points: 10
variable_min_distinct: 8
# PA applied when a rejected lane has no earlier cached value
variable_fallback_pa: 0.04
# Spoolman URL and the custom "extra" field name used to push calibrated
# PA values back to the spool record (so the value travels with the
# spool, not just with this printer's local save_variables cache).
# Create the field once with: _PA_SETUP_SPOOLMAN_FIELD (see below).
variable_spoolman_url: "http://localhost:7912"
variable_spoolman_field: "pa_value"
gcode: # intentionally empty -- variables only
# ---------------------------------------------------------------------------
# Spoolman write-back: push a calibrated PA value to a spool's "extra"
# field, so the value travels with the spool (e.g. if you move it to a
# different printer, or reinstall save_variables from scratch).
#
# Requires the custom field to exist once -- run _PA_SETUP_SPOOLMAN_FIELD
# (below) a single time, or create it manually:
# curl -X POST http://<spoolman_ip>:7912/api/v1/field/spool/pa_value -H "Content-Type: application/json" -d '{"name": "Pressure Advance", "field_type": "float", "default_value": "0"}'
#
# UNVERIFIED -- needs live testing the same way everything else in this
# file has needed it. In particular: confirm curl and python3 are both
# available in the shell shell_command runs in (they're standard on
# MainsailOS/FluiddOS, but worth confirming), and confirm the merge-write
# below doesn't clobber any other custom Spoolman fields you may have.
# ---------------------------------------------------------------------------
[gcode_shell_command _pa_write_spoolman]
# HISTORICAL NOTE ON ARGUMENT OFFSET (no longer applicable here, kept
# for context): an earlier version of this command ran the script
# inline via `bash -c '<script>' arg1 arg2 ...`, which makes the FIRST
# argument after the script string become $0 inside the script, not $1
# -- a well-known bash gotcha, confirmed empirically while building
# this file:
# bash -c 'echo "0=$0 1=$1 2=$2"' alpha beta gamma
# -> 0=alpha 1=beta 2=gamma (gamma, the 3rd arg, never reaches $3)
# This command now invokes a real .sh FILE instead (`bash script.sh
# arg1 arg2 ...`), which does NOT have this offset -- $1 really is the
# first argument in that invocation form (also confirmed empirically).
# So RUN_SHELL_COMMAND ... PARAMS="a b c d" maps cleanly to $1-$4 in
# the script with no placeholder needed.
#
# WHY THIS CALLS AN EXTERNAL .sh FILE INSTEAD OF EMBEDDING PYTHON
# INLINE: Klipper's config parser (Python's configparser) requires
# EVERY physical line of a multi-line value to be indented, and strips
# that indentation uniformly before passing the value to the shell.
# Confirmed empirically while building this file: this dedenting
# flattens ALL lines to the same level, which destroys the relative
# indentation Python's try/except blocks need to remain valid syntax --
# there is no way to embed multi-line, block-structured Python directly
# inside a Klipper command: value. Putting the logic in a separate .sh
# file sidesteps this entirely, since that file is never touched by
# Klipper's config parser.
#
# The script ships in this folder (klipper-macros/pressure_advance/bd_pa_write_spoolman.sh);
# install.sh makes it executable.
command: bash ~/printer_data/config/klipper-macros/pressure_advance/bd_pa_write_spoolman.sh
verbose: False
[gcode_shell_command _pa_create_spoolman_field]
# Same reasoning as _pa_write_spoolman above for using an external
# script file -- see the note there. This one has no embedded Python,
# so the only real issue was the closing quote sitting at column 0
# (un-indented), which is also a config parsing error in Klipper's
# parser -- every line of a multi-line value must be indented,
# including the very last line.
command: bash ~/printer_data/config/klipper-macros/pressure_advance/bd_pa_create_spoolman_field.sh
verbose: False
[gcode_macro _PA_SETUP_SPOOLMAN_FIELD]
description: One-time setup -- creates the pa_value custom field in Spoolman
# REQUIRES bd_pa_create_spoolman_field.sh to already exist on disk at
# ~/printer_data/config/ -- see the one-time SSH setup instructions
# given alongside this file. This macro just invokes that script; it
# does not create it.
gcode:
{% set cfg = printer["gcode_macro _PA_CFG"] %}
{action_respond_info("Creating Spoolman field '%s' at %s ..." % (cfg.spoolman_field, cfg.spoolman_url))}
RUN_SHELL_COMMAND CMD=_pa_create_spoolman_field PARAMS="{cfg.spoolman_url} {cfg.spoolman_field}"
{action_respond_info("Done. Verify with: curl %s/api/v1/field/spool" % cfg.spoolman_url)}
# ---------------------------------------------------------------------------
# Lane -> spool_id tracking
# ---------------------------------------------------------------------------
# AFC's own per-lane spool_id lives in Moonraker's database, which a
# Klipper gcode_macro cannot read directly (no HTTP from Jinja2). Instead,
# we track it ourselves with a small companion macro, _PA_TRACK_LANE_SPOOL.
#
# Two ways to keep it updated:
#
# A) AUTOMATIC -- the [gcode_macro SET_SPOOL_ID] wrapper below uses
# `rename_existing` to intercept AFC's real SET_SPOOL_ID command,
# call _PA_TRACK_LANE_SPOOL itself, then call through to AFC's
# original implementation. If this loads without error, you don't
# need to do anything extra -- just keep using SET_SPOOL_ID exactly
# as you already do (typed manually, or via Mainsail/Fluidd's UI,
# if that UI calls SET_SPOOL_ID under the hood -- unconfirmed).
#
# B) MANUAL -- call _PA_TRACK_LANE_SPOOL yourself right after every
# SET_SPOOL_ID:
# SET_SPOOL_ID LANE=lane1 SPOOL_ID=12345
# _PA_TRACK_LANE_SPOOL LANE=lane1 SPOOL_ID=12345
#
# IMPORTANT -- THIS NEEDS THE SAME LIVE VERIFICATION CHANGE_TOOL GOT:
# An earlier version of this file tried wrapping SET_SPOOL_ID the same
# way and got "Existing command 'SET_SPOOL_ID' not found in gcode_macro
# rename" -- but that was BEFORE this file's [include] line was moved to
# load after AFC's includes. The CHANGE_TOOL wrapper loaded cleanly after
# that fix, but later testing found CHANGE_TOOL's wrapper doesn't catch
# T0-T4 (a separate, real problem specific to how AFC registers per-lane
# tool-change commands -- see the WHY POLLING section near the top of
# this file). SET_SPOOL_ID has NOT been tested live the way CHANGE_TOOL
# was. It may load cleanly now and work correctly every time -- or it may
# load cleanly and still silently miss some assignment path (e.g. if the
# Mainsail/Fluidd spool-picker UI doesn't actually call SET_SPOOL_ID
# under the hood, calling some other AFC function instead). After adding
# this, test it the same way CHANGE_TOOL was tested: assign a spool, then
# immediately run PA_STATUS and confirm the new spool_id actually shows
# up WITHOUT having called _PA_TRACK_LANE_SPOOL manually. If it doesn't,
# fall back to option B (manual call) for whatever assignment path
# didn't get caught.
# ---------------------------------------------------------------------------
[gcode_macro SET_SPOOL_ID]
description: Wraps AFC's SET_SPOOL_ID to also track lane->spool_id for PA caching
rename_existing: _PA_SET_SPOOL_ID_BASE
gcode:
{% set lane = params.LANE|default("")|string %}
{% set spool_id = params.SPOOL_ID|default(0)|int %}
# Track first, in case AFC's real implementation (called below) ever
# errors or aborts partway -- we'd rather have the tracking recorded
# even if something downstream goes wrong, since SET_SPOOL_ID itself
# is what actually assigns the spool in AFC/Spoolman; our tracking is
# just bookkeeping on top of that, not a precondition for it.
{% if lane != "" %}
_PA_TRACK_LANE_SPOOL LANE={lane} SPOOL_ID={spool_id}
{% endif %}
# Call AFC's real implementation
_PA_SET_SPOOL_ID_BASE {rawparams}
[gcode_macro _PA_TRACK_LANE_SPOOL]
description: Records lane->spool_id for PA caching. Call right after SET_SPOOL_ID.
# LANE=<lane_name> SPOOL_ID=<int>
gcode:
{% set lane = params.LANE|default("")|string %}
{% set spool_id = params.SPOOL_ID|default(0)|int %}
{% if lane != "" %}
SAVE_VARIABLE VARIABLE=pa_lane_{lane}_spool_id VALUE={spool_id}
{action_respond_info("Tracked lane %s -> spool_id %d" % (lane, spool_id))}
{% else %}
_ERROR MSG="_PA_TRACK_LANE_SPOOL: LANE parameter required"
{% endif %}
# ---------------------------------------------------------------------------
# PA cache, keyed by spool_id, stored via Klipper's own save_variables.
# Requires [save_variables] (save_variables.cfg, see header notes).
#
# IMPORTANT: printer.save_variables.variables is read DIRECTLY inline in
# each macro below (not via a helper sub-macro + SET_GCODE_VARIABLE
# round-trip). This matters because Klipper's Jinja2 templates are fully
# evaluated up-front when a macro is triggered -- if macro A calls macro
# B to set a value and then macro A's own template (in the SAME trigger)
# tries to read that value back, the read happens at template-render
# time, BEFORE B has actually executed. So a pattern like:
# _PA_CACHE_GET SPOOL_ID={spool_id}
# {% set cache = printer["gcode_macro _PA_CACHE_RESULT"] %}
# would silently read stale data. Reading printer.save_variables.variables
# directly avoids this, since it is a live printer status object, not a
# value set earlier in the same template render.
# ---------------------------------------------------------------------------
[gcode_macro _PA_CACHE_SET]
description: Internal -- stores a PA value for a spool_id
# SPOOL_ID=<int> PA=<float>
gcode:
{% set spool_id = params.SPOOL_ID|default(0)|int %}
{% set pa = params.PA|default(0.0)|float %}
{% set varname = "pa_value_spool_" ~ spool_id %}
SAVE_VARIABLE VARIABLE={varname} VALUE={pa}
# ---------------------------------------------------------------------------
# Core: calibrate (or restore from cache) PA for one lane
# ---------------------------------------------------------------------------
# The lane's temperature (and material) come from Spoolman through AFC,
# but AFC only reads them when a spool is assigned or Klipper starts: a
# nozzle temperature changed in Spoolman afterwards was not used (TPU
# calibrated at 210C after Spoolman was set to 230C). When a calibration
# will run, assign the lane's spool again first so AFC reads Spoolman now.
# The calibration is a separate macro, rendered after that refresh.
[gcode_macro LANE_CALIBRATE_PA]
description: Calibrate PA for one AFC lane using bd_pressure, with caching (reads the lane's Spoolman data again first)
# LANE=<lane_name> [FORCE=1]
gcode:
{% set lane = params.LANE|default("")|string %}
{% set force = params.FORCE|default(0)|int %}
{% set vars = printer.save_variables.variables %}
{% set spool_id = vars["pa_lane_" ~ lane ~ "_spool_id"]|default(0)|int %}
{% set cached = ("pa_value_spool_" ~ spool_id) in vars %}
{% set key = "AFC_stepper " ~ lane %}
{% set afc_spool = printer[key].spool_id|default(none, true) if key in printer else none %}
{% if lane and spool_id > 0 and (force or not cached) and afc_spool %}
SET_SPOOL_ID LANE={lane} SPOOL_ID={afc_spool}
{% endif %}
_LANE_CALIBRATE_PA_RUN {rawparams}
[gcode_macro _LANE_CALIBRATE_PA_RUN]
description: Helper for LANE_CALIBRATE_PA: calibrate PA for one AFC lane using bd_pressure, with caching
# LANE=<lane_name> [FORCE=1]
gcode:
{% set lane = params.LANE|default("")|string %}
{% set force = params.FORCE|default(0)|int %}
{% set cfg = printer["gcode_macro _PA_CFG"] %}
{% set vars = printer.save_variables.variables %}
{% set lane_varname = "pa_lane_" ~ lane ~ "_spool_id" %}
{% set spool_id = vars[lane_varname]|default(0)|int if lane_varname in vars else 0 %}
{% if lane == "" %}
_ERROR MSG="LANE_CALIBRATE_PA: LANE parameter required"
{% elif spool_id == 0 %}
{action_respond_info("LANE_CALIBRATE_PA: no spool_id recorded for %s yet. Run SET_SPOOL_ID LANE=%s SPOOL_ID=<id> then _PA_TRACK_LANE_SPOOL LANE=%s SPOOL_ID=<id>." % (lane, lane, lane))}
{% else %}
{% set pa_varname = "pa_value_spool_" ~ spool_id %}
{% set cache_found = pa_varname in vars %}
{% set cached_pa = vars[pa_varname]|float if cache_found else 0.0 %}
{% if cache_found and force == 0 %}
{action_respond_info("Using cached PA=%.4f for %s (spool %d)" % (cached_pa, lane, spool_id))}
SET_PRESSURE_ADVANCE ADVANCE={cached_pa}
{% else %}
CHANGE_TOOL LANE={lane}
# Nozzle temp: the lane's own filament temperature (AFC, from
# Spoolman). Not printer.extruder.target: the template is rendered
# before CHANGE_TOOL runs, so that is still the previous lane's
# (or the print's) temperature. PA_CALIBRATE heats to it (M109).
{% set lane_obj = printer["AFC_stepper " ~ lane] if ("AFC_stepper " ~ lane) in printer else {} %}
{% set lane_temp = lane_obj.extruder_temp|default(0, true)|float %}
{% set target = printer.extruder.target|default(0)|float %}
{% set nozzle_temp = lane_temp if lane_temp > 0 else (target if target > 0 else cfg.fallback_nozzle_temp|float) %}
{action_respond_info("Calibrating PA for %s (spool %d) at %.0fC..." % (lane, spool_id, nozzle_temp))}
M117 Calibrating PA: {lane}
G1 Z50 F3000
G4 P400
G1 X30 Y349 Z10 F3000
G4 P400
G1 Z0 F3000
G4 P400
PA_CALIBRATE NOZZLE_TEMP={nozzle_temp} MAX_VOLUMETRIC={cfg.max_volumetric} ACC_WALL={cfg.acc_wall} TRAVEL_SPEED={cfg.travel_speed} ACC_TO_DECEL_FACTOR={cfg.acc_to_decel_factor}
# IMPORTANT: we do NOT read printer.extruder.pressure_advance
# here. Klipper fully evaluates a macro's Jinja2 template
# up-front, before any of the resulting gcode commands
# (including PA_CALIBRATE above) actually execute. So a
# {% set new_pa = printer.extruder.pressure_advance %} line
# placed here would capture the OLD value, from before
# calibration ran, not the new one.
# Source: https://www.klipper3d.org/Command_Templates.html
# ("Macros are first evaluated in entirety and only then are
# the resulting commands executed.")
#
# Instead we call a SEPARATE macro as a plain gcode command.
# Klipper queues and executes gcode commands strictly in
# order, each fully completing before the next one is even
# evaluated -- so by the time _PA_AFTER_CALIBRATE is
# triggered (and its own template is rendered), PA_CALIBRATE
# above has already finished and printer.extruder.pressure_advance
# is current.
_PA_AFTER_CALIBRATE LANE={lane} SPOOL_ID={spool_id} CACHE_FOUND={1 if cache_found else 0} CACHED_PA={cached_pa}
BLOBIFIER_CLEAN
G4 P400
G1 Z10 F3000
G4 P400
{% endif %}
{% endif %}
[gcode_macro _PA_AFTER_CALIBRATE]
description: Internal -- runs immediately after PA_CALIBRATE completes; reads the fresh PA value and caches it
# LANE=<lane_name> SPOOL_ID=<int> CACHE_FOUND=<0|1> CACHED_PA=<float>
gcode:
{% set lane = params.LANE|default("")|string %}
{% set spool_id = params.SPOOL_ID|default(0)|int %}
{% set cache_found = params.CACHE_FOUND|default(0)|int %}
{% set cached_pa = params.CACHED_PA|default(0.0)|float %}
{% set cfg = printer["gcode_macro _PA_CFG"] %}
# This template is evaluated only once this macro is actually
# triggered -- which happens after PA_CALIBRATE (queued earlier by
# the caller) has fully completed. So this read is fresh and correct.
{% set new_pa = printer.extruder.pressure_advance|default(0.0)|float %}
# Reject runs where the sensor or the printer had a problem
{% set problems = [] %}
{% if new_pa >= cfg.sweep_end_pa|float - 0.0001 %}
{% set _ = problems.append("no result (PA left at the end of the test range, %.3f)" % new_pa) %}
{% endif %}
{% if printer.pause_resume.is_paused %}
{% set _ = problems.append("the printer paused during the test") %}
{% endif %}
{% if 'bdpressure_check' in printer %}
{% set bdp = printer.bdpressure_check %}
{% set points = bdp.pa_points|default(-1)|int %}
{% set distinct = bdp.pa_distinct|default(-1)|int %}
{% if points >= 0 and points < cfg.min_points|int %}
{% set _ = problems.append("only %d readings from bd_pressure (sensor not answering?)" % points) %}
{% elif distinct >= 0 and distinct < cfg.min_distinct|int %}
{% set _ = problems.append("only %d different readings out of %d (sensor stuck?)" % (distinct, points)) %}
{% endif %}
{% endif %}
{% if problems %}
# A cached value at the end of the scan is an earlier failed run, not a PA
{% set use_cache = cache_found and cached_pa < cfg.sweep_end_pa|float - 0.0001 %}
{% set keep = cached_pa if use_cache else cfg.fallback_pa|float %}
_WARN MSG="PA calibration for {lane} (spool {spool_id}) rejected: {problems|join('; ')}. Nothing saved; using PA={'%.4f' % keep} ({'cached' if use_cache else 'fallback'}). Fix the cause, then LANE_CALIBRATE_PA LANE={lane} FORCE=1" DISPLAY="PA cal rejected: {lane}, see console"
SET_PRESSURE_ADVANCE ADVANCE={keep}
{% else %}
{action_respond_info("Calibration complete for %s: PA=%.4f" % (lane, new_pa))}
M117 PA cal done: {new_pa|round(4)}
{% set delta = (new_pa - cached_pa)|abs if cache_found else 999 %}
{% if delta > cfg.write_threshold|float %}
_PA_CACHE_SET SPOOL_ID={spool_id} PA={new_pa}
{action_respond_info("Writing PA=%.4f to Spoolman spool %d" % (new_pa, spool_id))}
RUN_SHELL_COMMAND CMD=_pa_write_spoolman PARAMS="{spool_id} {cfg.spoolman_url} {cfg.spoolman_field} {new_pa}"
{% else %}
{action_respond_info("PA change below threshold (%.4f), skipping cache/Spoolman write" % cfg.write_threshold|float)}
{% endif %}
{% endif %}
# ---------------------------------------------------------------------------
# Calibrate every lane in a comma-separated list (skips lanes with no
# spool_id recorded yet). Call this from PRINT_START.
# ---------------------------------------------------------------------------
[gcode_macro PA_CALIBRATE_ALL_LANES]
description: Calibrate (or restore) PA for all listed AFC lanes
# LANES="lane1,lane2,..." [FORCE=1]
# NOTE: the toolhead switch into each lane (CHANGE_TOOL LANE={lane}) now
# lives INSIDE LANE_CALIBRATE_PA itself, on the cache-miss path only --
# not here. This avoids a double tool-change per lane (one here, one
# inside LANE_CALIBRATE_PA) and means a cache HIT for a given lane skips
# the physical tool change entirely, since SET_PRESSURE_ADVANCE alone
# doesn't require the lane to actually be loaded in the toolhead.
# If you need every lane in the list physically staged/loaded regardless
# of cache state (e.g. as a pre-print warm-up), call CHANGE_TOOL LANE=
# yourself for each lane, or use FORCE=1 here to force every lane
# through the cache-miss (and therefore CHANGE_TOOL) path.
# UNVERIFIED, NEEDS LIVE TESTING:
# - CHANGE_TOOL LANE={lane} when {lane} is ALREADY the loaded lane --
# confirm it doesn't error or do anything unexpected in that case.
# - On a cache-miss run across all 5 lanes, that's up to 5 full tool
# changes plus 5 calibrations back to back. Confirm timing/wear is
# acceptable for how often you'll actually call this.
gcode:
{% set raw_lanes = params.LANES|default(printer["gcode_macro _PA_CFG"].lane_names)|string %}
{% set force = params.FORCE|default(0)|int %}
{% set lane_list = raw_lanes.split(",") %}
{action_respond_info("PA_CALIBRATE_ALL_LANES starting for: %s" % raw_lanes)}
{% for lane in lane_list %}
{% set lane = lane.strip() %}
{% set obj = "AFC_stepper " ~ lane %}
{% if lane == "" %}
{% elif obj not in printer %}
RESPOND MSG="PA_CALIBRATE_ALL_LANES: {lane} is not an AFC lane, skipped"
{% elif not (printer[obj].prep and printer[obj].load) %}
RESPOND MSG="PA_CALIBRATE_ALL_LANES: {lane} has no filament loaded, skipped"
{% else %}
LANE_CALIBRATE_PA LANE={lane} FORCE={force}
{% endif %}
{% endfor %}
# RESPOND runs in order, after the calibrations above
RESPOND MSG="PA_CALIBRATE_ALL_LANES complete."
# ---------------------------------------------------------------------------
# Tool-change detection: polling, not interception. See "WHY POLLING
# INSTEAD OF INTERCEPTING A TOOL-CHANGE COMMAND" near the top of this
# file for the full reasoning and the three approaches that were tried
# and ruled out first.
#
# Short version: printer.AFC.current_load (confirmed in AFC.py's
# get_status(): str['current_load'] = self.current) always reflects the
# currently-loaded lane regardless of how it got there, so polling it
# avoids any dependency on which specific command (T0-T4, CHANGE_TOOL,
# BT_CHANGE_TOOL, runout remapping) triggered the change.
# ---------------------------------------------------------------------------
[delayed_gcode _PA_POLL_LANE_CHANGE]
# delayed_gcode sections only support 'gcode' and 'initial_duration' --
# 'description' is not a valid option here (it's a gcode_macro-only
# field), so the explanation lives in this comment instead:
# Internal -- polls printer.AFC.current_load and applies cached PA when it changes.
initial_duration: 2
gcode:
{% set vars = printer.save_variables.variables %}
{% set last_seen = vars["pa_last_seen_lane"]|default("")|string if "pa_last_seen_lane" in vars else "" %}
# Guard against printer.AFC not existing at all (e.g. AFC failed to
# load, or this printer uses a differently-named AFC config section).
# Without this guard, a missing printer.AFC would make this whole
# delayed_gcode error out every 2 seconds, forever, which is worse
# than just skipping this poll cycle.
{% set afc_exists = "AFC" in printer %}
{% set current = printer.AFC.current_load|default(none) if afc_exists else none %}
{% set current_str = current|string if current is not none else "" %}
{% if not afc_exists %}
{% if last_seen != "__AFC_MISSING__" %}
SAVE_VARIABLE VARIABLE=pa_last_seen_lane VALUE='"__AFC_MISSING__"'
{action_respond_info("_PA_POLL_LANE_CHANGE: printer.AFC not found -- AFC may not be loaded. PA auto-apply on tool change is disabled until this is resolved.")}
{% endif %}
{% elif current_str != last_seen %}
SAVE_VARIABLE VARIABLE=pa_last_seen_lane VALUE='"{current_str}"'
{% if current_str != "" %}
_PA_APPLY_FOR_LANE LANE={current_str}
{% endif %}
{% endif %}
# Re-arm: check again in 2 seconds. This keeps running indefinitely
# once Klipper starts (initial_duration above triggers the first run).
# Re-arms even when printer.AFC is missing, so if AFC loads later
# (e.g. after a manual RESTART) this starts working without needing
# its own restart.
UPDATE_DELAYED_GCODE ID=_PA_POLL_LANE_CHANGE DURATION=2
# PA_CALIBRATE_ALL_LANES sets the PA of every lane in turn (the last one
# wins), and the lane-change poll only reacts to a change of the loaded lane.
# Run this after it so the PA matches the lane that is actually loaded.
[gcode_macro _PA_APPLY_LOADED_LANE]
description: Apply the cached PA of the lane loaded in the toolhead
gcode:
{% set lane = printer.AFC.current_load|default(none) if "AFC" in printer else none %}
{% if lane %}
_PA_APPLY_FOR_LANE LANE={lane}
{% else %}
RESPOND MSG="_PA_APPLY_LOADED_LANE: no lane loaded, PA unchanged"
{% endif %}
[gcode_macro _PA_APPLY_FOR_LANE]
description: Internal -- applies cached PA for a lane without calibrating
# LANE=<lane_name>
gcode:
{% set lane = params.LANE|default("")|string %}
{% set vars = printer.save_variables.variables %}
{% set lane_varname = "pa_lane_" ~ lane ~ "_spool_id" %}
{% set spool_id = vars[lane_varname]|default(0)|int if lane_varname in vars else 0 %}
{% if spool_id > 0 %}
{% set pa_varname = "pa_value_spool_" ~ spool_id %}
{% if pa_varname in vars %}
{% set cached_pa = vars[pa_varname]|float %}
SET_PRESSURE_ADVANCE ADVANCE={cached_pa}
{action_respond_info("Lane %s loaded: applied cached PA=%.4f (spool %d)" % (lane, cached_pa, spool_id))}
M117 PA: {lane} = {cached_pa|round(4)}
{% else %}
{action_respond_info("Lane %s loaded: spool %d has no cached PA yet. Run LANE_CALIBRATE_PA LANE=%s or PA_CALIBRATE_ALL_LANES." % (lane, spool_id, lane))}
{% endif %}
{% else %}
{action_respond_info("Lane %s loaded: no spool_id recorded yet (run SET_SPOOL_ID first)." % lane)}
{% endif %}
# ---------------------------------------------------------------------------
# Force re-calibration of one lane (e.g. after a filament change you
# suspect needs fresh tuning) and update the cache.
# ---------------------------------------------------------------------------
[gcode_macro LANE_RECAL_PA]
description: Force re-calibrate PA for one lane and update the cache
# LANE=<lane_name>
gcode:
{% set lane = params.LANE|default("lane1")|string %}
LANE_CALIBRATE_PA LANE={lane} FORCE=1
# ---------------------------------------------------------------------------
# Status helper
# ---------------------------------------------------------------------------
[gcode_macro PA_STATUS]
description: Show current PA cache and lane->spool_id mappings
gcode:
{% set cfg = printer["gcode_macro _PA_CFG"] %}
{% set lane_list = cfg.lane_names.split(",") %}
{% set vars = printer.save_variables.variables %}
{action_respond_info("=== PA Status ===")}
{% for lane in lane_list %}
{% set lane = lane.strip() %}
{% if lane != "" %}
{% set spool_varname = "pa_lane_" ~ lane ~ "_spool_id" %}
{% set spool_id = vars[spool_varname]|default(0)|int if spool_varname in vars else 0 %}
{% if spool_id > 0 %}
{% set pa_varname = "pa_value_spool_" ~ spool_id %}
{% if pa_varname in vars %}
{action_respond_info(" %s (spool %d): PA=%.4f cached" % (lane, spool_id, vars[pa_varname]|float))}
{% else %}
{action_respond_info(" %s (spool %d): not yet calibrated" % (lane, spool_id))}
{% endif %}
{% else %}
{action_respond_info(" %s: no spool_id recorded" % lane)}
{% endif %}
{% endif %}
{% endfor %}
{action_respond_info("Current extruder PA: %.4f" % printer.extruder.pressure_advance|default(0)|float)}