ROB-110 · Lesson 8 of 12 · 28 min

Custom interfaces, packages, colcon and workspaces

So far you have run nodes as loose Python files. Real ROS 2 software is organized into packages, the units in which code is built, installed, shared and released. A package bundles nodes with their launch files, configuration and a description of what they depend on, so that anyone can build and run them with a few commands. Packages live in a workspace and are built with a tool called colcon. This lesson shows how a workspace is laid out, how to create and build a Python package, how to declare dependencies so that rosdep can install them, and how to define your own message, service and action types when the standard ones do not fit.

Workspaces, underlays and overlays

A workspace is simply a directory with a src folder containing the source code of one or more packages. When you build it, colcon creates three more folders beside src: build, which holds intermediate files; install, which holds the finished, runnable packages; and log, which holds build logs. Only src is yours. The other three are generated, should be listed in .gitignore and can be deleted at any time to force a clean build.

A workspace directory ros2_ws containing src, build, install and log. Inside src are two packages, my_robot (a Python package) and robot_interfaces (a CMake package). Below the workspace, the ROS 2 installation in /opt/ros/lyrical is shown as the underlay.
A workspace overlays the ROS 2 installation. You edit only src; colcon generates build, install and log.

Workspaces stack. The ROS 2 installation in /opt/ros is itself a workspace, called the underlay, and your own workspace is an overlay built on top of it. When you source a workspace's install/setup.bash, it adds the workspace's packages in front of those of the underlay, so if both contain a package with the same name, the overlay's version wins. This lets you modify a copy of a standard package without touching the installed one. Always source the underlay before building an overlay, and source the overlay in any terminal where you want to run its nodes.

Worked problem

Which version runs?

The ROS 2 installation contains turtlesim version 1.8.4. You clone turtlesim's source into ~/ros2_ws/src, change it and build it as version 1.9.0. Which version runs in a terminal that has sourced only /opt/ros/lyrical/setup.bash, and in one that has also sourced ~/ros2_ws/install/setup.bash?

Show solution
  1. The first terminal knows only the underlay, so it runs the installed 1.8.4.
  2. The second terminal has the overlay in front of the underlay in its search paths.
  3. The overlay's turtlesim shadows the installed one, so it runs your 1.9.0.

Answer: 1.8.4 in the first terminal and 1.9.0 in the second. When behaviour differs between terminals, check which workspaces each has sourced.

Creating a Python package

ROS 2 packages come in two main build types. ament_python packages are pure Python and are built with Python's standard setuptools. ament_cmake packages use CMake and are used for C++ code and, importantly, for defining custom interfaces. The ros2 pkg create command generates a package skeleton with the right files. The --node-name option also creates a first node and registers it as a runnable program.

Creating an ament_python package
cd ~/ros2_ws/src
ros2 pkg create my_robot --build-type ament_python \
  --license Apache-2.0 --node-name talker \
  --dependencies rclpy std_msgs geometry_msgs sensor_msgs

The generated package contains package.xml, which describes the package and its dependencies; setup.py and setup.cfg, which tell setuptools how to install it; a resource folder holding an empty marker file that lets ROS 2 find the package; a Python package folder with the same name, where your node files go; and a test folder with automatic style and licence checks. Copy talker.py and obstacle_stop.py from lesson 6 into the inner my_robot folder.

setup.py does three important jobs. Its entry_points section lists console scripts: each line maps a program name to a function, so talker = my_robot.talker:main means that ros2 run my_robot talker will call the main function in my_robot/talker.py. Its data_files section installs non-Python files, such as launch files and YAML configuration, into the package's share directory, where ROS 2 tools look for them. And its metadata, such as version and licence, should match package.xml.

setup.py for my_robot, installing two nodes, launch files and configuration
import os
from glob import glob
from setuptools import find_packages, setup

package_name = 'my_robot'

setup(
    name=package_name,
    version='0.1.0',
    packages=find_packages(exclude=['test']),
    data_files=[
        ('share/ament_index/resource_index/packages',
            ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        (os.path.join('share', package_name, 'launch'),
            glob('launch/*.launch.py')),
        (os.path.join('share', package_name, 'config'),
            glob('config/*.yaml')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='Ada Robotics',
    maintainer_email='ada@example.com',
    description='Nodes for my robot',
    license='Apache-2.0',
    entry_points={
        'console_scripts': [
            'talker = my_robot.talker:main',
            'obstacle_stop = my_robot.obstacle_stop:main',
        ],
    },
)

A frequent puzzle for beginners is a launch file or YAML file that exists in src but cannot be found at run time. The reason is almost always a missing data_files entry: ROS 2 looks in the installed share directory, not in your source tree, so every file the package needs at run time must be installed.

Declaring dependencies

package.xml is an XML file in a standard format that lists the package's name, version, description, maintainer, licence and dependencies. Dependencies are declared with tags that say when they are needed: build_depend for building, exec_depend for running, test_depend for tests, and depend as a shorthand for all of them. For a Python package, which needs no compilation, most dependencies are exec_depend or depend. The names are ROS package names such as rclpy and sensor_msgs, or keys that rosdep maps to system packages, such as python3-numpy.

package.xml for my_robot
<?xml version="1.0"?>
<package format="3">
  <name>my_robot</name>
  <version>0.1.0</version>
  <description>Nodes for my robot</description>
  <maintainer email="ada@example.com">Ada Robotics</maintainer>
  <license>Apache-2.0</license>

  <depend>rclpy</depend>
  <depend>geometry_msgs</depend>
  <depend>sensor_msgs</depend>
  <depend>robot_interfaces</depend>
  <exec_depend>python3-numpy</exec_depend>

  <test_depend>python3-pytest</test_depend>
  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

Declaring dependencies accurately is not paperwork. rosdep reads package.xml files and installs whatever is missing for your distribution and operating system, so a new teammate, a fresh robot or an automated build server can go from source code to a working system in minutes. The commands are run once per machine to set up rosdep, then whenever dependencies change.

Installing dependencies with rosdep
# once per machine
sudo rosdep init
rosdep update
# install everything the workspace needs
cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -y

Building with colcon

colcon builds every package in a workspace in the right order, working out from package.xml which packages depend on which. Run it from the top of the workspace, never from inside src, then source the result. The --symlink-install option links Python files and other resources from install back to src instead of copying them, so edits to Python code take effect without rebuilding; you still need to rebuild after changing setup.py, package.xml, or adding new files. --packages-select builds only named packages, which saves time in a large workspace, and colcon test runs the packages' tests.

Building, running and testing with colcon
cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash
ros2 run my_robot talker
# rebuild one package, then run its tests
colcon build --packages-select my_robot
colcon test --packages-select my_robot
colcon test-result --verbose
Common package and build problems.
SymptomLikely causeFix
Package not found by ros2 runWorkspace not sourced, or build failedCheck the colcon output, then source install/setup.bash
No executable foundMissing entry point in setup.pyAdd the console script and rebuild
Launch or YAML file not foundFile not installedAdd it to data_files and rebuild
Import error for a ROS messageDependency missing or interface package not builtDeclare it in package.xml and build the interface package
Old code still runsBuilt without --symlink-install, or wrong workspace sourcedRebuild and check which workspaces are sourced
Strange build errors after big changesStale build productsDelete build, install and log, then rebuild

Defining your own interfaces

Use standard message types whenever they fit, but sometimes none does: a robot may report the speeds of both wheels together with a sequence number and its emergency-stop state, or offer an action, such as DriveDistance in lesson 7, with no standard equivalent. Custom interfaces are defined in .msg, .srv and .action files, conventionally in a separate package whose name ends in _interfaces or _msgs, so that other packages can depend on the interfaces without depending on your nodes. Interface types are written in CamelCase and fields in lowercase with underscores. Fields can be primitive types, strings, fixed or variable-length arrays and other messages, and can have default values and comments.

Interfaces are generated into Python and C++ code at build time by a code generator called rosidl, which currently requires an ament_cmake package even if all your nodes are in Python. Create one with ros2 pkg create robot_interfaces --build-type ament_cmake, put the definitions in msg, srv and action folders, and list them in CMakeLists.txt.

CMakeLists.txt for the robot_interfaces package
cmake_minimum_required(VERSION 3.8)
project(robot_interfaces)

find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/WheelSpeeds.msg"
  "action/DriveDistance.action"
)

ament_export_dependencies(rosidl_default_runtime)
ament_package()

Its package.xml must declare the generator and the runtime support, and mark the package as an interface package: add a buildtool_depend on rosidl_default_generators, an exec_depend on rosidl_default_runtime, a depend on action_msgs because the package defines an action, and the line <member_of_group>rosidl_interface_packages</member_of_group>. After building and sourcing, ros2 interface show robot_interfaces/msg/WheelSpeeds prints your definition, and Python code imports it with from robot_interfaces.msg import WheelSpeeds, exactly like a standard type.

It is worth estimating how big a custom message is. The program below reads a message definition and adds up the sizes of its fixed-size fields, which gives the payload size before serialization adds alignment padding and a small header.

Estimating the payload size of a custom message
SIZES = {"bool": 1, "byte": 1, "int8": 1, "uint8": 1,
         "int16": 2, "uint16": 2, "int32": 4, "uint32": 4,
         "float32": 4, "int64": 8, "uint64": 8, "float64": 8}

definition = """
# robot_interfaces/msg/WheelSpeeds.msg
float64 left      # rad/s
float64 right     # rad/s
uint32 sequence
bool estop_active
"""


def payload_bytes(text):
    total = 0
    for line in text.splitlines():
        line = line.split("#")[0].strip()   # drop comments
        if not line:
            continue
        ftype, name = line.split()[:2]
        total += SIZES[ftype]
        print(f"  {name:13s} {ftype:8s} {SIZES[ftype]} B")
    return total


size = payload_bytes(definition)
rate = 100   # Hz
print(f"payload {size} B, at {rate} Hz: "
      f"{size * rate * 8 / 1000:.1f} kbit/s")
Output
  left          float64  8 B
  right         float64  8 B
  sequence      uint32   4 B
  estop_active  bool     1 B
payload 21 B, at 100 Hz: 16.8 kbit/s

Worked problem

Adding a header

You add a std_msgs/Header to WheelSpeeds so each message carries a timestamp and frame name. A Header holds a time (two 4-byte integers) and a frame name string, here base_link. Estimate the new payload and the bandwidth at 100 Hz.

Show solution
  1. Time: \( 4 + 4 = 8 \) bytes.
  2. String base_link: 9 characters, plus a 4-byte length and a terminating byte in the serialized form, about 14 bytes.
  3. New payload: \( 21 + 8 + 14 \approx 43 \) bytes.
  4. At 100 Hz: \( 43 \times 100 \times 8 \approx 34 \) kbit/s.

Answer: About 43 bytes and 34 kbit/s, double the original but still tiny. Timestamps are almost always worth their cost.

Once an interface is in use, treat its definition as a contract. Changing a field's name or type breaks every node built against the old definition, and nodes built with different versions of an interface will refuse to communicate. Plan interfaces carefully, prefer adding new fields to changing existing ones, and when you must make a breaking change, increase the package's major version as lesson 4 described.

Common mistakes

  • MistakeRunning colcon build from inside the src folder.

    InsteadAlways build from the top of the workspace.

  • MistakeEditing files in install or build instead of src.

    InsteadEdit only src; build, install and log are regenerated.

  • MistakeAdding a launch file to the package but not to data_files in setup.py.

    InsteadInstall every run-time file through data_files, then rebuild.

  • MistakeTrying to generate custom messages from an ament_python package.

    InsteadDefine interfaces in a separate ament_cmake package and depend on it.

  • MistakeLeaving dependencies out of package.xml because they happen to be installed on your laptop.

    InsteadDeclare every dependency so rosdep can set up other machines.

Summary and key terms

  • A workspace holds packages in src; colcon generates build, install and log.
  • Your workspace is an overlay on the ROS 2 underlay; sourcing an overlay puts its packages first.
  • ament_python packages use setup.py for entry points and data files; ament_cmake packages are used for C++ and interfaces.
  • package.xml declares dependencies with depend, exec_depend, build_depend and test_depend; rosdep installs them.
  • colcon build --symlink-install builds the workspace; source install/setup.bash before running nodes.
  • Custom .msg, .srv and .action files go in an ament_cmake interfaces package listed in rosidl_generate_interfaces.
  • Interfaces are contracts: estimate their size, include headers where timing matters and avoid breaking changes.
Package
The unit of ROS 2 software that is built, installed and released, with a package.xml.
Workspace
A directory whose src folder contains packages to build together.
Underlay
A workspace, such as the ROS 2 installation, on which another is built.
Overlay
A workspace built on top of an underlay whose packages take precedence when sourced.
colcon
The command-line tool that builds and tests ROS 2 workspaces.
ament_python
The build type for pure Python ROS 2 packages.
ament_cmake
The build type for CMake packages, used for C++ and interface definitions.
Entry point
A setup.py line mapping a program name to a Python function.
rosdep
A tool that installs the system dependencies declared in package.xml files.
rosidl
The ROS 2 code generator that turns interface files into Python and C++ code.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Which of src, build, install and log should be committed to Git?

    Show answer

    Only src.

  2. What does --symlink-install change?

    Show answer

    Installed Python files and resources link back to src, so edits take effect without rebuilding.

  3. Write the setup.py entry point that makes ros2 run my_robot stopper call main in my_robot/obstacle_stop.py.

    Show answer

    'stopper = my_robot.obstacle_stop:main'

  4. Your YAML file is in src/my_robot/config but the node says it cannot be found. Why?

    Show answer

    It was not installed: add config/*.yaml to data_files and rebuild.

  5. Why put custom interfaces in their own package?

    Show answer

    So other packages can use the interfaces without depending on your nodes, and because they need an ament_cmake build.

  6. Which package.xml tag declares a dependency needed only for running tests?

    Show answer

    test_depend.

  7. Estimate the payload of a message with three float32 fields and one uint8.

    Show answer

    3 × 4 + 1 = 13 bytes.

  8. Two terminals run different versions of the same node. What should you check?

    Show answer

    Which workspaces each terminal has sourced, and in what order.

  9. What command installs all missing dependencies for the packages in src?

    Show answer

    rosdep install --from-paths src --ignore-src -y

  10. You rename a field in a widely used message. What version change does the interface package need?

    Show answer

    A major version increase, because nodes built against the old definition break.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz