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.
Prefer G-code? Use the G-code layer (G1, G28, M114, …)
over Cartesian or CoreXY mechanisms.
Real belt soak / high-speed oscillation (absolute ±50 mm about a median):
Belt axis (real-world mm) and examples/belt_oscillate.py.
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
# Belt example (mm per rev from pulley/belt geometry)
belt = Axis(bus.node(1), rotation_distance=77.2, name="belt")
belt.set_zero()
belt.move_by(10.0, speed=50.0, blocking=True) # +10 mm
# Leadscrew
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.
Leader / follower demo (encoder mirroring)
One motor is commanded; the second mirrors encoder data over CAN so both shafts stay synchronized (ideal for dual-Z and similar gantries):
CANStepper leader / follower — encoder-mirrored dual motors
Open on YouTubefrom 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.
Cartesian (Klipper-style)
Independent steppers for X, Y, and optional Z — motor travel equals workspace
travel on each axis (kinematics: cartesian in Klipper terms).
from canstepper import Cartesian
cart = Cartesian.from_nodes(
bus.node(1), bus.node(2), bus.node(3),
rotation_distance=40.0, # X/Y belt
rotation_distance_z=8.0, # Z leadscrew
)
cart.set_zero()
cart.move_to(100.0, 60.0, 5.0, speed=80.0) # path speed mm/s
print(cart.get_position()) # (100, 60, 5) from encodersPass a DualMotorAxis as z for a dual-Z gantry. Drive with G-code via
GCodeController.from_cartesian(cart) — see G-code layer.
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()