The Grafito CAN Stepper Protocol v1 is the contract between hosts and
boards. The Python reference codec is canstepper/protocol.py; the firmware
implements the same tables.
Physical layer
- CAN 2.0A, standard 11-bit identifiers, 1 Mbps.
- Daisy-chain, up to 31 nodes. On-board 120 Ω termination is enabled by default (0 Ω jumper). Keep termination only on the two physical ends; remove the 0 Ω resistor on mid-chain boards (termination pads can be shorted on ends if needed). See Hardware → CAN.
Addressing
CAN ID (11 bit) = (node_id << 6) | msg_id| Field | Bits | Range | Meaning |
|---|---|---|---|
| node_id | 10..6 | 0–31 | 0 = broadcast (all nodes execute), 1–31 = one board |
| msg_id | 5..0 | 0–63 | 0–31 command (host to node), 32–63 telemetry (node to host) |
- All multi-byte values are little-endian.
- A remote frame (RTR) with a telemetry msg_id polls that telemetry.
- Nodes broadcast telemetry periodically: the fast group (POSITION,
MOTION, PID_STATUS) at
fast_rate_hz(default 10 Hz), the slow group (STATUS, DRIVER, ENV, FOLLOW_STATUS, CAN_HEALTH) atslow_rate_hz(default 1 Hz). Rate 0 disables a group.
Serial bridge
Any node bridges its USB CDC port (115200 baud) to the bus; one text line per frame in each direction:
<CAN_ID hex> <RTR 0|1> <payload hex>\nExample — move node 1 to 180°: 44 0 0000000000C06640
(0x44 = (1 shl 6) or 4 = MOVE_ABS; payload is f64 180.0).
- Payload length = hex digits / 2 (0–8 bytes). RTR lines carry no payload.
- Lines starting with
#are debug output — skip them. - The bridging node executes frames addressed to it (or broadcast) and re-transmits them on CAN; everything it sees on the bus, plus its own telemetry, is printed to the port.
Commands (msg_id 0–31)
| # | Name | Payload | Behavior |
|---|---|---|---|
| 0 | PING | — | Node answers with STATUS immediately |
| 1 | ESTOP | — | Instant stop + driver disable. Latched until ENABLE 1 |
| 2 | STOP | — | Decelerating stop, driver stays energized |
| 3 | ENABLE | u8 0/1 | Driver off/on; enabling clears e-stop and TMC OT/short latches (fw ≥1.4) |
| 4 | MOVE_ABS | f64 deg | Absolute position. OL: FAS trap. CL fw ≥1.2: trap plan + v_ff + PID |
| 5 | MOVE_REL | f64 deg | Relative to active target (or current). Same profiles as MOVE_ABS |
| 6 | MOVE_VEL | f32 deg/s | Signed continuous velocity; 0 = ramped stop |
| 7 | SET_ZERO | — | Stop; current angle becomes 0° |
| 8 | HOME | u8 method, i8 dir, f32 speed | Homing routine (methods: 0 set-zero, 1 endstop, 2 StallGuard) |
| 9 | SET_PARAM | u8 id + 4-byte value | Node replies with PARAM (status + readback) |
| 10 | GET_PARAM | u8 id | Node replies with PARAM |
| 11 | SAVE_CONFIG | — | Persist parameter table to flash |
| 12 | LOAD_DEFAULTS | — | Factory defaults + persist; node keeps its ID |
| 13 | FOLLOW | u8 en, u8 leader, u8 flags, u8 rsvd, f32 ratio | flags: bit0 invert, bit1 encoder-corrected |
| 14 | FOLLOW_SYNC | — | Recapture leader/local zero on next leader frame |
| 15 | SET_POSITION | f64 deg | Relabel current shaft position without physical movement |
| 16–31 | reserved | ignored |
Closed-loop motion profile (firmware ≥1.2)
When closed_loop = 1 and the node receives MOVE_ABS / MOVE_REL:
- Plan a rest-to-rest trapezoid (or triangle if short) from the encoder
angle to the target using
vmax = min(max_speed, cl_max_speed)andamax = cl_max_accel. - Each 5 ms: reference position
r(t)and feedforward velocityv_ff(t). - Track:
v_cmd = v_ff + Kp·(r − encoder) + Ki·∫ − Kd·v_meas. - Settle when the plan is finished,
|target − encoder| ≤ pid_tolerance, and speed is low →MOVE_DONE(event 7).
Open-loop position uses FastAccelStepper’s trapezoid on step counts.
MOVE_VEL is open-loop continuous velocity. FOLLOW with encoder correction
uses a continuous braking-law chase (not a full re-planned trap each frame).
Tuning, measured RPM, and soak data: Closed-loop speed tuning.
Parameters
4-byte values, u32 or f32. Ranges are hardware-sanity checks only — motion limits are configurable defaults, never firmware clamps. Persisted by SAVE_CONFIG, loaded at power-on.
| ID | Name | Type | Default | Range | Description |
|---|---|---|---|---|---|
| 1 | node_id | u32 | 1 | 1–31 | CAN address |
| 2 | steps_per_rev | u32 | 200 | 1–100000 | full steps per motor rev |
| 3 | microsteps | u32 | 16 | 1–256, power of 2 | TMC microstepping |
| 4 | run_current | u32 | 30 | 1–100 | % of driver max |
| 5 | hold_current | u32 | 15 | 0–100 | standstill current, % |
| 6 | stall_threshold | u32 | 10 | 0–255 | SGTHRS, higher = more sensitive |
| 7 | invert_dir | u32 | 0 | 0/1 | flip physical rotation |
| 8 | closed_loop | u32 | 1 | 0/1 | encoder position loop |
| 9 | max_speed | f32 | 720 | positive | open-loop position cruise, deg/s |
| 10 | acceleration | f32 | 2880 | positive | open-loop position ramp, deg/s² |
| 11 | cl_max_speed | f32 | 720 | positive | CL trapezoid cruise vmax, deg/s (fw ≥1.2) |
| 12 | cl_max_accel | f32 | 2880 | positive | CL trapezoid accel/decel, deg/s² |
| 13 | pid_kp | f32 | 12.0 | ≥ 0 | tracking Kp on (r − encoder); v_ff carries the move |
| 14 | pid_ki | f32 | 0.3 | ≥ 0 | tracking Ki |
| 15 | pid_kd | f32 | 0.10 | ≥ 0 | D-on-measured-velocity |
| 16 | pid_tolerance | f32 | 0.35 | 0.001–360 | settle window, deg |
| 17 | fast_rate_hz | u32 | 10 | 0–500 | POSITION/MOTION/PID_STATUS |
| 18 | slow_rate_hz | u32 | 1 | 0–500 | STATUS/DRIVER/ENV/… |
| 19 | enable_on_boot | u32 | 1 | 0/1 | |
| 20 | zero_on_boot | u32 | 0 | 0/1 | |
| 21 | standstill_mode | u32 | 0 | 0–3 | normal / freewheel / brake / strong |
| 22 | endstop_enable | u32 | 0 | 0/1 | |
| 23 | endstop_active_high | u32 | 0 | 0/1 | 0 = active low, pull-up |
| 24 | endstop_action | u32 | 1 | 0–2 | report / stop / stop+zero |
| 25 | homing_current | u32 | 0 | 0–100 | 0 = keep run current |
| 26 | homing_backoff | f32 | 2.0 | ≥ 0 | deg |
| 27 | homing_timeout_ms | u32 | 30000 | 100–600000 | |
| 28 | stealthchop | u32 | 1 | 0/1 | off = SpreadCycle |
Telemetry (msg_id 32–63)
| # | Name | Payload | When |
|---|---|---|---|
| 32 | STATUS | u8 flags, u8 mode, u8 fault, u8 fw_major, u8 fw_minor, u8 proto, u8 node_id, u8 home_diag (fw ≥1.5) | slow, PING, RTR |
| 33 | POSITION | f64 encoder deg (multi-turn) | fast, RTR — drives followers |
| 34 | MOTION | f32 velocity deg/s, f32 position error deg | fast, RTR |
| 35 | TARGET | f64 active target deg | RTR |
| 36 | EVENT | u8 event, u8 detail, f32 data | on occurrence |
| 37 | DRIVER | TMC diagnostics, 8 bytes (fw ≥1.4; see below) | slow, RTR |
| 38 | ENV | f32 MCU temp °C, f32 bus volts (V is a fixed placeholder today) | slow, RTR |
| 39 | PARAM | u8 id, u8 status (0 ok / 1 unknown / 2 rejected), 4-byte value | reply to SET/GET_PARAM |
| 40 | FOLLOW_STATUS | u8 en, u8 leader, u8 flags, u8 synced, f32 ratio | slow (following), RTR |
| 41 | CAN_HEALTH | u8 state, u8 tx_err, u8 rx_err, u8 recoveries, u16 tx_failed, u16 bus_errors | slow, RTR |
| 42 | ENC_COUNTS | i64 counts (16384 per rev) | RTR |
| 43 | PID_STATUS | u8 state, u8 fault, f32 output deg/s | fast (closed loop), RTR |
| 44–63 | reserved |
STATUS flag bits
| Bit | Meaning |
|---|---|
| 0x01 | driver enabled |
| 0x02 | moving |
| 0x04 | homed |
| 0x08 | e-stop latched |
| 0x10 | endstop active |
| 0x20 | stall latched |
| 0x40 | encoder frame OK |
STATUS byte 7 home_diag (fw ≥1.5): bit0 = GPIO8 raw HIGH, bit1 = logical
endstop active, bit2 = endstop enable param, bit3 = endstop active-high param.
Decoded in Python as NodeStatus.home_raw_high / endstop_enabled /
endstop_active_high.
Fault codes (STATUS fault byte / EVENT)
| Code | Name | Typical cause |
|---|---|---|
| 0 | none | |
| 1 | encoder | sustained SSI loss |
| 2 | no progress | closed-loop stuck |
| 3 | homing timeout | |
| 4 | driver OT | TMC over-temperature |
| 5 | driver short | TMC short / low-side short flag (real or false at extreme rates) |
DRIVER payload (msg_id 37, firmware ≥1.4)
TMC2209 DRV_STATUS + GSTAT over UART, plus StallGuard. Older firmware
sent only 3 bytes (u16 StallGuard + u8 UART-ok); hosts should accept
both lengths.
| Bytes | Type | Meaning |
|---|---|---|
| 0–1 | u16 | StallGuard result |
| 2 | u8 flags_a | bit0 UART ok, bit1 OTPW, bit2 OT, bit3 S2GA, bit4 S2GB, bit5 S2VSA, bit6 S2VSB, bit7 OLA |
| 3 | u8 flags_b | bit0 OLB, bit1 t120, bit2 t143, bit3 t150, bit4 t157, bit5 stealth, bit6 standstill |
| 4 | u8 gstat | bit0 reset, bit1 drv_err, bit2 uv_cp |
| 5 | u8 | cs_actual (0–31) actual current scale |
| 6–7 | u16 | interstep duration (TSTEP-style, low 16 bits) |
Thermal behaviour (fw ≥1.4):
- OTPW (pre-warning): flag only; motion continues; serial log on rising edge.
- OT (shutdown): motion stops,
fault = 4,EVENT FAULTdetail 4; clear withENABLE 1after cool-down. - Shorts (S2G / low-side): motion stops,
fault = 5.
Python: node.get_driver_status() → DriverStatus (otpw, over_temp_shutdown, …).
Firmware implementation (GrafitoCANStepper_C3, fw ≥1.4)
Full sketch (download + scrollable preview):
Firmware download & preview · direct file:
GrafitoCANStepper_C3.ino
TMC diagnostics block as shipped (complete functions — no omissions):
// Fault codes (excerpt)
enum Fault : uint8_t {
FAULT_NONE = 0, FAULT_ENCODER = 1, FAULT_NO_PROGRESS = 2,
FAULT_HOMING_TIMEOUT = 3,
FAULT_DRIVER_OT = 4, // TMC over-temperature shutdown (OT)
FAULT_DRIVER_SHORT = 5, // TMC short-to-GND or low-side short
};
// Cached last TMC DRV_STATUS / GSTAT snapshot for diagnostics + fault latch.
static uint16_t tmcStallGuard = 0;
static uint8_t tmcFlagsA = 0;
static uint8_t tmcFlagsB = 0;
static uint8_t tmcGstat = 0;
static uint8_t tmcCsActual = 0;
static uint16_t tmcInterstep = 0;
static bool tmcOtLatched = false;
static bool tmcShortLatched = false;
static uint32_t tmcPollMs = 0;
// Refresh TMC UART registers. Safe to call from telemetry / loop (not ISR).
static void tmcPollStatus() {
bool uartOk = tmc.isSetupAndCommunicating();
tmcFlagsA = uartOk ? 0x01 : 0x00;
tmcFlagsB = 0;
tmcGstat = 0;
tmcCsActual = 0;
tmcInterstep = 0;
tmcStallGuard = 0;
if (!uartOk) return;
tmcStallGuard = (uint16_t)tmc.getStallGuardResult();
TMC2209::Status st = tmc.getStatus();
TMC2209::GlobalStatus gs = tmc.getGlobalStatus();
uint32_t tstep = tmc.getInterstepDuration();
tmcInterstep = (uint16_t)(tstep > 0xFFFFu ? 0xFFFFu : tstep);
tmcCsActual = (uint8_t)(st.current_scaling & 0x1F);
if (st.over_temperature_warning) tmcFlagsA |= (1u << 1);
if (st.over_temperature_shutdown) tmcFlagsA |= (1u << 2);
if (st.short_to_ground_a) tmcFlagsA |= (1u << 3);
if (st.short_to_ground_b) tmcFlagsA |= (1u << 4);
if (st.low_side_short_a) tmcFlagsA |= (1u << 5);
if (st.low_side_short_b) tmcFlagsA |= (1u << 6);
if (st.open_load_a) tmcFlagsA |= (1u << 7);
if (st.open_load_b) tmcFlagsB |= (1u << 0);
if (st.over_temperature_120c) tmcFlagsB |= (1u << 1);
if (st.over_temperature_143c) tmcFlagsB |= (1u << 2);
if (st.over_temperature_150c) tmcFlagsB |= (1u << 3);
if (st.over_temperature_157c) tmcFlagsB |= (1u << 4);
if (st.stealth_chop_mode) tmcFlagsB |= (1u << 5);
if (st.standstill) tmcFlagsB |= (1u << 6);
if (gs.reset) tmcGstat |= (1u << 0);
if (gs.drv_err) tmcGstat |= (1u << 1);
if (gs.uv_cp) tmcGstat |= (1u << 2);
if (st.over_temperature_shutdown) tmcOtLatched = true;
if (st.short_to_ground_a || st.short_to_ground_b ||
st.low_side_short_a || st.low_side_short_b) {
tmcShortLatched = true;
}
}
static void sendDriver() {
tmcPollStatus();
uint8_t p[8] = {0};
memcpy(p, &tmcStallGuard, 2);
p[2] = tmcFlagsA;
p[3] = tmcFlagsB;
p[4] = tmcGstat;
p[5] = tmcCsActual;
memcpy(p + 6, &tmcInterstep, 2);
canSend(TEL_DRIVER, p, 8);
}
// Periodic TMC health: raise FAULT_DRIVER_OT / FAULT_DRIVER_SHORT and stop motion.
static void tmcDriverService() {
uint32_t now = millis();
if ((uint32_t)(now - tmcPollMs) < 100) return;
tmcPollMs = now;
if (!driverEnabled) return;
tmcPollStatus();
bool ot = (tmcFlagsA & (1u << 2)) != 0;
bool shorted = (tmcFlagsA & ((1u << 3) | (1u << 4) | (1u << 5) | (1u << 6))) != 0;
if (ot && fault != FAULT_DRIVER_OT) {
stopImmediate();
pidState = 3;
fault = FAULT_DRIVER_OT;
sendEvent(EVT_FAULT, FAULT_DRIVER_OT, (float)temperatureRead());
Serial.println("# FAULT: TMC over-temperature shutdown (OT)");
} else if (shorted && fault != FAULT_DRIVER_SHORT && fault != FAULT_DRIVER_OT) {
stopImmediate();
pidState = 3;
fault = FAULT_DRIVER_SHORT;
sendEvent(EVT_FAULT, FAULT_DRIVER_SHORT, (float)tmcFlagsA);
Serial.println("# FAULT: TMC short detected (S2G/S2VS)");
}
// OTPW alone: log once per edge via serial (do not stop — warning only).
static bool otpwPrev = false;
bool otpw = (tmcFlagsA & (1u << 1)) != 0;
if (otpw && !otpwPrev) {
Serial.println("# WARN: TMC over-temperature pre-warning (OTPW)");
}
otpwPrev = otpw;
}
// Inside setDriverEnabled(true) — clears sticky thermal / GSTAT latches:
// tmc.clearDriveError();
// tmc.clearReset();
// tmcOtLatched = false;
// tmcShortLatched = false;
// if (fault == FAULT_DRIVER_OT || fault == FAULT_DRIVER_SHORT) {
// fault = FAULT_NONE;
// pidState = 0;
// }
// Inside loop():
// canHealthService();
// tmcDriverService();
// ...
// Slow telemetry also calls sendDriver() at slow_rate_hz.If this block looks clipped, scroll inside the code panel, or open the
full sketch download & preview page for the entire .ino
(~1780 lines) without needing monorepo access.
Enumerations
- mode: 0 idle, 1 position, 2 velocity, 3 homing, 4 following
- fault: 0 none, 1 encoder invalid, 2 no progress, 3 homing timeout, 4 TMC OT shutdown, 5 TMC short (fw ≥1.4)
- event: 1 boot, 2 endstop hit, 3 endstop released, 4 stall,
5 homing done, 6 homing failed, 7 move done, 8 e-stop, 9 fault
(
datais usually the position in degrees at the event; for OT, MCU temp °C)
Leader / follower
FOLLOW makes a node track another node's POSITION broadcasts entirely on-bus. The first leader frame after enable (or FOLLOW_SYNC) captures both zero references, so engagement never jumps:
follower = local_ref + (leader − leader_ref) × ratio × (invert ? −1 : 1)With flags bit1 the follower closes its own encoder loop on the mapped target (recommended); otherwise it steps open-loop. A 0.15° hysteresis deadband rejects stationary leader-encoder noise without adding lag.
Bus load
A 1 Mbps standard frame with 8-byte payload is roughly 111–135 bits with
stuffing. At default rates (about 35 frames/s per node), 31 nodes produce
about 1085 frames/s — roughly 15 % bus load. Budget accordingly when
raising fast_rate_hz.