DEV Community

Paul Ruiz
Paul Ruiz

Posted on

The Software Dev's Primer on RobStride Motors for Robotics

One obvious difference between robotics and other types of software engineering is the fact that hardware and the physical world are the core of the development problem. While understanding everything that goes into the physics and machines that run robotics is an incredibly complex topic worthy of multiple books, in this article I want to focus on the RobStride family of motors and provide a brief primer into using them so that the many software engineers that are just starting to get into the hardware space with the AI boom have a solid place to jump from with these incredible actuators. The core reason I am putting this article together is that I found the documentation around scripts for these motors to be lacking (or in some cases completely missing via dead links), so hopefully I can save others some time and headaches.

So first off, what is a RobStride motor? These are a specific family of motors that are being used in modern robotics, including the Seeed Studio ReBot or the open sourced HuggingFace LeRobot Humanoid. While other common motors used in smaller robotics projects, like the Feetech motors in the SO-ARM101, are gearbox servos or stepper motors,

the RobStride family of motors are Quasi-Direct Drive (QDD) actuators centered around a high-torque brushless DC (BLDC) outrunner with a planetary reduction gearbox using dual high-resolution magnetic encoders. To put that in more easily digestible terms, the inside of the motor stays completely still and the core mechcanism uses electrical signals to move the outer shell around the center by smoothly shifting magnetic forces to cause a spinning effect.

Setup

For this article, I'm going to use a RobStride O0 motor as it's one of the smaller and cheaper models. These motors expect 48 volts to operate, so my example wiring uses a small-ish e-bike battery and a SavvyCAN USB to CAN FD converter that I've pulled from my LeRobot Humanoid project to power the motor and communicate with it from my Linux laptop (other operating systems might work, but it got finicky with my Mac, whereas things just worked with my Linux machine).

As for wiring, you will have a hot and ground wire coming from the battery and tied into the motor. The ground will also need to be split to connect to the CAN converter. The converter will also have high and low data wires that you will connect to the motor. When all is said and done, your setup should look like this:

Hardware Note on CAN Termination: CAN bus networks require 120 ohm termination resistors on both ends of the bus to prevent signal reflections. Most USB-to-CAN adapters (like the SavvyCAN adapter that I'm using) have a built-in termination resistor enabled by default. However, if you are using a different CAN interface and experience dropped packets or framing errors, verify whether your adapter or motor needs its termination resistor toggled on.

RobStride Modes

When you first pull the RobStride motor out of the box, it will be set to the RobStride Private mode, though it will also support the MIT and CANopen modes. Each of these has a distinct way of framing packets, determining how feedback is triggered, and which low-level loops execute on the motor driver.

Private mode is a proprietary protocol that can be used during initial bench testing, when you need to reconfigure to a default system, or when you plan to use native position/velocity profile commands rather than a pure torque-feedforward control. This mode uses 29-bit extended frame IDs. MIT mode uses an 11-bit standard CAN frame format that is optimized for rapid lightweight packet transmission. Instead of separate position or velocity loops, it provides a low-latency control that takes five simultaneous parameters in a tight loop: target position, target velocity, position stiffness, velocity damping, and feed-forward torque. This is the default mode used by the LeRobot Humanoid project and other robotics projects. Finally there's the CANopen mode. This mode is used for various existing industrial machine architectures and is useful for multi-vendor interoperability. For this article I will focus on MIT mode.

Initial Testing

Now that we've covered some topics about the RobStride motor family, let's get to the interesting stuff: actually using the actuator.

On your Linux computer, you'll want to make sure you have the can-utils package installed to be able to communicate with the motor.

sudo apt-get update && sudo apt-get install -y can-utils
Enter fullscreen mode Exit fullscreen mode

Once you have that utility package installed, you will want to set up your can0 interface to use a 1 Mbps bitrate and be in the 'on' configuration. It's worth noting that Linux will reset this interface on every reboot unless it's saved under system network configurations.

sudo ip link set can0 type can bitrate 1000000

sudo ip link set can0 up
Enter fullscreen mode Exit fullscreen mode

With that out of the way, let's turn on sniffing for the live CAN bus.

candump can0 -c -t d -e
Enter fullscreen mode Exit fullscreen mode

Since the default motor configuration is in Private mode with an ID of 127, I'll start with the assumption that you have the same values. If you've already changed your mode or ID, then you're ahead of the game and can likely just read this to see if there's anything new to learn.

From a second terminal window, try pinging the motor to see if you get a response in the sniffer terminal window.

cansend can0 0000FD7F#0000000000000000
Enter fullscreen mode Exit fullscreen mode

You should get a 29-bit reply with an Arbitration ID (ArbID) of 0x00007FFD containing a 64-bit MCU UID.

 (000.000411)  can0  00007FFE   [8]  2D 19 30 02 0C 34 37 01
Enter fullscreen mode Exit fullscreen mode

If that worked, then everything should be set up correctly and we're ready to move on to doing more.

Discovery Scanner

One of the first hurdles I ran into when testing out the LeRobot Humanoid was that the script provided for locating a motor and checking its ID only worked in Private mode, so as soon as I converted to MIT mode the motor was invisible to the default provided script (though I recently rewrote and published it in the repo, so if you're trying that project out you shouldn't have any trouble now :)). This script will help you identify a motor in any mode/ID configuration connected to your machine to verify that it's properly communicating. You'll also want to make sure you install the Python package 'can'.

import can
import time

INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000

def make_ext_id(comm_type: int, host_id: int, target_id: int) -> int:
    return ((comm_type & 0x1F) << 24) | ((host_id & 0xFF) << 8) | (target_id & 0xFF)

def flush_bus(bus):
    while bus.recv(timeout=0.005) is not None:
        pass

def ping_private(bus, mid: int) -> bool:
    flush_bus(bus)
    arb_id = make_ext_id(0x00, 0xFD, mid)
    try:
        bus.send(can.Message(arbitration_id=arb_id, data=[0]*8, is_extended_id=True))
    except Exception:
        return False

    deadline = time.monotonic() + 0.02
    while time.monotonic() < deadline:
        msg = bus.recv(timeout=0.005)
        if msg and msg.is_extended_id and ((msg.arbitration_id >> 8) & 0xFF) == mid:
            return True

    return False

def ping_mit(bus, mid: int) -> bool:
    flush_bus(bus)
    try:
        bus.send(can.Message(arbitration_id=mid, data=[0xFF]*7 + [0xFB], is_extended_id=False))
    except Exception:
        return False

    deadline = time.monotonic() + 0.02
    while time.monotonic() < deadline:
        msg = bus.recv(timeout=0.005)
        if msg and not msg.is_extended_id and len(msg.data) > 0 and msg.data[0] == mid:
            return True

    return False

def ping_canopen(bus, mid: int) -> bool:
    flush_bus(bus)
    req_id = 0x600 + mid

    data = [0x40, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00]

    try:
        bus.send(can.Message(arbitration_id=req_id, data=data, is_extended_id=False))
    except Exception:
        return False

    deadline = time.monotonic() + 0.02
    while time.monotonic() < deadline:
        msg = bus.recv(timeout=0.005)
        if msg and not msg.is_extended_id and msg.arbitration_id == (0x580 + mid):
            return True

    return False

def find_all():
    try:
        bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    except Exception as e:
        print(f"CAN Interface Error: {e}")
        return

    deadline = time.monotonic() + 1.0
    while time.monotonic() < deadline:
        msg = bus.recv(timeout=0.1)
        if msg:
            if not msg.is_extended_id and 0x700 <= msg.arbitration_id <= 0x77F:
                node_id = msg.arbitration_id - 0x700
                print(f"CANopen Bootup/Heartbeat detected from Node ID: {node_id}")

    print("\nScanning IDs 0 through 127 across all protocols")
    found = []

    for mid in range(128):
        if ping_private(bus, mid):
            print(f"Found Motor ID {mid:3d} (0x{mid:02X}) | Protocol: PRIVATE Mode (29-bit)")
            found.append((mid, "Private"))

        if ping_canopen(bus, mid):
            print(f"Found Motor ID {mid:3d} (0x{mid:02X}) | Protocol: CANopen (CiA 402, 11-bit)")
            found.append((mid, "CANopen"))

        if ping_mit(bus, mid):
            print(f"Found Motor ID {mid:3d} (0x{mid:02X}) | Protocol: MIT Mode (11-bit)")
            found.append((mid, "MIT"))

    print("\n" + "="*55)
    print(f" SCAN COMPLETE: Found {len(found)} active motor(s) on bus.")
    print("="*55)

    for mid, proto in found:
        print(f"    CAN ID: {mid:<3d} (0x{mid:02X})  |  Protocol: {proto}")

    print("="*55 + "\n")
    bus.shutdown()

if __name__ == "__main__":
    find_all()
Enter fullscreen mode Exit fullscreen mode
python3 discovery.py

Scanning IDs 0 through 127 across all protocols
Found Motor ID 127 (0x7F) | Protocol: PRIVATE Mode (29-bit)

=======================================================
 SCAN COMPLETE: Found 1 active motor(s) on bus.
=======================================================
    CAN ID: 127 (0x7F)  |  Protocol: Private
=======================================================
Enter fullscreen mode Exit fullscreen mode

Changing Protocols

Now that you can find the mode and ID for your motor, it's time to switch those modes around. Assuming you're in private mode, you can use the following script to convert your motor into MIT mode. It's worth noting that you will need to power cycle (unplug and plug back in) the motor when you change the mode and again when you change the ID for everything to store correctly in the motor's flash memory.

import can
import time
import sys

INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000
TARGET_ID = 1 # This should be changed to whatever your motor's ID actually is.

def make_ext_id(comm_type: int, host_id: int, target_id: int) -> int:
    return ((comm_type & 0x1F) << 24) | ((host_id & 0xFF) << 8) | (target_id & 0xFF)

def switch_private_to_mit(bus, motor_id: int):
    print(f"Sending Type 25 protocol switch (Private -> MIT) to ID {motor_id}")
    arb_id = make_ext_id(0x19, 0xFD, motor_id)
    data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x02, 0x00]
    bus.send(can.Message(arbitration_id=arb_id, data=data, is_extended_id=True))
    time.sleep(0.05)

    print(f"Sending Type 22 Flash Commit to ID {motor_id}")
    save_id = make_ext_id(22, 0xFD, motor_id)
    save_data = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]
    bus.send(can.Message(arbitration_id=save_id, data=save_data, is_extended_id=True))
    time.sleep(0.1)

def set_mit_id(bus, old_id: int, new_id: int):
    print(f"Sending Command 0xFA (Modify MIT ID {old_id} -> {new_id})")

    msg = can.Message(
        arbitration_id=old_id,
        data=[0xFF]*6 + [new_id & 0xFF, 0xFA],
        is_extended_id=False
    )

    bus.send(msg)
    time.sleep(0.1)

def power_cycle_prompt(description: str):
    print("\n" + "="*65)
    print(f" [POWER CYCLE REQUIRED] {description}")
    print(" 1. Unplug 48V power. 2. Wait 3s. 3. Plug 48V power back in.")
    input(" >>> Press [ENTER] once re-powered")
    print("="*65)
    time.sleep(1.0)

def main():
    try:
        bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    except Exception as e:
        print(f"Failed to open CAN bus: {e}")
        return 1

    motor_id = int(input("Enter current Private Motor ID (e.g. 127): "))
    switch_private_to_mit(bus, motor_id)
    power_cycle_prompt("Switch committed. Power-cycle to boot into MIT mode.")
    if motor_id != TARGET_ID:
        set_mit_id(bus, motor_id, TARGET_ID)
        power_cycle_prompt(f"ID updated to {TARGET_ID}. Power-cycle to finalize.")
    print(f"\nSUCCESS: Motor converted to MIT Mode (Target ID: {TARGET_ID}).")
    bus.shutdown()

if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

result:

python3 private_to_mit.py 
Enter current Private Motor ID (e.g. 127): 127
Sending Type 25 protocol switch (Private -> MIT) to ID 127
Sending Type 22 Flash Commit to ID 127

=================================================================
 [POWER CYCLE REQUIRED] Switch committed. Power-cycle to boot into MIT mode.
 1. Unplug 48V power. 2. Wait 3s. 3. Plug 48V power back in.
 >>> Press [ENTER] once re-powered
=================================================================
Sending Command 0xFA (Modify MIT ID 127 -> 1)

=================================================================
 [POWER CYCLE REQUIRED] ID updated to 1. Power-cycle to finalize.
 1. Unplug 48V power. 2. Wait 3s. 3. Plug 48V power back in.
 >>> Press [ENTER] once re-powered
=================================================================

SUCCESS: Motor converted to MIT Mode (Target ID: 1).
Enter fullscreen mode Exit fullscreen mode

If you need to change back to Private mode from MIT mode, you can do that with this even more simple script:

import can
import time
import sys

INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000
TARGET_ID = 127

def make_ext_id(comm_type: int, host_id: int, target_id: int, extra_data: int = 0) -> int:
    return ((comm_type & 0x1F) << 24) | ((extra_data & 0xFF) << 16) | ((host_id & 0xFF) << 8) | (target_id & 0xFF)

def switch_mit_to_private(bus, motor_id: int):
    print(f"Sending MIT Command 8 (Switch to Private) to ID {motor_id}")
    switch_msg = can.Message(
        arbitration_id=motor_id,
        data=[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFD],
        is_extended_id=False
    )

    bus.send(switch_msg)
    time.sleep(0.1)

def main():
    try:
        bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    except Exception as e:
        print(f"Failed to open CAN bus: {e}")
        return 1

    motor_id = int(input("Enter current MIT Motor ID (e.g. 1): "))
    switch_mit_to_private(bus, motor_id)
    print("\n" + "="*60)
    print("1. Unplug 48V power. 2. Wait 3s. 3. Plug 48V power back in.")
    input(">>> Press [ENTER] once re-powered...")
    print("="*60)
    print(f"\nSUCCESS: Motor switched to Private Mode.")
    bus.shutdown()

if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

result:

python3 mit_to_private.py 
Enter current MIT Motor ID (e.g. 1): 1
Sending MIT Command 8 (Switch to Private) to ID 1

============================================================
1. Unplug 48V power. 2. Wait 3s. 3. Plug 48V power back in.
>>> Press [ENTER] once re-powered...
============================================================

SUCCESS: Motor switched to Private Mode.
Enter fullscreen mode Exit fullscreen mode

MIT Motor Control (Sway Test)

With a traditional servo, you control it using high mechanical impedance (high stiffness). You command the device to move to angle theta, and if an obstacle gets in the way, the controller cranks up the power to force its way through.

With a Quasi-Direct Drive motor, the low-ratio gearing makes the shaft easily backdrivable, meaning external forces can physically push and turn the motor from the outside unless overridden by high active torque. Instead of forcing a rigid position, we use impedance control to make the motor behave like a virtual mechanical spring attached to a shock absorber. Rather than commanding a hard position, we tell the motor where its target spring anchor is (p_target), how stiff the spring should be (Kp), and how much shock absorption to apply (Kd).

This is representable with the following equation:

torque = torque_ff + Kp * (p_target - p_actual) + Kd * (v_target - v_actual)

where Kp * (p_target - p_actual) represents the virtual stiffness (spring) pulling towards a target as an expression of Hooke's Law in physics, Kd * (v_target - v_actual) represents the virtual damper/shock absorber that resists rapid velocity changes and suppresses oscillations, and torque_ff (feed-forward torque) represents the baseline force needed to cancel external loads, like gravity, without needing a position error to build up torque.

We can use this information to set up our own sway test for the motor to essentially define the shape, scale, and speed of the movement using a p_target(time) of amplitude * sin(angular_frequency * time) and v_target(time) of angular_frequency * amplitude * cos(angular_frequency * time)

Be sure to have the motor in a safe spot or held down when running this script. If the motor isn't already in the zero position, it will rapidly move to it to start the oscillation test.

import can
import math
import time

MOTOR_ID = 1
INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000
CMD_ENABLE = 0xFC
CMD_DISABLE = 0xFD

def float_to_uint(x, x_min, x_max, bits):
    x_clamped = max(x_min, min(x_max, x))
    return int(((x_clamped - x_min) / (x_max - x_min)) * ((1 << bits) - 1))

def send_mit_position(bus, motor_id, pos_deg, kp=10.0, kd=0.2):
    p_rad = math.radians(pos_deg)
    q = float_to_uint(p_rad, -12.57, 12.57, 16)
    v = 2048   # 0 velocity -> 2048 is the midpoint for a 12-bit unsigned number (range 0->4095)
    kp_u = float_to_uint(kp, 0.0, 500.0, 12)
    kd_u = float_to_uint(kd, 0.0, 5.0, 12)
    t = 2048   # 0 torque

    data = [
        (q >> 8) & 0xFF,
        q & 0xFF,
        (v >> 4) & 0xFF,
        ((v & 0x0F) << 4) | ((kp_u >> 8) & 0x0F),
        kp_u & 0xFF,
        (kd_u >> 4) & 0xFF,
        ((kd_u & 0x0F) << 4) | ((t >> 8) & 0x0F),
        t & 0xFF
    ]

    bus.send(can.Message(arbitration_id=motor_id, data=data, is_extended_id=False))

def run_test():
    try:
        bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    except Exception as e:
        print(f"Error opening CAN bus: {e}")
        return

    print(f"Enabling Motor {MOTOR_ID} in MIT Mode")
    bus.send(can.Message(arbitration_id=MOTOR_ID, data=[0xFF]*7 + [CMD_ENABLE], is_extended_id=False))
    time.sleep(0.5)
    print("Running 10s sway test (-90° to +90°)... Press Ctrl+C to stop.")
    start_time = time.time()
    try:
        while (time.time() - start_time) < 10.0:
            elapsed = time.time() - start_time
            target_angle = 90.0 * math.sin(2 * math.pi * 0.2 * elapsed)
            send_mit_position(bus, MOTOR_ID, target_angle)
            time.sleep(0.02)

        print("Returning to 0.0 degrees")
        for _ in range(50):
            send_mit_position(bus, MOTOR_ID, 0.0)
            time.sleep(0.02)

    finally:
        print(f"Disabling Motor {MOTOR_ID}")
        bus.send(can.Message(arbitration_id=MOTOR_ID, data=[0xFF]*7 + [CMD_DISABLE], is_extended_id=False))
        bus.shutdown()

if __name__ == "__main__":
    run_test()
Enter fullscreen mode Exit fullscreen mode

When you run this, you should see your motor return to its zeroth position and then sway between -90 and 90 degrees before returning to zero.


python3 sway.py 
Enabling Motor 1 in MIT Mode
Running 10s sway test (-90° to +90°)... Press Ctrl+C to stop.
Returning to 0.0 degrees
Disabling Motor 1
Enter fullscreen mode Exit fullscreen mode

Set MIT Mode Motor to Mechanical Zero (Calibration)

In the previous code snippet, you may have seen your motor jump quickly to its zero position. To avoid that, or to set a default zero in a newly assembled robot, you can manually set the current position of the motor to be the new zero value. This is commonly done as one of the first steps in calibrating a robot so you have more control over what the max and min values should be for the device as it's moving around.

import can
import time

MOTOR_ID = 1
INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000

def main():
    bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    print(f"Setting Zero on Motor ID {MOTOR_ID} (MIT Mode)...")
    bus.send(can.Message(arbitration_id=MOTOR_ID, data=[0xFF]*7 + [0xFE], is_extended_id=False))
    time.sleep(0.1)
    print("SUCCESS: MIT zero position set.")
    bus.shutdown()

if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode
python3 setzero.py 
Setting Zero on Motor ID 1 (MIT Mode)...
SUCCESS: MIT zero position set.
Enter fullscreen mode Exit fullscreen mode

Once you have that zero position set, you can move the motor however you need to to record top and bottom bounds, then run this script to return to the calibrated zero position.

import can
import time
import math
import sys

# Fixed Configuration
INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000
MOTOR_ID = 1          # Set your fixed Motor ID here
DURATION = 3.0        # Set your fixed duration in seconds here

# RobStride MIT Mode Limit Constants
P_MIN, P_MAX = -12.57, 12.57     # Position range (rad)
V_MIN, V_MAX = -33.0, 33.0       # Velocity range (rad/s)
KP_MIN, KP_MAX = 0.0, 500.0      # Stiffness range (N·m/rad)
KD_MIN, KD_MAX = 0.0, 5.0        # Damping range (N·m·s/rad)


def float_to_uint(x: float, x_min: float, x_max: float, bits: int) -> int:
    x_clamped = max(float(x_min), min(float(x_max), float(x)))
    return int(((x_clamped - x_min) / float(x_max - x_min)) * ((1 << bits) - 1))


def uint_to_float(x_int: int, x_min: float, x_max: float, bits: int) -> float:
    return float(x_int) * float(x_max - x_min) / float((1 << bits) - 1) + x_min


def pack_mit_frame(p_des: float, v_des: float, kp: float, kd: float, t_ff: float) -> list:
    """
    Packs MIT parameters into standard 8-byte payload:
      - Position: 16 bits
      - Velocity: 12 bits
      - Kp:       12 bits
      - Kd:       12 bits
      - Torque:   12 bits
    """
    p_u = float_to_uint(p_des, P_MIN, P_MAX, 16)
    kp_u = float_to_uint(kp, KP_MIN, KP_MAX, 12)
    kd_u = float_to_uint(kd, KD_MIN, KD_MAX, 12)

    # Midpoint 0x800 corresponds to 0.0 for velocity and feed-forward torque
    v_u = 0x800 if v_des == 0.0 else float_to_uint(v_des, V_MIN, V_MAX, 12)
    t_u = 0x800

    return [
        (p_u >> 8) & 0xFF,
        p_u & 0xFF,
        (v_u >> 4) & 0xFF,
        ((v_u & 0x0F) << 4) | ((kp_u >> 8) & 0x0F),
        kp_u & 0xFF,
        (kd_u >> 4) & 0xFF,
        ((kd_u & 0x0F) << 4) | ((t_u >> 8) & 0x0F),
        t_u & 0xFF
    ]


def return_to_zero_mit(bus, motor_id: int, duration: float):
    print(f"\n[MIT Mode] Connecting to Motor ID {motor_id} on {CHANNEL}...")

    # Enable Motor
    enable_msg = can.Message(
        arbitration_id=motor_id,
        data=[0xFF] * 7 + [0xFC],
        is_extended_id=False
    )
    bus.send(enable_msg)
    time.sleep(0.1)

    # Query starting position with zero effort (Kp=0, Kd=0, Tau=0)
    start_rad = None
    zero_effort_data = [0x7F, 0xFF, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00]

    for _ in range(15):
        bus.send(can.Message(arbitration_id=motor_id, data=zero_effort_data, is_extended_id=False))
        msg = bus.recv(timeout=0.02)
        if msg and not msg.is_extended_id and len(msg.data) >= 3:
            p_raw = (msg.data[1] << 8) | msg.data[2]
            start_rad = uint_to_float(p_raw, P_MIN, P_MAX, 16)
            break
        time.sleep(0.02)

    if start_rad is None:
        print("ERROR: Could not read response from motor. Check CAN connection, bitrate, and Motor ID.")
        return

    print(f"Current Position: {math.degrees(start_rad):+06.1f}° ({start_rad:+05.2f} rad)")
    print(f"Homing to 0.0 rad over {duration:.1f}s")

    # Smooth S-Curve Trajectory Generation
    dt = 0.02  # 50 Hz control loop
    steps = int(duration / dt)

    for step in range(steps + 1):
        alpha = 0.5 * (1.0 - math.cos((step / float(steps)) * math.pi))
        target_rad = start_rad * (1.0 - alpha) + 0.0 * alpha

        frame_data = pack_mit_frame(
            p_des=target_rad,
            v_des=0.0,
            kp=15.0,
            kd=0.3,
            t_ff=0.0
        )
        bus.send(can.Message(arbitration_id=motor_id, data=frame_data, is_extended_id=False))
        time.sleep(dt)

    time.sleep(0.5)

    # Disable motor
    disable_msg = can.Message(
        arbitration_id=motor_id,
        data=[0xFF] * 7 + [0xFD],
        is_extended_id=False
    )
    bus.send(disable_msg)
    print("SUCCESS: Motor homed to 0.0° and safely disabled.")


def main():
    try:
        bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    except Exception as e:
        print(f"Failed to initialize CAN interface '{CHANNEL}': {e}")
        sys.exit(1)

    try:
        return_to_zero_mit(bus, motor_id=MOTOR_ID, duration=DURATION)
    finally:
        bus.shutdown()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode
python3 movetozero.py 

[MIT Mode] Connecting to Motor ID 1...
Current Position: +282.0° (+4.92 rad)
Homing to 0.0 rad over 3.0s...
SUCCESS: Motor homed to 0.0° and safely disabled.
Enter fullscreen mode Exit fullscreen mode

Reading More Data

With the core concepts behind movement and configuration out of the way, the last functionality that I want to highlight is that you can read multiple values from the motor to make decisions about things such as lowering applied force when a motor is starting to overheat to avoid burning it out, or understanding how quickly a motor is moving to ensure that it's within a safe limit.

In this example you can see how to read angle, velocity, torque, temperature, and get a general health status from the motor as it's operating, which you may do within a ROS2 node to trigger various actions.

import can
import time
import math
import sys

INTERFACE = 'socketcan'
CHANNEL = 'can0'
BITRATE = 1000000
MOTOR_ID = 1

# RobStride RS-00 Parameter Limits
P_MIN, P_MAX = -12.57, 12.57    # Position range (rad)
V_MIN, V_MAX = -33.0, 33.0      # Velocity range (rad/s)
T_MIN, T_MAX = -14.0, 14.0      # Torque range (Nm)


def uint_to_float(x_int: int, x_min: float, x_max: float, bits: int) -> float:
    return float(x_int) * float(x_max - x_min) / float((1 << bits) - 1) + x_min


def main():
    try:
        bus = can.interface.Bus(interface=INTERFACE, channel=CHANNEL, bitrate=BITRATE)
    except Exception as e:
        print(f"Failed to open CAN bus: {e}")
        return

    print(f"Streaming MIT Telemetry for Motor ID {MOTOR_ID}... (Press Ctrl+C to exit)\n")

    # Enable Motor in MIT Mode:
    enable_frame = can.Message(
        arbitration_id=MOTOR_ID,
        data=[0xFF] * 7 + [0xFC],
        is_extended_id=False
    )

    bus.send(enable_frame)
    time.sleep(0.05)

    # Passive telemetry polling frame (Kp=0, Kd=0, torque_ff=0 -> transparent tracking)
    # Byte 0-1: Pos = 0x7FFF (0 rad)
    # Byte 2-3: Vel = 0x800 (0 rad/s), Kp = 0x000 (0)
    # Byte 4-5: Kp low = 0x00, Kd = 0x000 (0)
    # Byte 6-7: Kd low = 0x0, Torque = 0x800 (0 Nm)
    passive_payload = [0x7F, 0xFF, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00]

    try:
        while True:
            bus.send(can.Message(arbitration_id=MOTOR_ID, data=passive_payload, is_extended_id=False))
            msg = bus.recv(timeout=0.04)

            if msg and not msg.is_extended_id and len(msg.data) >= 8:
                data = msg.data
                resp_id = data[0]

                if resp_id == MOTOR_ID:
                    p_raw = (data[1] << 8) | data[2]
                    v_raw = (data[3] << 4) | (data[4] >> 4)
                    t_raw = ((data[4] & 0x0F) << 8) | data[5]

                    temp_raw = (data[6] << 8) | data[7]
                    if temp_raw >= 0x8000:
                        temp_raw -= 0x10000
                    temp = temp_raw / 10.0

                    pos_deg = math.degrees(uint_to_float(p_raw, P_MIN, P_MAX, 16))
                    vel = uint_to_float(v_raw, V_MIN, V_MAX, 12)
                    torque = uint_to_float(t_raw, T_MIN, T_MAX, 12)

                    sys.stdout.write(
                        f"\r[RS00 MIT ID:{MOTOR_ID:02d}] "
                        f"Angle: {pos_deg:+06.1f}° | "
                        f"Vel: {vel:+05.2f} rad/s | "
                        f"Torque: {torque:+05.2f} Nm | "
                        f"Temp: {temp:04.1f}°C | Status: OK"
                    )
                    sys.stdout.flush()

            time.sleep(0.05)

    except KeyboardInterrupt:
        print("\nExiting telemetry monitor...")

    finally:
        # Disable
        disable_frame = can.Message(
            arbitration_id=MOTOR_ID,
            data=[0xFF] * 7 + [0xFD],
            is_extended_id=False
        )

        try:
            bus.send(disable_frame)
        except Exception:
            pass
        bus.shutdown()


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

This should output a continuous stream of status values, like this example where I turned on the status script and physically moved the motor's center to change the velocity and angle values during testing.

[RS00 MIT ID:01] Angle: +000.5° | Vel: -0.01 rad/s | Torque: -0.01 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +000.5° | Vel: -0.06 rad/s | Torque: +0.00 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +000.5° | Vel: +0.06 rad/s | Torque: +0.01 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +000.5° | Vel: -0.06 rad/s | Torque: +0.02 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +000.5° | Vel: -0.02 rad/s | Torque: -0.01 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +000.6° | Vel: +0.04 rad/s | Torque: -0.04 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +001.4° | Vel: +0.51 rad/s | Torque: -0.04 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +002.5° | Vel: +0.59 rad/s | Torque: -0.09 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +004.6° | Vel: +0.88 rad/s | Torque: -0.02 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +007.7° | Vel: +1.15 rad/s | Torque: -0.08 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +011.5° | Vel: +1.47 rad/s | Torque: -0.04 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +016.0° | Vel: +1.68 rad/s | Torque: -0.08 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +021.2° | Vel: +1.85 rad/s | Torque: -0.06 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +026.8° | Vel: +2.01 rad/s | Torque: -0.09 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +032.7° | Vel: +2.10 rad/s | Torque: -0.02 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +038.9° | Vel: +2.25 rad/s | Torque: -0.02 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +045.3° | Vel: +2.17 rad/s | Torque: -0.03 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +051.9° | Vel: +2.26 rad/s | Torque: -0.06 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +058.5° | Vel: +2.28 rad/s | Torque: +0.00 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +065.3° | Vel: +2.31 rad/s | Torque: -0.02 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +072.3° | Vel: +2.43 rad/s | Torque: -0.06 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +079.4° | Vel: +2.47 rad/s | Torque: +0.04 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +086.6° | Vel: +2.43 rad/s | Torque: +0.00 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +093.8° | Vel: +2.51 rad/s | Torque: +0.01 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +101.1° | Vel: +2.51 rad/s | Torque: -0.02 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +108.3° | Vel: +2.44 rad/s | Torque: -0.00 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +115.3° | Vel: +2.28 rad/s | Torque: +0.11 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +121.6° | Vel: +1.96 rad/s | Torque: +0.13 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +127.1° | Vel: +1.75 rad/s | Torque: +0.09 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +131.8° | Vel: +1.47 rad/s | Torque: +0.03 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +135.6° | Vel: +1.02 rad/s | Torque: +0.15 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +138.1° | Vel: +0.56 rad/s | Torque: +0.19 Nm | Temp: 22.0°C | Status: OK
[RS00 MIT ID:01] Angle: +139.0° | Vel: +0.14 rad/s | Torque: +0.02 Nm | Temp: 22.0°C | Status: OK
Enter fullscreen mode Exit fullscreen mode

Conclusion

So that was a lot, but there's so much more to these actuators. You can find the manual for them online to get information about physical traits, such as mounting hole distances, but the links for any source code are dead at this current point in time, so hopefully the code samples and explanations provided here will be helpful in getting you started with these motors that are quickly becoming more popular.

If you want to learn more about values that can be read from the motor, physical properties, and byte codes, I highly recommend checking out the official documentation here.

Top comments (1)

Collapse
 
benjamin_nguyen_8ca6ff360 profile image
Benjamin Nguyen

Wow! Great technical articles. I learn from it.