Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autostarting lifecycle nodes and example launch file demo #430

Open
wants to merge 13 commits into
base: rolling
Choose a base branch
from
55 changes: 55 additions & 0 deletions launch_ros/examples/lifecycle_autostart_pub_sub_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Copyright 2024 Open Navigation LLC
#
# 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.

"""Launch a lifecycle talker and a lifecycle listener."""

import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) # noqa
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'launch')) # noqa

import launch # noqa: E402
import launch.actions # noqa: E402
import launch.events # noqa: E402

import launch_ros.actions # noqa: E402
import launch_ros.events # noqa: E402
import launch_ros.events.lifecycle # noqa: E402


def main(argv=sys.argv[1:]):
ld = launch.LaunchDescription()
talker_node = launch_ros.actions.LifecycleNode(
name='talker',
namespace='',
package='lifecycle',
executable='lifecycle_talker',
output='screen',
autostart=True)
listener_node = launch_ros.actions.LifecycleNode(
name='listener',
namespace='',
package='lifecycle',
executable='lifecycle_listener',
output='screen',
autostart=True)
ld.add_action(talker_node)
ld.add_action(listener_node)
ls = launch.LaunchService(argv=argv)
ls.include_launch_description(ld)
return ls.run()


if __name__ == '__main__':
main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Copyright 2024 Open Navigation LLC
#
# 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.

"""Launch a lifecycle talker and a lifecycle listener."""

import sys

import launch # noqa: E402

from launch_ros.actions import ComposableNodeContainer, LoadComposableNodes
from launch_ros.descriptions import ComposableLifecycleNode


def main(argv=sys.argv[1:]):
ld = launch.LaunchDescription()

# Can autostart from the container
container = ComposableNodeContainer(
name='lifecycle_component_container',
namespace='',
package='rclcpp_components',
executable='component_container',
composable_node_descriptions=[
ComposableLifecycleNode(
package='composition',
plugin='composition::Listener',
name='listener',
autostart=True),
]
)

# ... and also from an external loader
loader = LoadComposableNodes(
target_container='lifecycle_component_container',
composable_node_descriptions=[
ComposableLifecycleNode(
package='composition',
plugin='composition::Talker',
name='talker',
autostart=True),
],
)

ld.add_action(container)
ld.add_action(loader)
ls = launch.LaunchService(argv=argv)
ls.include_launch_description(ld)
return ls.run()


if __name__ == '__main__':
main()
124 changes: 40 additions & 84 deletions launch_ros/launch_ros/actions/lifecycle_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@

"""Module for the LifecycleNode action."""

import functools
import threading
from typing import cast
from typing import List
from typing import Optional

Expand All @@ -28,11 +25,10 @@
import lifecycle_msgs.msg
import lifecycle_msgs.srv

from .lifecycle_transition import LifecycleTransition
from .node import Node
from ..events.lifecycle import ChangeState
from ..events.lifecycle import StateTransition

from ..ros_adapters import get_ros_node
from ..utilities import LifecycleEventManager


class LifecycleNode(Node):
Expand All @@ -43,6 +39,7 @@ def __init__(
*,
name: SomeSubstitutionsType,
namespace: SomeSubstitutionsType,
autostart: Optional[bool] = False,
**kwargs
) -> None:
"""
Expand Down Expand Up @@ -74,67 +71,17 @@ def __init__(
"""
super().__init__(name=name, namespace=namespace, **kwargs)
self.__logger = launch.logging.get_logger(__name__)
self.__rclpy_subscription = None
self.__current_state = \
ChangeState.valid_states[lifecycle_msgs.msg.State.PRIMARY_STATE_UNKNOWN]

def _on_transition_event(self, context, msg):
try:
event = StateTransition(action=self, msg=msg)
self.__current_state = ChangeState.valid_states[msg.goal_state.id]
context.asyncio_loop.call_soon_threadsafe(lambda: context.emit_event_sync(event))
except Exception as exc:
self.__logger.error(
"Exception in handling of 'lifecycle.msg.TransitionEvent': {}".format(exc))

def _call_change_state(self, request, context: launch.LaunchContext):
while not self.__rclpy_change_state_client.wait_for_service(timeout_sec=1.0):
if context.is_shutdown:
self.__logger.warning(
"Abandoning wait for the '{}' service, due to shutdown.".format(
self.__rclpy_change_state_client.srv_name),
)
return

# Asynchronously wait so that we can periodically check for shutdown.
event = threading.Event()

def unblock(future):
nonlocal event
event.set()

response_future = self.__rclpy_change_state_client.call_async(request)
response_future.add_done_callback(unblock)

while not event.wait(1.0):
if context.is_shutdown:
self.__logger.warning(
"Abandoning wait for the '{}' service response, due to shutdown.".format(
self.__rclpy_change_state_client.srv_name),
)
response_future.cancel()
return

if response_future.exception() is not None:
raise response_future.exception()
response = response_future.result()

if not response.success:
self.__logger.error(
"Failed to make transition '{}' for LifecycleNode '{}'".format(
ChangeState.valid_transitions[request.transition.id],
self.node_name,
)
)

def _on_change_state_event(self, context: launch.LaunchContext) -> None:
typed_event = cast(ChangeState, context.locals.event)
if not typed_event.lifecycle_node_matcher(self):
return None
request = lifecycle_msgs.srv.ChangeState.Request()
request.transition.id = typed_event.transition_id
context.add_completion_future(
context.asyncio_loop.run_in_executor(None, self._call_change_state, request, context))
self.__autostart = autostart
self.__lifecycle_event_manager = None

@property
def node_autostart(self):
"""Getter for autostart."""
return self.__autostart

@property
def is_lifecycle_node(self):
return True

def execute(self, context: launch.LaunchContext) -> Optional[List[Action]]:
"""
Expand All @@ -145,21 +92,30 @@ def execute(self, context: launch.LaunchContext) -> Optional[List[Action]]:
self._perform_substitutions(context) # ensure self.node_name is expanded
if '<node_name_unspecified>' in self.node_name:
raise RuntimeError('node_name unexpectedly incomplete for lifecycle node')
node = get_ros_node(context)
# Create a subscription to monitor the state changes of the subprocess.
self.__rclpy_subscription = node.create_subscription(
lifecycle_msgs.msg.TransitionEvent,
'{}/transition_event'.format(self.node_name),
functools.partial(self._on_transition_event, context),
10)
# Create a service client to change state on demand.
self.__rclpy_change_state_client = node.create_client(
lifecycle_msgs.srv.ChangeState,
'{}/change_state'.format(self.node_name))
# Register an event handler to change states on a ChangeState lifecycle event.
context.register_event_handler(launch.EventHandler(
matcher=lambda event: isinstance(event, ChangeState),
entities=[launch.actions.OpaqueFunction(function=self._on_change_state_event)],
))

self.__lifecycle_event_manager = LifecycleEventManager(self)
self.__lifecycle_event_manager.setup_lifecycle_manager(context)

# If autostart is enabled, transition to the 'active' state.
autostart_actions = None
if self.node_autostart:
autostart_actions = [
LifecycleTransition(
lifecycle_node_names=[self.node_name],
transition_ids=[lifecycle_msgs.msg.Transition.TRANSITION_CONFIGURE]
),
LifecycleTransition(
lifecycle_node_names=[self.node_name],
transition_ids=[lifecycle_msgs.msg.Transition.TRANSITION_ACTIVATE]
),
wjwwood marked this conversation as resolved.
Show resolved Hide resolved
]

# Delegate execution to Node and ExecuteProcess.
return super().execute(context)
node_actions = super().execute(context) # type: Optional[List[Action]]
if node_actions is not None and autostart_actions is not None:
return node_actions + autostart_actions
if node_actions is not None:
return node_actions
if autostart_actions is not None:
return autostart_actions
return None
32 changes: 32 additions & 0 deletions launch_ros/launch_ros/actions/load_composable_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@
from launch.utilities import perform_substitutions
from launch_ros.parameter_descriptions import ParameterFile

import lifecycle_msgs.msg

from .composable_node_container import ComposableNodeContainer
from .lifecycle_transition import LifecycleTransition

from ..descriptions import ComposableNode
from ..ros_adapters import get_ros_node
Expand Down Expand Up @@ -230,19 +233,48 @@ def execute(
# Generate load requests before execute() exits to avoid race with context changing
# due to scope change (e.g. if loading nodes from within a GroupAction).
load_node_requests = []
autostart_actions = []
for node_description in self.__composable_node_descriptions:
request = get_composable_node_load_request(node_description, context)
# The request can be None if the node description's condition evaluates to False
if request is not None:
load_node_requests.append(request)

# If autostart is enabled, transition to the 'active' state.
if node_description.node_autostart:
complete_node_name = request.node_namespace + request.node_name
if not complete_node_name.startswith('/'):
complete_node_name = '/' + complete_node_name
self.__logger.info(
'Autostart enabled for requested lifecycle node {}'.format(complete_node_name))
node_description.init_lifecycle_event_manager(node_description, context)
autostart_actions.append(
LifecycleTransition(
lifecycle_node_names=[complete_node_name],
transition_ids=[lifecycle_msgs.msg.Transition.TRANSITION_CONFIGURE]
))
autostart_actions.append(
LifecycleTransition(
lifecycle_node_names=[complete_node_name],
transition_ids=[lifecycle_msgs.msg.Transition.TRANSITION_ACTIVATE]
),
)
wjwwood marked this conversation as resolved.
Show resolved Hide resolved

if load_node_requests:
context.add_completion_future(
context.asyncio_loop.run_in_executor(
None, self._load_in_sequence, load_node_requests, context
)
)

load_actions = super().execute(context)
if load_actions is not None and len(autostart_actions) != 0:
return load_actions + autostart_actions
if load_actions is not None:
return load_actions
if len(autostart_actions) != 0:
return autostart_actions
wjwwood marked this conversation as resolved.
Show resolved Hide resolved


def get_composable_node_load_request(
composable_node_description: ComposableNode,
Expand Down
9 changes: 9 additions & 0 deletions launch_ros/launch_ros/actions/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,15 @@ def node_name(self):
raise RuntimeError("cannot access 'node_name' before executing action")
return self.__final_node_name

@property
def node_autostart(self):
"""Getter for autostart."""
return False

@property
def is_lifecycle_node(self):
return False

def is_node_name_fully_specified(self):
keywords = (self.UNSPECIFIED_NODE_NAME, self.UNSPECIFIED_NODE_NAMESPACE)
return all(x not in self.node_name for x in keywords)
Expand Down
2 changes: 2 additions & 0 deletions launch_ros/launch_ros/descriptions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
"""descriptions Module."""

from .composable_node import ComposableNode
from .composable_lifecycle_node import ComposableLifecycleNode
from ..parameter_descriptions import Parameter
from ..parameter_descriptions import ParameterFile
from ..parameter_descriptions import ParameterValue


__all__ = [
'ComposableNode',
'ComposableLifecycleNode',
'Parameter',
'ParameterFile',
'ParameterValue',
Expand Down
Loading