diff --git a/launch_ros/launch_ros/actions/node.py b/launch_ros/launch_ros/actions/node.py
index 0b5c0180..d4a7be39 100644
--- a/launch_ros/launch_ros/actions/node.py
+++ b/launch_ros/launch_ros/actions/node.py
@@ -32,6 +32,7 @@
from launch.frontend import Entity
from launch.frontend import expose_action
from launch.frontend import Parser
+from launch.conditions import IfCondition, UnlessCondition
from launch.frontend.type_utils import get_data_type_from_identifier
from launch.launch_context import LaunchContext
@@ -62,6 +63,7 @@
from ..descriptions import Parameter
from ..descriptions import ParameterFile
+from ..parameter_descriptions import ConditionalParameter
class NodeActionExtension:
@@ -106,6 +108,19 @@ def prepare_for_execute(self, context, ros_specific_arguments, node_action):
return [], ros_specific_arguments
+def _parse_if_unless_condition(entity, parser):
+ """Parse if/unless condition from a frontend entity."""
+ if_cond = entity.get_attr('if', optional=True)
+ unless_cond = entity.get_attr('unless', optional=True)
+ if if_cond is not None and unless_cond is not None:
+ raise RuntimeError("if and unless conditions can't be used simultaneously")
+ if if_cond is not None:
+ return IfCondition(predicate_expression=parser.parse_substitution(if_cond))
+ if unless_cond is not None:
+ return UnlessCondition(predicate_expression=parser.parse_substitution(unless_cond))
+ return None
+
+
@expose_action('node')
class Node(ExecuteProcess):
"""Action that executes a ROS node."""
@@ -252,6 +267,9 @@ def get_nested_dictionary_from_nested_key_value_pairs(params):
data_type = get_data_type_from_identifier(type_identifier)
value = param.get_attr('value', data_type=data_type, optional=True)
nested_params = param.get_attr('param', data_type=List[Entity], optional=True)
+ # Consume if/unless so assert_entity_completely_parsed doesn't raise
+ param.get_attr('if', optional=True)
+ param.get_attr('unless', optional=True)
param.assert_entity_completely_parsed()
if value is not None and nested_params:
raise RuntimeError(
@@ -285,16 +303,23 @@ def get_nested_dictionary_from_nested_key_value_pairs(params):
allow_substs = parser.parse_substitution(allow_substs)
else:
allow_substs = bool(allow_substs)
+ condition = _parse_if_unless_condition(param, parser)
param.assert_entity_completely_parsed()
- normalized_params.append(
- ParameterFile(parser.parse_substitution(from_attr), allow_substs=allow_substs))
+ result = ParameterFile(
+ parser.parse_substitution(from_attr), allow_substs=allow_substs)
+ if condition is not None:
+ result = ConditionalParameter(result, condition=condition)
+ normalized_params.append(result)
continue
elif name is not None:
if allow_substs is not None:
raise RuntimeError(
"'allow_substs' can only be used together with 'from' attribute")
- normalized_params.append(
- get_nested_dictionary_from_nested_key_value_pairs([param]))
+ condition = _parse_if_unless_condition(param, parser)
+ result = get_nested_dictionary_from_nested_key_value_pairs([param])
+ if condition is not None:
+ result = ConditionalParameter(result, condition=condition)
+ normalized_params.append(result)
continue
raise ValueError('param Entity should have name or from attribute')
return normalized_params
diff --git a/launch_ros/launch_ros/descriptions/composable_node.py b/launch_ros/launch_ros/descriptions/composable_node.py
index e3028ea0..9218d8c5 100644
--- a/launch_ros/launch_ros/descriptions/composable_node.py
+++ b/launch_ros/launch_ros/descriptions/composable_node.py
@@ -18,7 +18,6 @@
from typing import Optional
from launch.condition import Condition
-from launch.conditions import IfCondition, UnlessCondition
from launch.frontend import Entity
from launch.frontend import Parser
from launch.some_substitutions_type import SomeSubstitutionsType
@@ -87,24 +86,16 @@ def __init__(
def parse(cls, parser: Parser, entity: Entity):
"""Parse composable_node."""
from launch_ros.actions import Node
+ from launch_ros.actions.node import _parse_if_unless_condition
kwargs = {}
kwargs['package'] = parser.parse_substitution(entity.get_attr('pkg'))
kwargs['plugin'] = parser.parse_substitution(entity.get_attr('plugin'))
kwargs['name'] = parser.parse_substitution(entity.get_attr('name'))
- if_cond = entity.get_attr('if', optional=True)
- unless_cond = entity.get_attr('unless', optional=True)
- if if_cond is not None and unless_cond is not None:
- raise RuntimeError("if and unless conditions can't be used simultaneously")
- if if_cond is not None:
- kwargs['condition'] = IfCondition(
- predicate_expression=parser.parse_substitution(if_cond)
- )
- if unless_cond is not None:
- kwargs['condition'] = UnlessCondition(
- predicate_expression=parser.parse_substitution(unless_cond)
- )
+ condition = _parse_if_unless_condition(entity, parser)
+ if condition is not None:
+ kwargs['condition'] = condition
namespace = entity.get_attr('namespace', optional=True)
if namespace is not None:
diff --git a/launch_ros/launch_ros/parameter_descriptions.py b/launch_ros/launch_ros/parameter_descriptions.py
index 46f226d3..713e0cdb 100644
--- a/launch_ros/launch_ros/parameter_descriptions.py
+++ b/launch_ros/launch_ros/parameter_descriptions.py
@@ -45,6 +45,30 @@
from .parameters_type import EvaluatedParameterValue
+class ConditionalParameter:
+ """Wraps a parameter (dict or ParameterFile) with an optional condition."""
+
+ def __init__(self, parameter, *, condition=None):
+ """
+ Construct a conditional parameter.
+
+ :param parameter: The wrapped parameter (dict or ParameterFile).
+ :param condition: An optional Condition to evaluate at runtime.
+ """
+ self.__parameter = parameter
+ self.__condition = condition
+
+ @property
+ def parameter(self):
+ """Getter for the wrapped parameter."""
+ return self.__parameter
+
+ @property
+ def condition(self):
+ """Getter for condition."""
+ return self.__condition
+
+
class ParameterValue:
"""Describes a ROS parameter value."""
diff --git a/launch_ros/launch_ros/utilities/evaluate_parameters.py b/launch_ros/launch_ros/utilities/evaluate_parameters.py
index ade9594a..65ebd29d 100644
--- a/launch_ros/launch_ros/utilities/evaluate_parameters.py
+++ b/launch_ros/launch_ros/utilities/evaluate_parameters.py
@@ -31,6 +31,7 @@
import yaml
+from ..parameter_descriptions import ConditionalParameter
from ..parameter_descriptions import Parameter as ParameterDescription
from ..parameter_descriptions import ParameterFile
from ..parameter_descriptions import ParameterValue as ParameterValueDescription
@@ -159,6 +160,10 @@ def evaluate_parameters(context: LaunchContext, parameters: Parameters) -> Evalu
"""
output_params: List[Union[pathlib.Path, Dict[str, EvaluatedParameterValue]]] = []
for param in parameters:
+ if isinstance(param, ConditionalParameter):
+ if param.condition is not None and not param.condition.evaluate(context):
+ continue
+ param = param.parameter
if isinstance(param, ParameterFile):
# Evaluate a list of Substitution to a file path
output_params.append(param.evaluate(context))
diff --git a/launch_ros/launch_ros/utilities/normalize_parameters.py b/launch_ros/launch_ros/utilities/normalize_parameters.py
index f61957b9..9ad802e9 100644
--- a/launch_ros/launch_ros/utilities/normalize_parameters.py
+++ b/launch_ros/launch_ros/utilities/normalize_parameters.py
@@ -32,6 +32,7 @@
import yaml
+from ..parameter_descriptions import ConditionalParameter
from ..parameter_descriptions import Parameter as ParameterDescription
from ..parameter_descriptions import ParameterFile
from ..parameter_descriptions import ParameterValue as ParameterValueDescription
@@ -184,6 +185,8 @@ def normalize_parameters(parameters: SomeParameters) -> Parameters:
normalized_params.append(normalize_parameter_dict(param))
elif isinstance(param, ParameterDescription):
normalized_params.append(param)
+ elif isinstance(param, ConditionalParameter):
+ normalized_params.append(param)
elif isinstance(param, ParameterFile):
normalized_params.append(param)
else:
diff --git a/test_launch_ros/test/test_launch_ros/frontend/test_node_conditional_param.py b/test_launch_ros/test/test_launch_ros/frontend/test_node_conditional_param.py
new file mode 100644
index 00000000..5b050cf7
--- /dev/null
+++ b/test_launch_ros/test/test_launch_ros/frontend/test_node_conditional_param.py
@@ -0,0 +1,144 @@
+# Copyright 2026 Open Source Robotics Foundation, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for conditional if/unless on param elements."""
+
+import io
+import textwrap
+
+import pytest
+
+from launch import LaunchService
+from launch.frontend import Parser
+
+from launch_ros.utilities import evaluate_parameters
+
+
+def _get_evaluated_params(xml_file):
+ """Parse XML, run launch service, and return evaluated parameters."""
+ with io.StringIO(xml_file) as f:
+ root_entity, parser = Parser.load(f)
+ ld = parser.parse_description(root_entity)
+ ls = LaunchService()
+ ls.include_launch_description(ld)
+ assert 0 == ls.run()
+ evaluated = evaluate_parameters(
+ ls.context, ld.describe_sub_entities()[1]._Node__parameters)
+ all_keys = {}
+ for p in evaluated:
+ if isinstance(p, dict):
+ all_keys.update(p)
+ return all_keys
+
+
+def test_param_if_true():
+ """Param with if=true should be included."""
+ xml_file = textwrap.dedent(
+ r"""
+
+
+
+
+
+
+
+ """)
+ keys = _get_evaluated_params(xml_file)
+ assert 'included' in keys
+ assert 'always' in keys
+
+
+def test_param_if_false():
+ """Param with if=false should be excluded."""
+ xml_file = textwrap.dedent(
+ r"""
+
+
+
+
+
+
+
+ """)
+ keys = _get_evaluated_params(xml_file)
+ assert 'excluded' not in keys
+ assert 'always' in keys
+
+
+def test_param_unless_true():
+ """Param with unless=true should be excluded."""
+ xml_file = textwrap.dedent(
+ r"""
+
+
+
+
+
+
+
+ """)
+ keys = _get_evaluated_params(xml_file)
+ assert 'excluded' not in keys
+ assert 'always' in keys
+
+
+def test_param_unless_false():
+ """Param with unless=false should be included."""
+ xml_file = textwrap.dedent(
+ r"""
+
+
+
+
+
+
+
+ """)
+ keys = _get_evaluated_params(xml_file)
+ assert 'included' in keys
+ assert 'always' in keys
+
+
+def test_param_if_and_unless_raises():
+ """Using both if and unless on same param should raise RuntimeError."""
+ xml_file = textwrap.dedent(
+ r"""
+
+
+
+
+
+
+ """)
+ with pytest.raises(RuntimeError, match="if and unless"):
+ with io.StringIO(xml_file) as f:
+ root_entity, parser = Parser.load(f)
+ parser.parse_description(root_entity)
+
+
+def test_param_no_condition():
+ """Param without if/unless should always be included (regression test)."""
+ xml_file = textwrap.dedent(
+ r"""
+
+
+
+
+
+
+
+ """)
+ keys = _get_evaluated_params(xml_file)
+ assert 'param1' in keys
+ assert 'param2' in keys