From 9dc733b5bb85a30ac948d373403369ad1cf3464d Mon Sep 17 00:00:00 2001 From: MangaValk Date: Sat, 10 Oct 2026 15:20:28 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 84 +++++++++++++++++++++++ README.md | 10 ++- macros/heating/heat_soak.cfg | 9 +-- macros/klipper-macros.cfg | 9 ++- macros/print/pause_resume.cfg | 110 ++++++++++++++----------------- macros/print/print_start_end.cfg | 26 ++++---- tools/effective_config.py | 82 +++++++++++++++++++++++ 7 files changed, 245 insertions(+), 85 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tools/effective_config.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a20c11 --- /dev/null +++ b/CLAUDE.md @@ -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/`, + `/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`. diff --git a/README.md b/README.md index de3fd0b..f43790e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/macros/heating/heat_soak.cfg b/macros/heating/heat_soak.cfg index 195b11d..c66d111 100644 --- a/macros/heating/heat_soak.cfg +++ b/macros/heating/heat_soak.cfg @@ -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 diff --git a/macros/klipper-macros.cfg b/macros/klipper-macros.cfg index d9ef007..d2c5438 100644 --- a/macros/klipper-macros.cfg +++ b/macros/klipper-macros.cfg @@ -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] diff --git a/macros/print/pause_resume.cfg b/macros/print/pause_resume.cfg index d908c37..30dd3db 100644 --- a/macros/print/pause_resume.cfg +++ b/macros/print/pause_resume.cfg @@ -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] # diff --git a/macros/print/print_start_end.cfg b/macros/print/print_start_end.cfg index aaae04e..6281d45 100644 --- a/macros/print/print_start_end.cfg +++ b/macros/print/print_start_end.cfg @@ -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: diff --git a/tools/effective_config.py b/tools/effective_config.py new file mode 100644 index 0000000..f0919f0 --- /dev/null +++ b/tools/effective_config.py @@ -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:]))