Nodes, Topics, and Services
Learning Objectives
By the end of this lesson, you will be able to:
- Create and manage ROS 2 nodes in Python and C++
- Implement publishers and subscribers for topic-based communication
- Build service servers and clients for request/response patterns
- Use ROS 2 command-line tools for debugging and introspection
- Apply best practices for node design and error handling
ROS 2 Nodes: The Building Blocks
A node is a process that performs computation. ROS 2 applications are composed of many nodes working together.
Node Design Principles
- Single Responsibility: Each node does one thing well
- Loose Coupling: Nodes communicate via topics/services, not function calls
- Configurability: Use parameters for runtime configuration
- Error Handling: Handle failures gracefully, log errors
- Resource Management: Clean up on shutdown
Creating a Minimal Node (Python)
import rclpy
from rclpy.node import Node
class MinimalNode(Node):
def __init__(self):
# Initialize with node name
super().__init__('minimal_node')
# Log startup
self.get_logger().info('MinimalNode has been started')
def main(args=None):
# Initialize ROS 2
rclpy.init(args=args)
# Create node
node = MinimalNode()
# Keep node running
rclpy.spin(node)
# Cleanup
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Run the node:
python3 minimal_node.py
# Or via ros2 run (after packaging)
ros2 run my_package minimal_node
Publishers and Subscribers
Publisher: Sending Data
A publisher sends messages on a topic.
Example: Robot Pose Publisher
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import PoseStamped
import math
class PosePublisher(Node):
def __init__(self):
super().__init__('pose_publisher')
# Create publisher
self.publisher_ = self.create_publisher(
PoseStamped, # Message type
'/robot/pose', # Topic name
10 # Queue size (QoS depth)
)
# Publish at 10 Hz
self.timer = self.create_timer(0.1, self.publish_pose)
self.counter = 0
self.get_logger().info('PosePublisher started')
def publish_pose(self):
msg = PoseStamped()
# Header
msg.header.stamp = self.get_clock().now().to_msg()
msg.header.frame_id = 'map'
# Pose (circular motion)
t = self.counter * 0.1
msg.pose.position.x = 5.0 * math.cos(t)
msg.pose.position.y = 5.0 * math.sin(t)
msg.pose.position.z = 0.0
# Publish
self.publisher_.publish(msg)
self.get_logger().info(f'Published pose: x={msg.pose.position.x:.2f}, y={msg.pose.position.y:.2f}')
self.counter += 1
def main():
rclpy.init()
node = PosePublisher()
rclpy.spin(node)
rclpy.shutdown()
Subscriber: Receiving Data
A subscriber listens to messages on a topic.
Example: Pose Subscriber
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import PoseStamped
class PoseSubscriber(Node):
def __init__(self):
super().__init__('pose_subscriber')
# Create subscription
self.subscription = self.create_subscription(
PoseStamped,
'/robot/pose',
self.pose_callback, # Callback function
10
)
self.get_logger().info('PoseSubscriber started, waiting for messages...')
def pose_callback(self, msg):
x = msg.pose.position.x
y = msg.pose.position.y
# Calculate distance from origin
distance = (x**2 + y**2)**0.5
self.get_logger().info(f'Received pose at ({x:.2f}, {y:.2f}), distance: {distance:.2f}m')
def main():
rclpy.init()
node = PoseSubscriber()
rclpy.spin(node)
rclpy.shutdown()
Test the pub/sub pair:
# Terminal 1: Run publisher
ros2 run my_package pose_publisher
# Terminal 2: Run subscriber
ros2 run my_package pose_subscriber
# Terminal 3: Inspect topic
ros2 topic list
ros2 topic info /robot/pose
ros2 topic echo /robot/pose
ros2 topic hz /robot/pose
Combining Publisher and Subscriber
Many nodes both subscribe AND publish (e.g., sensor fusion, filters).
Example: LiDAR Filter Node
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
class LaserFilter(Node):
def __init__(self):
super().__init__('laser_filter')
# Subscribe to raw laser scan
self.subscription = self.create_subscription(
LaserScan,
'/scan_raw',
self.scan_callback,
10
)
# Publish filtered laser scan
self.publisher_ = self.create_publisher(
LaserScan,
'/scan_filtered',
10
)
# Parameters
self.declare_parameter('min_range', 0.5) # Ignore readings < 0.5m
self.declare_parameter('max_range', 10.0) # Ignore readings > 10m
self.get_logger().info('LaserFilter started')
def scan_callback(self, msg_in):
# Get parameters
min_range = self.get_parameter('min_range').value
max_range = self.get_parameter('max_range').value
# Create output message (copy input)
msg_out = LaserScan()
msg_out.header = msg_in.header
msg_out.angle_min = msg_in.angle_min
msg_out.angle_max = msg_in.angle_max
msg_out.angle_increment = msg_in.angle_increment
msg_out.time_increment = msg_in.time_increment
msg_out.scan_time = msg_in.scan_time
msg_out.range_min = min_range
msg_out.range_max = max_range
# Filter ranges
msg_out.ranges = [
r if (min_range <= r <= max_range) else float('inf')
for r in msg_in.ranges
]
# Publish filtered scan
self.publisher_.publish(msg_out)
valid_readings = sum(1 for r in msg_out.ranges if r != float('inf'))
self.get_logger().info(f'Filtered scan: {valid_readings}/{len(msg_out.ranges)} valid readings')
def main():
rclpy.init()
node = LaserFilter()
rclpy.spin(node)
rclpy.shutdown()
Run with custom parameters:
ros2 run my_package laser_filter --ros-args \
-p min_range:=1.0 \
-p max_range:=8.0
Services: Request/Response Communication
Services are for one-time interactions where a client requests something and waits for a response.
Service Server
Example: String Reversal Service
import rclpy
from rclpy.node import Node
from example_interfaces.srv import SetBool # Using standard service
# For custom service, you'd import your own .srv definition
class StringReverseService(Node):
def __init__(self):
super().__init__('string_reverse_server')
# Create service
self.srv = self.create_service(
SetBool, # Service type
'reverse_string', # Service name
self.reverse_callback # Callback function
)
self.get_logger().info('StringReverseService ready')
def reverse_callback(self, request, response):
# Process request
input_str = "True" if request.data else "False"
reversed_str = input_str[::-1]
# Build response
response.success = True
response.message = f'Reversed: {reversed_str}'
self.get_logger().info(f'Request: {input_str} -> Response: {reversed_str}')
return response
def main():
rclpy.init()
node = StringReverseService()
rclpy.spin(node)
rclpy.shutdown()
Service Client
Example: Service Caller
import rclpy
from rclpy.node import Node
from example_interfaces.srv import SetBool
import sys
class StringReverseClient(Node):
def __init__(self):
super().__init__('string_reverse_client')
# Create client
self.client = self.create_client(SetBool, 'reverse_string')
# Wait for service to be available
while not self.client.wait_for_service(timeout_sec=1.0):
self.get_logger().info('Waiting for reverse_string service...')
def send_request(self, value):
# Create request
request = SetBool.Request()
request.data = value
# Call service asynchronously
self.future = self.client.call_async(request)
self.get_logger().info(f'Sent request: {value}')
def main():
rclpy.init()
node = StringReverseClient()
# Send request
node.send_request(True)
# Wait for response
rclpy.spin_until_future_complete(node, node.future)
# Get result
response = node.future.result()
node.get_logger().info(f'Received response: {response.message}')
rclpy.shutdown()
Test the service:
# Terminal 1: Start server
ros2 run my_package string_reverse_server
# Terminal 2: Call service via CLI
ros2 service list
ros2 service type /reverse_string
ros2 service call /reverse_string example_interfaces/srv/SetBool "{data: true}"
# Or run client node
ros2 run my_package string_reverse_client
Real-World Example: Robot Arm Controller
Let's combine topics and services in a practical robot arm controller.
Architecture:
- Topic:
/joint_states(publishes current joint positions) - Service:
/set_joint_target(sets target joint positions)
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from example_interfaces.srv import Trigger
import math
class RobotArmController(Node):
def __init__(self):
super().__init__('robot_arm_controller')
# Joint state publisher
self.joint_pub = self.create_publisher(JointState, '/joint_states', 10)
# Calibration service
self.calibrate_srv = self.create_service(
Trigger,
'/calibrate_arm',
self.calibrate_callback
)
# Internal state
self.joint_positions = [0.0, 0.0, 0.0, 0.0, 0.0] # 5 joints
self.is_calibrated = False
# Publish joint states at 50 Hz
self.timer = self.create_timer(0.02, self.publish_joint_states)
self.get_logger().info('RobotArmController started (uncalibrated)')
def publish_joint_states(self):
msg = JointState()
msg.header.stamp = self.get_clock().now().to_msg()
msg.name = ['joint_1', 'joint_2', 'joint_3', 'joint_4', 'joint_5']
msg.position = self.joint_positions
self.joint_pub.publish(msg)
def calibrate_callback(self, request, response):
self.get_logger().info('Calibrating arm...')
# Simulate calibration (move to home position)
self.joint_positions = [0.0] * 5
self.is_calibrated = True
response.success = True
response.message = 'Arm calibrated successfully'
self.get_logger().info('Calibration complete')
return response
def main():
rclpy.init()
node = RobotArmController()
rclpy.spin(node)
rclpy.shutdown()
Test the arm controller:
# Start controller
ros2 run my_package robot_arm_controller
# Monitor joint states
ros2 topic echo /joint_states
# Call calibration service
ros2 service call /calibrate_arm example_interfaces/srv/Trigger
ROS 2 Command-Line Tools
Topic Introspection
# List all topics
ros2 topic list
# Show topic type
ros2 topic info /scan
# Echo messages
ros2 topic echo /scan
# Measure frequency
ros2 topic hz /scan
# Publish from CLI
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}, angular: {z: 0.1}}"
Service Introspection
# List services
ros2 service list
# Show service type
ros2 service type /set_motor_speed
# Call service
ros2 service call /calibrate_arm example_interfaces/srv/Trigger
Node Introspection
# List running nodes
ros2 node list
# Show node info
ros2 node info /laser_filter
# See computation graph
rqt_graph
Best Practices
1. Use Namespaces
Organize topics/services under namespaces:
# Good: Namespaced
self.publisher_ = self.create_publisher(Image, '/camera/left/image', 10)
# Bad: Flat namespace
self.publisher_ = self.create_publisher(Image, '/left_camera_image', 10)
2. Handle Uninitialized Data
class SafeSubscriber(Node):
def __init__(self):
super().__init__('safe_subscriber')
self.latest_pose = None
self.subscription = self.create_subscription(
PoseStamped, '/pose', self.pose_callback, 10
)
def pose_callback(self, msg):
self.latest_pose = msg
def get_current_pose(self):
if self.latest_pose is None:
self.get_logger().warn('No pose received yet!')
return None
return self.latest_pose
3. Use Timers, Not Loops
# Good: Timer-based publishing
self.timer = self.create_timer(0.1, self.publish_data)
# Bad: While loop
while rclpy.ok():
self.publish_data()
time.sleep(0.1)
4. Clean Shutdown
def main():
rclpy.init()
node = MyNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
node.get_logger().info('Shutting down gracefully...')
finally:
node.destroy_node()
rclpy.shutdown()
Hands-On Exercise: Teleoperation System
Goal: Build a keyboard teleoperation system for a mobile robot.
Requirements:
- Keyboard Node: Read arrow keys, publish
Twistto/cmd_vel - Motor Controller Node: Subscribe to
/cmd_vel, control motors - Emergency Stop Service:
/emergency_stopservice to halt robot
Starter Code:
# keyboard_node.py
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
import sys, tty, termios
class KeyboardTeleop(Node):
def __init__(self):
super().__init__('keyboard_teleop')
self.publisher_ = self.create_publisher(Twist, '/cmd_vel', 10)
self.get_logger().info('Use arrow keys to move (q to quit)')
def get_key(self):
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
key = sys.stdin.read(1)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return key
def run(self):
while rclpy.ok():
key = self.get_key()
msg = Twist()
if key == '\x1b[A': # Up arrow
msg.linear.x = 0.5
elif key == '\x1b[B': # Down arrow
msg.linear.x = -0.5
elif key == '\x1b[C': # Right arrow
msg.angular.z = -0.5
elif key == '\x1b[D': # Left arrow
msg.angular.z = 0.5
elif key == 'q':
break
self.publisher_.publish(msg)
self.get_logger().info(f'Published: linear={msg.linear.x}, angular={msg.angular.z}')
# Implement motor_controller.py yourself!
Test:
ros2 run my_package keyboard_teleop
ros2 topic echo /cmd_vel
Key Takeaways
✅ Nodes are processes: Each does one thing well
✅ Topics for streaming: Sensor data, robot state
✅ Services for requests: One-shot commands, queries
✅ Use CLI tools: Debug with ros2 topic, ros2 service, ros2 node
✅ Follow best practices: Namespaces, error handling, timers
What's Next?
Now that you can build nodes, let's package and distribute them:
👉 Building ROS 2 Packages — Create reusable packages