gwordal

Lesson 3 of 5 · 26 min

Services and actions

Topics are one-way streams, and a robot needs more than that. Sometimes you want to ask a question and get an answer. Sometimes you want to start a task that takes a minute, watch its progress, and be able to cancel it. ROS 2 gives you three communication patterns for this, and choosing the right one is a design skill that separates tidy systems from fragile ones.

Topic, service or action

PatternShapeUse it forExample
TopicContinuous stream, many to manyData that keeps flowingLidar scan, odometry, velocity commands
ServiceOne request, one responseQuick questions and settingsReset odometry, set a parameter, add two numbers
ActionGoal, feedback, result, cancelLong tasks that can fail or be stoppedNavigate to a pose, pick up an object

A useful test: how long can the caller wait, and does it care about progress? A service call blocks (or at least waits) until the answer arrives, so it should finish in milliseconds. If a task takes seconds and the caller needs to know how it is going, use an action. If the data never "completes", use a topic.

A service: server and client

A service has a type with two parts, a request and a response. example_interfaces/srv/AddTwoInts ships with ROS. Inspect it with ros2 interface show example_interfaces/srv/AddTwoInts: the request is int64 a and int64 b, the response is int64 sum.

Save this as add_server.py:

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts


class AddServer(Node):
    def __init__(self):
        super().__init__('add_server')
        # service type, service name, handler
        self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.handle)

    def handle(self, request, response):
        response.sum = request.a + request.b
        self.get_logger().info(f'{request.a} + {request.b} = {response.sum}')
        return response


def main():
    rclpy.init()
    node = AddServer()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()


if __name__ == '__main__':
    main()

Any script that only needs rclpy can be run directly with python3 add_server.py once you have sourced ROS, which is handy for experiments before packaging. Now call it from the shell, then from code:

ros2 service list -t
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "a: 2
b: 3"
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts


def main():
    rclpy.init()
    node = Node('add_client')
    client = node.create_client(AddTwoInts, 'add_two_ints')
    if not client.wait_for_service(timeout_sec=3.0):
        node.get_logger().error('service not available')
        return
    request = AddTwoInts.Request()
    request.a = 2
    request.b = 3
    future = client.call_async(request)
    rclpy.spin_until_future_complete(node, future)
    node.get_logger().info(f'result: {future.result().sum}')
    node.destroy_node()
    rclpy.try_shutdown()


if __name__ == '__main__':
    main()

The client uses call_async on purpose. A synchronous call inside a callback can deadlock the executor, because the thread that should deliver the reply is the one that is waiting. Always call asynchronously and let the future complete.

An action: navigating a robot

An action is three services and two topics bundled together, but you use it as one thing. It has a goal (where to go), feedback (how far along), and a result (how it ended), and the client may cancel at any time. Nav2, the standard navigation stack, exposes nav2_msgs/action/NavigateToPose. Install the message package, which is small and needs no robot:

sudo apt install ros-jazzy-nav2-msgs
ros2 interface show nav2_msgs/action/NavigateToPose

Here is a fake navigator that drives an imaginary robot in a straight line at 0.5 m/s. It speaks the same interface a real planner would, so any client you write now works later.

import math
import time
import rclpy
from rclpy.action import ActionServer, CancelResponse
from rclpy.callback_groups import ReentrantCallbackGroup
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from nav2_msgs.action import NavigateToPose


class FakeNavigator(Node):
    def __init__(self):
        super().__init__('fake_navigator')
        self.x = 0.0
        self.y = 0.0
        self.server = ActionServer(
            self, NavigateToPose, 'navigate_to_pose',
            execute_callback=self.execute,
            cancel_callback=lambda goal: CancelResponse.ACCEPT,
            callback_group=ReentrantCallbackGroup())

    def execute(self, goal_handle):
        target = goal_handle.request.pose.pose.position
        feedback = NavigateToPose.Feedback()
        dt, speed = 0.2, 0.5
        while True:
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                return NavigateToPose.Result()
            dx, dy = target.x - self.x, target.y - self.y
            dist = math.hypot(dx, dy)
            if dist < 0.05:
                break
            step = min(speed * dt, dist)
            self.x += step * dx / dist
            self.y += step * dy / dist
            feedback.current_pose.pose.position.x = self.x
            feedback.current_pose.pose.position.y = self.y
            feedback.distance_remaining = dist - step
            goal_handle.publish_feedback(feedback)
            time.sleep(dt)
        goal_handle.succeed()
        return NavigateToPose.Result()


def main():
    rclpy.init()
    node = FakeNavigator()
    executor = MultiThreadedExecutor()
    executor.add_node(node)
    try:
        executor.spin()
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()


if __name__ == '__main__':
    main()

Two details deserve attention. First, rclpy rejects cancel requests by default, so you must supply a cancel callback to allow cancelling. Second, the execute callback loops for a long time. On a single-threaded executor that loop would block the thread that needs to receive the cancel request, so the cancel would never be seen. A MultiThreadedExecutor with a ReentrantCallbackGroup lets the cancel handler run while execute is busy.

Send a goal from another terminal and watch the feedback stream in:

ros2 action list -t
ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose "pose: {header: {frame_id: map}, pose: {position: {x: 2.0, y: 1.0}}}" --feedback

Press Ctrl+C during the run and the goal is cancelled, and the server reports it. This is why actions exist: the caller can supervise a long task instead of firing and hoping.

Check yourself

You want a node that reports the robot's battery voltage ten times per second to any node that is interested. Which pattern fits best?

Check yourself

Why does the fake navigator need a MultiThreadedExecutor and a ReentrantCallbackGroup?