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
| Pattern | Shape | Use it for | Example |
|---|---|---|---|
| Topic | Continuous stream, many to many | Data that keeps flowing | Lidar scan, odometry, velocity commands |
| Service | One request, one response | Quick questions and settings | Reset odometry, set a parameter, add two numbers |
| Action | Goal, feedback, result, cancel | Long tasks that can fail or be stopped | Navigate 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?