ROS 2 Control#

Drive a robot in NVIDIA Isaac Sim with the standard ros2_control stack running inside the simulation. The isaacsim.ros2.control extension hosts a ros2_control Controller Manager in-process and presents the simulated robot as a hardware interface, so the controllers, YAML, and command-line tools you would use on real hardware work against the simulation.

This is the in-process counterpart to the bridge-based MoveIt workflow in MoveIt 2, where MoveIt drives the arm over the Isaac Sim ROS 2 bridge, exchanging joint states and commands on ROS 2 topics. Hosting the real Controller Manager inside the simulation runs the controllers next to physics, removes the topic round-trip between the controller and the robot, and mirrors how you bring up a physical robot. To move to hardware later, you swap the hardware interface and reuse the same controllers and YAML.

If ros2_control is new to you: it is the standard ROS 2 control framework, where a Controller Manager loads controllers that read and write a robot’s hardware interfaces. See ros2_control for the full picture.

Learning objectives#

You will:

  • Install the demo package and its dependencies.

  • Drive a UR10 with MoveIt 2 through the in-process Controller Manager.

  • Command the arm directly, without MoveIt.

  • See how a robot’s USD drives map to ros2_control interfaces, so you can apply the extension to your own robot, add sensors, and run several robots at once.

Quick check without a workspace#

Before the full MoveIt walkthrough, you can confirm the extension works with a single command. This needs only Isaac Sim, not a ROS 2 workspace or the packages below. The script builds a one-joint arm, starts the in-process Controller Manager, activates a broadcaster and a trajectory controller, commands the joint, and checks that it moves. Run from the NVIDIA Isaac Sim root:

./python.sh standalone_examples/api/isaacsim.ros2.control/ros2_control_smoke_test.py

It prints DEMO SMOKE TEST: PASS and exits 0 once the joint reaches its target. Add --headless to run without a viewport.

Prerequisites#

  • Complete ROS 2 Installation so your ROS 2 workspace is set up. The isaac_ros2_control_demo package and the supporting ros2_control packages ship with your NVIDIA Isaac Sim download, inside the workspace (for example humble_ws or jazzy_ws).

  • Install the controller plugins and the UR10 MoveIt configuration:

    sudo apt install ros-$ROS_DISTRO-ros2-controllers ros-$ROS_DISTRO-ur-moveit-config
    

    The RViz motion-planning panel also uses ros-$ROS_DISTRO-rviz-visual-tools. It is optional: without it the panel logs a harmless plugin error and planning still works.

  • Build the demo package and source the install. The standard clone path from ROS 2 Installation is ~/IsaacSim-ros_workspaces/${ROS_DISTRO}_ws; substitute the path you used:

    cd ~/IsaacSim-ros_workspaces/${ROS_DISTRO}_ws
    colcon build --packages-select isaac_ros2_control_demo
    source install/setup.bash
    
  • On multiple machines, set FASTRTPS_DEFAULT_PROFILES_FILE as described in ROS 2 Installation before launching Isaac Sim and in every terminal that sends or receives ROS 2 messages.

Run the UR10 with MoveIt 2#

You will start the simulation, bring up MoveIt 2, then plan and execute a motion that the UR10 follows in the viewport.

  1. Terminal 1. Start Isaac Sim with the standalone example. The script opens a UR10 USD, builds an Action Graph with ROS2 Publish Clock and ROS2ControlManager against ur10_controllers.yaml, and presses Play. Run from the NVIDIA Isaac Sim root:

    ./python.sh standalone_examples/api/isaacsim.ros2.control/ur10_ros2_control_demo.py
    

    Wait until the viewport opens and the UR10 is visible. The Controller Manager is created on the first physics tick after Play, so by the time the UR10 is visible it is normally up. First-run asset download can add up to a minute.

    Note

    At Info log verbosity the extension prints [ros2.control] backend ready to stdout once it finishes loading. That is an earlier signal than the UR10 appearing, and it does not mean the Controller Manager exists yet; the CM comes up on the first physics tick after Play.

  2. Terminal 2. Source the workspace and launch MoveIt 2:

    source ~/IsaacSim-ros_workspaces/${ROS_DISTRO}_ws/install/setup.bash
    ros2 launch isaac_ros2_control_demo ur10_in_process.launch.py
    

    This starts move_group, robot_state_publisher, and RViz with the motion-planning panel, and activates the controllers against the in-process Controller Manager. Confirm both controllers are active:

    ros2 control list_controllers
    

    Expected: the table lists joint_state_broadcaster and scaled_joint_trajectory_controller, each showing active in the State column.

    Note

    The launch first waits up to 60 seconds for Isaac Sim to publish /robot_description, then brings up MoveIt and activates the controllers, so start Terminal 1 first and wait for the UR10 to appear. If it times out, confirm Isaac Sim is running and at Play.

  3. In RViz, plan and execute a motion with the MotionPlanning panel. Set a goal pose (drag the interactive marker, or pick <random_valid> under Goal State), click Plan, then Execute. The trajectory goes to the in-process Controller Manager, and the UR10 moves.

    Expected: RViz shows the planned trajectory, then the UR10 in the viewport follows it in real time.

    Isaac Sim viewport and RViz side by side as the UR10 plans and executes a motion

    The UR10 following a MoveIt-planned trajectory in the NVIDIA Isaac Sim viewport (left), driven from RViz (right).#

How it works#

The extension is driven by a small Action Graph: an On Playback Tick node wired into a ROS2ControlManager node. When useSimTime is enabled, add a ROS2 Publish Clock node or another ROS clock source to provide /clock. On the first tick after Play, the ROS2ControlManager node reads the articulation under targetPrim, builds a URDF from its drive configuration, and starts the Controller Manager against your controller YAML. Later ticks do nothing.

graph LR Tick["On Playback Tick"] --> Clock["ROS2 Publish Clock"] Tick --> Node["ROS2ControlManager<br/>(targetPrim + controller YAML)"] Node --> CM["Controller Manager<br/>(runs in-process)"]
The ROS2 Control Manager OmniGraph node and its inputs

The node takes these inputs:

Input

Default

Description

targetPrim

A single ArticulationRootAPI prim, or a parent containing exactly one.

controllerConfig

Path to a standard ros2_control YAML (the same file you would pass ros2_control_node on a real robot).

urdfPath

""

Optional overlay URDF that adds sensors or extra hardware params (see adding a sensor). Read from the importer’s isaac:sourceUrdf when left empty.

namespace

""

ROS 2 namespace for the Controller Manager, its controllers, and /robot_description.

publishRobotDescription

true

Latch the synthesized URDF on /<namespace>/robot_description (transient_local QoS) for MoveIt 2, RViz, and robot_state_publisher. Disable if an external robot_state_publisher owns it.

useSimTime

true

Set use_sim_time on the Controller Manager. When enabled, provide /clock with a ROS2 Publish Clock node or another ROS clock source.

The Controller Manager runs inside Isaac Sim, not as a separate ros2_control_node, so ps shows no such process and ros2 control commands talk to it directly. Its read, update, and write cycle runs from the physics step at the YAML’s update_rate, capped at the physics rate.

You do not write a URDF. The extension synthesizes one from USD on each Play and infers every joint’s command interface from its drive:

Joint drive in USD

Command interface

Stiffness above zero

position

Damping only (no stiffness)

velocity

Drive with neither gain

effort

No drive

state only (reported, not commandable)

Driven joints export position, velocity, and effort, so any standard controller works and you can switch modes at runtime. A joint with no drive shows up in /joint_states but cannot be commanded.

To configure the Controller Manager from a script instead of an Action Graph, call Ros2ControlManager.setup() and Ros2ControlManager.teardown(). When using the default use_sim_time=True, provide /clock with ROS2PublishClock or another ROS clock source. See the standalone example under standalone_examples/api/isaacsim.ros2.control/.

Use your own robot#

The walkthrough uses a UR10, but the extension works with any articulation that meets the requirements below (verified with the UR10). Point targetPrim at your robot, give it a controller YAML, and the rest is the same as the walkthrough.

Your USD needs:

  • One ArticulationRootAPI prim at or under targetPrim.

  • Revolute or prismatic joints for everything you want to control. Fixed and other joint types are skipped and get no ros2_control interface.

  • A drive on each joint you want to command. The drive’s gains pick the command interface, per the table above.

Write a standard ros2_control YAML for your controllers. You do not add a <hardware> block; the extension injects its own hardware interface. After Play, confirm the interfaces are present:

ros2 control list_hardware_interfaces

If the Controller Manager fails to start with ControllerManager init failed, the synthesized URDF or the YAML is the usual cause. Check that the joint names in your YAML match the robot.

Drive the arm without MoveIt#

Once both controllers are active you can command the arm directly. This is the fastest way to confirm the ros2_control pipeline independently of any MoveIt configuration. Publish a trajectory straight to the controller:

ros2 topic pub --once /scaled_joint_trajectory_controller/joint_trajectory \
    trajectory_msgs/msg/JointTrajectory \
    '{joint_names: ["shoulder_pan_joint", "shoulder_lift_joint", "elbow_joint",
                    "wrist_1_joint", "wrist_2_joint", "wrist_3_joint"],
      points: [{positions: [0.5, -1.2, 1.2, -1.5, -1.5, 0.0],
                time_from_start: {sec: 3}}]}'

The UR10 moves to the commanded pose within a few seconds. The controller also serves a FollowJointTrajectory action on /scaled_joint_trajectory_controller/follow_joint_trajectory for goal-based clients such as MoveIt. If the arm moves here but not from MoveIt, the problem is in the MoveIt configuration, not the simulation side.

Add a force-torque or IMU sensor#

The synthesized URDF carries joints but no sensors. To publish force-torque or IMU data through a ros2_control broadcaster, supply a small overlay URDF that adds a <sensor> block and point the node’s urdfPath input at it. The URDF importer fills in urdfPath from isaac:sourceUrdf at import time, so you often do not set it by hand. The overlay only contributes <sensor> blocks and extra hardware <param> entries; the kinematic tree stays auto-generated.

Force-torque sensor. Bind the sensor to an articulation link, and add a matching force_torque_sensor_broadcaster to the controller YAML:

<robot name="arm">
  <ros2_control name="arm">
    <sensor name="ft_sensor">
      <state_interface name="force.x"/>
      <state_interface name="force.y"/>
      <state_interface name="force.z"/>
      <state_interface name="torque.x"/>
      <state_interface name="torque.y"/>
      <state_interface name="torque.z"/>
      <param name="link">link1</param>
    </sensor>
  </ros2_control>
</robot>
controller_manager:
  ros__parameters:
    update_rate: 60
    ft_broadcaster:
      type: force_torque_sensor_broadcaster/ForceTorqueSensorBroadcaster

ft_broadcaster:
  ros__parameters:
    sensor_name: ft_sensor
    frame_id: link1

The <param name="link"> must name a link in the articulation; an unknown link fails setup. The wrench is read from that link’s incoming joint force.

IMU sensor. Author an IsaacImuSensor prim under the articulation, then declare the ten IMU interfaces. The sensor is resolved by name to the matching IsaacImuSensor prim, so no prim_path param is needed:

<robot name="arm">
  <ros2_control name="arm">
    <sensor name="imu_sensor">
      <state_interface name="orientation.x"/>
      <state_interface name="orientation.y"/>
      <state_interface name="orientation.z"/>
      <state_interface name="orientation.w"/>
      <state_interface name="angular_velocity.x"/>
      <state_interface name="angular_velocity.y"/>
      <state_interface name="angular_velocity.z"/>
      <state_interface name="linear_acceleration.x"/>
      <state_interface name="linear_acceleration.y"/>
      <state_interface name="linear_acceleration.z"/>
    </sensor>
  </ros2_control>
</robot>
controller_manager:
  ros__parameters:
    update_rate: 60
    imu_broadcaster:
      type: imu_sensor_broadcaster/IMUSensorBroadcaster

imu_broadcaster:
  ros__parameters:
    sensor_name: imu_sensor
    frame_id: link1

Note

IMU linear acceleration is reported in meters per second squared, following the REP-145 convention (a stationary sensor reads about 9.81 from gravity). Set the stage to meters; on a default centimeter stage the acceleration reads roughly 100 times too large.

Activate a broadcaster like any other controller, then read its topic:

ros2 control load_controller --set-state active ft_broadcaster
ros2 topic echo /ft_broadcaster/wrench

Mimic joints and grippers#

PhysX mimic joints, common in parallel grippers, come through automatically. The follower joint is reported in /joint_states and tracks its leader, but it is not separately commandable; command the leader and the follower follows.

Run multiple robots#

Each ROS2ControlManager node hosts one Controller Manager for one articulation root; pointing targetPrim at a scene with more than one root is rejected. To run several robots, add one node per robot and give each a distinct namespace. The namespace scopes that robot’s Controller Manager, its controllers, and its latched /robot_description. Leave each robot’s YAML flat; the node applies the namespace.

ros2 control list_controllers --controller-manager /arm_a/controller_manager
ros2 control list_controllers --controller-manager /arm_b/controller_manager

State and command topics resolve under each namespace (for example /arm_a/joint_states and /arm_b/joint_states), so the robots stay isolated.

Troubleshooting#

If update_rate in ur10_controllers.yaml is greater than the physics step rate, the Controller Manager clamps it to the physics rate and logs a one-time warning; the controllers keep running at the clamped rate. To run faster, raise the physics step rate so that update_rate is at most the physics step rate.

Pressing Stop tears down the in-process CM, so ros2 control list_controllers returns an error or empty list while the timeline is stopped. Pressing Play recreates the CM from scratch; re-run the launch from Terminal 2 to bring the controllers back. This is expected across Play and Stop cycles, not a crash.

If a setup error appears once and then nothing happens on later ticks, the node has latched the failure for this run. Fix the YAML or the USD, then press Stop and Play to retry.

If ros2 launch isaac_ros2_control_demo ur10_in_process.launch.py fails with package or controller plugin errors, confirm the apt packages from the prerequisites are installed:

sudo apt install ros-$ROS_DISTRO-ros2-controllers ros-$ROS_DISTRO-ur-moveit-config

If your RViz window shows a black screen where the robot should be, update your mesa driver:

sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository ppa:kisak/kisak-mesa
sudo apt install -y mesa-utils
sudo apt -y upgrade

Summary#

You drove a UR10 in NVIDIA Isaac Sim with MoveIt 2 through a ros2_control Controller Manager hosted inside the simulation, commanded the arm directly, and saw how the extension maps a robot’s USD drives to ros2_control interfaces so you can use it with your own robots and sensors.

Next Steps#

Continue on to the next tutorial in our ROS 2 Tutorials series, ROS 2 Generic Server and Client, to create generic ROS 2 service servers and clients.

Further Learning#

  • Compare this with the topic-based approach in MoveIt 2, which drives MoveIt 2 over the ROS 2 bridge instead of an in-process Controller Manager.

  • Learn more about ros2_control.

  • Learn more about MoveIt 2.

  • Browse the upstream ros2_controllers plugin set.