Vaibhav Shende Vaibhav Shende

ROS 2 Parameter Server: The Silent Killer of Robot Debuggability

Master ROS 2 parameters: dynamic reconfiguration, type safety, namespacing, and the gotchas that will haunt you at 2 AM.

ROS 2

ROS 2 Parameter Server: The Silent Killer of Robot Debuggability

The Problem Nobody Talks About

You hardcoded a value. It works. You push to production. The customer’s hardware is slightly different. You hardcode a different value. Now you have 15 hardcoded configs for 15 different robots.

This is what parameters are supposed to prevent. But the ROS 2 parameter system is not intuitive. It’s powerful, but the documentation assumes you already understand it.

This guide is for the person who just spent 3 hours debugging why a parameter change didn’t take effect.


The Fundamental Truth About ROS 2 Parameters

# This is NOT how ROS 2 parameters work:
self.get_parameter('my_param')  # It doesn't magically appear
 
# This IS how they work:
# 1. Parameter server (rclcpp) holds values
# 2. Your node requests them
# 3. They're set BEFORE node initialization (usually)
# 4. You can change them dynamically IF the node subscribes to updates

Reality check: Parameters are stored in the parameter server, not in your node. Your node reads them. If you don’t listen for changes, modifying a parameter does nothing.


Part 1: Static Parameters (The Simple Case)

1.1 Declaring Parameters in Your Node

import rclpy
from rclpy.node import Node
 
class MyRobot(Node):
    def __init__(self):
        super().__init__('my_robot')
        
        # Declare a parameter with default value
        self.declare_parameter('wheel_radius', 0.05)
        
        # Get the value
        wheel_radius = self.get_parameter('wheel_radius').value
        
        self.get_logger().info(f'Wheel radius: {wheel_radius}')
 
def main():
    rclpy.init()
    node = MyRobot()
    rclpy.spin(node)
 
if __name__ == '__main__':
    main()

Key insight: declare_parameter() registers the parameter. get_parameter() reads it.

1.2 Setting Parameters from Launch File

from launch import LaunchDescription
from launch_ros.actions import Node
 
def generate_launch_description():
    
    my_node = Node(
        package='my_pkg',
        executable='my_node',
        name='robot',
        parameters=[
            {
                'wheel_radius': 0.033,
                'wheel_separation': 0.16,
                'max_velocity': 1.5,
            }
        ]
    )
    
    return LaunchDescription([my_node])

1.3 Setting Parameters from YAML

# config/robot_params.yaml
my_robot:
  ros__parameters:
    wheel_radius: 0.033
    wheel_separation: 0.16
    max_velocity: 1.5
    pid_gains:
      p: 1.0
      i: 0.1
      d: 0.05

Important: The node name (my_robot) must match the namespace in your YAML, or parameters won’t be found.

# In launch file
from launch.substitutions import FindPackageShare, PathJoinSubstitution
 
pkg_share = FindPackageShare('my_robot_bringup')
params_file = PathJoinSubstitution([pkg_share, 'config', 'robot_params.yaml'])
 
node = Node(
    package='my_pkg',
    executable='my_node',
    name='my_robot',  # This must match YAML namespace!
    parameters=[params_file]
)

Part 2: Dynamic Parameters (The Tricky Part)

Problem: You change a parameter with ros2 param set /my_robot wheel_radius 0.04 and nothing happens.

Why: Your node isn’t listening for changes.

Solution: Use add_on_set_parameters_callback().

2.1 Listening for Parameter Changes

import rclpy
from rclpy.node import Node
from rcl_interfaces.msg import SetParametersResult
 
class DynamicRobot(Node):
    def __init__(self):
        super().__init__('robot')
        
        # Declare parameters
        self.declare_parameter('wheel_radius', 0.05)
        self.declare_parameter('max_velocity', 1.5)
        
        # Register callback for parameter changes
        self.add_on_set_parameters_callback(self.parameters_callback)
        
        # Store current values
        self.wheel_radius = self.get_parameter('wheel_radius').value
        self.max_velocity = self.get_parameter('max_velocity').value
    
    def parameters_callback(self, params):
        """Called whenever ANY parameter changes"""
        for param in params:
            if param.name == 'wheel_radius':
                self.wheel_radius = param.value
                self.get_logger().info(f'Updated wheel_radius to {param.value}')
            elif param.name == 'max_velocity':
                self.max_velocity = param.value
                self.get_logger().info(f'Updated max_velocity to {param.value}')
        
        # Return SetParametersResult(successful=True) to accept
        # Return SetParametersResult(successful=False) to reject
        return SetParametersResult(successful=True)

Now you can dynamically reconfigure:

# Change a parameter at runtime
ros2 param set /robot wheel_radius 0.04
 
# Your callback fires and the node updates
# Output: "Updated wheel_radius to 0.04"

2.2 Validating Parameters Before Accepting

def parameters_callback(self, params):
    """Validate parameters before accepting"""
    for param in params:
        if param.name == 'wheel_radius':
            if param.value <= 0:
                self.get_logger().warn(f'Wheel radius must be positive!')
                return SetParametersResult(successful=False)  # Reject!
            if param.value > 0.5:
                self.get_logger().warn(f'Wheel radius suspiciously large!')
                return SetParametersResult(successful=False)
            self.wheel_radius = param.value
    
    return SetParametersResult(successful=True)

Now invalid parameters are rejected:

ros2 param set /robot wheel_radius -0.5
# Error: Parameter change rejected

Part 3: The Gotchas That Will Break Your Code

Gotcha 1: Parameter Type Mismatches

# YAML file says:
# max_velocity: "1.5"  # Oops, it's a STRING
 
# Your code does:
velocity = self.get_parameter('max_velocity').value  # string "1.5"
accel = velocity * 2  # ❌ TypeError: can't multiply str by int

Solution: Always check the type or convert explicitly.

param = self.get_parameter('max_velocity')
velocity = float(param.value)  # Convert to float
accel = velocity * 2  # ✅ Works

Gotcha 2: Declaring Parameters Multiple Times

def __init__(self):
    self.declare_parameter('param', 1.0)
    self.declare_parameter('param', 2.0)  # ❌ Error! Already declared

Solution: Check if already declared.

if not self.has_parameter('param'):
    self.declare_parameter('param', 1.0)

Gotcha 3: Namespace Hell

# config/params.yaml
my_namespace:
  ros__parameters:
    param1: 100
# Launch file
node = Node(
    package='pkg',
    executable='exe',
    name='my_node',
    namespace='my_namespace',
    parameters=[params_file]  # ✅ This works
)
 
# But if you forget namespace in launch:
node = Node(
    package='pkg',
    executable='exe',
    name='my_node',
    parameters=[params_file]  # ❌ Parameters not found!
)

Pro tip: Make sure launch file namespace matches YAML namespace.

Gotcha 4: Parameters Aren’t Inherited

# Node 1 in namespace /robot
self.declare_parameter('shared_param', 10)
 
# Node 2 in namespace /robot (different process)
value = self.get_parameter('shared_param').value  # ❌ Parameter doesn't exist

Why: Each node has its own parameter space. There’s no global inheritance.

Solution: Set parameters globally in launch file, or use ROS services for inter-node communication.


Part 4: Advanced Patterns

4.1 Parameter Namespacing (the right way)

# config/params.yaml
motor_controller:
  ros__parameters:
    max_velocity: 1.5
    acceleration: 2.0
    pid:
      kp: 1.0
      ki: 0.1
      kd: 0.05
 
lidar_driver:
  ros__parameters:
    frame_id: "laser"
    range_max: 30.0
class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        
        # These are automatically namespaced under 'motor_controller'
        self.max_velocity = self.get_parameter('max_velocity').value
        
        # Nested parameters
        pid_gains = {
            'kp': self.get_parameter('pid.kp').value,
            'ki': self.get_parameter('pid.ki').value,
            'kd': self.get_parameter('pid.kd').value,
        }

4.2 Reading All Parameters at Once

def __init__(self):
    super().__init__('my_node')
    
    # Get all parameters in this node's namespace
    all_params = self.list_parameters(
        prefixes=['']  # Empty prefix = all
    ).names
    
    self.get_logger().info(f'Available parameters: {all_params}')

4.3 Parameter Descriptor (Advanced Type Info)

from rcl_interfaces.msg import ParameterDescriptor, IntegerRange
 
descriptor = ParameterDescriptor(
    description='Maximum velocity of the robot',
    integer_range=[IntegerRange(
        from_value=0,
        to_value=5,
        step=1
    )]
)
 
self.declare_parameter(
    'max_velocity',
    2,  # default
    descriptor=descriptor
)

This helps ROS tools validate parameters automatically.


Part 5: Real-World Pattern - Configuration with Validation

import rclpy
from rclpy.node import Node
from rcl_interfaces.msg import SetParametersResult
from dataclasses import dataclass
 
@dataclass
class RobotConfig:
    """Strongly-typed configuration"""
    wheel_radius: float
    wheel_separation: float
    max_velocity: float
    
    def validate(self) -> bool:
        """Validate before applying"""
        if self.wheel_radius <= 0:
            return False
        if self.wheel_separation <= 0:
            return False
        if self.max_velocity <= 0:
            return False
        return True
 
class ConfigurableRobot(Node):
    def __init__(self):
        super().__init__('robot')
        
        # Declare all parameters
        self.declare_parameter('wheel_radius', 0.05)
        self.declare_parameter('wheel_separation', 0.16)
        self.declare_parameter('max_velocity', 1.5)
        
        # Load initial config
        self.config = self._load_config()
        
        # Listen for changes
        self.add_on_set_parameters_callback(self.on_parameters_changed)
    
    def _load_config(self) -> RobotConfig:
        """Load configuration from parameters"""
        return RobotConfig(
            wheel_radius=self.get_parameter('wheel_radius').value,
            wheel_separation=self.get_parameter('wheel_separation').value,
            max_velocity=self.get_parameter('max_velocity').value,
        )
    
    def on_parameters_changed(self, params):
        """Update configuration when parameters change"""
        # Reload config
        new_config = self._load_config()
        
        # Validate
        if not new_config.validate():
            self.get_logger().error('Invalid configuration!')
            return SetParametersResult(successful=False)
        
        # Apply
        self.config = new_config
        self.get_logger().info(f'Configuration updated: {self.config}')
        
        return SetParametersResult(successful=True)

Debugging Parameters

# List all parameters on a node
ros2 param list /robot
 
# Get a parameter value
ros2 param get /robot wheel_radius
 
# Set a parameter
ros2 param set /robot wheel_radius 0.04
 
# Dump all parameters to a file
ros2 param dump /robot > /tmp/robot_params.yaml
 
# Load parameters from a file
ros2 param load /robot /tmp/robot_params.yaml

Key Takeaways

  1. Static parameters are set at startup via launch files or YAML
  2. Dynamic parameters require add_on_set_parameters_callback()
  3. Namespace carefully - YAML namespace must match launch file namespace
  4. Validate always - Users will pass invalid values
  5. Use typed parameters - Don’t assume string == number
  6. Test parameter changes - Many bugs hide in the parameter system

Quick Reference

# Declare
self.declare_parameter('param_name', default_value)
 
# Read
value = self.get_parameter('param_name').value
 
# Listen for changes
self.add_on_set_parameters_callback(self.on_params_changed)
 
# In callback
def on_params_changed(self, params):
    for param in params:
        if param.name == 'target_param':
            # Do something
            pass
    return SetParametersResult(successful=True)
 
# Check if exists
self.has_parameter('param_name')
 
# Get parameter type
param_type = self.get_parameter('param_name').type_

Next time your parameter change doesn’t work, remember: it’s probably because your node isn’t listening for changes.