5.7. Services#

The second type of connections between nodes is a service.

Nodes can run a server, which only serves data when it is requested to by a client. It’s similar to a regular function in programming, except it works across nodes.

Let’s make a server that does the same thing as in our publisher/subscriber node, but only when requested. When you give the server a String, it will return a string with some punctuation added to the end.

The first thing we have to do is define the data types, for input and output. Our inputs and outputs are both strings, so lets define a message that consists of an input and output string.

5.7.1. Messages#

The way we can define what goes in our message is with msg and srv files. Message definitions must be in a CMake package and not a python package, so we’ll define our messages in another package, but we can use them anywhere as long as our workspace is loaded. Run the following commands to create a new package.

# Initialize your workspace variables
workspace = '/home/ubuntu/learn_ros_ws'
%cd $workspace
package_name = 'basic_nodes'
interface_package_name = 'learn_ros_interfaces'
/home/ubuntu/learn_ros_ws
%%bash --no-raise-error -s "$package_name" "$interface_package_name"
cd src

# Create a package
# ros2 pkg create --build-type ament_python $1 # Should already be created. Uncomment if you skipped the first tutorial.
ros2 pkg create --build-type ament_cmake $2
going to create a new package
package name: learn_ros_interfaces
destination directory: /home/ubuntu/learn_ros_ws/src
package format: 3
version: 0.0.0
description: TODO: Package description
maintainer: ['root <87028711+jgoldberger26@users.noreply.github.com>']
licenses: ['TODO: License declaration']
build type: ament_cmake
dependencies: []
creating folder ./learn_ros_interfaces
creating ./learn_ros_interfaces/package.xml
creating source and include folder
creating folder ./learn_ros_interfaces/src
creating folder ./learn_ros_interfaces/include/learn_ros_interfaces
creating ./learn_ros_interfaces/CMakeLists.txt

[WARNING]: Unknown license 'TODO: License declaration'.  This has been set in the package.xml, but no LICENSE file has been created.
It is recommended to use one of the ament license identifiers:
Apache-2.0
BSL-1.0
BSD-2.0
BSD-2-Clause
BSD-3-Clause
GPL-3.0-only
LGPL-3.0-only
MIT
MIT-0

Now, we need to create two directories in our package: msg and srv. This is where our message and service definitions will go.

%%bash --no-raise-error -s "$interface_package_name"

mkdir src/$1/src/msg src/$1/src/srv

So, what goes in these directiories? We need our message and service files.

The name of the file is the name of your message or service, and the contents are defined as follows:

type1 part1
type2 part2

This is like a struct in programming.

Service definitions are similar, and are defined as follows:

type1 input1
type2 input2
---
type3 output

A service actually consists of two messages: One above the line, and one below it. This means that you can have as many inputs or outputs as you want!

For our service, lets define a service with one input string and one output string.

%%writefile src/$interface_package_name/src/srv/AddPunctuation.srv

string input
---
string output
Overwriting src/learn_ros_interfaces/src/srv/AddPunctuation.srv

Now we need to tell our package about our new service. We’re going to edit package.xml and CMakeLists.txt, which are two files that tell the build system what other files should be used.

Add these lines to the end of your CMakeLists.txt in the interfaces.

find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/AddPunctuation.srv"
)

Note that we didn’t actually make any custom messages, since we don’t need them for this tutorial. If you did need to make a message, it would need to be added here as well.

Your final CMakeLists.txt should look like this:

%%writefile src/$interface_package_name/CMakeLists.txt

cmake_minimum_required(VERSION 3.8)
project(learn_ros_interfaces)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
# uncomment the following section in order to fill in
# further dependencies manually.
# find_package(<dependency> REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "src/srv/AddPunctuation.srv"
)

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  # the following line skips the linter which checks for copyrights
  # comment the line when a copyright and license is added to all source files
  set(ament_cmake_copyright_FOUND TRUE)
  # the following line skips cpplint (only works in a git repo)
  # comment the line when this package is in a git repo and when
  # a copyright and license is added to all source files
  set(ament_cmake_cpplint_FOUND TRUE)
  ament_lint_auto_find_test_dependencies()
endif()

ament_package()
Overwriting src/learn_ros_interfaces/CMakeLists.txt

We’re also going to need to edit package.xml, right next to CMakeLists.txt.

Add these lines to package.xml:

  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <exec_depend>rosidl_default_runtime</exec_depend>
  <member_of_group>rosidl_interface_packages</member_of_group>

So your final file should look like this:

%%writefile src/$interface_package_name/package.xml
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
  <name>learn_ros_interfaces</name>
  <version>0.0.0</version>
  <description>TODO: Package description</description>
  <maintainer email="youremailhere@example.com">root</maintainer>
  <license>TODO: License declaration</license>

  <buildtool_depend>ament_cmake</buildtool_depend>

  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <exec_depend>rosidl_default_runtime</exec_depend>
  <member_of_group>rosidl_interface_packages</member_of_group>

  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>
Overwriting src/learn_ros_interfaces/package.xml

And now build! Once you reload your terminal, you should see your new service interface in the ros2 list.

%%bash --no-raise-error -s "$interface_package_name"

colcon build --packages-select $1
source install/local_setup.bash
ros2 interface list | grep AddPunctuation
Starting >>> learn_ros_interfaces
Finished <<< learn_ros_interfaces [0.46s]

Summary: 1 package finished [0.54s]
    learn_ros_interfaces/srv/AddPunctuation

Your output should look like this:

Starting >>> learn_ros_interfaces
Finished <<< learn_ros_interfaces [0.45s]

Summary: 1 package finished [0.54s]
    learn_ros_interfaces/srv/AddPunctuation

If you don’t see the last line, go back and make sure you followed all the steps.

5.8. Writing a Server#

To use make use of our service, we need to write a server! This will actually read the request message, generate the response, and send it back to the client.

%%writefile src/$package_name/$package_name/server_node.py
import rclpy
from rclpy.node import Node
from learn_ros_interfaces.srv import AddPunctuation


class MyServer(Node):
    def __init__(self):
        super().__init__('server_node')
        
        self.my_server = self.create_service(AddPunctuation, 'boring_strings_srv', self.add_punctuation)
        
    def add_punctuation(self, request, response):
        response.output = request.input + '!'
        return response
    

def main(args=None):
    print('Starting server node...')
    rclpy.init(args=args)
    server = MyServer()
    rclpy.spin(server)


if __name__ == '__main__':
    main()
Overwriting src/basic_nodes/basic_nodes/server_node.py

Don’t forget to add the new entrypoint to your setup.py!

    entry_points={
        'console_scripts': [
            # other nodes,
            'server_node = basic_nodes.server_node:main'
        ]
    }

It’s pretty similar to our subscriber from before! Notice the first argument of create_service; it’s the .srv type we just created! We also had to import it from our other package, learn_ros_interfaces.

Once we build and spin up our server, we can send a request to our server with the following command:

ros2 run run basic_nodes server_node
%%bash --no-raise-error -s "$package_name"
colcon build --packages-select $1
source install/local_setup.bash
ros2 run basic_nodes server_node
Starting >>> basic_nodes
Finished <<< basic_nodes [1.06s]

Summary: 1 package finished [1.14s]
Starting server node...

We can send a request to our server from the command line. Try it out!

ros2 service call /boring_strings_srv learn_ros_interfaces/srv/AddPunctuation "{input: Hello}"

You should get a response with some exciting punctuation added to the end of it!

As with the publisher, we don’t want to call the service from the command line every time. Instead, we want to write another node that can call the service by itself. Let’s write a client node to use our server node.

%%writefile src/$package_name/$package_name/client_node.py

# https://docs.ros.org/en/jazzy/Tutorials/Beginner-Client-Libraries/Writing-A-Simple-Py-Service-And-Client.html#write-the-client-node

import sys
import rclpy
from rclpy.node import Node
from learn_ros_interfaces.srv import AddPunctuation


class MyClient(Node):
    def __init__(self):
        super().__init__('client_node')
        
        self.client = self.create_client(AddPunctuation, 'boring_strings_cli')
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().info('service not available, waiting again...')
        self.req = AddPunctuation.Request()
        
    def send_request(self, input):
        self.req.input = input
        return self.client.call_async(self.req)
        
    

def main(args=None):
    print('Starting client node...')
    rclpy.init(args=args)
    client = MyClient()
    future = client.send_request(sys.argv[1])
    rclpy.spin_until_future_complete(client, future)
    response = future.result()
    client.get_logger().info('Boring string:\t' + sys.argv[1] +
                             '\nExciting String:\t' + response.output)
    
    client.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()
Overwriting src/basic_nodes/basic_nodes/client_node.py

Update your setup.py again:

    entry_points={
        'console_scripts': [
            # Other nodes
            'server_node = basic_nodes.server_node:main',
            'client_node = basic_nodes.client_node:main'
        ],
    },

And run your client_node! Remember to build and re-source.

Note that it takes in a commandline parameter, so make sure you call it with one.

ros2 run basic_nodes client_node "Hello from the client"
%%bash --no-raise-error -s "$package_name"

colcon build --packages-select $1
Starting >>> basic_nodes
Finished <<< basic_nodes [1.07s]

Summary: 1 package finished [1.18s]

5.8.1. Activity#

For this activity, we’re going to edit our activity from BasicNodes to follow a path. We’re going to use the teleport and set_pen service calls to set the turtle to specific places, use topics from before to move the robot along a path.

TODO GIF TODO colcon/workspace setup for activity

5.8.1.1. Path Following#

Set the robot to follow a path. Use topics to monitor when the turtle reaches a point, and when it does, set a new pen color with a service call, and use topics to set a new direction until the turtle reaches the next point. We’re going to make a rainbow shape! Copy the template code below to begin.

%%writefile src/packagename/package_name/draw_node.py

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from turtlesim.msg import Pose
from turtlesim.srv import SetPen
from turtlesim.srv import TeleportAbsolute
import math
import colorsys

star = [(5.0, 1.0), (7.0, 8.0), (1.0, 7.0), (9.0, 7.0), (3.0, 8.0)]
zigzag = [(1.0, 1.0), (2.0, 2.0), (3.0, 1.0), (4.0, 2.0), (5.0, 1.0)]
# Make your own path!

goals = zigzag # Replace with star or your own path once zigzag works.

class DrawNode(Node):
    def __init__(self):
        super().__init__('draw_node')        
        self.next_goal = 0
        
        self.pose_subscriber = None # TODO create a subscriber. You've already done this!
        self.velocity_publisher = None # TODO create a publisher. You've already done this!
        
        self.teleport_client = None # TODO create a teleport client!
        self.set_pen_client = self.create_client(SetPen, '/turtle1/set_pen')
        
        # TODO teleport to the first goal position
        
    def teleport(self, position):
        # TODO Write the teleport function. Position is a tuple of size 2,
        # with x being position[0] and y being position[1]. See the tuples in
        # zigzag and star.
        pass
    
    def set_pen(self, r, g, b):
        req = SetPen.Request()
        req.r = r
        req.g = g
        req.b = b
        req.width = 2
        self.set_pen_client.call_async(req)
        # We don't care what the result is, so don't return it
        
    """
    This function should run every time self.pose_subscriber gets a new pose.
    It will also handle publishing: Every time we get a new position, we should
    publish a velocity for that position.
    """
    def pose_callback(self, pos: Pose):
        if self.close_to_pos(pos, goals[self.next_goal]):
            self.next_goal = (self.next_goal + 1) % len(goals)
            (r, g, b) = generate_rgb(1.0 * self.next_goal / len(goals))
            self.set_pen(r, g, b)
        
        x_dist = goals[self.next_goal][0] - pos.x
        y_dist = goals[self.next_goal][1] - pos.y
        
        # We can use atan2 to get the angle of a vector. Here, the vector points
        # from our robot to the goal position
        desired_angle = math.atan2(y_dist, x_dist)
        
        # The angle error is the difference between the angle we want to face,
        # and the angle that we are facing. But what if the goal is, say, -pi
        # (directly left) and we are currently facing pi/2? (directly up)
        # This will give a negative angle error of -3pi/2, causing us to turn
        # clockwise, even though the fastest way to turn would be counter-clockwise.
        # By bounding the angle_error between -pi and pi, we get a positive angle
        # of pi/2, causing us to turn counter-clockwise.
        angle_error = normalize_angle(desired_angle - pos.theta)
        
        # Publish a new velocity twist whenever we recieve a new pose.
        # Since the turtle can only move forwards (linear.x), you'll have to
        # make use of its rotation (angular.z). Setting linear.y won't do
        # anything.
        # The robot can be set to always move forward. The angle should be
        # related to the angle_error we calculated before.
        # TODO publish new velocity
        

    
"""
close_to_pos returns true if we are close to a given position, and false
if we are not close. Close is defined here as within 0.01 of the x and y
coordinates. With floating point values as used here, and in the real world,
we are never going to be exactly where we want, so we need to define an
acceptable error bound.
"""
def close_to_pos(self, cur_pos, next_pos):
    epsilon = 0.01
    return abs(next_pos[0] - cur_pos.x) < epsilon and abs(next_pos[1] - cur_pos.y) < epsilon

"""
normalize_angle takes any value in radians and returns its equivalent from
-PI to PI. For example, if you give it 5pi/2, it will return pi/2.
"""
def normalize_angle(angle):
    while angle > math.pi:
        angle -= 2 * math.pi
    while angle < -math.pi:
        angle += 2 * math.pi
    return angle

"""
generate_rrgb takes a value between 0 and 1 (inclusive), and returns R, G, and B
values from 0 to 255. 0 and 1 will return (255, 0, 0) (red), and values in
between will return hues in between. See HSL color strips for a visual.
"""
def generate_rgb(percent):
    (r, g, b) = colorsys.hsv_to_rgb(percent, 1, 1)
    r = math.floor(r * 255)
    g = math.floor(g * 255)
    b = math.floor(b * 255)
    return (r, g, b)

def main(args=None):
    print('Starting draw node...')
    rclpy.init(args=args)
    draw_node = DrawNode()
    rclpy.spin(draw_node)

if __name__ == '__main__':
    main()

5.9. Review Quiz#