Grafito CANStepper

Using Claude, Cursor, Codex, or Grok? Start with the AI assistants guide and llms-full.txt machine-readable codebase brief.

Installation

pip install grafito-canstepper

Python 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 here

blocking=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 reachable5000 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 cycles

Writes 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 60

Telemetry

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 seconds

Blocking 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 result

TMC2209 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
FieldMeaning
otpwOver-temperature pre-warning (driver still running)
over_temp_shutdownTMC OT shutdown — motion stopped, Fault.DRIVER_OT (4)
temp_120c … temp_157cTMC 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_actualTMC current scale 0–31 from DRV_STATUS (not bus amperes)
commanded_rms_amps()Host converts cs_actual → commanded/regulated I_RMS (A)
stealth_chop / standstillChopper 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 helper

This 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 frame

E-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 wire

Errors

All errors derive from CANStepperError: RequestTimeout, ParamRejected, UnknownParam, NodeFault, HomingFailed, EStopActive, LimitViolation, NotHomed, ConfigError, TransportError.

On this page