Closed-loop position profile (firmware ≥1.2)
On-board closed-loop move_to is not pure PID chase. The firmware:
- Plans a rest-to-rest trapezoid (
cl_max_speed/cl_max_accel) - Streams velocity feedforward
v_ff(t)along that plan - Trims with a light PID on
(r − encoder) - 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 levelingThe 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 encodersThe 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
DualMotorAxisover 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()