Grafito CANStepper

Closed-loop position profile (firmware ≥1.2)

On-board closed-loop move_to is not pure PID chase. The firmware:

  1. Plans a rest-to-rest trapezoid (cl_max_speed / cl_max_accel)
  2. Streams velocity feedforward v_ff(t) along that plan
  3. Trims with a light PID on (r − encoder)
  4. Emits MOVE_DONE after settle inside pid_tolerance

Open-loop position still uses FastAccelStepper’s trapezoid on step counts. Host MotionGroup uses the same trapezoid duration math so axes finish together (point-to-point, not continuous look-ahead).

node.configure_closed_loop_speed(4800, run_current=70, microsteps=8,
                                 stealthchop=False, persist=True)
node.move_to(720.0, blocking=True)

Measured ceilings and a 10-minute soak log: Closed-loop speed tuning.

Axes: motors in real units

An Axis wraps a node in Klipper-style rotation_distance — the distance travelled per motor revolution:

from canstepper import Axis

z = Axis(bus.node(2),
         rotation_distance=8.0,   # 8 mm leadscrew
         min_pos=0.0, max_pos=200.0,
         max_speed=15.0,          # mm/s, host-side policy
         require_homing=True)

z.move_to(50.0, speed=10.0, blocking=True)
print(z.get_position())           # 50.0 (mm, from the encoder)

gear_ratio handles reductions (motor revs per output rev). Soft limits and max_speed are enforced by the library before anything is transmitted; violations raise LimitViolation.

Dual-motor axes (e.g. dual-Z gantries)

Two motors, one mechanical axis. DualMotorAxis couples them with the firmware's leader/follower mode: the follower tracks the leader's encoder angle on the CAN bus itself — the host is not in the loop, so the coupling keeps working even if your program blocks.

from canstepper import DualMotorAxis

z = DualMotorAxis(bus.node(2), bus.node(3),
                  rotation_distance=8.0,
                  invert_secondary=True,        # mirrored motor
                  encoder_corrected=True)       # follower closes its own loop
z.enable()
z.home(method="stallguard", direction=-1, speed=5.0, current_percent=25)
z.move_to(50.0, blocking=True)                  # command the leader only
z.resync()                                      # re-capture offsets after manual leveling

The first leader frame after coupling (or resync()) captures both zero references, so engagement never jerks. ratio supports geared pairs.

CoreXY

from canstepper import CoreXY

xy = CoreXY(bus.node(4), bus.node(5),
            rotation_distance=40.0)   # GT2 belt, 20T pulley
xy.set_zero()
xy.move_to(100.0, 60.0, speed=80.0)   # straight line, 80 mm/s path speed
print(xy.get_position())              # (100.0, 60.0) from the two encoders

The library solves the belt equations (a = x + y, b = x − y) and splits the path speed across the two motors so they finish together and the line stays straight.

MotionGroup: finish together

For independent axes that should move as one gesture:

from canstepper import MotionGroup

group = MotionGroup([x_axis, y_axis, z_axis])
duration = group.move_to({"x": 120.0, "y": 40.0, "z": 10.0})

The host computes one common duration, then scales each axis's cruise speed and acceleration (proper trapezoid math, triangular fallback for short moves) so all axes start and finish together.

Accuracy model — read before building a CNC

  • Start skew is one CAN frame per motor (about 0.15 ms at 1 Mbps) plus host scheduling.
  • Between endpoints, each axis follows its own trapezoid: excellent for gantries, pick-and-place and feeders; not a contouring controller — the mid-path trajectory is not interpolated.
  • For rigidly coupled mechanics, always prefer DualMotorAxis over trying to synchronize two independent moves.

Velocity mode

Continuous rotation for feeders, conveyors, pumps:

feeder = Axis(bus.node(6), rotation_distance=30.0)
feeder.run(12.5)     # mm/s of material, signed
feeder.stop()

On this page