Grafito CANStepper

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

blocking=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 soak800 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 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.

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.

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))

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