How to make joystick jogging smoother and lower latency?

Basic Information:

Printer Model: modified Cindy mill
MCU / Printerboard: BigTreeTech M8P (STM32H723)
Host / SBC Raspberry Pi CM4

I’m trying to use an analog joystick to jog my CNC router and I’m having the exact issues in lhelmig’s gamepad jogging video (sorry, can’t have > 2 links as new user)

This is the relevant printer.cfg for experiment 1. I also changed the ADC update rate (REPORT_TIME in adc_temperature.py) from 300 to 50ms.

[adc_temperature my_sensor_type]
voltage1: 0
temperature1:0
voltage2:5
temperature2:1

[temperature_sensor joystick_x]
sensor_type:my_sensor_type
sensor_pin:PF10
min_temp:0
max_temp:1

[temperature_sensor joystick_y]
sensor_type:my_sensor_type
sensor_pin:PC0
min_temp:0
max_temp:1

[delayed_gcode joystick_runner]
initial_duration: 0.1
gcode:
  {% set x = printer[“temperature_sensor joystick_x”].temperature - 0.5 %}
  {% set y = printer[“temperature_sensor joystick_y”].temperature - 0.5 %}
  {% set magnitude = [0.001, (x * x + y * y) ** 0.5]|max %}
  {% set dead_zone = 0.08 %}
  {% if magnitude > dead_zone %}
    G91
    G1 X{x*8} Y{y*8}
  {% endif %}
  UPDATE_DELAYED_GCODE ID=joystick_runner DURATION=0.2
  1. If the loop executes with duration=0.2, there is very little lag, but the moves are jerky, probably because the motion planner doesn’t have enough future moves, so deaccelerates after each.

    experiment1.zip (9.3 MB)

  2. If the loop executes every 0.1s (and lower X & Y multipliers from 8 to 4), the delay is 1s, but the motion is much smoother. Also it stutters.

experiment2.zip (9.4 MB)

I can totally understand the command queue getting full and block further commands from being issued, but that shouldn’t cause a 1s delay. It seems like when the joystick loop gets blocked, it prevents already issued commands from executing :flushed_face:

Is there such a priority inversion? Regardless, any ideas on how to improve things?

Klipper schedules everything up front.
It cannot control anything in real-time with precise timings.

*There was 2 second buffering time, and now there is 1 second

For more information, one can look here:

To paraphrase @nefelim4ag

The klipper motion planner is designed to move to a POSITION at a time with velocity and acceleration constraints. To synchronize multiple motors and multiple MCUs the moves are calculated (and loaded into the MCU) to be executed LATER.

You are attempting to get it to accept a VELOCITY input. Never going to work.

As far as I can see you need a custom stand alone jog module DESIGNED to accept velocity requests.

Here’s the thread that got the same behavior Real time control of a Voron 2.4 using a gamepad

that I couldn’t link earlier due to limit for newcombers. klippy.log for experiment 2 klippy.log (21.3 KB)

I’m painfully aware that a CPU and MCU communicating via 12Mbit/s UART isn’t conducive to low latency. But I’m not asking for very low latency. Even 200ms lag and 1 cm accuracy from over or undershoot is good enough for coarse grained jogging. Can always do fine grained jogging another way.

That’s not much to ask for is it? It would be a shame if even 200ms isn’t achievable and I would have to switch to Marlin, which has much less support on this board.

designed to move to a POSITION … You are attempting to get it to accept a VELOCITY input

Exactly, I want velocity control, which gcode doesn’t really allow. I thought the closest thing to velocity control was doing a bunch of relative G1 with constant distance and variable feedrate. But I don’t see how that’s much different than variable distance and constant feed rate. I tried both ways and they both suffer the 1s delay when loop executes every 100ms.

Could the lag and jerkiness be bypassed by using non-gcode commands like

toolhead.manual_move

FORCE_MOVE STEPPER

I don’t know if it is achievable. If you want to experiment, you can modify the XXX_TIME_XXX constants in klippy/toolhead.py along with the constants at the top of klippy/extras/motion_queuing.py . These constants control the amount of buffering in the host software. Alas, I don’t know of an easy way to tune these values - they are all interrelated and inappropriate settings can lead to print stalls (movement stutters) or “timer too close” errors.

If you do make any change to the host software, be sure to run a full “sudo service klipper restart”.

Maybe that helps a little,
-Kevin

You already have your joystick wired.

To solve the latency issues specifically for a “near real-time” feel You’d need to add a custom Python module to bypass G-code injection and talk to Klipper’s motion planner.

Here is a VIBE CODED outline of the .py module logic you would need to implement in your fork to achieve smooth motion:

import logging
from . import analog_in

class JogHandler:
    def __init__(self, config):
        self.printer = config.get_printer()
        self.toolhead = self.printer.lookup_object('toolhead')
        
        # Load config values
        self.max_velocity = config.getfloat('max_velocity', 50.0)
        self.deadzone = config.getfloat('deadzone', 0.05)
        self.quickstop_accel = config.getfloat('quickstop_accel', 1000.0)
        
        # Tracking state
        self._last_was_moving = False
        
        # Register the ADC pins for X and Y axes
        self.adc_x = analog_in.PrinterAnalogIn(config.get_section('analog_jog'), 'x_pin')
        self.adc_y = analog_in.PrinterAnalogIn(config.get_section('analog_jog'), 'y_pin')
        
        # Register a timer to poll at 20Hz (50ms)
        self.reactor = self.printer.get_reactor()
        self.reactor.register_timer(self._jog_loop, self.reactor.now() + 0.05)

    def _quick_stop(self):
        """Immediately halts the toolhead and clears the look-ahead buffer."""
        if self._last_was_moving:
            logging.info("JogHandler: Quickstop triggered.")
            # stop_move() is safer than M112 as it doesn't shut down the whole printer
            self.toolhead.stop_move()
            self._last_was_moving = False

    def _jog_loop(self, eventtime):
        # 1. Read and Normalize ADC
        val_x = self.adc_x.read() - 0.5
        val_y = self.adc_y.read() - 0.5

        # 2. Apply Deadzone
        if abs(val_x) < self.deadzone and abs(val_y) < self.deadzone:
            self._quick_stop()
            return eventtime + 0.05

        # 3. Calculate "Infinite" Target
        # Projecting 1000mm keeps the planner from slowing down prematurely
        curr_pos = self.toolhead.get_position()
        target_x = curr_pos[0] + (val_x * 1000)
        target_y = curr_pos[1] + (val_y * 1000)

        # 4. Scale Velocity (displacement * 2 because val is -0.5 to 0.5)
        speed = self.max_velocity * (max(abs(val_x), abs(val_y)) * 2)

        # 5. Inject move
        try:
            self.toolhead.manual_move([target_x, target_y, None, None], speed)
            self._last_was_moving = True
        except Exception as e:
            logging.error("Jog Error: %s" % (str(e),))
            self._quick_stop()

        return eventtime + 0.05

def load_config(config):
    return JogHandler(config)

I’m not a programmer so hopefully the other guys will chime in and point out errors that I’m sure exist.

Smoothness: As Kevin mentioned, you can further tune responsiveness by lowering LOOKAHEAD_TIME in toolhead.py to ~25ms once you have this Python loop running.

I have a working python module now. Unfortunately there’s no toolhead.stop_move() to do your approach of taking a large move for smoothness, and then stop when velocity changes. Maybe there’s something equivalent? So I implemented what I did earlier, and the behavior is the same as the earlier jogging using gcode.

joystick_jog.py.txt (3.0 KB)

I tried these in the Python code,

  1. try to estimate the # moves in the queue and don’t issue new ones if >= 4. It reduced the 1s delay back to ~200ms, but then it’s back to jerky moves.

  2. LOOKAHEAD_TIME in toolhead.py to ~25ms

I think you mean LOOKAHEAD_FLUSH_TIME, but didn’t help.

you can modify the XXX_TIME_XXX constants in klippy/toolhead.py

I didn’t know what else to try

  1. Try to see where it’s stuck by overriding the SIGINT handler to print the call stack of all threads when ctrl-C is pressed. I tried twice, pressing ctrl-C 0.5s after moving joystick and seeing nothing happening, and got this:
Thread 0x0000007f81f7f160 (most recent call first):
  File "/home/pi/klipper/klippy/serialhdl.py", line 46 in _bg_thread
  File "/usr/lib/python3.13/threading.py", line 994 in run
  File "/usr/lib/python3.13/threading.py", line 1043 in _bootstrap_inner
  File "/usr/lib/python3.13/threading.py", line 1014 in _bootstrap

Thread 0x0000007f97a5f160 (most recent call first):
  File "/usr/lib/python3.13/threading.py", line 359 in wait
  File "/usr/lib/python3.13/queue.py", line 202 in get
  File "/home/pi/klipper/klippy/queuelogger.py", line 34 in _bg_thread
  File "/usr/lib/python3.13/threading.py", line 994 in run
  File "/usr/lib/python3.13/threading.py", line 1043 in _bootstrap_inner
  File "/usr/lib/python3.13/threading.py", line 1014 in _bootstrap

Current thread 0x0000007f98d30ba0 (most recent call first):
  File "/home/pi/klipper/klippy/reactor.py", line 395 in _dispatch_loop

3 threads doesn’t match the description Code overview - Klipper documentation . Should have 4 + (motors = 4) = 8.

One thing I read that would explain the deacceleration after each move is,

When ToolHead._process_lookahead() resumes, everything about the move is known - its start location, its end location, its acceleration, its start/cruising/end velocity,

I can see it would be complicated for new moves coming in to influence the acceleration of the current move being executed. But it sounds like not only does that not happen, the newest pending moves don’t influence the earliest pending moves. Is LOOKAHEAD_FLUSH_TIME what controls the time between when a move is issued to when its start & end velocities are frozen?