Fix pause/resume chain, shared print cleanup, add CLAUDE.md

- RESUME is defined only in print/pause_resume.cfg; heat_soak.cfg's
  duplicate replaced it (no unretract, no fan restore). It now runs
  HEAT_SOAK_RESUME -> _RESUME_PRINT -> RESUME_BASE.
- PAUSE runs PAUSE_BASE first, then retracts and parks once (X20 Y345);
  a parked flag makes resume undo only what PAUSE did.
- New _PRINT_CLEANUP shared by PRINT_END and CANCEL_PRINT: heaters incl.
  iHEATER_OFF, fan, M221 S100, bdwidth off, mesh and skew cleared.
  In PRINT_END it runs after RESTORE_GCODE_STATE so the flow reset sticks.
- Add CLAUDE.md and tools/effective_config.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-10 15:20:28 +02:00
1 parent eaf9c82617
commit 9dc733b5bb
7 files changed
+245 -85

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
# klipper-macros
Klipper macros, Klipper modules and scripts for one printer, installed on
the printer's Raspberry Pi with `install.sh` and updated through Moonraker.
See README.md for the user-facing description.
## The printer
Voron 2.4 350, Klipper + Moonraker + Mainsail on a Raspberry Pi 4
(`http://mainsailos.local`, Moonraker API on port 80). AFC Box Turtle with
5 lanes, Blobifier purge bucket, Beacon (Z homing and probing), Nitehawk-SB
toolboard (USB), Leviathan mainboard as USB-CAN bridge, bd_pressure
(load-check firmware, branch `load-check-extension` in
`D:\Claude\bd_pressure-load-check\bd_pressure`), bdwidth (measures width and
motion optically with a CCD; its motion reading is unreliable, so the purge
check runs with `motion_check: False`), iHeater chamber heater.
## Working with the printer
- Read freely through Moonraker: `GET /server/files/config/<path>`,
`/server/files/logs/klippy.log`, `/printer/objects/query?...`,
`/server/gcode_store`. The merged config Klipper loaded is in klippy.log
after `===== Config file =====`.
- Do **not** change anything on the printer (upload files, edit config, send
G-code, restart services) unless the user asks for that specific action.
Prepare files here and let the user upload them.
- Never run anything that moves the printer or heats while a print may be
running; check `print_stats.state` first.
## Repo layout
- `macros/klipper-macros.cfg` is the only file `printer.cfg` includes; it
includes everything else. On the Pi, `macros/` is linked as
`~/printer_data/config/klipper-macros/`.
- `extras/*.py` are linked into `~/klipper/klippy/extras/`.
- `scripts/` MCU firmware update scripts (printer-specific IDs).
- `tools/effective_config.py` expands the includes like Klipper and compares
the result between git refs.
## Rules for macro changes
- **Never define the same `[gcode_macro X]` in two files.** Klipper merges
sections with the same name and the later file wins completely (this is
how heat_soak.cfg's RESUME silently replaced the real RESUME). Include
order in `klipper-macros.cfg` matters for this.
- `RESUME` and `PAUSE` are defined only in `print/pause_resume.cfg`. AFC
renames PAUSE/RESUME at startup and wraps them:
`PAUSE -> AFC_PAUSE -> PAUSE -> PAUSE_BASE`,
`RESUME -> AFC_RESUME -> RESUME -> HEAT_SOAK_RESUME -> _RESUME_PRINT -> RESUME_BASE`.
- Only one `[save_variables]`, in `macros/save_variables.cfg`.
- `PRINT_END` and `CANCEL_PRINT` share `_PRINT_CLEANUP` (heaters including
`iHEATER_OFF`, fan, `M221 S100`, bdwidth off). Anything that must happen
at the end of every print goes there. `RESTORE_GCODE_STATE` restores the
flow (M221), so reset flow after it, not before.
- Inside `gcode:` use `;` for comments on G-code lines; `#` lines are
stripped by the config parser.
- After a refactor run `python tools/effective_config.py HEAD` and check that
only the intended sections changed.
- Render-test macros with Jinja2 using Klipper's delimiters
(`jinja2.Environment('{%', '%}', '{', '}')`) and mocked `printer` objects.
## Rules for the Python modules (`extras/`)
- Klipper only loads changed `.py` files when the **Klipper service**
restarts (`sudo systemctl restart klipper`); `RESTART` and
`FIRMWARE_RESTART` keep the old code in memory.
- Event handlers must never raise: an exception in a handler shuts Klipper
down. Example: Beacon sends `homing:home_rails_end` with `rails=[]` and a
homing state whose `get_trigger_position` raises.
- Keep them read-only wrappers where possible (look up the other module's
object, do not patch third-party files).
## Reference values
- Purge check (150 mm Blobifier purge): good load = longest pressure
13-18 s, peak 64-181; empty nozzle about 30 ms.
- XY drift check: endstop noise about 0.01-0.02 mm; limit 0.1 mm (one motor
skipping one cycle = 0.4 mm shows as 0.2 mm in X and Y).
## Git
- LF line endings everywhere (`.gitattributes`); the files run on Linux.
- Commit only when the user says so; never push without asking. Remote:
`https://mangavalk.com/mangavalk/klipper-macros.git`.
+9 -1
View File
@@ -21,7 +21,7 @@ everything below, grouped by topic. Comment out what you do not use there.
| `blobifier_hw.cfg` | servo and bucket switch pins | — |
| **print/** | | |
| `print_start_end.cfg` | `PRINT_WARMUP`, `PRINT_START`, `PRINT_END` | Beacon, AFC, Blobifier, `HEAT_SOAK`, iHeater, bdwidth, bd_pressure PA macros |
| `pause_resume.cfg` | `PAUSE`, `RESUME`, `CANCEL_PRINT`, `M600` | — |
| `pause_resume.cfg` | `PAUSE`, `RESUME`, `CANCEL_PRINT`, `M600` (RESUME also handles a running `HEAT_SOAK`; CANCEL_PRINT runs the same cleanup as `PRINT_END`) | `heating/heat_soak.cfg`, `leds/` |
| `filament.cfg` | `LOAD_FILAMENT`, `UNLOAD_FILAMENT` | — |
| `homing.cfg` | `HOME` | — |
| `idle_timeout.cfg` | `[idle_timeout]`: heaters, chamber heater and motors off | iHeater macros |
@@ -59,6 +59,14 @@ Firmware update scripts for all MCUs (Klipper and Katapult). They contain
this printer's CAN UUIDs and serial IDs: see [scripts/README.md](scripts/README.md)
before using them on another printer.
### Tools (`tools/`)
`effective_config.py` builds the config Klipper gets from
`klipper-macros.cfg` (includes expanded, duplicate sections merged) and
compares it between git refs, for example `python tools/effective_config.py HEAD`
after a change. `CLAUDE.md` has the notes for working on this repo with
Claude Code.
## Install
On the Klipper host:
+3 -6
View File
@@ -118,7 +118,7 @@ gcode:
description: Resume while heat soaking results in the soaking phase being skipped, any complete callback is run
gcode:
# {% set ON_RESUME = (params.ON_RESUME | default("_HEAT_SOAK__BASE_RESUME")) | string %}
{% set ON_RESUME = (params.ON_RESUME | default("_HEAT_SOAK__BASE_RESUME")) | string %}
{% set ON_RESUME = (params.ON_RESUME | default("RESUME_BASE")) | string %}
{% set heat_soak = printer['gcode_macro HEAT_SOAK'] %}
{% set stage = heat_soak.stage %}
{% if stage == "heating" %}
@@ -133,11 +133,8 @@ gcode:
{ON_RESUME} {% for p in params %}{'%s=%s ' % (p, params[p])}{% endfor %}
{% endif %}
[gcode_macro RESUME]
description: Resume while heat soaking results in the soaking phase being skipped, any complete callback is run
rename_existing: _HEAT_SOAK__BASE_RESUME
gcode:
HEAT_SOAK_RESUME ON_RESUME=_HEAT_SOAK__BASE_RESUME
# RESUME is defined in print/pause_resume.cfg and calls HEAT_SOAK_RESUME
# (a second [gcode_macro RESUME] here would replace that one completely).
[delayed_gcode _heat_soaker]
# description: internal macro to handle heat soaking activity
+4 -5
View File
@@ -5,9 +5,8 @@
#
# [include klipper-macros/klipper-macros.cfg]
#
# Comment out what you do not use. Keep the order where noted: some
# macros (PAUSE, RESUME, CANCEL_PRINT) are defined in more than one file
# and the last definition wins.
# Comment out what you do not use. Every macro is defined in exactly one
# file: Klipper merges sections with the same name and the last one wins.
#####################################################################
[include save_variables.cfg] # the one [save_variables] for all macros
@@ -16,7 +15,7 @@
[include blobifier/blobifier.cfg]
# Printing
[include print/pause_resume.cfg] # before heating/heat_soak.cfg
[include print/pause_resume.cfg]
[include print/filament.cfg]
[include print/homing.cfg]
[include print/idle_timeout.cfg]
@@ -25,7 +24,7 @@
# Heating
[include heating/bed_fans.cfg]
[include heating/chamber_iheater.cfg]
[include heating/heat_soak.cfg] # its RESUME wraps the one above
[include heating/heat_soak.cfg]
[include heating/soak_my_bed.cfg]
[include heating/filament_drying.cfg]
+48 -62
View File
@@ -1,44 +1,42 @@
# PAUSE, RESUME, CANCEL_PRINT, M600 (based on the KlipperScreen recommended macros)
#
# Chain on this printer (AFC renames PAUSE/RESUME at startup):
# PAUSE -> AFC_PAUSE (saves position, z-hop) -> this PAUSE -> PAUSE_BASE
# RESUME -> AFC_RESUME -> this RESUME -> HEAT_SOAK_RESUME -> _RESUME_PRINT
# -> RESUME_BASE, then AFC restores the saved position
# RESUME is defined only here: heating/heat_soak.cfg used to define its own,
# which replaced this one completely (no unretract, no fan restore).
# https://klipperscreen.readthedocs.io/en/latest/macros/#recommended
[gcode_macro PAUSE]
description: Pause the actual running print
rename_existing: PAUSE_BASE
variable_saved_fan_speed: 0.0
variable_parked: False # set by PAUSE, used by _RESUME_PRINT
gcode:
# SAVE_GCODE_STATE NAME=PAUSE_MACRO_state
# Save current fan speed
{% set current_fan = printer.fan.speed %}
SET_GCODE_VARIABLE MACRO=PAUSE VARIABLE=saved_fan_speed VALUE={current_fan}
# Current position
{% set x = printer.toolhead.position.x|float %}
{% set y = printer.toolhead.position.y|float %}
# Determine target positions
{% set target_x = 340 if (x < 340 or x > 350) else x %}
{% set target_y = 340 if (y < 340 or y > 350) else y %}
# Only move if needed
{% if target_x != x or target_y != y %}
G90
G1 X{target_x} Y{target_y} F12000
{% if not printer.pause_resume.is_paused %}
SET_GCODE_VARIABLE MACRO=PAUSE VARIABLE=saved_fan_speed VALUE={printer.fan.speed}
# Pause first, so the saved state is the print position, then retract and park
PAUSE_BASE
_TOOLHEAD_PARK_PAUSE_CANCEL
SET_GCODE_VARIABLE MACRO=PAUSE VARIABLE=parked VALUE=True
# Set fan to S28 (28/255 ≈ 0.1098)
M106 S28
{% else %}
PAUSE_BASE
{% endif %}
# Pause print
PAUSE_BASE
_TOOLHEAD_PARK_PAUSE_CANCEL
# Set fan to S28 (28/255 ≈ 0.1098)
M106 S28
[gcode_macro RESUME]
description: Resume the actual running print
description: Resume the print (while HEAT_SOAK runs: skip the soak phase)
rename_existing: RESUME_BASE
variable_skip_restore: 0
gcode:
HEAT_SOAK_RESUME ON_RESUME=_RESUME_PRINT {rawparams}
[gcode_macro _RESUME_PRINT]
description: Resume after PAUSE: unretract, restore the part fan, continue the print
gcode:
{% set pause = printer["gcode_macro PAUSE"] %}
{% set extrude = printer['gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL'].extrude %}
{% set skip = printer['gcode_macro RESUME'].skip_restore|int %}
#### get VELOCITY parameter if specified ####
{% if 'VELOCITY' in params|upper %}
{% set get_params = ('VELOCITY=' + params.VELOCITY) %}
@@ -46,40 +44,28 @@ gcode:
{% set get_params = "" %}
{% endif %}
##### end of definitions #####
{% if printer.extruder.can_extrude|lower == 'true' %}
M83
G1 E{extrude} F2100
{% if printer.gcode_move.absolute_extrude |lower == 'true' %} M82 {% endif %}
{% else %}
{action_respond_info("Extruder not hot enough")}
{% endif %}
#BLOBIFIER_CLEAN
#G1 Z10
# Only undo what PAUSE did; HEAT_SOAK pauses with PAUSE_BASE (no retract, no park)
{% if pause.parked %}
{% if printer.extruder.can_extrude|lower == 'true' %}
M83
G1 E{extrude} F2100
{% if printer.gcode_move.absolute_extrude |lower == 'true' %} M82 {% endif %}
{% else %}
{action_respond_info("Extruder not hot enough")}
{% endif %}
# Restore saved fan speed
{% set old_fan = printer["gcode_macro PAUSE"].saved_fan_speed %}
{% if old_fan > 0 %}
{% set fan_pwm = (old_fan * 255) | int %}
{% if fan_pwm > 255 %}
{% set fan_pwm = 255 %}
{% endif %}
M106 S{fan_pwm}
{% else %}
# Restore saved fan speed
{% set old_fan = pause.saved_fan_speed %}
{% if old_fan > 0 %}
M106 S{[(old_fan * 255)|int, 255]|min}
{% else %}
M107
{% endif %}
SET_GCODE_VARIABLE MACRO=PAUSE VARIABLE=parked VALUE=False
{% endif %}
{% if skip == 0 %}
# RESTORE_GCODE_STATE name=PAUSE_MACRO_state
RESUME_BASE {get_params}
{% else %}
RESPOND PREFIX="AFC" MSG="Skipping RESTORE_GCODE_STATE (drift resume)"
{% endif %}
RESUME_BASE {get_params}
_status_printing
CLEAR_PAUSE
RESET_FAILURE
SET_GCODE_VARIABLE MACRO=RESUME VARIABLE=skip_restore VALUE=0
[gcode_macro _TOOLHEAD_PARK_PAUSE_CANCEL]
description: Helper: park toolhead used in PAUSE and CANCEL_PRINT
@@ -129,12 +115,12 @@ gcode:
_TOOLHEAD_PARK_PAUSE_CANCEL
{% endif %}
STOP_HEAT_SOAK
TURN_OFF_HEATERS
STOP_HEAT_SOAK
CANCEL_PRINT_BASE_KLIPPERSCREEN
# Same cleanup as PRINT_END: heaters incl. chamber heater, fan, flow, bdwidth
_PRINT_CLEANUP
SET_GCODE_VARIABLE MACRO=PAUSE VARIABLE=parked VALUE=False
_status_ready
[gcode_macro M600]
#
+15 -11
View File
@@ -125,7 +125,7 @@ gcode:
[gcode_macro _NOTHING]
gcode:
_HEAT_SOAK__BASE_RESUME
RESUME_BASE
[gcode_macro PRINT_START]
gcode:
@@ -220,8 +220,6 @@ gcode:
_motion_detected
M221 S100
M400 ; wait for buffer to clear
G92 E0 ; zero the extruder
# G1 E-0.5 F1800 ; retract filament
@@ -234,21 +232,27 @@ gcode:
G0 X{x_safe} Y{y_safe} Z{z_safe} F20000 ; move nozzle to remove stringing
G0 X{th.axis_maximum.x//2} Y{th.axis_maximum.y - 5} F3600 ; park nozzle at rear
M107 ; turn off fan
{% if printer.pause_resume.is_paused|lower == 'false' and park|lower == 'true'%}
_TOOLHEAD_PARK_PAUSE_CANCEL
{% endif %}
SET_BDWIDTH NAME=fila_width_0 COMMAND=DISABLE ;disable the bdwidth
#SET_PIN PIN=caselight VALUE=0.03
_status_part_ready
UPDATE_DELAYED_GCODE ID=_delayed_motors_off DURATION=3600
RESTORE_GCODE_STATE NAME=STATE_PRINT_END
# After the restore: RESTORE_GCODE_STATE also restores the flow (M221)
_PRINT_CLEANUP
# End-of-print cleanup shared by PRINT_END and CANCEL_PRINT
[gcode_macro _PRINT_CLEANUP]
description: Heaters (incl. chamber heater) and part fan off, flow back to 100%, bdwidth off, mesh and skew cleared
gcode:
TURN_OFF_HEATERS
iHEATER_OFF ; stops the chamber heater control loop too
M107
SET_BDWIDTH NAME=fila_width_0 COMMAND=DISABLE ; also sets M221 S100
M221 S100
BED_MESH_CLEAR
SET_SKEW CLEAR=1
RESTORE_GCODE_STATE NAME=STATE_PRINT_END
[delayed_gcode _delayed_motors_off]
gcode:
+82
View File
@@ -0,0 +1,82 @@
"""Show or compare the config Klipper builds from macros/klipper-macros.cfg.
Klipper expands [include ...] in order and merges sections with the same
name (the last value of an option wins). This script does the same, so a
refactor can be checked for unintended changes.
python tools/effective_config.py list sections of the working tree
python tools/effective_config.py HEAD compare HEAD with the working tree
python tools/effective_config.py A B compare two git refs
python tools/effective_config.py HEAD -v also print old/new values
"""
import configparser
import os
import re
import subprocess
import sys
import tempfile
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BASE = os.path.join('macros', 'klipper-macros.cfg')
def expand(path):
lines = []
with open(path, encoding='utf-8') as f:
for line in f.read().replace('\r\n', '\n').split('\n'):
m = re.match(r'^\[include ([^\]]+)\]', line.split('#')[0])
if m:
inc = os.path.join(os.path.dirname(path), m.group(1).strip())
lines += expand(inc)
else:
lines.append(line)
return lines
def load(root):
cp = configparser.RawConfigParser(strict=False,
inline_comment_prefixes=(';', '#'))
cp.read_string('\n'.join(expand(os.path.join(root, BASE))))
return {s: dict(cp.items(s)) for s in cp.sections()}
def checkout(ref, tmp):
archive = subprocess.run(['git', '-C', ROOT, 'archive', ref, 'macros'],
check=True, capture_output=True).stdout
subprocess.run(['tar', '-x', '-C', tmp], input=archive, check=True)
return tmp
def main(argv):
verbose = '-v' in argv
refs = [a for a in argv if a != '-v']
with tempfile.TemporaryDirectory() as t1, tempfile.TemporaryDirectory() as t2:
if not refs:
for s in sorted(load(ROOT)):
print(s)
return 0
old = load(checkout(refs[0], t1))
new = load(checkout(refs[1], t2) if len(refs) > 1 else ROOT)
added, removed = sorted(set(new) - set(old)), sorted(set(old) - set(new))
changed = 0
for s in added:
print('added ', s)
for s in removed:
print('removed ', s)
for s in sorted(set(old) & set(new)):
keys = sorted(k for k in set(old[s]) | set(new[s])
if old[s].get(k) != new[s].get(k))
if keys:
changed += 1
print('changed ', s, keys)
if verbose:
for k in keys:
print(' old %s: %r' % (k, old[s].get(k)))
print(' new %s: %r' % (k, new[s].get(k)))
print('%d sections, %d added, %d removed, %d changed'
% (len(new), len(added), len(removed), changed))
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv[1:]))