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