This fork retains the current reBot torque, retry and thermal behavior and rebases bounded motion-mode feedforward onto it. See NOTICE for source revisions. Use the matching PickNik SDK: it decodes Type 0x15 fault words already; no driver-side byte-swap is needed.
A ros2_control hardware_interface::SystemInterface plugin for RobStride CAN actuators (RS00–RS06), built on robstride_sdk and robstride_interfaces.
read() and write() never block on CAN I/O — they touch only the SDK's lock-free per-motor state and batch outgoing frames through sendmmsg(). The achievable update rate is therefore bounded by CAN bus bandwidth rather than by request/response round trips; see §10.
- ROS 2 Jazzy
- A SocketCAN adapter per bus (e.g. a canable/candlelight USB-CAN), RobStride motors, and a motor power supply
robstride_sdkandrobstride_interfacesin the same workspace
cd ~/${WORKSPACE}/src
git clone https://github.com/PickNikRobotics/RobstrideSDK.git
git clone https://github.com/PickNikRobotics/robstride_hardware_interface.git
# Select the reviewed matching revisions from your workspace's submodule pins.
# The fork default branches may not contain an unmerged integration branch.
git clone https://github.com/kmj060703/robstride_interfaces.git
cd ~/${WORKSPACE}
colcon build --packages-select robstride_sdk robstride_interfaces robstride_hardware_interface
source install/setup.bashRobStride motors run at 1 Mbps. Bring each interface up before launching:
./scripts/setup_can.sh can0 # 1 Mbps, txqueuelen 1000
./scripts/setup_can.sh can0 1000000 1000 # explicit bitrate / txqueuelentxqueuelen matters: activation sends a burst of several frames per motor, and the kernel default of 10 is small enough to drop most of it on a rig of any size.
Many gs_usb/candlelight adapters do not support bus-off auto-recovery (restart-ms); the script detects this and continues without it. On those adapters a bus-off event needs a manual down/up cycle, or the adapter re-plugged. The symptom is SendFrames() failing with ENOBUFS while the interface still reports UP, which the plugin logs explicitly rather than letting it surface as every motor going silent.
| Param | Meaning | Default |
|---|---|---|
master_id |
Host CAN id used as the source address of outgoing frames | 253 (0xFD) |
number_of_joints |
Optional cross-check against the number of <joint> blocks |
(unchecked) |
error_timeout_ms |
Time without fresh feedback before a joint counts as lost | 4000 |
motor_can_timeout_ms |
Each motor's own CAN_TIMEOUT watchdog (0x7028): after this long without a command the motor releases torque. 0 disables it, so when the bus dies a motor keeps holding the last target it received (the freeze hold). Motors that lose power still come back limp. A clean shutdown disables torque unless hold_torque_on_deactivate says otherwise |
error_timeout_ms |
freeze_on_joint_loss |
See §6 | true |
torque_enable |
Whether activation engages torque. false brings the rig up readable but limp: joints report state and can be moved by hand until ~/set_torque is called |
true |
hold_torque_on_deactivate |
Whether on_deactivate() leaves torque engaged. false disables every motor, which drops a gravity-loaded arm wherever it is standing. true holds the pose through a clean stop, and then only while motor_can_timeout_ms is 0 — an armed motor watchdog releases torque that long after the bus goes quiet, whatever this is set to. on_init() warns on that combination |
false |
Each motor is described twice, separating the two things that are otherwise easy to confuse:
<joint>— theros2_controlcontract: which motor id it drives, which command interface it is driven through, which states it reports.<gpio>— the motor as a device: model, bus, run mode and gains, plus interfaces for reading health and retuning gains at runtime.
The two are matched by id — a joint's id must equal some gpio's ID, and on_init refuses to start otherwise.
Joint params:
| Param | Meaning |
|---|---|
id |
Motor's CAN id. Must match a gpio's ID. |
gpio params:
| Param | Meaning |
|---|---|
type |
robstride |
ID |
Motor's CAN id |
can_interface |
SocketCAN interface this motor is on. Motors may use different buses, e.g. one per quadruped leg. |
actuator_type |
"00".."06", "0".."6", or "RS00".."RS06" |
control_mode |
motion (default), position_pp, velocity, current, position_csp |
kp, kd |
Initial gains. motion mode only; the other modes are closed by the motor's own loops. |
direction |
1 (default) or -1. Joint frame = direction × motor frame for position, velocity and effort, so a motor mounted opposite to the URDF axis needs no model change. Raw parameter writes through ~/set_data_to_robstride stay in the motor frame. |
max_feedforward_effort |
motion mode only. Bound (N·m, joint frame) on the torque feedforward taken from the joint's effort command. Defaults to a quarter of the model's torque range: 3.5 N·m on an RS00 and 9 N·m on an RS06, below their 5 and 11 N·m rated torque. |
gpio interfaces:
| Interface | Kind | Meaning |
|---|---|---|
kp, kd |
command | Retune gains while running. Same destination as ~/set_data_to_robstride. |
temperature |
state | Motor temperature (°C) |
run_state |
state | RunState enum (2 = torqued) |
fault_bits |
state | Latched fault word |
In motion mode a joint may also declare an effort command interface. It is the torque feedforward of the motor's impedance law, torque = kp * (target - position) + kd * (target velocity - velocity) + effort, clamped to max_feedforward_effort. A gravity compensation controller writes it while a trajectory controller keeps position. It reads 0 until something writes it, is reset to 0 on activation and whenever the joint is untorqued, and is dropped while the component is frozen (§6).
The joint's command interface must match its gpio's control_mode: velocity mode is driven through velocity, current through effort, and the position modes through position. on_init rejects any other pairing — a controller claiming an interface the joint's mode never writes would claim it successfully and then achieve nothing, with no error to explain why the joint does not move.
Only declared interfaces are exported, so a joint cannot be claimed through an interface it did not declare.
<ros2_control name="robstride" type="system">
<hardware>
<plugin>robstride_hardware_interface/RobstrideHardware</plugin>
<param name="master_id">253</param>
<param name="error_timeout_ms">4000</param>
</hardware>
<joint name="rs_joint1">
<param name="id">1</param>
<command_interface name="velocity"/>
<state_interface name="position"/>
<state_interface name="velocity"/>
<state_interface name="effort"/>
</joint>
<gpio name="rs1">
<param name="type">robstride</param>
<param name="ID">1</param>
<param name="actuator_type">00</param>
<param name="can_interface">can0</param>
<param name="control_mode">velocity</param>
<param name="kp">5.0</param>
<param name="kd">0.3</param>
<param name="direction">-1</param>
<command_interface name="kp"/>
<command_interface name="kd"/>
<state_interface name="temperature"/>
<state_interface name="run_state"/>
<state_interface name="fault_bits"/>
</gpio>
</ros2_control>A joint driven through velocity should be type="continuous" in the URDF, not revolute. With command limit enforcement on, a revolute joint that ends up outside its position limits has its velocity clamped to zero in both directions, including the one that would bring it back, so any overshoot is unrecoverable without intervention. Position-commanded joints are unaffected: their commands are clamped to the limit rather than zeroed, which drives them back into range.
For a bench test without hardware, swap the <hardware> block for mock_components/GenericSystem.
Activation is designed to engage torque at the measured position, not an old target.
It is not a physical no-motion guarantee: the Stop probes below release torque,
and a gravity-loaded arm without brakes can move or fall. Support it at its
verified rest pose before activation, reactivation or a readable-but-limp start. The closed-loop modes keep their target inside the motor, in RAM that survives a deactivate/reactivate cycle, so enabling with a stale loc_ref still loaded would make the motor servo back to wherever it was last aimed. on_activate() therefore preloads every target before any torque exists:
- Open each bus and start its read thread.
- Arm each motor's
CAN_TIMEOUTwatchdog and select itsrun_mode. - Send a Stop frame to every motor and wait for the reply. Stop is answered with the same Type-2 feedback as any other command, so this doubles as a position probe that does not command a new target — but it releases torque, including torque left on by a run that died before
on_deactivate. - Preload each mode's target (
loc_ref/spd_ref/iq_ref) from that measured position, and seed the command interfaces from the same reading. - If
torque_enableisfalse, stop here: the motors stay readable and free to move. - Otherwise send Enable and wait up to
error_timeout_msfor each motor to reportrun_state == MOTOR, resending every 200 ms since CAN frames are fire-and-forget. - If any joint never confirms, disable everything, tear down the buses and return
ERROR.
If a joint reports no position in step 3, activation is refused rather than engaging torque against an unknown target.
A joint counts as lost when either:
error_timeout_mspasses without fresh feedback, or- with torque requested, it leaves the enabled state after having been confirmed enabled. A motor whose supply blips reboots with torque off and can answer again well inside
error_timeout_ms, so staleness alone would miss it. A fault that drops the motor out of the enabled state counts the same way.
With freeze_on_joint_loss at its default of true, the component freezes:
- The lost joint is disabled and is not re-enabled. (Its own
CAN_TIMEOUTwatchdog has usually released torque already.) - Every reachable joint keeps torque and holds the position it had at the moment of the loss.
- Controller commands are ignored, since the controllers are advancing setpoints against partly unknown state.
read()returnsOK, so nothing is torn down and no controllers are deactivated.
The freeze is latched. A recovered joint stays limp and control is not handed back: a rebooted motor's position may be a whole turn off, a link that dropped once can drop again, and the controllers' setpoints have moved on in the meantime. Only a fresh activation resumes control. Because a fault now freezes too, ~/reboot_robstride clears the fault but does not by itself bring the joint back; reactivate afterwards.
Setting freeze_on_joint_loss to false restores the conventional behavior: read() returns ERROR, the framework deactivates the component, and torque is cut on every joint.
Independently of this, write() substitutes a Disable frame for any joint that is stale or reporting a fault, rather than continuing to drive it toward a possibly outdated target. While a joint is untorqued, read() tracks its command position to its measured position, so it re-engages targeting where it actually is.
By default on_deactivate() disables every motor. That is right for a rig that can sit
unpowered, and wrong for an arm that gravity acts on: the joints release wherever they happen to
be standing and the arm drops onto whatever is below it.
hold_torque_on_deactivate makes a clean stop hold the pose instead. Nothing new is commanded
either way — the motors simply keep the target they were already holding. Two consequences worth
stating plainly:
- The arm stays rigid after the stack stops, until motor power is cut. There is no software path back to limp once the component is gone.
- It only holds while
motor_can_timeout_msis0. Deactivation closes the bus, so a motor whose own watchdog is armed releases torque that long afterwards no matter what this setting says.on_init()warns when it sees that combination.
A hard kill already behaves this way: the destructor stops and closes the buses without sending
Disable, so the motors hold until their own watchdog fires. Setting
hold_torque_on_deactivate makes the clean path agree with the crash path rather than
contradict it.
A motor only knows its angle within one turn when it powers up, so after a power cycle its reading can come back offset by a whole multiple of 2π. The reported position is therefore wrapped into a one-turn window centered on the middle of the joint's URDF limits (0 for a joint without position limits), and write() adds the dropped turns back before sending a target. A joint whose range is narrower than a full turn reads the same after a power cycle as before it. A joint without position limits is treated as continuous: its targets take the shortest way round.
This recovers whole turns only. A mechanical zero the motor forgets across a power cycle shifts the reading by an arbitrary amount, and the wrapped value then looks plausible but is wrong. Make sure the zero is stored in the motor before relying on this.
Position feedback wraps at ±4π — a firmware characteristic, not something this driver adds. See robstride_sdk's README for what that means for continuously-rotating joints.
~/set_torque and ~/set_zero_robstride talk only to the motor. Neither knows about, nor can override, whichever controller currently claims the command interface — an active joint_trajectory_controller keeps writing its own held setpoint every cycle regardless. Deactivate it first:
ros2 control switch_controllers --deactivate robstride_controller --controller-manager /robstride/controller_manager
ros2 service call /robstride/robstride_hardware_interface/set_torque std_srvs/srv/SetBool "{data: false}"
# ... move the joint by hand, call set_zero_robstride, etc ...
ros2 service call /robstride/robstride_hardware_interface/set_torque std_srvs/srv/SetBool "{data: true}"
ros2 control switch_controllers --activate robstride_controller --controller-manager /robstride/controller_managerController activation alone is not a general measured-pose anchor. In particular, MoveIt Pro's Joint Trajectory Admittance Controller can retain an earlier target. Use the robot configuration's measured-pose anchoring procedure before restoring spring gains; do not use this generic torque example as a reBot float/recovery procedure. Neither a service acknowledgement nor an accepted goal proves a motor has received a gain or reached a pose.
~/set_zero_robstride is the more dangerous of the two, for a different reason. It moves nothing itself, but it redefines what zero means at the joint's current position. If a controller is holding a setpoint of, say, 3.0 at that moment, the physical spot that used to read 3.0 now reads 0.0, and the controller — still chasing the literal number — drives the motor somewhere new to match. The motion comes entirely from the controller reacting to the reference frame shifting underneath it.
All under the hardware component's own node namespace (~):
| Name | Type | Description |
|---|---|---|
~/robstride_state |
robstride_interfaces/msg/RobstrideState |
Per-joint id/enabled/run_state/fault_bits, published every read() cycle |
~/get_data_from_robstride |
robstride_interfaces/srv/GetDataFromRobstride |
Read live telemetry for one motor |
~/set_data_to_robstride |
robstride_interfaces/srv/SetDataToRobstride |
Set kp/kd, or write an arbitrary parameter index |
~/set_zero_robstride |
robstride_interfaces/srv/SetZeroRobstride |
Set a motor's mechanical zero |
~/set_torque |
std_srvs/srv/SetBool |
Enable/disable all configured motors |
~/reboot_robstride |
robstride_interfaces/srv/RebootRobstride |
Clear a motor's latched fault. Unconditional: the caller decides which faults are safe to clear. The joint re-enables only after the hardware is reactivated (see §6). |
See robstride_interfaces' README for field descriptions.
This plugin inherits rclcpp::Node for the services above, so it has to spin itself. That spinning runs on its own executor thread (service_executor_, started in on_init), independent of the RT cycle.
An earlier version called rclcpp::spin_some() at the end of read() instead. That ran every callback synchronously inside the RT cycle, and a callback that blocked for a few seconds froze read()/write() for that whole duration — long enough for the resulting CAN silence to trip this plugin's own staleness check and force-deactivate the hardware.
The tradeoff is that state shared between a callback and the RT cycle is now touched by two genuinely concurrent threads. Service callbacks must not write the RT cycle's plain buffers directly; they park values in an atomic that write() picks up (see pending_kp_ and torque_enabled_desired_).
A 1 Mbps CAN bus is shared by every motor on it. Each cycle needs 2 · N_motors frames — one command out, one feedback frame back per motor — and an 8-byte extended frame takes roughly 130–190 µs on the wire depending on bit stuffing:
utilization ≈ 2 · N_motors · update_rate_hz · frame_time_s
Measure rather than trust the estimate:
canbusload can0@1000000 -r -t -b -eIf utilization is near saturation, or staleness errors appear under load, reduce update_rate or the number of motors per bus. Leave headroom: a bus at 100% has no margin for retries, parameter writes, or momentary noise, and starts intermittently missing the error_timeout_ms deadline.
Apache License 2.0. The unmodified LICENSE and attribution/history NOTICE are installed with the package.
thermal_cutoff_c is an optional ros2_control hardware parameter (degrees C;
finite and nonnegative, default 0 disables detection). At or above the threshold,
the driver latches ~/thermal_tripped (std_msgs/msg/Bool, reliable,
transient-local depth 1, 10 Hz). Cooling alone and lifecycle transitions do not
clear it. Already-enabled motors keep accepting recovery commands; the trip itself
never sends Stop frames or returns a ros2_control error. New activation and
~/set_torque=true are rejected while latched; write() cannot automatically
re-enable a motor that drops out. Hot initial feedback prevents startup enable.
A runtime consumer must stop the active task, return to the robot's supported
rest pose, verify fresh measured rest, and only then use the existing
~/set_torque service to switch torque off. If recovery or state freshness
fails, keep torque engaged and report intervention required. The driver has no
knowledge of a robot's HOME pose. Existing motor fault and communication-loss
handling remains in force.
After successful recovery, the runtime may attest fresh measured rest through
~/thermal_rest_verified (std_msgs/msg/UInt64, reliable, volatile, depth 1).
The payload is the measured rest sample's same-host steady-clock timestamp in
nanoseconds. The control loop consumes the attestation only while that sample
is less than 100 ms old, so delayed delivery cannot authorize clear. It clears only
when torque is requested OFF, all configured motors report RESET/OFF without
faults, and every motor's complete Type-2 feedback is less than 500 ms old with
a finite nonnegative temperature strictly below thermal_clear_c (default
75 C). This hardware parameter must be finite, nonnegative and below the enabled
cutoff. Missing, invalid, future or stale feedback cannot clear; fault frames
cannot refresh temperature freshness. The runtime must never attest rest after
failed recovery or stale/off-rest measurements, and must refresh the attestation
while waiting for cooling. This generic driver cannot verify the robot's HOME.
Clearing publishes false and leaves torque requested OFF. Only an explicit operator HOME-enable procedure and a newly started Objective may re-engage; clearing never sends Enable or restarts following. This is a software protection handoff, not an independent safety-rated overtemperature shutdown.