Using Claude, Cursor, Codex, or Grok? Start with the
AI assistants guide and
llms-full.txt machine-readable codebase brief.
Installation
pip install grafito-canstepperPython 3.9+. The only runtime dependency is pyserial.
Connecting
from canstepper import CANStepperBus
bus = CANStepperBus.serial("/dev/ttyACM0")
print(bus.discover()) # {1: '1.9', 2: '1.9'}discover() broadcasts a ping and returns every node that answered with its
firmware version. The bus object owns a receive thread; use it as a context
manager or call bus.close() when done.
Nodes
node = bus.node(1)
# Configuration setters chain:
node.set_run_current(40).set_hold_current(15).set_microsteps(16)
node.set_max_speed(720.0).set_acceleration(1440.0)
node.enable() # required on fw 1.9 — coils stay off at boot
# Motion:
node.move_to(180.0, blocking=True) # absolute degrees
node.move_by(-45.0) # relative
node.run(90.0) # continuous deg/s, signed
node.stop() # ramped stop
node.set_zero() # define zero hereblocking=True waits for the firmware's move done event (closed loop:
S-curve finished and settled within pid_tolerance) and raises
NodeFault if the axis faults.
Closed-loop S-curve (firmware ≥1.11)
Closed-loop position moves plan a rest-to-rest 7-segment S-curve
(cl_max_speed / cl_max_accel / cl_max_jerk) and track it with
velocity + acceleration feedforward
(v = v_ff + Ka·a_ff + PID(r − encoder)):
node.calibrate_encoder_lut()
node.configure_closed_loop_speed(
720.0, # precision cruise ≈ 120 RPM
accel_deg_s2=1440.0,
run_current=40,
microsteps=8,
stealthchop=False, # SpreadCycle
persist=True,
)
node.move_to(180.0, blocking=True)Open-loop run() | Closed-loop settle | |
|---|---|---|
| Max measured* | ~3000 RPM (2 µstep, 80%) | median |err| 0.125° at 120 RPM |
| Not reachable | 5000 RPM | — |
*PR42HS40-1204AF-02 @ 24 V, fw 1.11 — see Closed-loop speed tuning.
Host-side trap helpers (same math as firmware):
canstepper.kinematics.plan_trapezoid, eval_trapezoid, trapezoid_time.
Parameters
Every firmware setting is a named parameter — set it, read it back, persist it:
from canstepper import Param
node.set_param(Param.CL_MAX_SPEED, 720.0) # S-curve cruise vmax
node.set_param("stall_threshold", 60) # names work too
print(node.get_param("cl_max_speed"))
node.save_config() # survives power cyclesWrites are verified: the node acknowledges every set, and a rejected
value (outside the hardware sanity range) raises ParamRejected. The full
table is in the protocol reference.
Homing (endstop / sensorless / set-zero)
Three firmware methods; full walkthrough and example scripts: Homing and endstops.
node.configure_endstop(enabled=True, active_high=False, action=1)
node.home(method="endstop", direction=-1, speed_deg_s=20.0)
node.set_stall_threshold(60)
node.home(method="stallguard", direction=-1, speed_deg_s=25.0) # or "sensorless"
node.home(method="set_zero")PYTHONPATH=. python3 examples/home_endstop.py /dev/ttyACM0 1 -1
PYTHONPATH=. python3 examples/home_sensorless.py /dev/ttyACM0 1 -1 60Telemetry
Nodes stream telemetry continuously. The last decoded values are always available without touching the bus:
st = node.state
print(st.position_deg, st.velocity_deg_s, st.age()) # age in secondsBlocking getters poll the node directly:
node.get_position() # degrees, straight from the encoder
node.get_motion() # (velocity, position error)
node.get_pid_status() # loop state, fault, output
node.get_can_health() # bus-off counters etc.
node.get_env() # (MCU temp °C, bus V placeholder)
node.get_stallguard() # StallGuard resultTMC2209 driver diagnostics (firmware ≥1.4)
TEL_DRIVER carries full TMC status: OTPW / OT, short-to-GND, open load,
StealthChop/standstill, GSTAT, and cs_actual:
from canstepper import CANStepperBus
with CANStepperBus.serial("/dev/ttyACM0") as bus:
node = bus.node(1)
node.enable()
d = node.get_driver_status()
print(d.uart_ok, d.stallguard)
print("OTPW", d.otpw, "OT", d.over_temp_shutdown)
print("thermal_warning", d.thermal_warning, "thermal_fault", d.thermal_fault)
print("shorts", d.any_short, "stealth", d.stealth_chop, "cs", d.cs_actual)
print("Irms", d.commanded_rms_amps(), "A") # host-side, TMC2209 datasheet| Field | Meaning |
|---|---|
otpw | Over-temperature pre-warning (driver still running) |
over_temp_shutdown | TMC OT shutdown — motion stopped, Fault.DRIVER_OT (4) |
temp_120c … temp_157c | TMC internal temperature comparator flags |
short_to_gnd_* / low_side_short_* | Bridge short flags → Fault.DRIVER_SHORT (5) |
open_load_* | Open-load flags (often set at standstill — interpret carefully) |
cs_actual | TMC current scale 0–31 from DRV_STATUS (not bus amperes) |
commanded_rms_amps() | Host converts cs_actual → commanded/regulated I_RMS (A) |
stealth_chop / standstill | Chopper mode / standstill state |
Commanded motor I_RMS from cs_actual (library ≥0.2.1)
Firmware ≥1.4 sends raw cs_actual in TEL_DRIVER. The library converts it
using the TMC2209 datasheet formula and the CANStepper defaults (100 mΩ
sense, V_FS = 0.325 V):
from canstepper import CANStepperBus, commanded_rms_amps, full_scale_rms_amps
print(full_scale_rms_amps()) # ≈ 1.915 A at CS=31 on stock CANStepper
with CANStepperBus.serial("/dev/ttyACM0") as bus:
d = bus.node(1).get_driver_status()
print(d.cs_actual, d.commanded_rms_amps()) # e.g. 15 → ≈ 0.958 A
print(commanded_rms_amps(d.cs_actual)) # same, module helperThis is the driver’s regulation scale, not 24 V SMPS input current and not
instantaneous coil current. Use your bench supply for bus power; use
commanded_rms_amps() for current-limit diagnostics and tuning.
OTPW alone does not stop the motor. OT or shorts stop motion until you
call enable() again (and the driver has recovered). Older firmware only
returns StallGuard + UART-ok; decode still works.
Cooling: frequent OTPW or OT under continuous high speed is often because the TMC has no heatsink. See Hardware → cooling.
Firmware that packs these flags is in the public sketch
(download / preview; functions tmcPollStatus, sendDriver,
tmcDriverService) — excerpt also on the
protocol page.
Or subscribe:
from canstepper import Tel
bus.subscribe(node_id=1, msg_id=Tel.POSITION, callback=lambda f: print(f))
bus.on_event(lambda nid, evt, detail, data: print(nid, evt.name))G-code (Klipper-inspired)
Drive a Cartesian or CoreXY mechanism with standard G/M lines — see the
dedicated G-code layer page (Cartesian,
GCodeController.from_cartesian, from_corexy, G1, G28, M112, …).
Groups and e-stop scopes
feeders = bus.group([6, 7, 8])
feeders.enable().set_param("run_current", 35).run(720.0)
node.estop() # one node
feeders.estop() # the group (one frame per node)
bus.estop_all() # the whole bus, one broadcast frameE-stop is latched: the node ignores motion until enable() is called.
Host-side motion attempts against a latched node raise EStopActive.
Host-side speed policy
The firmware never clamps motion. If you want guardrails, declare them on the host:
node.set_speed_limits(min_speed=1.0, max_speed=1800.0)
node.run(3600.0) # raises LimitViolation before anything hits the wireErrors
All errors derive from CANStepperError:
RequestTimeout, ParamRejected, UnknownParam, NodeFault,
HomingFailed, EStopActive, LimitViolation, NotHomed, ConfigError,
TransportError.