Interruptible heat soak

I wanted a macro for heating the bed of my printer and allowing the bed, chamber, and frame to come up to temp for a while. My first solution worked, but M190 and G4 are both blocking commands: it was impossible to interrupt the macro without using the emergency stop. And sometimes I wanted to be able to move the toolhead or do minor maintenance while the printer is warming up. This is the first macro I used:

[gcode_macro HEAT_SOAK]
description: heats the bed for a while
gcode:
    {% set TARGET = params.TARGET | default(0) %}
    {% set PERIOD = (params.PERIOD | default(60) | int) %} ## minutes

    ;; put the bed and nozzle where they're a safe distance apart
    G28
    CENTER

    M84 ;; turn off steppers

    ;; run the fan to circulate air
    _SET_FAN_SPEED PERCENT=50

    M190 S{ TARGET } ; wait for bed temp

    RESPOND TYPE=command MSG="will soak for { PERIOD }m"
    G4 P{ PERIOD * 1000 * 60 }

After noodling on it for a while I realized I could do a timer-based macro with delayed_gcode that would keep the heater on and the printer from becoming idle while still allowing me to cancel it cleanly or to do other stuff like move the head around. This is that solution:

[gcode_macro HEAT_SOAK]
description: heats the bed for a while

variable_target_temp: 0
variable_stage: None ## heating -> soaking -> done -> None

## in seconds
variable_check_interval: 10
variable_soak_time_remaining: 0
variable_total_time_elapsed: 0

gcode:
    {% set TARGET = params.TARGET | default(0) | float %}
    {% set DURATION = (params.DURATION | default(5) | int) * 60 %} ## minutes to seconds

    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=target_temp         VALUE={ TARGET }
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage               VALUE="'heating'"
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE={ DURATION }
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed  VALUE=0

    ;; fire up the heater
    SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET={ TARGET }

    ;; run the fan to circulate air
    _SET_FAN_SPEED PERCENT=50

    ;; put the bed and nozzle where they're a safe distance apart
    G28
    CENTER

    M84 ;; turn off steppers

    UPDATE_DELAYED_GCODE ID=heat_soaker DURATION={ check_interval }

[gcode_macro CANCEL_HEAT_SOAK]
description: cancels an in-progress HEAT_SOAK cycle
gcode:
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'cancel'"
    UPDATE_DELAYED_GCODE ID=heat_soaker DURATION=1

[delayed_gcode heat_soaker]
; ## debug
; { action_respond_info( printer['gcode_macro HEAT_SOAK'] | tojson )}
gcode:
    {% set heat_soak = printer['gcode_macro HEAT_SOAK'] %}

    ## update total time elapsed
    {% set total_time_elapsed = heat_soak.total_time_elapsed + heat_soak.check_interval %}
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed VALUE={ total_time_elapsed }

    {% set stage = heat_soak.stage %}
    {% if stage == "heating" and printer.heater_bed.temperature >= heat_soak.target_temp %}
        {% set stage = "soaking" %}
    {% endif %}

    {% if stage == "soaking" %}
        ## update soak countdown
        {% set soak_time_remaining = [heat_soak.soak_time_remaining - heat_soak.check_interval, 0] | max %}
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE={ soak_time_remaining }

        {% if soak_time_remaining == 0 %}
            {% set stage = "done" %}
        {% endif %}
    {% endif %}

    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'{ stage }'"

    {% if stage in ("done", "cancel") %}

        {% if stage == "cancel" %}
            {% set stage = "done" %}
            TURN_OFF_HEATERS
            M107 ; turn off fan

            M117 { "soak cancelled after ~%.1fm" | format(total_time_elapsed / 60.0) }
        {% else %}
            M117 { "soak complete after %.1fm" | format(total_time_elapsed / 60.0) }
        {% endif %}

        ## reset all state vars, except stage, which may be queried via the api
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=target_temp         VALUE=0
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE=0
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed  VALUE=0

    {% else %}

        {% if total_time_elapsed % 90 == 0 %}
            ## output status periodically
            {% if stage == "heating" %}
                M117 { "heating -- %.1fm elapsed" | format(total_time_elapsed / 60.0) }
            {% elif stage == "soaking" %}
                M117 { "soaking -- %.1fm remaining" | format(soak_time_remaining / 60.0) }
            {% endif %}
        {% endif %}

        ## trigger ourselves again
        UPDATE_DELAYED_GCODE ID=heat_soaker DURATION={ heat_soak.check_interval }

        ## dwell for 1ms to prevent from going idle
        G4 P1

    {% endif %}

There are actually 3 macros in here:

  • HEAT_SOAK – turns on the heater and starts a timer that calls itself until the soak duration has elapsed
  • CANCEL_HEAT_SOAK – causes the timer to cancel the soak process and turn off the heater
  • heat_soaker (delayed g-code macro) – called periodically (every 10s) to update state and prevent the idle timeout from triggering.

It’s worth noting that after the heat soak process completes, the heater is left on. The idle timeout will kick in and shut everything down later on. This gives me time to start a print after soaking’s finished, without things cooling down too much. CANCEL_HEAT_SOAK causes the heater to be turned off immediately.

The implementation feels a bit clunky but overall I’m happy with it, I think. I welcome any suggestions or critiques.

A fun bonus: this will also allow me to use Home Assistant to send me a notification when the soak process is complete! Moonraker allows you to query a gcode_macro and return its variables. The stage variable will be what I trigger my automation on.

8 Likes

Wow, it seems you have a real good grasp of macro’s. I am impressed. I am a newbie on klipper and just finished the construction of my first printer (a 350mm Voron 2.4) so I am still tinkering with settings and have a lot to learn. Thanks for providing the macro’s. They are a good example and I will probably use them. As a retired programmer I know that for any task there are a million ways to accomplish it. Klunky isn’t a bad thing as long as it works.

1 Like

(I know it’s an old topic but just found it on google)
This looks awesome!! Thanks for sharing this :slight_smile:
I’m trying to use it and missing some other macros you call from within this one. i.e.:
Unknown command:"_SET_FAN_SPEED"
Unknown command:“CENTER”

Is there any github you have where you have all your macros and configurations by any chance?

Thanks in advance!!

Sorry about that! This was supposed to be a stand-alone example. I cannot edit the first post, but what I use is below.

[gcode_macro _SET_FAN_SPEED]
gcode:
    M106 S{ (params.PERCENT | float) * 255 / 100 }


[gcode_macro CENTER]
gcode:
    G90
    G0 X{ printer.toolhead.axis_maximum.x/2 } Y{ printer.toolhead.axis_maximum.y/2 } Z{ printer.toolhead.axis_maximum.z/2 } F7200
1 Like

Awesome! Thanks so much!!

Thanks for the macro. It is working well for me (standalone), and I’ve been able modify it to use my chamber sensor for the target.

What I haven’t been able to figure out is how to use this as part of my PRINT_START macro. It seems like HEAT_SOAK doesn’t block, so PRINT_START continues while “heat_soaker” is still being ran. Reading your comment, it sounds like you are using using your notification to let you know when you can start your print, so it’s different than what I’m trying.

I’m glad it’s useful! Yeah, I only use it as a standalone process. I don’t think there’s any way to “join” to a background task in Klipper, although that’d be useful!

So the way I use this is to start the heat soak – say 15m at 110°C – when I’m getting ready to start a job. When I get the notification it’s done, then I actually start the job. If I’m doing multiple small prints I only need to soak once.

Add a chamber thermistor, add the macro call in START_PRINT/PRINT_START, add HEAT_SOAK and TEMPERATURE_WAIT. Done? It won’t wait for the specified time but it will wait for the chamber to reach the temperature you want.

If you want to use this during a print start script and wait for the heat soak, here are the changes I made:

  • Put a PAUSE after the safe distance apart code in the HEAT_SOAK macro
  • Put a CANCEL_PRINT in the if stage == "cancel" condition
  • Put a RESUME in the else case for the above cancel condition check
  • Optional: If you have additional gcode to run after the heat soak completes and before the print resumes, add a SOAK_COMPLETE call right before RESUME and create the SOAK_COMPLETE macro to do whatever else you need to do

Note that if you have customized PAUSE, ‘CANCEL_PRINT’, or RESUME macros, and you used rename_existing, you can call the original instead of the customized macros if you desire. Here is my alteration of the HEAT_SOAK macro:

[gcode_macro HEAT_SOAK]
description: heats the bed for a while

variable_target_temp: 0
variable_stage: None ## heating -> soaking -> done -> None

## in seconds
variable_check_interval: 10
variable_soak_time_remaining: 0
variable_total_time_elapsed: 0

gcode:
    {% set TARGET = params.TARGET | default(0) | float %}
    {% set DURATION = (params.DURATION | default(15) | int) * 60 %} ## minutes to seconds

    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=target_temp         VALUE={ TARGET }
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage               VALUE="'heating'"
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE={ DURATION }
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed  VALUE=0

    ;; fire up the heater
    SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET={ TARGET }

    ;; run the fan to circulate air
    _SET_FAN_SPEED PERCENT=50
    ;; put the bed and nozzle where they're a safe distance apart
    G28
    CENTER

    M84 ;; turn off steppers

    UPDATE_DELAYED_GCODE ID=heat_soaker DURATION={ check_interval }

    ;; pause the print during heat soak
    BASE_PAUSE

[gcode_macro CANCEL_HEAT_SOAK]
description: cancels an in-progress HEAT_SOAK cycle
gcode:
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'cancel'"
    UPDATE_DELAYED_GCODE ID=heat_soaker DURATION=1

[delayed_gcode heat_soaker]
; ## debug
; { action_respond_info( printer['gcode_macro HEAT_SOAK'] | tojson )}
gcode:
    {% set heat_soak = printer['gcode_macro HEAT_SOAK'] %}

    ## update total time elapsed
    {% set total_time_elapsed = heat_soak.total_time_elapsed + heat_soak.check_interval %}
    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed VALUE={ total_time_elapsed }

    {% set stage = heat_soak.stage %}
    {% if stage == "heating" and printer.heater_bed.temperature >= heat_soak.target_temp %}
        {% set stage = "soaking" %}
    {% endif %}

    {% if stage == "soaking" %}
        ## update soak countdown
        {% set soak_time_remaining = [heat_soak.soak_time_remaining - heat_soak.check_interval, 0] | max %}
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE={ soak_time_remaining }

        {% if soak_time_remaining == 0 %}
            {% set stage = "done" %}
        {% endif %}
    {% endif %}

    SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=stage VALUE="'{ stage }'"

    {% if stage in ("done", "cancel") %}

        {% if stage == "cancel" %}
            {% set stage = "done" %}
            TURN_OFF_HEATERS
            M107 ; turn off fan

            M117 { "soak cancelled after ~%.1fm" | format(total_time_elapsed / 60.0) }

            ;; heat soak was cancelled -- also cancel the print
            CANCEL_PRINT
        {% else %}
            M117 { "soak complete after %.1fm" | format(total_time_elapsed / 60.0) }

            ;; heat soak is complete. Call the complete macro prior to starting the print
            SOAK_COMPLETE
            ;; resume / start the print
            BASE_RESUME
        {% endif %}

        ## reset all state vars, except stage, which may be queried via the api
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=target_temp         VALUE=0
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=soak_time_remaining VALUE=0
        SET_GCODE_VARIABLE MACRO=HEAT_SOAK VARIABLE=total_time_elapsed  VALUE=0

    {% else %}

        {% if total_time_elapsed % 90 == 0 %}
            ## output status periodically
            {% if stage == "heating" %}
                M117 { "heating -- %.1fm elapsed" | format(total_time_elapsed / 60.0) }
            {% elif stage == "soaking" %}
                M117 { "soaking -- %.1fm remaining" | format(soak_time_remaining / 60.0) }
            {% endif %}
        {% endif %}

        ## trigger ourselves again
        UPDATE_DELAYED_GCODE ID=heat_soaker DURATION={ heat_soak.check_interval }

        ## dwell for 1ms to prevent from going idle
        G4 P1

    {% endif %}

ooh, that’s really clever!

hello, just built a voron 2.4r2, i’m having problems with parts staying stuck when initially printing, so I thought heat soak would be a good idea. I ran across your post. I added the macro to my printer.cfg.

I run heat_soak, my toolhead homes and moves to the center, but the heater_bed never turn on.

I’m assuming I need to add the temperature I want to the variable_target_temp: 0. I set it to 110, but it does not turn on, any ideas?

thank you!

It’s a parameterized macro. You need to specify the temperature and duration, like HEAT_SOAK TARGET=110 DURATION=30 to soak for 30m at 110°C.

Thanks @blalor and @mwu for this script! I’ve wanted a way to do this so badly I hacked up klippy to do it: Add MAX_SLOPE capability to TEMPERATURE_WAIT by garethky · Pull Request #4796 · Klipper3d/klipper · GitHub But I gave up on getting the PR merged because Kevin has not so much time for this kind of thing. Bit I still wanted this functionality!

My version of the script is here: klipper-voron2.4-config/heatsoak.cfg at mainline · garethky/klipper-voron2.4-config · GitHub
It uses a second temperature sensor and stops the heat soak when the rate of temperature change on that sensor drops below a target. I have the default set to a rate of change of less than 0.75 degrees C per minute. This means that if the bed is already hot the soak time will be shorter. You call it like this:

HEAT_SOAK HEATER='heater_bed' TARGET=100 SOAKER='temperature_sensor top_bed' RATE=0.75

You also have to split your print start script where you call HEAT_SOAK. The call to PAUSE will not stop the print start script from running, it just stops the next line of GCode in the file being printed from running. So now I have:

PRINT_PREPARE  [...]     ; ends with a call to HEAT_SOAK
PRINT_START  [...]       ; does mesh bed leveling after heat soak

The only real gap now is that we cant configure a smooth_time on temperature sensors so the data we get from the sensor is noisy. I might do something about that but honestly this is working fine for my needs.

Hello guys,

I’m trying to use your macro @garethky but klipper is giving me this erro os starting:

Error loading template ‘delayed_gcode heat_soaker:gcode’: TemplateSyntaxError: unexpected ‘end of print statement’

Any idea?

I found the problem:

Must change line 98 from

{action_respond_info("Heat soak timed out after ~%.1fm" | format(total_time_elapsed / 60.0)) %}

to

{action_respond_info("Heat soak timed out after ~%.1fm" % (format(total_time_elapsed / 60.0)))}
1 Like

Ahh, sorry about that, updated on github.

Is there a way to do a callback in a macro? I could add an argument to HEAT_SOAK that calls another macro when it reaches the done condition.

Oh yeah, callbacks work:

[gcode_macro MACRO_A]
gcode:
    {action_respond_info("Macro A")}
    {% set CALLBACK = (params.CALLBACK | string) %}
    {CALLBACK}

[gcode_macro MACRO_B]
gcode:
    {action_respond_info("Macro B")}
09:38:19  $ macro_a CALLBACK=MACRO_B
09:38:19  // Macro A
09:38:19  // Macro B

Ok I had yet another bug with that action_respond_info statement that I fixed. No more format() calls in the code now.

I implemented a callback in heat_soak. My print_start passes a callback to heat_soak. heat_soak then calls that macro when it reaches the done state. This means the 2 parts of the print start process are chained together and I only have to call print_start in my slicer.

I also added a callback to print_start itself, so it can start running a test print macro after the entire startup process finishes. Running the pressure advance test macro after print start looks like this:

{% set PA_CAL_CALL -%}
    PA_CAL BED={BED_TEMP} EXTRUDER={EXTRUDER_TEMP} EXTRUSION_FACTOR={ER}
{%- endset %}
PRINT_START BED_TEMP={BED_TEMP} EXTRUDER_TEMP={EXTRUDER_TEMP} CALLBACK='{PA_CAL_CALL}'

The -%} and {%- are how you remove whitespace around the value, so the result is 1 line, this is required for this trick to work.

I didn’t find a way to nest the callbacks, with the way jinja and klipper handle string escaping I don’t see how to keep the quotes in the strings intact. I can think of more devious ways of achieving :speak_no_evil: this but you probably wont like them.

Programming macros with callbacks destroys the intended simplicity of the language. Some sort of barrier statement that blocks further evaluation of a macro is probably a better solution. Like the await keyword.

1 Like

Hy @garethky , I made some changes in your macro. The most important are:
mintemp variable: my chamber temp sensor takes too long to react upon the bed heating, so I need to define a minimum temperature to finish the heatsoak, otherwise it finishes in the beginning;
check_interval: defined two check_interval, one for heating stage, which is fast and need more frequent update and another to soak stage, where longer updates helps with delta temperature calculations.

I hope you enjoy the suggestions.

1 Like

I like it!

The MIN_TEMP idea makes sense. I could also see calling the macro with a formula, like MIN_TEMP=(TARGET_TEMP * 0.85) so it scales with the target temp. I can also make it so that its optional, so if you don’t provide a value it is not checked.

Would smoothing the temperature reading have made a difference? I’ve been thinking about making the interval 1 second, gathering a reading in an array and then computing an average over 5 seconds. Basically doing the smoothing in the macro. I’m not exactly sure there are enough array operators to do this in the language though, you would ideally want something like push() and pop() or slice().

This would uncouple the interval that the temp gets polled and the interval that the messages are posted, so that would add the same kind of interval variables that you added.