diff --git a/docs/Config_Changes.md b/docs/Config_Changes.md index a649826c3..ffb0f8c07 100644 --- a/docs/Config_Changes.md +++ b/docs/Config_Changes.md @@ -8,6 +8,11 @@ All dates in this document are approximate. ## Changes +20260201: The manual_stepper `STOP_ON_ENDSTOP` feature may now take +less time to complete. Previously, the command would wait the entire +time the move could possibly take even if the endstop triggered +earlier. Now, the command finishes shortly after the endstop trigger. + 20260121: Kalico now uses automatic monthly release tags in the format `vYYYY.MM.NN` (e.g., `v2026.01.00`). Users can configure Moonraker to track stable monthly releases instead of the latest commits. See diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 9718b2e00..a279ca14b 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -3104,6 +3104,13 @@ printer kinematics. # Endstop switch detection pin. If specified, then one may perform # "homing moves" by adding a STOP_ON_ENDSTOP parameter to # MANUAL_STEPPER movement commands. +#position_min: +#position_max: +# The minimum and maximum position the stepper can be commanded to +# move to. If specified then one may not command the stepper to move +# past the given position. Note that these limits do not prevent +# setting an arbitrary position with the `MANUAL_STEPPER +# SET_POSITION=x` command. The default is to not enforce a limit. ``` ### [mixing_extruder] diff --git a/docs/G-Codes.md b/docs/G-Codes.md index f466e5c6a..7af5ec96f 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -1075,6 +1075,25 @@ scheduled to run after the stepper move completes, however if a manual stepper move uses SYNC=0 then future G-Code movement commands may run in parallel with the stepper movement. +`MANUAL_STEPPER STEPPER=config_name GCODE_AXIS=[A-Z] +[LIMIT_VELOCITY=] [LIMIT_ACCEL=] +[INSTANTANEOUS_CORNER_VELOCITY=]`: If the `GCODE_AXIS` +parameter is specified then it configures the stepper motor as an +extra axis on `G1` move commands. For example, if one were to issue a +`MANUAL_STEPPER ... GCODE_AXIS=R` command then one could issue +commands like `G1 X10 Y20 R30` to move the stepper motor. The +resulting moves will occur synchronously with the associated toolhead +xyz movements. If the motor is associated with a `GCODE_AXIS` then +one may no longer issue movements using the above `MANUAL_STEPPER` +command - one may unregister the stepper with a `MANUAL_STEPPER +... GCODE_AXIS=` command to resume manual control of the motor. The +`LIMIT_VELOCITY` and `LIMIT_ACCEL` parameters allow one to reduce the +speed of `G1` moves if those moves would result in a velocity or +acceleration above the specified limits. The +`INSTANTANEOUS_CORNER_VELOCITY` specifies the maximum instantaneous +velocity change (in mm/s) of the motor during the junction of two +moves (the default is 1mm/s). + ### [mcp4018] The following command is available when a diff --git a/klippy/extras/bed_mesh.py b/klippy/extras/bed_mesh.py index 32cb6f1c5..b19fe47a4 100644 --- a/klippy/extras/bed_mesh.py +++ b/klippy/extras/bed_mesh.py @@ -243,7 +243,8 @@ def get_position(self): self.last_position[2] -= self.fade_target else: # return current position minus the current z-adjustment - x, y, z, e = self.toolhead.get_position() + cur_pos = self.toolhead.get_position() + x, y, z = cur_pos[:3] max_adj = self.z_mesh.calc_z(x, y) factor = 1.0 z_adj = max_adj - self.fade_target @@ -260,21 +261,21 @@ def get_position(self): ) factor = constrain(factor, 0.0, 1.0) final_z_adj = factor * z_adj + self.fade_target - self.last_position[:] = [x, y, z - final_z_adj, e] + self.last_position[:] = [x, y, z - final_z_adj] + cur_pos[3:] return list(self.last_position) def move(self, newpos, speed): factor = self.get_z_factor(newpos[2]) if self.z_mesh is None or not factor: # No mesh calibrated, or mesh leveling phased out. - x, y, z, e = newpos + x, y, z = newpos[:3] if self.log_fade_complete: self.log_fade_complete = False logging.info( "bed_mesh fade complete: Current Z: %.4f fade_target: %.4f " % (z, self.fade_target) ) - self.toolhead.move([x, y, z + self.fade_target, e], speed) + self.toolhead.move([x, y, z + self.fade_target] + newpos[3:], speed) else: self.splitter.build_move(self.last_position, newpos, factor) while not self.splitter.traverse_complete: @@ -1188,7 +1189,7 @@ def build_move(self, prev_pos, next_pos, factor): self.z_offset = self._calc_z_offset(prev_pos) self.traverse_complete = False self.distance_checked = 0.0 - axes_d = [self.next_pos[i] - self.prev_pos[i] for i in range(4)] + axes_d = [np - pp for np, pp in zip(self.next_pos, self.prev_pos)] self.total_move_length = math.sqrt(sum([d * d for d in axes_d[:3]])) self.axis_move = [not isclose(d, 0.0, abs_tol=1e-10) for d in axes_d] @@ -1204,7 +1205,7 @@ def _set_next_move(self, distance_from_prev): "bed_mesh: Slice distance is negative " "or greater than entire move length" ) - for i in range(4): + for i in range(len(self.next_pos)): if self.axis_move[i]: self.current_pos[i] = lerp( t, self.prev_pos[i], self.next_pos[i] @@ -1223,12 +1224,9 @@ def split(self): next_z = self._calc_z_offset(self.current_pos) if abs(next_z - self.z_offset) >= self.split_delta_z: self.z_offset = next_z - return ( - self.current_pos[0], - self.current_pos[1], - self.current_pos[2] + self.z_offset, - self.current_pos[3], - ) + newpos = list(self.current_pos) + newpos[2] += self.z_offset + return newpos # end of move reached self.current_pos[:] = self.next_pos self.z_offset = self._calc_z_offset(self.current_pos) diff --git a/klippy/extras/bed_tilt.py b/klippy/extras/bed_tilt.py index 4a62b6a1b..e2e105044 100644 --- a/klippy/extras/bed_tilt.py +++ b/klippy/extras/bed_tilt.py @@ -30,25 +30,15 @@ def handle_connect(self): self.toolhead = self.printer.lookup_object("toolhead") def get_position(self): - x, y, z, e = self.toolhead.get_position() - return [ - x, - y, - z - x * self.x_adjust - y * self.y_adjust - self.z_adjust, - e, - ] + pos = self.toolhead.get_position() + x, y, z = pos[:3] + z -= x * self.x_adjust + y * self.y_adjust + self.z_adjust + return [x, y, z] + pos[3:] def move(self, newpos, speed): - x, y, z, e = newpos - self.toolhead.move( - [ - x, - y, - z + x * self.x_adjust + y * self.y_adjust + self.z_adjust, - e, - ], - speed, - ) + x, y, z = newpos[:3] + z += x * self.x_adjust + y * self.y_adjust + self.z_adjust + self.toolhead.move([x, y, z] + newpos[3:], speed) def update_adjust(self, x_adjust, y_adjust, z_adjust): self.x_adjust = x_adjust diff --git a/klippy/extras/exclude_object.py b/klippy/extras/exclude_object.py index 732f55de6..150ed50cc 100644 --- a/klippy/extras/exclude_object.py +++ b/klippy/extras/exclude_object.py @@ -102,26 +102,25 @@ def _reset_file(self): self._reset_state() self._unregister_transform() - def _get_extrusion_offsets(self): - offset = self.extrusion_offsets.get( - self.toolhead.get_extruder().get_name() - ) + def _get_extrusion_offsets(self, num_coord): + ename = self.toolhead.get_extruder().get_name() + offset = self.extrusion_offsets.get(ename) if offset is None: - offset = [0.0, 0.0, 0.0, 0.0] - self.extrusion_offsets[self.toolhead.get_extruder().get_name()] = ( - offset - ) + offset = [0.0] * num_coord + self.extrusion_offsets[ename] = offset + if len(offset) < num_coord: + offset.extend([0.0] * (len(num_coord) - len(offset))) return offset def get_position(self): - offset = self._get_extrusion_offsets() pos = self.next_transform.get_position() - for i in range(4): + offset = self._get_extrusion_offsets(len(pos)) + for i in range(len(pos)): self.last_position[i] = pos[i] + offset[i] return list(self.last_position) def _normal_move(self, newpos, speed): - offset = self._get_extrusion_offsets() + offset = self._get_extrusion_offsets(len(newpos)) if ( self.initial_extrusion_moves > 0 @@ -147,9 +146,9 @@ def _normal_move(self, newpos, speed): newpos[0] != self.last_position_excluded[0] or newpos[1] != self.last_position_excluded[1] ): - offset[0] = 0 - offset[1] = 0 - offset[2] = 0 + for i in range(len(newpos)): + if i != 3: + offset[i] = 0 offset[3] += self.extruder_adj self.extruder_adj = 0 @@ -164,14 +163,15 @@ def _normal_move(self, newpos, speed): self.extruder_adj = 0 tx_pos = newpos[:] - for i in range(4): + for i in range(len(newpos)): tx_pos[i] = newpos[i] - offset[i] self.next_transform.move(tx_pos, speed) def _ignore_move(self, newpos, speed): - offset = self._get_extrusion_offsets() - for i in range(3): - offset[i] = newpos[i] - self.last_position_extruded[i] + offset = self._get_extrusion_offsets(len(newpos)) + for i in range(len(newpos)): + if i != 3: + offset[i] = newpos[i] - self.last_position_extruded[i] offset[3] = offset[3] + newpos[3] - self.last_position[3] self.last_position[:] = newpos self.last_position_excluded[:] = self.last_position diff --git a/klippy/extras/force_move.py b/klippy/extras/force_move.py index 46f3d72a0..b292a865f 100644 --- a/klippy/extras/force_move.py +++ b/klippy/extras/force_move.py @@ -186,7 +186,7 @@ def cmd_SET_KINEMATIC_POSITION(self, gcmd): z, ",".join((axes[i] for i in clear_axes)), ) - toolhead.set_position([x, y, z, curpos[3]], homing_axes=(0, 1, 2)) + toolhead.set_position([x, y, z], homing_axes=(0, 1, 2)) toolhead.get_kinematics().clear_homing_state(clear_axes) diff --git a/klippy/extras/gcode_move.py b/klippy/extras/gcode_move.py index 1868d7a6f..e6930f9fb 100644 --- a/klippy/extras/gcode_move.py +++ b/klippy/extras/gcode_move.py @@ -1,6 +1,6 @@ # G-Code G1 movement commands (and associated coordinate manipulation) # -# Copyright (C) 2016-2021 Kevin O'Connor +# Copyright (C) 2016-2025 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import logging @@ -17,6 +17,9 @@ def __init__(self, config): printer.register_event_handler( "toolhead:manual_move", self.reset_last_position ) + printer.register_event_handler( + "toolhead:update_extra_axes", self._update_extra_axes + ) printer.register_event_handler( "gcode:command_error", self.reset_last_position ) @@ -62,6 +65,7 @@ def __init__(self, config): self.base_position = [0.0, 0.0, 0.0, 0.0] self.last_position = [0.0, 0.0, 0.0, 0.0] self.homing_position = [0.0, 0.0, 0.0, 0.0] + self.axis_map = {"X": 0, "Y": 1, "Z": 2, "E": 3} self.speed = 25.0 self.speed_factor = 1.0 / 60.0 self.extrude_factor = 1.0 @@ -138,37 +142,49 @@ def get_status(self, eventtime=None): "extrude_factor": self.extrude_factor, "absolute_coordinates": self.absolute_coord, "absolute_extrude": self.absolute_extrude, - "homing_origin": self.Coord(*self.homing_position), - "position": self.Coord(*self.last_position), - "gcode_position": self.Coord(*move_position), + "homing_origin": self.Coord(*self.homing_position[:4]), + "position": self.Coord(*self.last_position[:4]), + "gcode_position": self.Coord(*move_position[:4]), } def reset_last_position(self): if self.is_printer_ready: self.last_position = self.position_with_transform() + def _update_extra_axes(self): + toolhead = self.printer.lookup_object("toolhead") + axis_map = {"X": 0, "Y": 1, "Z": 2, "E": 3} + extra_axes = toolhead.get_extra_axes() + for index, ea in enumerate(extra_axes): + if ea is None: + continue + gcode_id = ea.get_axis_gcode_id() + if gcode_id is None or gcode_id in axis_map or gcode_id in "FN": + continue + axis_map[gcode_id] = index + self.axis_map = axis_map + self.base_position[4:] = [0.0] * (len(extra_axes) - 4) + self.reset_last_position() + # G-Code movement commands def cmd_G1(self, gcmd): # Move params = gcmd.get_command_parameters() try: - for pos, axis in enumerate("XYZ"): + for axis, pos in self.axis_map.items(): if axis in params: v = float(params[axis]) - if not self.absolute_coord: + absolute_coord = self.absolute_coord + if axis == "E": + v *= self.extrude_factor + if not self.absolute_extrude: + absolute_coord = False + if not absolute_coord: # value relative to position of last move self.last_position[pos] += v else: # value relative to base coordinate position self.last_position[pos] = v + self.base_position[pos] - if "E" in params: - v = float(params["E"]) * self.extrude_factor - if not self.absolute_coord or not self.absolute_extrude: - # value relative to position of last move - self.last_position[3] += v - else: - # value relative to base coordinate position - self.last_position[3] = v + self.base_position[3] if "F" in params: gcode_speed = float(params["F"]) if gcode_speed <= 0.0: @@ -216,7 +232,7 @@ def cmd_G92(self, gcmd): offset *= self.extrude_factor self.base_position[i] = self.last_position[i] - offset if offsets == [None, None, None, None]: - self.base_position = list(self.last_position) + self.base_position[:4] = self.last_position[:4] def cmd_M114(self, gcmd): # Get Current Position @@ -284,7 +300,7 @@ def cmd_RESTORE_GCODE_STATE(self, gcmd): # Restore state self.absolute_coord = state["absolute_coord"] self.absolute_extrude = state["absolute_extrude"] - self.base_position = list(state["base_position"]) + self.base_position[:4] = state["base_position"][:4] self.homing_position = list(state["homing_position"]) self.speed = state["speed"] self.speed_factor = state["speed_factor"] @@ -318,7 +334,7 @@ def cmd_GET_POSITION(self, gcmd): toolhead_pos = " ".join( [ "%s:%.6f" % (a, v) - for a, v in zip("XYZE", toolhead.get_position()) + for a, v in zip("XYZE", toolhead.get_position()[:4]) ] ) gcode_pos = " ".join( diff --git a/klippy/extras/manual_stepper.py b/klippy/extras/manual_stepper.py index 6fe2598c3..949d215c1 100644 --- a/klippy/extras/manual_stepper.py +++ b/klippy/extras/manual_stepper.py @@ -1,6 +1,6 @@ # Support for a manual controlled stepper # -# Copyright (C) 2019-2021 Kevin O'Connor +# Copyright (C) 2019-2025 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. from klippy import chelper, stepper @@ -26,6 +26,8 @@ def __init__(self, config): "accel", 0.0, minval=0.0 ) self.next_cmd_time = 0.0 + self.pos_min = config.getfloat("position_min", None) + self.pos_max = config.getfloat("position_max", None) # Setup iterative solver ffi_main, ffi_lib = chelper.get_ffi() self.trapq = ffi_main.gc(ffi_lib.trapq_alloc(), ffi_lib.trapq_free) @@ -33,6 +35,10 @@ def __init__(self, config): self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves self.rail.setup_itersolve("cartesian_stepper_alloc", b"x") self.rail.set_trapq(self.trapq) + # Registered with toolhead as an axtra axis + self.axis_gcode_id = None + self.instant_corner_v = 0.0 + self.gaxis_limit_velocity = self.gaxis_limit_accel = 0.0 # Register commands stepper_name = config.get_name().split()[1] gcode = self.printer.lookup_object("gcode") @@ -68,8 +74,7 @@ def do_enable(self, enable): def do_set_position(self, setpos): self.rail.set_position([setpos, 0.0, 0.0]) - def do_move(self, movepos, speed, accel, sync=True): - self.sync_print_time() + def _submit_move(self, movetime, movepos, speed, accel): cp = self.rail.get_commanded_position() dist = movepos - cp axis_r, accel_t, cruise_t, cruise_v = force_move.calc_move_time( @@ -77,7 +82,7 @@ def do_move(self, movepos, speed, accel, sync=True): ) self.trapq_append( self.trapq, - self.next_cmd_time, + movetime, accel_t, cruise_t, accel_t, @@ -91,7 +96,13 @@ def do_move(self, movepos, speed, accel, sync=True): cruise_v, accel, ) - self.next_cmd_time = self.next_cmd_time + accel_t + cruise_t + accel_t + return movetime + accel_t + cruise_t + accel_t + + def do_move(self, movepos, speed, accel, sync=True): + self.sync_print_time() + self.next_cmd_time = self._submit_move( + self.next_cmd_time, movepos, speed, accel + ) self.rail.generate_steps(self.next_cmd_time) self.trapq_finalize_moves( self.trapq, @@ -119,6 +130,10 @@ def do_homing_move(self, movepos, speed, accel, triggered, check_trigger): cmd_MANUAL_STEPPER_help = "Command a manually configured stepper" def cmd_MANUAL_STEPPER(self, gcmd): + if gcmd.get("GCODE_AXIS", None) is not None: + return self.command_with_gcode_axis(gcmd) + if self.axis_gcode_id is not None: + raise gcmd.error("Must unregister from gcode axis first") enable = gcmd.get_int("ENABLE", None) if enable is not None: self.do_enable(enable) @@ -130,16 +145,111 @@ def cmd_MANUAL_STEPPER(self, gcmd): homing_move = gcmd.get_int("STOP_ON_ENDSTOP", 0) if homing_move: movepos = gcmd.get_float("MOVE") + if (self.pos_min is not None and movepos < self.pos_min) or ( + self.pos_max is not None and movepos > self.pos_max + ): + raise gcmd.error("Move out of range") self.do_homing_move( movepos, speed, accel, homing_move > 0, abs(homing_move) == 1 ) elif gcmd.get_float("MOVE", None) is not None: movepos = gcmd.get_float("MOVE") + if (self.pos_min is not None and movepos < self.pos_min) or ( + self.pos_max is not None and movepos > self.pos_max + ): + raise gcmd.error("Move out of range") sync = gcmd.get_int("SYNC", 1) self.do_move(movepos, speed, accel, sync) elif gcmd.get_int("SYNC", 0): self.sync_print_time() + # Register as a gcode axis + def command_with_gcode_axis(self, gcmd): + gcode_move = self.printer.lookup_object("gcode_move") + toolhead = self.printer.lookup_object("toolhead") + gcode_axis = gcmd.get("GCODE_AXIS").upper() + instant_corner_v = gcmd.get_float( + "INSTANTANEOUS_CORNER_VELOCITY", 1.0, minval=0.0 + ) + limit_velocity = gcmd.get_float("LIMIT_VELOCITY", 999999.9, above=0.0) + limit_accel = gcmd.get_float("LIMIT_ACCEL", 999999.9, above=0.0) + if self.axis_gcode_id is not None: + if gcode_axis: + raise gcmd.error("Must unregister axis first") + # Unregister + toolhead.remove_extra_axis(self) + toolhead.unregister_step_generator(self.rail.generate_steps) + self.axis_gcode_id = None + return + if ( + len(gcode_axis) != 1 + or not gcode_axis.isupper() + or gcode_axis in "XYZEFN" + ): + if not gcode_axis: + # Request to unregister already unregistered axis + return + raise gcmd.error("Not a valid GCODE_AXIS") + for ea in toolhead.get_extra_axes(): + if ea is not None and ea.get_axis_gcode_id() == gcode_axis: + raise gcmd.error("Axis '%s' already registered" % (gcode_axis,)) + self.axis_gcode_id = gcode_axis + self.instant_corner_v = instant_corner_v + self.gaxis_limit_velocity = limit_velocity + self.gaxis_limit_accel = limit_accel + toolhead.add_extra_axis(self, self.get_position()[0]) + toolhead.register_step_generator(self.rail.generate_steps) + + def process_move(self, print_time, move, ea_index): + axis_r = move.axes_r[ea_index] + start_pos = move.start_pos[ea_index] + accel = move.accel * axis_r + start_v = move.start_v * axis_r + cruise_v = move.cruise_v * axis_r + self.trapq_append( + self.trapq, + print_time, + move.accel_t, + move.cruise_t, + move.decel_t, + start_pos, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + start_v, + cruise_v, + accel, + ) + + def check_move(self, move, ea_index): + # Check move is in bounds + movepos = move.end_pos[ea_index] + if (self.pos_min is not None and movepos < self.pos_min) or ( + self.pos_max is not None and movepos > self.pos_max + ): + raise move.move_error() + # Check if need to limit maximum velocity and acceleration + axis_ratio = move.move_d / abs(move.axes_d[ea_index]) + limit_velocity = self.gaxis_limit_velocity * axis_ratio + limit_accel = self.gaxis_limit_accel * axis_ratio + if not move.is_kinematic_move and self.accel: + limit_accel = min(limit_accel, self.accel * axis_ratio) + move.limit_speed(limit_velocity, limit_accel) + + def calc_junction(self, prev_move, move, ea_index): + diff_r = move.axes_r[ea_index] - prev_move.axes_r[ea_index] + if diff_r: + return (self.instant_corner_v / abs(diff_r)) ** 2 + return move.max_cruise_v2 + + def get_axis_gcode_id(self): + return self.axis_gcode_id + + def get_trapq(self): + return self.trapq + # Toolhead wrappers to support homing def flush_step_generation(self): self.sync_print_time() @@ -158,7 +268,19 @@ def dwell(self, delay): self.next_cmd_time += max(0.0, delay) def drip_move(self, newpos, speed, drip_completion): - self.do_move(newpos[0], speed, self.homing_accel) + # Submit move to trapq + self.sync_print_time() + maxtime = self._submit_move( + self.next_cmd_time, newpos[0], speed, self.homing_accel + ) + # Drip updates to motors + toolhead = self.printer.lookup_object("toolhead") + toolhead.drip_update_time(maxtime, drip_completion, self.steppers) + # Clear trapq of any remaining parts of movement + reactor = self.printer.get_reactor() + self.trapq_finalize_moves(self.trapq, reactor.NEVER, 0) + self.rail.set_position([newpos[0], 0.0, 0.0]) + self.sync_print_time() def get_kinematics(self): return self diff --git a/klippy/extras/resonance_tester.py b/klippy/extras/resonance_tester.py index 879485304..4fddf59c9 100644 --- a/klippy/extras/resonance_tester.py +++ b/klippy/extras/resonance_tester.py @@ -238,7 +238,10 @@ def run_test(self, test_seq, axis, freq_end, accel_per_hz, gcmd): def _run_test(self, test_seq, axis, gcmd): reactor = self.printer.get_reactor() toolhead = self.printer.lookup_object("toolhead") - X, Y, Z, E = toolhead.get_position() + tpos = toolhead.get_position() + X, Y = tpos[:2] + # Override maximum acceleration and acceleration to + # deceleration based on the maximum test frequency systime = reactor.monotonic() toolhead_info = toolhead.get_status(systime) old_max_accel = toolhead_info["max_accel"] @@ -267,10 +270,10 @@ def _run_test(self, test_seq, axis, gcmd): # The move first goes to a complete stop, then changes direction d_decel = -last_v2 * half_inv_accel decel_X, decel_Y = axis.get_point(d_decel) - toolhead.move([X + decel_X, Y + decel_Y, Z, E], abs_last_v) - toolhead.move([nX, nY, Z, E], abs_v) + toolhead.move([X + decel_X, Y + decel_Y] + tpos[2:], abs_last_v) + toolhead.move([nX, nY] + tpos[2:], abs_v) else: - toolhead.move([nX, nY, Z, E], max(abs_v, abs_last_v)) + toolhead.move([nX, nY] + tpos[2:], max(abs_v, abs_last_v)) if math.floor(freq) > math.floor(last_freq): gcmd.respond_info("Testing frequency %.0f Hz" % (freq,)) reactor.pause(reactor.monotonic() + 0.01) @@ -286,7 +289,7 @@ def _run_test(self, test_seq, axis, gcmd): "M204", "M204", {"S": old_max_accel} ) ) - toolhead.move([X + decel_X, Y + decel_Y, Z, E], abs(last_v)) + toolhead.move([X + decel_X, Y + decel_Y] + tpos[2:], abs(last_v)) class ResonanceTester: diff --git a/klippy/extras/skew_correction.py b/klippy/extras/skew_correction.py index 311c02900..d3144ee51 100644 --- a/klippy/extras/skew_correction.py +++ b/klippy/extras/skew_correction.py @@ -78,12 +78,12 @@ def calc_skew(self, pos): - pos[2] * (self.xz_factor - (self.xy_factor * self.yz_factor)) ) skewed_y = pos[1] - pos[2] * self.yz_factor - return [skewed_x, skewed_y, pos[2], pos[3]] + return [skewed_x, skewed_y] + pos[2:] def calc_unskew(self, pos): skewed_x = pos[0] + pos[1] * self.xy_factor + pos[2] * self.xz_factor skewed_y = pos[1] + pos[2] * self.yz_factor - return [skewed_x, skewed_y, pos[2], pos[3]] + return [skewed_x, skewed_y] + pos[2:] def get_position(self): return self.calc_unskew(self.next_transform.get_position()) diff --git a/klippy/extras/trad_rack.py b/klippy/extras/trad_rack.py index cac95a1d1..c334d07b0 100644 --- a/klippy/extras/trad_rack.py +++ b/klippy/extras/trad_rack.py @@ -12,7 +12,7 @@ from .. import chelper, toolhead from ..gcode import CommandError -from ..kinematics import extruder +from ..kinematics import extruder as kinematics_extruder from ..stepper import LookupMultiRail from .homing import Homing, HomingMove @@ -2321,7 +2321,7 @@ def __init__(self, config, buffer_pull_speed, is_extruder_synced): ] self.mcu = self.all_mcus[0] if hasattr(toolhead, "LookAheadQueue"): - self.lookahead = toolhead.LookAheadQueue(self) + self.lookahead = toolhead.LookAheadQueue() self.lookahead.set_flush_time(toolhead.BUFFER_TIME_HIGH) else: self.move_queue = toolhead.MoveQueue(self) @@ -2372,7 +2372,6 @@ def __init__(self, config, buffer_pull_speed, is_extruder_synced): self.print_time = 0.0 self.special_queuing_state = "NeedPrime" self.priming_timer = None - self.drip_completion = None # Flush tracking self.flush_timer = self.reactor.register_timer(self._flush_handler) self.do_kick_flush_timer = True @@ -2390,11 +2389,14 @@ def __init__(self, config, buffer_pull_speed, is_extruder_synced): self.trapq = ffi_main.gc(ffi_lib.trapq_alloc(), ffi_lib.trapq_free) self.trapq_append = ffi_lib.trapq_append self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves + # Motion flushing self.step_generators = [] + self.flush_trapqs = [self.trapq] # Create kinematic class gcode = self.printer.lookup_object("gcode") self.Coord = gcode.Coord - self.extruder = extruder.DummyExtruder(self.printer) + extruder = kinematics_extruder.DummyExtruder(self.printer) + self.extra_axes = [extruder] try: self.kin = TradRackKinematics(self, config, is_extruder_synced) except config.error as e: diff --git a/klippy/extras/z_thermal_adjust.py b/klippy/extras/z_thermal_adjust.py index 24e030318..45222afed 100644 --- a/klippy/extras/z_thermal_adjust.py +++ b/klippy/extras/z_thermal_adjust.py @@ -124,12 +124,12 @@ def calc_adjust(self, pos): # Apply Z adjustment new_z = pos[2] + self.z_adjust_mm - return [pos[0], pos[1], new_z, pos[3]] + return [pos[0], pos[1], new_z] + pos[3:] def calc_unadjust(self, pos): "Remove Z adjustment" unadjusted_z = pos[2] - self.z_adjust_mm - return [pos[0], pos[1], unadjusted_z, pos[3]] + return [pos[0], pos[1], unadjusted_z] + pos[3:] def get_position(self): position = self.calc_unadjust(self.next_transform.get_position()) diff --git a/klippy/kinematics/extruder.py b/klippy/kinematics/extruder.py index d421eb32f..526e9ff78 100644 --- a/klippy/kinematics/extruder.py +++ b/klippy/kinematics/extruder.py @@ -1,6 +1,6 @@ # Code for handling printer nozzle extruders # -# Copyright (C) 2016-2022 Kevin O'Connor +# Copyright (C) 2016-2025 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import logging @@ -262,9 +262,6 @@ def __init__(self, config, extruder_num): desc=self.cmd_ACTIVATE_EXTRUDER_help, ) - def update_move_time(self, flush_time, clear_history_time): - self.trapq_finalize_moves(self.trapq, flush_time, clear_history_time) - def get_status(self, eventtime): sts = self.heater.get_status(eventtime) sts["can_extrude"] = self.heater.can_extrude @@ -281,23 +278,27 @@ def get_heater(self): def get_trapq(self): return self.trapq + def get_axis_gcode_id(self): + return "E" + def stats(self, eventtime): return self.heater.stats(eventtime) - def check_move(self, move): - axis_r = move.axes_r[3] + def check_move(self, move, ea_index): if not self.heater.can_extrude: raise self.printer.command_error( "Extrude below minimum temp\n" "See the 'min_extrude_temp' config option for details" ) + axis_r = move.axes_r[ea_index] + axis_d = move.axes_d[ea_index] if (not move.axes_d[0] and not move.axes_d[1]) or axis_r < 0.0: # Extrude only move (or retraction move) - limit accel and velocity - if abs(move.axes_d[3]) > self.max_e_dist: + if abs(axis_d) > self.max_e_dist: raise self.printer.command_error( "Extrude only move too long (%.3fmm vs %.3fmm)\n" "See the 'max_extrude_only_distance' config" - " option for details" % (move.axes_d[3], self.max_e_dist) + " option for details" % (axis_d, self.max_e_dist) ) inv_extrude_r = 1.0 / abs(axis_r) move.limit_speed( @@ -305,7 +306,7 @@ def check_move(self, move): self.max_e_accel * inv_extrude_r, ) elif axis_r > self.max_extrude_ratio: - if move.axes_d[3] <= self.nozzle_diameter * self.max_extrude_ratio: + if axis_d <= self.nozzle_diameter * self.max_extrude_ratio: # Permit extrusion if amount extruded is tiny return area = axis_r * self.filament_area @@ -322,14 +323,14 @@ def check_move(self, move): % (area, self.max_extrude_ratio * self.filament_area) ) - def calc_junction(self, prev_move, move): - diff_r = move.axes_r[3] - prev_move.axes_r[3] + def calc_junction(self, prev_move, move, ea_index): + diff_r = move.axes_r[ea_index] - prev_move.axes_r[ea_index] if diff_r: return (self.instant_corner_v / abs(diff_r)) ** 2 return move.max_cruise_v2 - def move(self, print_time, move): - axis_r = move.axes_r[3] + def process_move(self, print_time, move, ea_index): + axis_r = move.axes_r[ea_index] accel = move.accel * axis_r start_v = move.start_v * axis_r cruise_v = move.cruise_v * axis_r @@ -347,7 +348,7 @@ def move(self, print_time, move): move.accel_t, move.cruise_t, move.decel_t, - move.start_pos[3], + move.start_pos[ea_index], 0.0, 0.0, 1.0, @@ -357,7 +358,7 @@ def move(self, print_time, move): cruise_v, accel, ) - self.last_position = move.end_pos[3] + self.last_position = move.end_pos[ea_index] def find_past_position(self, print_time): if self.extruder_stepper is None: @@ -422,16 +423,13 @@ class DummyExtruder: def __init__(self, printer): self.printer = printer - def update_move_time(self, flush_time, clear_history_time): - pass - - def check_move(self, move): + def check_move(self, move, ea_index): raise move.move_error("Extrude when no extruder present") def find_past_position(self, print_time): return 0.0 - def calc_junction(self, prev_move, move): + def calc_junction(self, prev_move, move, ea_index): return move.max_cruise_v2 def get_name(self): @@ -441,7 +439,10 @@ def get_heater(self): raise self.printer.command_error("Extruder not configured") def get_trapq(self): - raise self.printer.command_error("Extruder not configured") + return None + + def get_axis_gcode_id(self): + return "E" def add_printer_objects(config): diff --git a/klippy/toolhead.py b/klippy/toolhead.py index c8dc22a6d..be5d2bc0d 100644 --- a/klippy/toolhead.py +++ b/klippy/toolhead.py @@ -1,6 +1,6 @@ # Code for coordinating events on the printer toolhead # -# Copyright (C) 2016-2024 Kevin O'Connor +# Copyright (C) 2016-2025 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import importlib @@ -9,7 +9,7 @@ from . import chelper from .extras.danger_options import get_danger_options -from .kinematics import extruder +from .kinematics import extruder as kinematics_extruder # Common suffixes: _d is distance (in mm), _v is velocity (in # mm/second), _v2 is velocity squared (mm^2/s^2), _t is time (in @@ -27,7 +27,7 @@ def __init__(self, toolhead, start_pos, end_pos, speed): self.timing_callbacks = [] velocity = min(speed, toolhead.max_velocity) self.is_kinematic_move = True - self.axes_d = axes_d = [end_pos[i] - start_pos[i] for i in (0, 1, 2, 3)] + self.axes_d = axes_d = [ep - sp for sp, ep in zip(start_pos, end_pos)] self.move_d = move_d = math.sqrt(sum([d * d for d in axes_d[:3]])) if move_d < 0.000000001: # Extrude only move @@ -35,10 +35,9 @@ def __init__(self, toolhead, start_pos, end_pos, speed): start_pos[0], start_pos[1], start_pos[2], - end_pos[3], - ) + ) + self.end_pos[3:] axes_d[0] = axes_d[1] = axes_d[2] = 0.0 - self.move_d = move_d = abs(axes_d[3]) + self.move_d = move_d = max([abs(ad) for ad in axes_d[3:]]) inv_move_d = 0.0 if move_d: inv_move_d = 1.0 / move_d @@ -79,14 +78,19 @@ def move_error(self, msg="Move out of range"): def calc_junction(self, prev_move): if not self.is_kinematic_move or not prev_move.is_kinematic_move: return - # Allow extruder to calculate its maximum junction - extruder_v2 = self.toolhead.extruder.calc_junction(prev_move, self) + # Allow extra axes to calculate maximum junction + ea_v2 = [ + ea.calc_junction(prev_move, self, e_index + 3) + for e_index, ea in enumerate(self.toolhead.extra_axes) + ] max_start_v2 = min( - extruder_v2, - self.max_cruise_v2, - prev_move.max_cruise_v2, - prev_move.next_junction_v2, - prev_move.max_start_v2 + prev_move.delta_v2, + [ + self.max_cruise_v2, + prev_move.max_cruise_v2, + prev_move.next_junction_v2, + prev_move.max_start_v2 + prev_move.delta_v2, + ] + + ea_v2 ) # Find max velocity using "approximated centripetal velocity" axes_r = self.axes_r @@ -144,8 +148,7 @@ def set_junction(self, start_v2, cruise_v2, end_v2): # Class to track a list of pending move requests and to facilitate # "look-ahead" across moves to reduce acceleration between moves. class LookAheadQueue: - def __init__(self, toolhead): - self.toolhead = toolhead + def __init__(self): self.queue = [] self.junction_flush = LOOKAHEAD_FLUSH_TIME @@ -219,11 +222,11 @@ def flush(self, lazy=False): next_end_v2 = start_v2 next_smoothed_v2 = smoothed_v2 if update_flush_count or not flush_count: - return - # Generate step times for all moves ready to be flushed - self.toolhead._process_moves(queue[:flush_count]) + return [] # Remove processed moves from the queue + res = queue[:flush_count] del queue[:flush_count] + return res def add_move(self, move): self.queue.append(move) @@ -231,9 +234,8 @@ def add_move(self, move): return move.calc_junction(self.queue[-2]) self.junction_flush -= move.min_move_t - if self.junction_flush <= 0.0: - # Enough moves have been queued to reach the target flush time. - self.flush(lazy=True) + # Check if enough moves have been queued to reach the target flush time. + return self.junction_flush <= 0.0 BUFFER_TIME_LOW = 1.0 @@ -251,10 +253,6 @@ def add_move(self, move): DRIP_TIME = 0.100 -class DripModeEndSignal(Exception): - pass - - # Main code to track events (and their timing) on the printer toolhead class ToolHead: def __init__(self, config): @@ -264,7 +262,7 @@ def __init__(self, config): m for n, m in self.printer.lookup_objects(module="mcu") ] self.mcu = self.all_mcus[0] - self.lookahead = LookAheadQueue(self) + self.lookahead = LookAheadQueue() self.lookahead.set_flush_time(BUFFER_TIME_HIGH) self.commanded_pos = [0.0, 0.0, 0.0, 0.0] # Velocity and acceleration control @@ -305,7 +303,6 @@ def __init__(self, config): self.print_time = 0.0 self.special_queuing_state = "NeedPrime" self.priming_timer = None - self.drip_completion = None # Flush tracking self.flush_timer = self.reactor.register_timer(self._flush_handler) self.do_kick_flush_timer = True @@ -321,11 +318,14 @@ def __init__(self, config): self.trapq = ffi_main.gc(ffi_lib.trapq_alloc(), ffi_lib.trapq_free) self.trapq_append = ffi_lib.trapq_append self.trapq_finalize_moves = ffi_lib.trapq_finalize_moves + # Motion flushing self.step_generators = [] + self.flush_trapqs = [self.trapq] # Create kinematics class gcode = self.printer.lookup_object("gcode") self.Coord = gcode.Coord - self.extruder = extruder.DummyExtruder(self.printer) + extruder = kinematics_extruder.DummyExtruder(self.printer) + self.extra_axes = [extruder] kin_name = config.get("kinematics") try: mod = importlib.import_module("klippy.kinematics." + kin_name) @@ -417,8 +417,8 @@ def _advance_flush_time(self, flush_time): if not self.can_pause: clear_history_time = flush_time - MOVE_HISTORY_EXPIRE free_time = sg_flush_time - self.kin_flush_delay - self.trapq_finalize_moves(self.trapq, free_time, clear_history_time) - self.extruder.update_move_time(free_time, clear_history_time) + for trapq in self.flush_trapqs: + self.trapq_finalize_moves(trapq, free_time, clear_history_time) # Flush stepcompress and mcu steppersync for m in self.all_mcus: m.flush_moves(flush_time, clear_history_time) @@ -450,13 +450,15 @@ def _calc_print_time(self): self.print_time, ) - def _process_moves(self, moves): + def _process_lookahead(self, lazy=False): + moves = self.lookahead.flush(lazy=lazy) + if not moves: + return # Resync print_time if necessary if self.special_queuing_state: - if self.special_queuing_state != "Drip": - # Transition from "NeedPrime"/"Priming" state to main state - self.special_queuing_state = "" - self.need_check_pause = -1.0 + # Transition from "NeedPrime"/"Priming" state to main state + self.special_queuing_state = "" + self.need_check_pause = -1.0 self._calc_print_time() # Queue moves into trapezoid motion queue (trapq) next_move_time = self.print_time @@ -478,16 +480,15 @@ def _process_moves(self, moves): move.cruise_v, move.accel, ) - if move.axes_d[3]: - self.extruder.move(next_move_time, move) + for e_index, ea in enumerate(self.extra_axes): + if move.axes_d[e_index + 3]: + ea.process_move(next_move_time, move, e_index + 3) next_move_time = ( next_move_time + move.accel_t + move.cruise_t + move.decel_t ) for cb in move.timing_callbacks: cb(next_move_time) # Generate steps for moves - if self.special_queuing_state: - self._update_drip_move_time(next_move_time) self.note_mcu_movequeue_activity( next_move_time + self.kin_flush_delay, set_step_gen_time=True ) @@ -495,7 +496,7 @@ def _process_moves(self, moves): def _flush_lookahead(self): # Transit from "NeedPrime"/"Priming"/"Drip"/main state to "NeedPrime" - self.lookahead.flush() + self._process_lookahead() self.special_queuing_state = "NeedPrime" self.need_check_pause = -1.0 self.lookahead.set_flush_time(BUFFER_TIME_HIGH) @@ -511,7 +512,7 @@ def get_last_move_time(self): self._flush_lookahead() self._calc_print_time() else: - self.lookahead.flush() + self._process_lookahead() return self.print_time def _check_pause(self): @@ -603,7 +604,7 @@ def set_position(self, newpos, homing_axes=()): ffi_lib.trapq_set_position( self.trapq, self.print_time, newpos[0], newpos[1], newpos[2] ) - self.commanded_pos[:] = newpos + self.commanded_pos[:3] = newpos[:3] self.kin.set_position(newpos, homing_axes) self.printer.send_event("toolhead:set_position") @@ -618,10 +619,13 @@ def move(self, newpos, speed): return if move.is_kinematic_move: self.kin.check_move(move) - if move.axes_d[3]: - self.extruder.check_move(move) + for e_index, ea in enumerate(self.extra_axes): + if move.axes_d[e_index + 3]: + ea.check_move(move, e_index + 3) self.commanded_pos[:] = move.end_pos - self.lookahead.add_move(move) + want_flush = self.lookahead.add_move(move) + if want_flush: + self._process_lookahead(lazy=True) if self.print_time > self.need_check_pause: self._check_pause() @@ -650,58 +654,120 @@ def wait_moves(self): eventtime = self.reactor.pause(eventtime + 0.100) def set_extruder(self, extruder, extrude_pos): - self.extruder = extruder + # XXX - should use add_extra_axis + prev_ea_trapq = self.extra_axes[0].get_trapq() + if prev_ea_trapq in self.flush_trapqs: + self.flush_trapqs.remove(prev_ea_trapq) + self.extra_axes[0] = extruder self.commanded_pos[3] = extrude_pos + ea_trapq = extruder.get_trapq() + if ea_trapq is not None: + self.flush_trapqs.append(ea_trapq) def get_extruder(self): - return self.extruder + return self.extra_axes[0] + + def add_extra_axis(self, ea, axis_pos): + self._flush_lookahead() + self.extra_axes.append(ea) + self.commanded_pos.append(axis_pos) + ea_trapq = ea.get_trapq() + if ea_trapq is not None: + self.flush_trapqs.append(ea_trapq) + self.printer.send_event("toolhead:update_extra_axes") + + def remove_extra_axis(self, ea): + self._flush_lookahead() + if ea not in self.extra_axes: + return + ea_index = self.extra_axes.index(ea) + 3 + ea_trapq = ea.get_trapq() + if ea_trapq in self.flush_trapqs: + self.flush_trapqs.remove(ea_trapq) + self.commanded_pos.pop(ea_index) + self.extra_axes.pop(ea_index - 3) + self.printer.send_event("toolhead:update_extra_axes") + + def get_extra_axes(self): + return [None, None, None] + self.extra_axes # Homing "drip move" handling - def _update_drip_move_time(self, next_print_time): + def drip_update_time(self, next_print_time, drip_completion, addstepper=()): + # Transition from "NeedPrime"/"Priming"/main state to "Drip" state + self.special_queuing_state = "Drip" + self.need_check_pause = self.reactor.NEVER + self.reactor.update_timer(self.flush_timer, self.reactor.NEVER) + self.do_kick_flush_timer = False + self.lookahead.set_flush_time(BUFFER_TIME_HIGH) + self.check_stall_time = 0.0 + # Update print_time in segments until drip_completion signal flush_delay = DRIP_TIME + STEPCOMPRESS_FLUSH_TIME + self.kin_flush_delay while self.print_time < next_print_time: - if self.drip_completion.test(): - raise DripModeEndSignal() + if drip_completion.test(): + break curtime = self.reactor.monotonic() est_print_time = self.mcu.estimated_print_time(curtime) wait_time = self.print_time - est_print_time - flush_delay if wait_time > 0.0 and self.can_pause: # Pause before sending more steps - self.drip_completion.wait(curtime + wait_time) + drip_completion.wait(curtime + wait_time) continue npt = min(self.print_time + DRIP_SEGMENT_TIME, next_print_time) self.note_mcu_movequeue_activity( npt + self.kin_flush_delay, set_step_gen_time=True ) + for stepper in addstepper: + stepper.generate_steps(npt) self._advance_move_time(npt) + # Exit "Drip" state + self.reactor.update_timer(self.flush_timer, self.reactor.NOW) + self.flush_step_generation() + + def _drip_load_trapq(self, submit_move): + # Queue move into trapezoid motion queue (trapq) + if submit_move.move_d: + self.commanded_pos[:] = submit_move.end_pos + self.lookahead.add_move(submit_move) + moves = self.lookahead.flush() + self._calc_print_time() + next_move_time = self.print_time + for move in moves: + self.trapq_append( + self.trapq, + next_move_time, + move.accel_t, + move.cruise_t, + move.decel_t, + move.start_pos[0], + move.start_pos[1], + move.start_pos[2], + move.axes_r[0], + move.axes_r[1], + move.axes_r[2], + move.start_v, + move.cruise_v, + move.accel, + ) + next_move_time = ( + next_move_time + move.accel_t + move.cruise_t + move.decel_t + ) + self.lookahead.reset() + return next_move_time def drip_move(self, newpos, speed, drip_completion): + # Create and verify move is valid + newpos = newpos[:3] + self.commanded_pos[3:] + move = Move(self, self.commanded_pos, newpos, speed) + if move.move_d: + self.kin.check_move(move) + # Make sure stepper movement doesn't start before nominal start time self.dwell(self.kin_flush_delay) - # Transition from "NeedPrime"/"Priming"/main state to "Drip" state - self.lookahead.flush() - self.special_queuing_state = "Drip" - self.need_check_pause = self.reactor.NEVER - self.reactor.update_timer(self.flush_timer, self.reactor.NEVER) - self.do_kick_flush_timer = False - self.lookahead.set_flush_time(BUFFER_TIME_HIGH) - self.check_stall_time = 0.0 - self.drip_completion = drip_completion - # Submit move - try: - self.move(newpos, speed) - except self.printer.command_error as e: - self.reactor.update_timer(self.flush_timer, self.reactor.NOW) - self.flush_step_generation() - raise # Transmit move in "drip" mode - try: - self.lookahead.flush() - except DripModeEndSignal as e: - self.lookahead.reset() - self.trapq_finalize_moves(self.trapq, self.reactor.NEVER, 0) - # Exit "Drip" state - self.reactor.update_timer(self.flush_timer, self.reactor.NOW) - self.flush_step_generation() + self._process_lookahead() + next_move_time = self._drip_load_trapq(move) + self.drip_update_time(next_move_time, drip_completion) + # Move finished; cleanup any remnants on trapq + self.trapq_finalize_moves(self.trapq, self.reactor.NEVER, 0) # Misc commands def stats(self, eventtime): @@ -728,14 +794,15 @@ def check_busy(self, eventtime): def get_status(self, eventtime): print_time = self.print_time estimated_print_time = self.mcu.estimated_print_time(eventtime) + extruder = self.extra_axes[0] res = dict(self.kin.get_status(eventtime)) res.update( { "print_time": print_time, "stalls": self.print_stall, "estimated_print_time": estimated_print_time, - "extruder": self.extruder.get_name(), - "position": self.Coord(*self.commanded_pos), + "extruder": extruder.get_name(), + "position": self.Coord(*self.commanded_pos[:4]), "max_velocity": self.max_velocity, "max_accel": self.max_accel, "minimum_cruise_ratio": self.min_cruise_ratio, @@ -757,6 +824,10 @@ def get_trapq(self): def register_step_generator(self, handler): self.step_generators.append(handler) + def unregister_step_generator(self, handler): + if handler in self.step_generators: + self.step_generators.remove(handler) + def note_step_generation_scan_time(self, delay, old_delay=0.0): self.flush_step_generation() if old_delay: @@ -966,4 +1037,4 @@ def reset_accel(self): def add_printer_objects(config): config.get_printer().add_object("toolhead", ToolHead(config)) - extruder.add_printer_objects(config) + kinematics_extruder.add_printer_objects(config) diff --git a/test/klippy/manual_stepper.test b/test/klippy/manual_stepper.test index af0d406a3..d19c39c5e 100644 --- a/test/klippy/manual_stepper.test +++ b/test/klippy/manual_stepper.test @@ -22,3 +22,18 @@ M84 # Verify stepper_buzz STEPPER_BUZZ STEPPER="manual_stepper basic_stepper" STEPPER_BUZZ STEPPER="manual_stepper homing_stepper" + +# Register with g-code +MANUAL_STEPPER STEPPER=basic_stepper GCODE_AXIS=A +G28 +G1 X20 Y20 Z10 +G1 A10 X22 + +# Test unregistering +MANUAL_STEPPER STEPPER=basic_stepper GCODE_AXIS= +G1 X15 + +# Test registering again +G28 +MANUAL_STEPPER STEPPER=basic_stepper GCODE_AXIS=A +G1 X20 Y20 Z10 A20