diff --git a/CHANGELOG b/CHANGELOG index 2549cc6..bbfa8a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [0.1.9] - 2025-08-12 + +### Added +- Parser support for `EqualsSubstitution` and `NotEqualsSubstitution` +- Test coverage for `nav2_localization.launch.py` +- LaunchMap Watermark + +### Fixed +- Composable node containers visualization without being called in launch description + ## [0.1.8] - 2025-08-08 ### Added diff --git a/package-lock.json b/package-lock.json index 8dd44c3..b30455b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "launchmap", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "launchmap", - "version": "0.1.8", + "version": "0.1.9", "license": "Apache-2.0", "dependencies": { "which": "^5.0.0" diff --git a/package.json b/package.json index fe71fde..f78299b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "launchmap", "displayName": "LaunchMap", "description": "Visualize ROS2 Launch Files", - "version": "0.1.8", + "version": "0.1.9", "publisher": "kodorobotics", "icon": "assets/launchmap-logo.png", "bugs": { diff --git a/parser/context.py b/parser/context.py index 6dc40f4..385cfad 100644 --- a/parser/context.py +++ b/parser/context.py @@ -89,14 +89,17 @@ def current_namespace(self) -> str | None: ## Composable node groups + def has_composable_node_group(self, container_name: str): + return container_name in self.composable_node_groups + def register_composable_node_group(self, container_name: str, container_metadata: dict): self.composable_node_groups[container_name].update(container_metadata) def extend_composable_node_group(self, container_name: str, nodes): self.composable_node_groups[container_name]["composable_nodes"].extend(nodes) - def get_composable_node_groups(self) -> list[dict]: - results = [] + def get_composable_node_groups(self) -> dict: + results = {} for name, data in self.composable_node_groups.items(): if not data["composable_nodes"]: continue @@ -109,8 +112,7 @@ def get_composable_node_groups(self) -> list[dict]: if value not in (None, "", [], {}): entry[key] = value - results.append(entry) - + results[name] = entry return results ## Utility diff --git a/parser/entrypoint/parser_runner.py b/parser/entrypoint/parser_runner.py index d7a2088..8b86d0e 100644 --- a/parser/entrypoint/parser_runner.py +++ b/parser/entrypoint/parser_runner.py @@ -64,7 +64,7 @@ def parse_launch_file(filepath: str) -> dict: ), "environment_variables": context.introspection.get_environment_variables(), "python_expressions": context.introspection.get_python_expressions(), - "composable_node_containers": sorted(context.get_composable_node_groups()), + "composable_containers": context.get_composable_node_groups(), "additional_components": context.introspection.get_registered_entities(), } diff --git a/parser/entrypoint/user_interface.py b/parser/entrypoint/user_interface.py index 0de662e..b576620 100644 --- a/parser/entrypoint/user_interface.py +++ b/parser/entrypoint/user_interface.py @@ -14,6 +14,7 @@ from parser.entrypoint.parser_runner import parse_launch_file from parser.parser.introspection_utils import ( + collect_composable_node_containers, collect_environment_variable_usages, collect_event_handler_usages, collect_launch_config_usages, @@ -29,10 +30,8 @@ def parse_and_format_launch_file(filepath: str) -> dict: """ raw = parse_launch_file(filepath) grouped = group_entities_by_type(raw["parsed"] + raw["additional_components"]) - - composable_node_containers = raw.get("composable_node_containers") - if composable_node_containers: - grouped["composable_nodes_container"] = composable_node_containers + + grouped = collect_composable_node_containers(grouped, raw["composable_containers"]) launch_argument_usages = collect_launch_config_usages(grouped) if launch_argument_usages: diff --git a/parser/parser/handlers/equals_handler.py b/parser/parser/handlers/equals_handler.py new file mode 100644 index 0000000..8fdf00f --- /dev/null +++ b/parser/parser/handlers/equals_handler.py @@ -0,0 +1,40 @@ +# Copyright (c) 2025 Kodo Robotics +# +# 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. + +import ast + +from parser.context import ParseContext +from parser.parser.postprocessing import simplify_launch_configurations +from parser.parser.registry import register_handler +from parser.resolution.utils import resolve_call_signature + + +@register_handler("EqualsSubstitution", "launch.substitutions.EqualsSubstitution") +def handle_equals_substitution(node: ast.Call, context: ParseContext) -> dict: + args, _ = resolve_call_signature(node, context.engine) + if len(args) != 2: + raise ValueError("EqualsSubstitution must receive exactly two arguments.") + + left, right = (simplify_launch_configurations(arg) for arg in args) + return f"${{EqualsSubstitution:{left}, {right}}}" + + +@register_handler("NotEqualsSubstitution", "launch.substitutions.NotEqualsSubstitution") +def handle_not_equals_substitution(node: ast.Call, context: ParseContext) -> dict: + args, _ = resolve_call_signature(node, context.engine) + if len(args) != 2: + raise ValueError("NotEqualsSubstitution must receive exactly two arguments.") + + left, right = (simplify_launch_configurations(arg) for arg in args) + return f"${{NotEqualsSubstitution:{left}, {right}}}" diff --git a/parser/parser/handlers/group_handler.py b/parser/parser/handlers/group_handler.py index 940e16c..d535f7a 100644 --- a/parser/parser/handlers/group_handler.py +++ b/parser/parser/handlers/group_handler.py @@ -34,11 +34,14 @@ def handle_group_action(node: ast.Call, context: ParseContext) -> dict: resolved_flat = flatten_once(raw_expr) namespace = None + parameters = [] actions = [] for item in resolved_flat: - if isinstance(item, dict) and item.get("type") == "PushRosNamespace": + if isinstance(item, dict) and item.get("type") == "PushROSNamespace": namespace = item.get("namespace") context.push_namespace(namespace) + elif isinstance(item, dict) and item.get("type") == "SetParameter": + parameters.append({item.get("name"): item.get("value")}) else: actions.append(item) @@ -55,5 +58,7 @@ def handle_group_action(node: ast.Call, context: ParseContext) -> dict: if namespace: result["namespace"] = namespace + if parameters: + result["parameters"] = parameters return result diff --git a/parser/parser/handlers/load_composable_node_handler.py b/parser/parser/handlers/load_composable_nodes_handler.py similarity index 72% rename from parser/parser/handlers/load_composable_node_handler.py rename to parser/parser/handlers/load_composable_nodes_handler.py index cd7fc73..8bf32b5 100644 --- a/parser/parser/handlers/load_composable_node_handler.py +++ b/parser/parser/handlers/load_composable_nodes_handler.py @@ -15,6 +15,7 @@ import ast from parser.context import ParseContext +from parser.parser.postprocessing import simplify_launch_configurations from parser.parser.registry import register_handler from parser.parser.utils.common import flatten_once, group_entities_by_type from parser.resolution.utils import resolve_call_signature @@ -30,13 +31,25 @@ def handle_load_composable_nodes(node: ast.Call, context: ParseContext) -> dict: grouped = group_entities_by_type(resolved_flat) composable_nodes = grouped.get("unattached_composable_nodes", []) + # Add additional metadata to composable nodes + condition = kwargs.get("condition", {}) + if condition: + for idx, _ in enumerate(composable_nodes): + composable_nodes[idx].update({"condition": condition}) + # Determine target container - target_container = kwargs.get("target_container") + target_container = simplify_launch_configurations(kwargs.get("target_container")) if not target_container: raise ValueError("LoadComposableNodes requires a target_container to be specified.") + if isinstance(target_container, list): + target_container = "".join(target_container) # Ensure group is registered + first_instance = not context.has_composable_node_group(target_container) context.register_composable_node_group(target_container, {"target_container": target_container}) context.extend_composable_node_group(target_container, composable_nodes) + if first_instance: + return {"type": "ComposableNodeContainer", "target_container": target_container} + return {"type": "LoadComposableNodes", "target_container": target_container} diff --git a/parser/parser/handlers/push_ros_namespace_handler.py b/parser/parser/handlers/push_ros_namespace_handler.py index a9cd16c..01d9088 100644 --- a/parser/parser/handlers/push_ros_namespace_handler.py +++ b/parser/parser/handlers/push_ros_namespace_handler.py @@ -19,9 +19,10 @@ from parser.resolution.utils import resolve_call_signature -@register_handler("PushRosNamespace", "launch_ros.actions.PushRosNamespace") +@register_handler("PushROSNamespace", "launch_ros.actions.PushROSNamespace", + "PushRosNamespace", "launch_ros.actions.PushRosNamespace") def handle_push_ros_namespace(node: ast.Call, context: ParseContext): args, kwargs = resolve_call_signature(node, context.engine) ns = args[0] if args else kwargs.get("namespace") - return {"type": "PushRosNamespace", "namespace": ns} + return {"type": "PushROSNamespace", "namespace": ns} diff --git a/parser/parser/handlers/set_parameter_handler.py b/parser/parser/handlers/set_parameter_handler.py index 39dd4f9..313ffb0 100644 --- a/parser/parser/handlers/set_parameter_handler.py +++ b/parser/parser/handlers/set_parameter_handler.py @@ -23,6 +23,10 @@ def handle_set_parameter(node: ast.Call, context: ParseContext) -> dict: args, kwargs = resolve_call_signature(node, context.engine) + if len(args) == 2: + kwargs["name"] = args[0] + kwargs["value"] = args[1] + name = kwargs.get("name") value = kwargs.get("value") diff --git a/parser/parser/introspection_utils.py b/parser/parser/introspection_utils.py index 0aebec2..e5bf74a 100644 --- a/parser/parser/introspection_utils.py +++ b/parser/parser/introspection_utils.py @@ -161,3 +161,30 @@ def walk(obj, path): walk(entry, f"{top_key}[{idx}]") return usages + +def collect_composable_node_containers(grouped: dict, composable_containers: dict) -> dict: + """ + Recursively walk the grouped data and return the instances of composable_nodes_container + """ + def walk(obj): + if isinstance(obj, dict): + for key, value in obj.items(): + if key == "composable_nodes_container": + containers = obj.get("composable_nodes_container") + for idx, container in enumerate(containers): + container_name = container.get("target_container") + new_container = composable_containers.get(container_name, {}) + obj[key][idx] = new_container + else: + walk(value) + + elif isinstance(obj, list): + for idx, item in enumerate(obj): + walk(item) + + elif isinstance(obj, tuple): + for idx, item in enumerate(obj): + walk(item) + + walk(grouped) + return grouped \ No newline at end of file diff --git a/parser/tests/real_cases/expected_outputs/nav2_localization.launch.py.json b/parser/tests/real_cases/expected_outputs/nav2_localization.launch.py.json new file mode 100644 index 0000000..f4e18ef --- /dev/null +++ b/parser/tests/real_cases/expected_outputs/nav2_localization.launch.py.json @@ -0,0 +1,408 @@ +{ + "arguments": [ + { + "default_value": "", + "description": "Top-level namespace", + "name": "namespace" + }, + { + "default_value": "", + "description": "Full path to map yaml file to load", + "name": "map" + }, + { + "default_value": "false", + "description": "Use simulation (Gazebo) clock if true", + "name": "use_sim_time" + }, + { + "default_value": "${os.path.join:['${get_package_share_directory:nav2_bringup}', 'params', 'nav2_params.yaml']}", + "description": "Full path to the ROS2 parameters file to use for all launched nodes", + "name": "params_file" + }, + { + "default_value": "true", + "description": "Automatically startup the nav2 stack", + "name": "autostart" + }, + { + "default_value": "False", + "description": "Use composed bringup if True", + "name": "use_composition" + }, + { + "default_value": "nav2_container", + "description": "the name of container that nodes will load in if use composition", + "name": "container_name" + }, + { + "default_value": "False", + "description": "Whether to respawn if a node crashes. Applied when composition is disabled.", + "name": "use_respawn" + }, + { + "default_value": "info", + "description": "log level", + "name": "log_level" + } + ], + "groups": [ + { + "condition": "${IfCondition:not ${LaunchConfiguration:use_composition}}", + "actions": { + "nodes": [ + { + "condition": "${IfCondition:${EqualsSubstitution:${LaunchConfiguration:map}, }}", + "package": "nav2_map_server", + "executable": "map_server", + "name": "map_server", + "output": "screen", + "respawn": "${LaunchConfiguration:use_respawn}", + "respawn_delay": 2.0, + "parameters": [ + "${ParameterFile:${CustomHandler:RewrittenYaml(source_file=${LaunchConfiguration:params_file}, root_key=${LaunchConfiguration:namespace}, param_rewrites={}, convert_types=True)} allow_substs=True}" + ], + "arguments": [ + "--ros-args", + "--log-level", + "${LaunchConfiguration:log_level}" + ], + "remappings": [ + [ + "/tf", + "tf" + ], + [ + "/tf_static", + "tf_static" + ] + ] + }, + { + "condition": "${IfCondition:${NotEqualsSubstitution:${LaunchConfiguration:map}, }}", + "package": "nav2_map_server", + "executable": "map_server", + "name": "map_server", + "output": "screen", + "respawn": "${LaunchConfiguration:use_respawn}", + "respawn_delay": 2.0, + "parameters": [ + "${ParameterFile:${CustomHandler:RewrittenYaml(source_file=${LaunchConfiguration:params_file}, root_key=${LaunchConfiguration:namespace}, param_rewrites={}, convert_types=True)} allow_substs=True}", + { + "yaml_filename": "${LaunchConfiguration:map}" + } + ], + "arguments": [ + "--ros-args", + "--log-level", + "${LaunchConfiguration:log_level}" + ], + "remappings": [ + [ + "/tf", + "tf" + ], + [ + "/tf_static", + "tf_static" + ] + ] + }, + { + "package": "nav2_amcl", + "executable": "amcl", + "name": "amcl", + "output": "screen", + "respawn": "${LaunchConfiguration:use_respawn}", + "respawn_delay": 2.0, + "parameters": [ + "${ParameterFile:${CustomHandler:RewrittenYaml(source_file=${LaunchConfiguration:params_file}, root_key=${LaunchConfiguration:namespace}, param_rewrites={}, convert_types=True)} allow_substs=True}" + ], + "arguments": [ + "--ros-args", + "--log-level", + "${LaunchConfiguration:log_level}" + ], + "remappings": [ + [ + "/tf", + "tf" + ], + [ + "/tf_static", + "tf_static" + ] + ] + }, + { + "package": "nav2_lifecycle_manager", + "executable": "lifecycle_manager", + "name": "lifecycle_manager_localization", + "output": "screen", + "arguments": [ + "--ros-args", + "--log-level", + "${LaunchConfiguration:log_level}" + ], + "parameters": [ + { + "autostart": "${LaunchConfiguration:autostart}" + }, + { + "node_names": [ + "map_server", + "amcl" + ] + } + ] + } + ] + }, + "namespace": "${LaunchConfiguration:namespace}", + "parameters": [ + { + "use_sim_time": "${LaunchConfiguration:use_sim_time}" + } + ] + }, + { + "condition": "${IfCondition:${LaunchConfiguration:use_composition}}", + "actions": { + "composable_nodes_container": [ + { + "target_container": "${LaunchConfiguration:namespace}/${LaunchConfiguration:container_name}", + "composable_nodes": [ + { + "package": "nav2_map_server", + "plugin": "nav2_map_server::MapServer", + "name": "map_server", + "parameters": [ + "${ParameterFile:${CustomHandler:RewrittenYaml(source_file=${LaunchConfiguration:params_file}, root_key=${LaunchConfiguration:namespace}, param_rewrites={}, convert_types=True)} allow_substs=True}" + ], + "remappings": [ + [ + "/tf", + "tf" + ], + [ + "/tf_static", + "tf_static" + ] + ], + "condition": "${IfCondition:${EqualsSubstitution:${LaunchConfiguration:map}, }}" + }, + { + "package": "nav2_map_server", + "plugin": "nav2_map_server::MapServer", + "name": "map_server", + "parameters": [ + "${ParameterFile:${CustomHandler:RewrittenYaml(source_file=${LaunchConfiguration:params_file}, root_key=${LaunchConfiguration:namespace}, param_rewrites={}, convert_types=True)} allow_substs=True}", + { + "yaml_filename": "${LaunchConfiguration:map}" + } + ], + "remappings": [ + [ + "/tf", + "tf" + ], + [ + "/tf_static", + "tf_static" + ] + ], + "condition": "${IfCondition:${NotEqualsSubstitution:${LaunchConfiguration:map}, }}" + }, + { + "package": "nav2_amcl", + "plugin": "nav2_amcl::AmclNode", + "name": "amcl", + "parameters": [ + "${ParameterFile:${CustomHandler:RewrittenYaml(source_file=${LaunchConfiguration:params_file}, root_key=${LaunchConfiguration:namespace}, param_rewrites={}, convert_types=True)} allow_substs=True}" + ], + "remappings": [ + [ + "/tf", + "tf" + ], + [ + "/tf_static", + "tf_static" + ] + ] + }, + { + "package": "nav2_lifecycle_manager", + "plugin": "nav2_lifecycle_manager::LifecycleManager", + "name": "lifecycle_manager_localization", + "parameters": [ + { + "autostart": "${LaunchConfiguration:autostart}", + "node_names": [ + "map_server", + "amcl" + ] + } + ] + } + ] + } + ] + }, + "namespace": "${LaunchConfiguration:namespace}", + "parameters": [ + { + "use_sim_time": "${LaunchConfiguration:use_sim_time}" + } + ] + } + ], + "launch_argument_usages": [ + { + "argument": "use_composition", + "path": "groups[0].condition" + }, + { + "argument": "map", + "path": "groups[0].actions.nodes[0].condition" + }, + { + "argument": "use_respawn", + "path": "groups[0].actions.nodes[0].respawn" + }, + { + "argument": "params_file", + "path": "groups[0].actions.nodes[0].parameters[0]" + }, + { + "argument": "namespace", + "path": "groups[0].actions.nodes[0].parameters[0]" + }, + { + "argument": "log_level", + "path": "groups[0].actions.nodes[0].arguments[2]" + }, + { + "argument": "map", + "path": "groups[0].actions.nodes[1].condition" + }, + { + "argument": "use_respawn", + "path": "groups[0].actions.nodes[1].respawn" + }, + { + "argument": "params_file", + "path": "groups[0].actions.nodes[1].parameters[0]" + }, + { + "argument": "namespace", + "path": "groups[0].actions.nodes[1].parameters[0]" + }, + { + "argument": "map", + "path": "groups[0].actions.nodes[1].parameters[1].yaml_filename" + }, + { + "argument": "log_level", + "path": "groups[0].actions.nodes[1].arguments[2]" + }, + { + "argument": "use_respawn", + "path": "groups[0].actions.nodes[2].respawn" + }, + { + "argument": "params_file", + "path": "groups[0].actions.nodes[2].parameters[0]" + }, + { + "argument": "namespace", + "path": "groups[0].actions.nodes[2].parameters[0]" + }, + { + "argument": "log_level", + "path": "groups[0].actions.nodes[2].arguments[2]" + }, + { + "argument": "log_level", + "path": "groups[0].actions.nodes[3].arguments[2]" + }, + { + "argument": "autostart", + "path": "groups[0].actions.nodes[3].parameters[0].autostart" + }, + { + "argument": "namespace", + "path": "groups[0].namespace" + }, + { + "argument": "use_sim_time", + "path": "groups[0].parameters[0].use_sim_time" + }, + { + "argument": "use_composition", + "path": "groups[1].condition" + }, + { + "argument": "namespace", + "path": "groups[1].actions.composable_nodes_container[0].target_container" + }, + { + "argument": "container_name", + "path": "groups[1].actions.composable_nodes_container[0].target_container" + }, + { + "argument": "params_file", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[0].parameters[0]" + }, + { + "argument": "namespace", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[0].parameters[0]" + }, + { + "argument": "map", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[0].condition" + }, + { + "argument": "params_file", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[1].parameters[0]" + }, + { + "argument": "namespace", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[1].parameters[0]" + }, + { + "argument": "map", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[1].parameters[1].yaml_filename" + }, + { + "argument": "map", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[1].condition" + }, + { + "argument": "params_file", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[2].parameters[0]" + }, + { + "argument": "namespace", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[2].parameters[0]" + }, + { + "argument": "autostart", + "path": "groups[1].actions.composable_nodes_container[0].composable_nodes[3].parameters[0].autostart" + }, + { + "argument": "namespace", + "path": "groups[1].namespace" + }, + { + "argument": "use_sim_time", + "path": "groups[1].parameters[0].use_sim_time" + } + ], + "environment_variables": [ + { + "name": "RCUTILS_LOGGING_BUFFERED_STREAM", + "value": "1" + } + ] +} \ No newline at end of file diff --git a/parser/tests/real_cases/launch_files/nav2_localization.launch.py b/parser/tests/real_cases/launch_files/nav2_localization.launch.py new file mode 100644 index 0000000..2340f3f --- /dev/null +++ b/parser/tests/real_cases/launch_files/nav2_localization.launch.py @@ -0,0 +1,255 @@ +# Copyright (c) 2018 Intel Corporation +# +# 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. + +import os + +from ament_index_python.packages import get_package_share_directory +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, GroupAction, SetEnvironmentVariable +from launch.conditions import IfCondition +from launch.substitutions import ( + EqualsSubstitution, + LaunchConfiguration, + NotEqualsSubstitution, + PythonExpression, +) +from launch_ros.actions import LoadComposableNodes, Node, PushROSNamespace, SetParameter +from launch_ros.descriptions import ComposableNode, ParameterFile +from nav2_common.launch import LaunchConfigAsBool, RewrittenYaml + + +def generate_launch_description() -> LaunchDescription: + # Get the launch directory + bringup_dir = get_package_share_directory('nav2_bringup') + + namespace = LaunchConfiguration('namespace') + map_yaml_file = LaunchConfiguration('map') + use_sim_time = LaunchConfigAsBool('use_sim_time') + autostart = LaunchConfigAsBool('autostart') + params_file = LaunchConfiguration('params_file') + use_composition = LaunchConfigAsBool('use_composition') + container_name = LaunchConfiguration('container_name') + container_name_full = (namespace, '/', container_name) + use_respawn = LaunchConfigAsBool('use_respawn') + log_level = LaunchConfiguration('log_level') + + lifecycle_nodes = ['map_server', 'amcl'] + + # Map fully qualified names to relative ones so the node's namespace can be prepended. + remappings = [('/tf', 'tf'), ('/tf_static', 'tf_static')] + + configured_params = ParameterFile( + RewrittenYaml( + source_file=params_file, + root_key=namespace, + param_rewrites={}, + convert_types=True, + ), + allow_substs=True, + ) + + stdout_linebuf_envvar = SetEnvironmentVariable( + 'RCUTILS_LOGGING_BUFFERED_STREAM', '1' + ) + + declare_namespace_cmd = DeclareLaunchArgument( + 'namespace', default_value='', description='Top-level namespace' + ) + + declare_map_yaml_cmd = DeclareLaunchArgument( + 'map', default_value='', description='Full path to map yaml file to load' + ) + + declare_use_sim_time_cmd = DeclareLaunchArgument( + 'use_sim_time', + default_value='false', + description='Use simulation (Gazebo) clock if true', + ) + + declare_params_file_cmd = DeclareLaunchArgument( + 'params_file', + default_value=os.path.join(bringup_dir, 'params', 'nav2_params.yaml'), + description='Full path to the ROS2 parameters file to use for all launched nodes', + ) + + declare_autostart_cmd = DeclareLaunchArgument( + 'autostart', + default_value='true', + description='Automatically startup the nav2 stack', + ) + + declare_use_composition_cmd = DeclareLaunchArgument( + 'use_composition', + default_value='False', + description='Use composed bringup if True', + ) + + declare_container_name_cmd = DeclareLaunchArgument( + 'container_name', + default_value='nav2_container', + description='the name of container that nodes will load in if use composition', + ) + + declare_use_respawn_cmd = DeclareLaunchArgument( + 'use_respawn', + default_value='False', + description='Whether to respawn if a node crashes. Applied when composition is disabled.', + ) + + declare_log_level_cmd = DeclareLaunchArgument( + 'log_level', default_value='info', description='log level' + ) + + load_nodes = GroupAction( + condition=IfCondition(PythonExpression(['not ', use_composition])), + actions=[ + PushROSNamespace(namespace), + SetParameter('use_sim_time', use_sim_time), + Node( + condition=IfCondition( + EqualsSubstitution(LaunchConfiguration('map'), '') + ), + package='nav2_map_server', + executable='map_server', + name='map_server', + output='screen', + respawn=use_respawn, + respawn_delay=2.0, + parameters=[configured_params], + arguments=['--ros-args', '--log-level', log_level], + remappings=remappings, + ), + Node( + condition=IfCondition( + NotEqualsSubstitution(LaunchConfiguration('map'), '') + ), + package='nav2_map_server', + executable='map_server', + name='map_server', + output='screen', + respawn=use_respawn, + respawn_delay=2.0, + parameters=[configured_params, {'yaml_filename': map_yaml_file}], + arguments=['--ros-args', '--log-level', log_level], + remappings=remappings, + ), + Node( + package='nav2_amcl', + executable='amcl', + name='amcl', + output='screen', + respawn=use_respawn, + respawn_delay=2.0, + parameters=[configured_params], + arguments=['--ros-args', '--log-level', log_level], + remappings=remappings, + ), + Node( + package='nav2_lifecycle_manager', + executable='lifecycle_manager', + name='lifecycle_manager_localization', + output='screen', + arguments=['--ros-args', '--log-level', log_level], + parameters=[{'autostart': autostart}, {'node_names': lifecycle_nodes}], + ), + ], + ) + # LoadComposableNode for map server twice depending if we should use the + # value of map from a CLI or launch default or user defined value in the + # yaml configuration file. They are separated since the conditions + # currently only work on the LoadComposableNodes commands and not on the + # ComposableNode node function itself + load_composable_nodes = GroupAction( + condition=IfCondition(use_composition), + actions=[ + PushROSNamespace(namespace), + SetParameter('use_sim_time', use_sim_time), + LoadComposableNodes( + target_container=container_name_full, + condition=IfCondition( + EqualsSubstitution(LaunchConfiguration('map'), '') + ), + composable_node_descriptions=[ + ComposableNode( + package='nav2_map_server', + plugin='nav2_map_server::MapServer', + name='map_server', + parameters=[configured_params], + remappings=remappings, + ), + ], + ), + LoadComposableNodes( + target_container=container_name_full, + condition=IfCondition( + NotEqualsSubstitution(LaunchConfiguration('map'), '') + ), + composable_node_descriptions=[ + ComposableNode( + package='nav2_map_server', + plugin='nav2_map_server::MapServer', + name='map_server', + parameters=[ + configured_params, + {'yaml_filename': map_yaml_file}, + ], + remappings=remappings, + ), + ], + ), + LoadComposableNodes( + target_container=container_name_full, + composable_node_descriptions=[ + ComposableNode( + package='nav2_amcl', + plugin='nav2_amcl::AmclNode', + name='amcl', + parameters=[configured_params], + remappings=remappings, + ), + ComposableNode( + package='nav2_lifecycle_manager', + plugin='nav2_lifecycle_manager::LifecycleManager', + name='lifecycle_manager_localization', + parameters=[ + {'autostart': autostart, 'node_names': lifecycle_nodes} + ], + ), + ], + ), + ], + ) + + # Create the launch description and populate + ld = LaunchDescription() + + # Set environment variables + ld.add_action(stdout_linebuf_envvar) + + # Declare the launch options + ld.add_action(declare_namespace_cmd) + ld.add_action(declare_map_yaml_cmd) + ld.add_action(declare_use_sim_time_cmd) + ld.add_action(declare_params_file_cmd) + ld.add_action(declare_autostart_cmd) + ld.add_action(declare_use_composition_cmd) + ld.add_action(declare_container_name_cmd) + ld.add_action(declare_use_respawn_cmd) + ld.add_action(declare_log_level_cmd) + + # Add the actions to launch all of the localiztion nodes + ld.add_action(load_nodes) + ld.add_action(load_composable_nodes) + + return ld \ No newline at end of file diff --git a/parser/tests/test_cases/group_action_tests.yaml b/parser/tests/test_cases/group_action_tests.yaml index 4cd42ca..eee449d 100644 --- a/parser/tests/test_cases/group_action_tests.yaml +++ b/parser/tests/test_cases/group_action_tests.yaml @@ -20,16 +20,16 @@ tests: executable: basic - name: group_with_namespace - description: GroupAction with PushRosNamespace + description: GroupAction with PushROSNamespace input: | from launch import LaunchDescription - from launch.actions import GroupAction, PushRosNamespace + from launch.actions import GroupAction, PushROSNamespace from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ GroupAction([ - PushRosNamespace("robot1"), + PushROSNamespace("robot1"), Node(package="demo", executable="ns_node") ]) ]) @@ -111,10 +111,10 @@ tests: executable: n2 - name: group_with_namespace_and_variable - description: PushRosNamespace from config + children from variable + description: PushROSNamespace from config + children from variable input: | from launch import LaunchDescription - from launch.actions import GroupAction, PushRosNamespace, DeclareLaunchArgument + from launch.actions import GroupAction, PushROSNamespace, DeclareLaunchArgument from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node @@ -126,7 +126,7 @@ tests: return LaunchDescription([ declare, GroupAction([ - PushRosNamespace(ns), + PushROSNamespace(ns), *group_nodes ]) ]) @@ -149,15 +149,15 @@ tests: description: Nested GroupAction with namespace and nodes input: | from launch import LaunchDescription - from launch.actions import GroupAction, PushRosNamespace + from launch.actions import GroupAction, PushROSNamespace from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ GroupAction([ - PushRosNamespace("outer_ns"), + PushROSNamespace("outer_ns"), GroupAction([ - PushRosNamespace("inner_ns"), + PushROSNamespace("inner_ns"), Node(package="demo", executable="deep") ]) ]) diff --git a/parser/tests/test_cases/launch_config_tests.yaml b/parser/tests/test_cases/launch_config_tests.yaml index 6d48486..c1ef502 100644 --- a/parser/tests/test_cases/launch_config_tests.yaml +++ b/parser/tests/test_cases/launch_config_tests.yaml @@ -82,7 +82,7 @@ tests: description: LaunchConfiguration used in PushRosNamespace inside GroupAction input: | from launch import LaunchDescription - from launch.actions import DeclareLaunchArgument, GroupAction, PushRosNamespace + from launch.actions import DeclareLaunchArgument, GroupAction, PushROSNamespace from launch.substitutions import LaunchConfiguration from launch_ros.actions import Node @@ -90,7 +90,7 @@ tests: return LaunchDescription([ DeclareLaunchArgument("robot_namespace", default_value="robot1"), GroupAction([ - PushRosNamespace(LaunchConfiguration("robot_namespace")), + PushROSNamespace(LaunchConfiguration("robot_namespace")), Node(package='demo', executable='x') ]) ]) diff --git a/webview/components/renderComposableContainer.js b/webview/components/renderComposableContainer.js index 15ab457..30920f6 100644 --- a/webview/components/renderComposableContainer.js +++ b/webview/components/renderComposableContainer.js @@ -43,7 +43,7 @@ export function renderComposableContainer(group, options = {}) { // Render additional sections const metaSections = [ - { key: 'container', icon: '📛', label: 'Container', value: group.target_container }, + { key: 'target_container', icon: '📛', label: 'Container', value: group.target_container }, { key: 'package', icon: '📦', label: 'Package', value: group.package }, { key: 'executable', icon: '▶️', label: 'Executable', value: group.executable }, { key: 'output', icon: '🖥️', label: 'Output', value: group.output } diff --git a/webview/components/renderGroup.js b/webview/components/renderGroup.js index 2e8df6c..2c4bbb0 100644 --- a/webview/components/renderGroup.js +++ b/webview/components/renderGroup.js @@ -46,7 +46,8 @@ export function renderGroup(group, options = {}) { // Render additional sections const metaSections = [ { key: 'namespace', icon: '🧭', label: 'Namespace', value: ns }, - { key: 'condition', icon: '❓', label: 'Condition', value: group.condition } + { key: 'condition', icon: '❓', label: 'Condition', value: group.condition }, + { key: 'parameters', icon: '⚙️', label: 'Params', value: group.parameters } ]; metaSections.forEach(({ key, icon, label, value }) => { diff --git a/webview/tests/__screenshots__/interaction.spec.js-snapshots/nav2-localization-launch-py-json-final-linux.png b/webview/tests/__screenshots__/interaction.spec.js-snapshots/nav2-localization-launch-py-json-final-linux.png new file mode 100644 index 0000000..6a80686 Binary files /dev/null and b/webview/tests/__screenshots__/interaction.spec.js-snapshots/nav2-localization-launch-py-json-final-linux.png differ diff --git a/webview/tests/__screenshots__/visual.spec.js-snapshots/nav2-localization-launch-py-json-linux.png b/webview/tests/__screenshots__/visual.spec.js-snapshots/nav2-localization-launch-py-json-linux.png new file mode 100644 index 0000000..dda85cb Binary files /dev/null and b/webview/tests/__screenshots__/visual.spec.js-snapshots/nav2-localization-launch-py-json-linux.png differ