Back to projects

Simulating a 6‑DoF Robotic Arm in ROS 2 + Gazebo

Build, spawn and control a Doosan a0912 six‑axis arm in simulation — from the URDF/xacro description to a working trajectory controller — on ROS 2 Jazzy and Gazebo Harmonic (Ubuntu 24.04).

ROS 2 JazzyGazebo Harmonicros2_controlURDF / xacro

Este tutorial está disponible únicamente en inglés.

Updated June 2026. This tutorial was rewritten for ROS 2 Jazzy + Gazebo Harmonic (gz‑sim). The original targeted ROS 2 Foxy + Gazebo Classic, both now end‑of‑life. The biggest practical change: you no longer build ros2_control and gazebo_ros2_control from source — everything installs from apt. A summary of all the changes is at the end of the page.

What you’ll build

By the end you will be able to:

  • Install the ROS 2 control and Gazebo packages.
  • Spawn the 6‑DoF arm in Gazebo Harmonic.
  • Visualize the robot in RViz 2 and move each joint with sliders.
  • Drive the arm with a joint_trajectory_controller and send your own trajectory goals.
Doosan a0912 arm spawned in Gazebo Harmonic
The Doosan a0912 arm spawned in Gazebo Harmonic.

Prerequisites

You only need the platform installed first:

RequirementVersion (tested)
Ubuntu24.04
ROS 2Jazzy — install guide

1. Install the dependencies

A single apt command installs everything: Gazebo Harmonic (pulled in by ros‑gz), the ros_gz bridge/sim, ros2_control, gz_ros2_control, the controllers, and the visualization tools. It is safe to run on any ROS 2 Jazzy install — packages you already have are simply skipped.

sudo apt install ros-jazzy-ros-gz ros-jazzy-gz-ros2-control \
  ros-jazzy-ros2-control ros-jazzy-ros2-controllers \
  ros-jazzy-joint-state-publisher-gui ros-jazzy-xacro \
  ros-jazzy-robot-state-publisher ros-jazzy-rviz2 ros-jazzy-tf2-ros

2. Get the code

Create a colcon workspace and clone the repository into its src folder:

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
git clone https://github.com/dvalenciar/robotic_arm_environment.git
cd ~/ros2_ws
colcon build
source install/setup.bash

Remember to source install/setup.bash in every new terminal (after first sourcing /opt/ros/jazzy/setup.bash).

3. Spawn and move the arm

This launch file starts Gazebo, spawns the arm, and brings up the controllers — all in one command:

ros2 launch my_doosan_pkg my_doosan_gazebo_controller.launch.py

Gazebo opens with the arm standing on the ground plane. In a second terminal, confirm the controllers are active:

ros2 control list_controllers
joint_trajectory_controller  joint_trajectory_controller/JointTrajectoryController  active
joint_state_broadcaster      joint_state_broadcaster/JointStateBroadcaster          active

Now command a pose by publishing a trajectory goal (six joint angles, in radians):

ros2 topic pub --once /joint_trajectory_controller/joint_trajectory \
  trajectory_msgs/msg/JointTrajectory \
  '{joint_names: [joint1, joint2, joint3, joint4, joint5, joint6],
    points: [{positions: [1.0, -0.5, 0.8, 0.0, 0.7, 0.0], time_from_start: {sec: 2}}]}'

The arm interpolates smoothly to that configuration over two seconds. 🎉

4. Visualize in RViz 2

To inspect the robot model and drive each joint by hand, launch the RViz view (no physics — pure visualization):

ros2 launch my_doosan_pkg my_doosan_rviz.launch.py

A joint_state_publisher_gui window opens with one slider per joint; drag them to move the arm in RViz. If the model does not appear, set Fixed Frame to world in the top‑left of RViz.

Doosan arm in RViz 2 with joint sliders
The arm in RViz 2 with the joint‑state‑publisher sliders.

How it works

The my_doosan_pkg package is organized like this:

my_doosan_pkg/
├── description/
│   ├── xacro/      # robot description + control plugin (xacro → URDF)
│   └── meshes/     # COLLADA (.dae) visual meshes from Doosan
├── config/         # controller configuration (simple_controller.yaml)
├── launch/         # launch files
└── worlds/         # gz-sim SDF world

The arm geometry comes from Doosan Robotics’ official a0912 model (COLLADA meshes and joint definitions). The interesting part for simulation is how the robot is wired to ros2_control through Gazebo.

The control plugin (xacro)

Two things are declared in the description. First, a <ros2_control> block tells the framework which joints exist and which interfaces they expose:

<ros2_control name="GazeboSimSystem" type="system">
  <hardware>
    <plugin>gz_ros2_control/GazeboSimSystem</plugin>
  </hardware>
  <joint name="joint1">
    <command_interface name="position">
      <param name="min">-6.2832</param>
      <param name="max">6.2832</param>
    </command_interface>
    <state_interface name="position"/>
    <state_interface name="velocity"/>
    <state_interface name="effort"/>
  </joint>
  <!-- joint2 … joint6 follow the same pattern -->
</ros2_control>

Second, the Gazebo plugin that hosts the controller manager inside the simulation and reads the controller configuration:

<gazebo>
  <plugin filename="gz_ros2_control-system"
          name="gz_ros2_control::GazeboSimROS2ControlPlugin">
    <parameters>$(find my_doosan_pkg)/config/simple_controller.yaml</parameters>
  </plugin>
</gazebo>

Hard-won tip — damping & friction. The original Doosan description does not include damping and friction on the joints. Add them yourself (per joint, in the xacro). Without them the arm looks unstable in simulation and the joints make strange, jerky movements that the controller then fights. Tune the values for your robot — too little and it wobbles, too much and it feels sluggish.

The controller configuration

config/simple_controller.yaml declares the two controllers and the joints the trajectory controller drives:

controller_manager:
  ros__parameters:
    update_rate: 100  # Hz
    joint_state_broadcaster:
      type: joint_state_broadcaster/JointStateBroadcaster
    joint_trajectory_controller:
      type: joint_trajectory_controller/JointTrajectoryController

joint_trajectory_controller:
  ros__parameters:
    joints: [joint1, joint2, joint3, joint4, joint5, joint6]
    command_interfaces: [position]
    state_interfaces:    [position, velocity]

ROS 1 → ROS 2 gotcha. Notice the controller YAML is not loaded in the launch file. In ROS 2 it is referenced from the robot description — the <parameters> tag inside the Gazebo control plugin above points at simple_controller.yaml. (In ROS 1 you loaded it from the launch file instead.)

The two controllers

These are the heart of the simulation — one writes, one reads:

  • joint_trajectory_controller (write): you send it a target configuration and a time; it interpolates a smooth path and commands the joints at every control tick.
  • joint_state_broadcaster (read): publishes the current joint positions and velocities on /joint_states — what RViz, TF, and your own code consume.
Data flow between your node, the controllers, and Gazebo
How a command travels to the simulated joints and the state comes back.

The launch file

my_doosan_gazebo_controller.launch.py wires it together:

  1. Publish the robot description from the xacro through robot_state_publisher.
  2. Start Gazebo with the empty world via ros_gz_sim.
  3. Spawn the robot from the /robot_description topic with ros_gz_sim create.
  4. Bridge the simulation clock (/clock) so ROS nodes use sim time.
  5. Load the controllers with the controller_manager spawner, in order.

Gotcha. The robot description must be wrapped so launch treats it as a string, otherwise it tries to parse the whole URDF as YAML and the launch crashes:

ParameterValue(Command(['xacro', ' ', xacro_file]), value_type=str)

The launch files

The package ships four launch files, each for a different need:

Launch fileWhat it does
my_doosan_gazebo_controller.launch.pyGazebo + arm + controllers — the full demo used above. Spawn it and drive it with the trajectory controller.
my_doosan_rviz.launch.pyRViz only (no physics) with the joint‑slider GUI — for inspecting the model and moving joints by hand.
my_doosan_gazebo.launch.pyGazebo + arm only, no controllers — just spawns the robot to check it loads.
my_doosan_controller.launch.pyArm + controllers but no Gazebo — a building block meant to be included by another launch (e.g. the RL environment) that starts Gazebo itself.

What changed from the old (Foxy + Gazebo Classic) tutorial

If you followed the original version, here is everything that moved:

 Old (Foxy / Classic)New (Jazzy / Harmonic)
Ubuntu20.0424.04
ROS 2FoxyJazzy
SimulatorGazebo ClassicGazebo Harmonic (gz‑sim)
Control plugingazebo_ros2_control (built from source)gz_ros2_control (from apt)
Hardware interfacegazebo_ros2_control/GazeboSystemgz_ros2_control/GazeboSimSystem
Launch / spawngazebo_ros, spawn_entity.pyros_gz_sim (gz_sim.launch.py, create)
World fileClassic SDF + libgazebo_ros_* pluginsgz‑sim SDF + gz‑sim‑*‑system plugins
Controller YAMLincluded deprecated write_op_modes / interface_namecleaned up (those keys removed)

The old tutorial also required building ros2_control and gazebo_ros2_control from specific source branches. On Jazzy that whole step is gone — the single apt command above covers it.

Next steps

This arm is the foundation for a full reinforcement‑learning reacher task — adding a target sphere and a Gymnasium‑style environment. See the companion project: