Grafito CANStepper

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
FieldBitsRangeMeaning
node_id10..60–310 = broadcast (all nodes execute), 1–31 = one board
msg_id5..00–630–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) at slow_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>\n

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

#NamePayloadBehavior
0PINGNode answers with STATUS immediately
1ESTOPInstant stop + driver disable. Latched until ENABLE 1
2STOPDecelerating stop, driver stays energized
3ENABLEu8 0/1Driver off/on; enabling clears e-stop and TMC OT/short latches (fw ≥1.4)
4MOVE_ABSf64 degAbsolute position. OL: FAS trap. CL fw ≥1.2: trap plan + v_ff + PID
5MOVE_RELf64 degRelative to active target (or current). Same profiles as MOVE_ABS
6MOVE_VELf32 deg/sSigned continuous velocity; 0 = ramped stop
7SET_ZEROStop; current angle becomes 0°
8HOMEu8 method, i8 dir, f32 speedHoming routine (methods: 0 set-zero, 1 endstop, 2 StallGuard)
9SET_PARAMu8 id + 4-byte valueNode replies with PARAM (status + readback)
10GET_PARAMu8 idNode replies with PARAM
11SAVE_CONFIGPersist parameter table to flash
12LOAD_DEFAULTSFactory defaults + persist; node keeps its ID
13FOLLOWu8 en, u8 leader, u8 flags, u8 rsvd, f32 ratioflags: bit0 invert, bit1 encoder-corrected
14FOLLOW_SYNCRecapture leader/local zero on next leader frame
15SET_POSITIONf64 degRelabel current shaft position without physical movement
16–31reservedignored

Closed-loop motion profile (firmware ≥1.2)

When closed_loop = 1 and the node receives MOVE_ABS / MOVE_REL:

  1. 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) and amax = cl_max_accel.
  2. Each 5 ms: reference position r(t) and feedforward velocity v_ff(t).
  3. Track: v_cmd = v_ff + Kp·(r − encoder) + Ki·∫ − Kd·v_meas.
  4. 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.

IDNameTypeDefaultRangeDescription
1node_idu3211–31CAN address
2steps_per_revu322001–100000full steps per motor rev
3microstepsu32161–256, power of 2TMC microstepping
4run_currentu32301–100% of driver max
5hold_currentu32150–100standstill current, %
6stall_thresholdu32100–255SGTHRS, higher = more sensitive
7invert_diru3200/1flip physical rotation
8closed_loopu3210/1encoder position loop
9max_speedf32720positiveopen-loop position cruise, deg/s
10accelerationf322880positiveopen-loop position ramp, deg/s²
11cl_max_speedf32720positiveCL trapezoid cruise vmax, deg/s (fw ≥1.2)
12cl_max_accelf322880positiveCL trapezoid accel/decel, deg/s²
13pid_kpf3212.0≥ 0tracking Kp on (r − encoder); v_ff carries the move
14pid_kif320.3≥ 0tracking Ki
15pid_kdf320.10≥ 0D-on-measured-velocity
16pid_tolerancef320.350.001–360settle window, deg
17fast_rate_hzu32100–500POSITION/MOTION/PID_STATUS
18slow_rate_hzu3210–500STATUS/DRIVER/ENV/…
19enable_on_bootu3210/1
20zero_on_bootu3200/1
21standstill_modeu3200–3normal / freewheel / brake / strong
22endstop_enableu3200/1
23endstop_active_highu3200/10 = active low, pull-up
24endstop_actionu3210–2report / stop / stop+zero
25homing_currentu3200–1000 = keep run current
26homing_backofff322.0≥ 0deg
27homing_timeout_msu3230000100–600000
28stealthchopu3210/1off = SpreadCycle

Telemetry (msg_id 32–63)

#NamePayloadWhen
32STATUSu8 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
33POSITIONf64 encoder deg (multi-turn)fast, RTR — drives followers
34MOTIONf32 velocity deg/s, f32 position error degfast, RTR
35TARGETf64 active target degRTR
36EVENTu8 event, u8 detail, f32 dataon occurrence
37DRIVERTMC diagnostics, 8 bytes (fw ≥1.4; see below)slow, RTR
38ENVf32 MCU temp °C, f32 bus volts (V is a fixed placeholder today)slow, RTR
39PARAMu8 id, u8 status (0 ok / 1 unknown / 2 rejected), 4-byte valuereply to SET/GET_PARAM
40FOLLOW_STATUSu8 en, u8 leader, u8 flags, u8 synced, f32 ratioslow (following), RTR
41CAN_HEALTHu8 state, u8 tx_err, u8 rx_err, u8 recoveries, u16 tx_failed, u16 bus_errorsslow, RTR
42ENC_COUNTSi64 counts (16384 per rev)RTR
43PID_STATUSu8 state, u8 fault, f32 output deg/sfast (closed loop), RTR
44–63reserved

STATUS flag bits

BitMeaning
0x01driver enabled
0x02moving
0x04homed
0x08e-stop latched
0x10endstop active
0x20stall latched
0x40encoder 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)

CodeNameTypical cause
0none
1encodersustained SSI loss
2no progressclosed-loop stuck
3homing timeout
4driver OTTMC over-temperature
5driver shortTMC 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.

BytesTypeMeaning
0–1u16StallGuard result
2u8 flags_abit0 UART ok, bit1 OTPW, bit2 OT, bit3 S2GA, bit4 S2GB, bit5 S2VSA, bit6 S2VSB, bit7 OLA
3u8 flags_bbit0 OLB, bit1 t120, bit2 t143, bit3 t150, bit4 t157, bit5 stealth, bit6 standstill
4u8 gstatbit0 reset, bit1 drv_err, bit2 uv_cp
5u8cs_actual (0–31) actual current scale
6–7u16interstep 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 FAULT detail 4; clear with ENABLE 1 after 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 (data is 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.

On this page