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.0', 2: '1.0'}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()
# 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:
trapezoid finished and settled within pid_tolerance) and raises
NodeFault if the axis faults.
Closed-loop trapezoid (firmware ≥1.2)
Closed-loop position moves plan a rest-to-rest trapezoid
(cl_max_speed / cl_max_accel) and track it with velocity feedforward
(v = v_ff + PID(r − encoder)):
node.configure_closed_loop_speed(
4800.0, # production cruise ≈ 800 RPM
run_current=70,
microsteps=8,
stealthchop=False, # SpreadCycle
persist=True,
)
node.move_to(720.0, blocking=True)Open-loop run() | Closed-loop cruise | |
|---|---|---|
| Max measured* | ~1200 RPM | ~1000 RPM |
| Production soak | — | 800 RPM, 10 min 100% success |
*PR42HS40-1204AF-02 @ 24 V — 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, 1440.0) # trap 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)| 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 | Actual current scale 0–31 |
stealth_chop / standstill | Chopper mode / standstill state |
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.