Group macros by topic, add Blobifier, fix first tool change

- Split the macro files into folders by topic (print, heating, afc,
  pressure_advance, spoolman, leds, sensors); klipper-macros.cfg is the
  one file printer.cfg includes. Macros moved unchanged; the effective
  config is identical apart from the paths of the PA shell scripts.
- Add the modified Blobifier (Turtleblobifier for AFC) and define
  [save_variables] once in save_variables.cfg instead of in both
  blobifier.cfg and the PA macros (same file, bd_pa_variables.cfg).
- PRINT_WARMUP: heat to the loaded lane's temperature before the first
  tool change. AFC skips heating when the loaded lane's tool number is
  not in the print file's temperature list, and the unload then failed
  below min_extrude_temp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-10 14:24:37 +02:00
1 parent 6dbb4ca3fb
commit eaf9c82617
27 files changed
+1366 -340

No files matched your search

+640
View File
@@ -0,0 +1,640 @@
########################################################################
# 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
# 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 %}
{action_raise_error("_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
# ---------------------------------------------------------------------------
[gcode_macro LANE_CALIBRATE_PA]
description: 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 %}
{% if lane == "" %}
{action_raise_error("LANE_CALIBRATE_PA: LANE parameter required")}
{% endif %}
{% 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 %}
{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: use the extruder's current target, which AFC
# already sets from Spoolman when the lane was loaded. Fall
# back to the configured default if nothing is set yet.
{% set target = printer.extruder.target|default(0)|float %}
{% set nozzle_temp = 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 %}
{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 %}
# ---------------------------------------------------------------------------
# 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() %}
{% if lane != "" %}
LANE_CALIBRATE_PA LANE={lane} FORCE={force}
{% endif %}
{% endfor %}
{action_respond_info("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
[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)}