Experimentation - Python Gcode macros

Gcode macros have their warts and limitations. So I played around with just using Python for the macros.
With resulted in something like this:

[gcode_macro clean_nozzle]
variable_total_wipes: 0
python:
  wipe_count = int(params.get('TIMES', 8))
  gcode.SAVE_GCODE_STATE(NAME='clean_nozzle_state')
  gcode.G90()
  gcode.G0(Z=15, F=300)
  for wipe in range(wipe_count):
    for coordinate in [(275, 4),(235, 4)]:
      gcode.G0( X=coordinate[0], Y = coordinate[1] + 0.25 * wipe, Z=9.7, F=12000)
  variables.total_wipes += 1
  gcode.RESTORE_GCODE_STATE(NAME='clean_nozzle_state')

Why this is useful

  • python compilation at klipper start, meaningful errors and line numbers
  • immediate gcode evaluation with side effects immediately usable
  • almost all string processing is eliminated, resulting in order of magnitude faster evaluation is a nice plus.

How this works

It is similar to normal jinja environment, but:

  • gcode - a pseudo object to run Gcodes. Gcodes are executed immediately and printer state updated.
    • named arguments are passed direclty to the gcode handler
    • positional arguments go through the normal gcode parsing, you can still do G0(“X10 Y20 Z30 F40”)
  • variables - the gcode macro variables dict, allowing to directly read/change them
  • printer - is still there, but now with live updates
  • dicts are wrapped in pseudo objects to allow both dot and braces syntax, like Jinja
  • is isolated from the core Klipper Python code with same access as Jinja

What’s next

This is an early experiment to play with the idea. Proof of concept code here.

Some things that I have been considering:

  • Maybe a flatter namespace would be easier to use:
  G0()          instead of gocde.G0()
  total_wipes   instead of variables.total_wipes
  TIMES         instead of params.TIMES
  • Allow the code to define a function, which is then called with appropriate parameters. This will require some manual parsing, as python type annotations are not available in the runtime.
[gcode_macro clean_nozzle]
python:
  total_wipes = 0 # A normal python variable, persisted across calls.
  def run(times: int = 8):
     for i in range(times): 
       ...
  • The above, plus add lifecycle functions, making this closer to Klipper extension code:
[gcode_macro clean_nozzle]
python:
  total_wipes = 0 
  def handle_ready():
     # Do something on kipper start.
  def handle_homing_move_end(hmove):
     # Do something after homing move
  def run(times: int = 8):
     # The macro
  • The python ConfigParser strips all whitespace and kills Python code, so made a quick fork of that. Not sure if there is a less intrusive way to fix this.

  • Conceptually python code can import other modules and be arbitarily large.
    Performance wise it is fine, as it is all compiled at startup. There are some basic imports that are useful, like math, random. But this might open a potential security issue, we don’t want macros talking over the net? Or maybe?

5 Likes

That looks really good.

Add a source code debugger and you can have my first born child.

This reminds me of klipper_extras/extended_macro at main · droans/klipper_extras · GitHub which allows invocation of python code.

If you are fine with only supporting newer python versions, you could use some syntactic sugar like with:

[gcode_macro clean_nozzle]
variable_total_wipes: 0
python:
  wipe_count = int(params.get('TIMES', 8))

  with gcode_state(name = 'clean_nozzle_state') as gcode:
    gcode.G90()
    gcode.G0(Z=15, F=300)
    for wipe in range(wipe_count):
      for coordinate in [(275, 4),(235, 4)]:
        gcode.G0( X=coordinate[0], Y = coordinate[1] + 0.25 * wipe, Z=9.7, F=12000)
    variables.total_wipes += 1

    # automatically called on exit of the with block
    # gcode.RESTORE_GCODE_STATE(NAME='clean_nozzle_state')

Does this mean that a python macro is interruptible and isn’t treated like a single gcode command? And does the evaluation work the same way as Jinja, where variable values are set before execution?

Most of my macros are full of unwieldy hacks and workarounds to achieve python-like behavior so this would be amazing. And for my use case it would also eliminate my need to use the shell command extra.

Very exciting!

The variables are read from current state whenever printer.x is accessed. And reflect any changes done during already executed gcodes.

Macros or any klipper gcode is not interruptable the same way a “print” is interruptable. I agree that would be a nice feature to interrupt that, but quite out of scope for this.

For reference, a print is the virtual_sdcard module looping through the file and sending gcodes to be evaluated. And that loop has a if (cancelled) check that stops the stream. If anything the jinja macros would be quite straightforward to process in similar way. Python code, maybe, with throwing exceptions as a way to force exit.

What I mean is that currently, if a macro is running and the user issues a CANCEL_PRINT or really any gcode (baby stepping, change temp, disable/enable fans, etc.) it will not be processed until the entire macro has finished executing. So for instance, if a user realizes during the START_PRINT macro that something is amiss, they have to use emergency shutdown to stop the printer because CANCEL or PAUSE won’t actually do anything until the START_PRINT macro is finished.

Any chance your python macro implementation alters this behavior so that the gcodes entered by the user get inserted into the queue mid-macro?