diff --git a/.github/workflows/release_mirror.yml b/.github/workflows/release_mirror.yml deleted file mode 100644 index ef7eb55..0000000 --- a/.github/workflows/release_mirror.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Copy repo to release mirror - -on: - workflow_dispatch: - release: - types: - - published - - -jobs: - release_mirror: - name: Push main to release repo - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - with: - ref: main - - - uses: automatika-robotics/push-to-release-repo-action@v2 - with: - destination-username: "${{ secrets.DESTINATION_USERNAME }}" - destination-access-token: ${{ secrets.DESTINATION_ACCESS_TOKEN }} - destination-repository: "automatika-robotics/ros-sugar-release" diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e100b28..827ad5a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Changelog for package automatika_ros_sugar ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +0.2.5 (2025-01-07) +------------------ +* (fix) Gets imports and default values based on installed distro +* (fix) Fix launch and launch_ros imports based on ros distro +* Contributors: ahr, mkabtoul + 0.2.4 (2024-12-27) ------------------ * (fix) Adds algorithm auto re-configuration from YAML file diff --git a/package.xml b/package.xml index 24f9027..9a80492 100644 --- a/package.xml +++ b/package.xml @@ -2,7 +2,7 @@ automatika_ros_sugar - 0.2.4 + 0.2.5 Syntactic sugar for ROS2 nodes creation and management Automatika Robotics https://github.com/automatika/ros-sugar diff --git a/ros_sugar/config/base_config.py b/ros_sugar/config/base_config.py index 02c875c..e034011 100644 --- a/ros_sugar/config/base_config.py +++ b/ros_sugar/config/base_config.py @@ -29,10 +29,7 @@ class QoSConfig(BaseAttrs): history: int = field( converter=_get_enum_value, default=qos.HistoryPolicy.KEEP_LAST, - validator=base_validators.in_([ - qos.HistoryPolicy.KEEP_LAST, - qos.HistoryPolicy.KEEP_ALL, - ]), + validator=base_validators.in_(list(qos.HistoryPolicy)), ) # used only if the “history” policy was set to “keep last” @@ -45,10 +42,7 @@ class QoSConfig(BaseAttrs): reliability: int = field( converter=_get_enum_value, default=qos.ReliabilityPolicy.RELIABLE, - validator=base_validators.in_([ - qos.ReliabilityPolicy.BEST_EFFORT, - qos.ReliabilityPolicy.RELIABLE, - ]), + validator=base_validators.in_(list(qos.ReliabilityPolicy)), ) # Transient local: the publisher becomes responsible for persisting samples for “late-joining” subscriptions @@ -56,13 +50,7 @@ class QoSConfig(BaseAttrs): durability: int = field( converter=_get_enum_value, default=qos.DurabilityPolicy.VOLATILE, - validator=base_validators.in_([ - qos.DurabilityPolicy.TRANSIENT_LOCAL, - qos.DurabilityPolicy.VOLATILE, - # qos.DurabilityPolicy.BEST_AVAILABLE, # Only available in iron -> TODO: Get values from rclpy - qos.DurabilityPolicy.UNKNOWN, - qos.DurabilityPolicy.SYSTEM_DEFAULT, - ]), + validator=base_validators.in_(list(qos.DurabilityPolicy)), ) # TODO: Fix default values diff --git a/ros_sugar/core/component.py b/ros_sugar/core/component.py index 0d33617..62fbe25 100644 --- a/ros_sugar/core/component.py +++ b/ros_sugar/core/component.py @@ -187,7 +187,7 @@ def _reparse_inputs_callbacks(self, inputs: Sequence[Topic]) -> Sequence[Topic]: :rtype: List[Topic] """ for inp in inputs: - if not isinstance(inp.msg_type.callback, List): + if not inp or not isinstance(inp.msg_type.callback, List): continue module_name = ( self.__module__[: self.__module__.index(".")] @@ -217,7 +217,7 @@ def _reparse_outputs_converts(self, outputs: Sequence[Topic]) -> Sequence[Topic] :rtype: List[Topic] """ for out in outputs: - if not isinstance(out.msg_type.convert, List): + if not out or not isinstance(out.msg_type.convert, List): continue module_name = self.__module__ # Get first callback by default diff --git a/ros_sugar/core/event.py b/ros_sugar/core/event.py index eefb68e..eea5cf3 100644 --- a/ros_sugar/core/event.py +++ b/ros_sugar/core/event.py @@ -12,16 +12,7 @@ from ..io.topic import Topic from .action import Action - -# Get ROS distro -__installed_distro = os.environ.get("ROS_DISTRO", "").lower() - -if __installed_distro == "foxy": - from launch.some_actions_type import SomeActionsType as SomeType -else: - from launch.some_entities_type import SomeEntitiesType as SomeType - -SomeEntitiesType = SomeType +from ..utils import SomeEntitiesType class Timer: @@ -380,12 +371,12 @@ def __init__( # Check if given trigger is of valid type if trigger_value and not _check_attribute( - self.event_topic.msg_type._ros_type, + self.event_topic.msg_type.get_ros_type(), type(self.trigger_ref_value), self._attrs, ): raise TypeError( - f"Cannot initiate with trigger of type {type(trigger_value)} for a data of type {_get_attribute_type(self.event_topic.msg_type._ros_type, self._attrs)}" + f"Cannot initiate with trigger of type {type(trigger_value)} for a data of type {_get_attribute_type(self.event_topic.msg_type.get_ros_type(), self._attrs)}" ) # Init trigger as False diff --git a/ros_sugar/io/supported_types.py b/ros_sugar/io/supported_types.py index 3596a61..801f2bc 100644 --- a/ros_sugar/io/supported_types.py +++ b/ros_sugar/io/supported_types.py @@ -103,7 +103,9 @@ def add_additional_datatypes(types: List[type]) -> None: _update_supportedtype_callback(existing_class, new_type) - if not existing_class._ros_type: + if hasattr(new_type, "_ros_type") and ( + not hasattr(existing_class, "_ros_type") or not existing_class._ros_type + ): existing_class._ros_type = new_type._ros_type _update_supportedtype_conversion(existing_class, new_type) @@ -154,6 +156,15 @@ def convert(cls, output, **_) -> Any: """ return output + @classmethod + def get_ros_type(cls) -> type: + """Getter of the ROS2 message type + + :return: ROS2 type + :rtype: type + """ + return cls._ros_type + class String(SupportedType): """String.""" diff --git a/ros_sugar/io/topic.py b/ros_sugar/io/topic.py index 4fba766..9b69f98 100644 --- a/ros_sugar/io/topic.py +++ b/ros_sugar/io/topic.py @@ -154,7 +154,7 @@ def _msg_type_validator(self, _, val): f"Got value of 'msg_type': {val}, which is not in available datatypes. Topics can only be created with one of the following types: { {msg_t.__name__: msg_t for msg_t in msg_types} }" ) # Set ros type - self.ros_msg_type = self.msg_type._ros_type + self.ros_msg_type = self.msg_type.get_ros_type() @define(kw_only=True) diff --git a/ros_sugar/launch/_lifecycle_transition.py b/ros_sugar/launch/_lifecycle_transition.py new file mode 100644 index 0000000..46bdc9c --- /dev/null +++ b/ros_sugar/launch/_lifecycle_transition.py @@ -0,0 +1,249 @@ +# This Action file is copied here from OSRF to support distributions prior to Iron +# Copyright 2022 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. + +import functools + +from typing import Iterable +from typing import List +from typing import Optional +from typing import Union + + +import launch +from launch import LaunchContext, SomeSubstitutionsType +from launch.action import Action +from launch.actions import EmitEvent, RegisterEventHandler +from launch.utilities import normalize_to_list_of_substitutions +from launch.utilities import perform_substitutions + +from launch_ros.event_handlers import OnStateTransition +from launch_ros.events.lifecycle import ChangeState, StateTransition +from launch_ros.events.matchers import matches_node_name +from lifecycle_msgs.msg import Transition + + +class LifecycleTransition(Action): + """An action that simplifies execution of lifecycle transitions.""" + + transition_targets = { + Transition.TRANSITION_CONFIGURE: { + "start_state": "configuring", + "goal_state": "inactive", + }, + Transition.TRANSITION_CLEANUP: { + "start_state": "cleaningup", + "goal_state": "unconfigured", + }, + Transition.TRANSITION_ACTIVATE: { + "start_state": "activating", + "goal_state": "active", + }, + Transition.TRANSITION_DEACTIVATE: { + "start_state": "deactivating", + "goal_state": "inactive", + }, + Transition.TRANSITION_UNCONFIGURED_SHUTDOWN: { + "start_state": "shuttingdown", + "goal_state": "finalized", + }, + Transition.TRANSITION_INACTIVE_SHUTDOWN: { + "start_state": "shuttingdown", + "goal_state": "finalized", + }, + Transition.TRANSITION_ACTIVE_SHUTDOWN: { + "start_state": "shuttingdown", + "goal_state": "finalized", + }, + } + + def __init__( + self, + *, + lifecycle_node_names: Iterable[SomeSubstitutionsType], + transition_ids: Iterable[Union[int, SomeSubstitutionsType]], + **kwargs, + ) -> None: + """ + Construct a LifecycleTransition action. + + The action will execute the passed in lifecycle transition for the + lifecycle nodes with the indicated node names. The action will emit + an event that triggers the first lifecycle transition of each node + wait that the node reaches the transition goal and trigger the next + transition in the list. + You need to make sure, that the sequence of lifecyle transition you + pass in is possible. + + :param lifecycle_node_names: The names of the lifecycle nodes to transition + :param transitions_ids: The transitions to be executed. + """ + super().__init__(**kwargs) + if len(transition_ids) == 0: + raise ValueError("No transition_ids provided.") + + if len(lifecycle_node_names) == 0: + raise ValueError("No lifecycle_node_names provided.") + + self.__lifecycle_node_names = [ + normalize_to_list_of_substitutions(name) for name in lifecycle_node_names + ] + transition_ids = [ + str(transition_id) if isinstance(transition_id, int) else transition_id + for transition_id in transition_ids + ] + self.__transition_ids = [ + normalize_to_list_of_substitutions(transition_id) + for transition_id in transition_ids + ] + + self.__event_handlers = {} + self.__logger = launch.logging.get_logger(__name__) + + def _remove_event_handlers( + self, context: LaunchContext, node_name: str, reason: str = None + ): + """Remove all consequent transitions if error occurs.""" + if reason is not None: + self.__logger.info( + f"Stopping transitions for {node_name} because '{reason}'" + ) + + for event_handler in self.__event_handlers[node_name]: + # Unregister event handlers and ignore failures, as these are + # already unregistered event handlers. + try: + context.unregister_event_handler(event_handler=event_handler) + except ValueError: + pass + + def execute(self, context: launch.LaunchContext) -> Optional[List[Action]]: + """ + Execute the LifecycleTransition action. + + :return Returns a list of actions to be executed to achieve specified transitions. + These are EventHandlers and EventEmitters for ChangeState and + StateTransition events of the nodes indicated. + """ + lifecycle_node_names = [ + perform_substitutions(context, name) for name in self.__lifecycle_node_names + ] + subs_transition_ids = [ + perform_substitutions(context, id_) for id_ in self.__transition_ids + ] + transition_ids = [] + for tid in subs_transition_ids: + try: + transition_ids.append(int(tid)) + except ValueError as e: + raise ValueError( + f"expected integer for lifecycle transition, got {tid}" + ) from e + + emit_actions = {} + actions: List[Action] = [] + + # Create EmitEvents for ChangeStates and store + for name in lifecycle_node_names: + own_emit_actions = [] + for tid in transition_ids: + change_event = ChangeState( + lifecycle_node_matcher=matches_node_name(name), transition_id=tid + ) + emit_action = EmitEvent(event=change_event) + own_emit_actions.append(emit_action) + emit_actions[name] = own_emit_actions + self.__event_handlers[name] = [] + # Create Transition EventHandlers and Registration actions + i = 1 + for tid in transition_ids: + # Create Transition handler for all indicated nodes + for node_name in lifecycle_node_names: + states = self.transition_targets[tid] + event_handler = None + # For all transitions except the last, emit next ChangeState Event + if i < len(transition_ids): + event_handler = OnStateTransition( + matcher=match_node_name_start_goal( + node_name, states["start_state"], states["goal_state"] + ), + entities=[emit_actions[node_name][i]], + handle_once=True, + ) + # For last transition emit Log message and remove untriggered error handlers + else: + event_handler = OnStateTransition( + matcher=match_node_name_start_goal( + node_name, states["start_state"], states["goal_state"] + ), + entities=[ + launch.actions.OpaqueFunction( + function=functools.partial( + self._remove_event_handlers, node_name=node_name + ) + ), + ], + handle_once=True, + ) + self.__event_handlers[node_name].append(event_handler) + # Create register event handler action + register_action = RegisterEventHandler(event_handler=event_handler) + # Append to actions + actions.append(register_action) + # increment next ChangeState action by one + i += 1 + # Create Error processing event handlers. + for node_name in lifecycle_node_names: + event_handler = launch.EventHandler( + matcher=match_node_name_goal(node_name, "errorprocessing"), + entities=[ + launch.actions.OpaqueFunction( + function=functools.partial( + self._remove_event_handlers, + node_name=node_name, + reason="error occured during transitions", + ) + ) + ], + handle_once=True, + ) + self.__event_handlers[node_name].append(event_handler) + context.register_event_handler(event_handler=event_handler) + + # Add first Emit actions to actions + for node_name in lifecycle_node_names: + actions.append(emit_actions[node_name][0]) + + return actions + + +def match_node_name_start_goal(node_name: str, start_state: str, goal_state: str): + if not node_name.startswith("/"): + node_name = f"/{node_name}" + return lambda event: ( + isinstance(event, StateTransition) + and (event.action.node_name == node_name) + and (event.goal_state == goal_state) + and (event.start_state == start_state) + ) + + +def match_node_name_goal(node_name: str, goal_state: str): + if not node_name.startswith("/"): + node_name = f"/{node_name}" + return lambda event: ( + isinstance(event, StateTransition) + and (event.action.node_name == node_name) + and (event.goal_state == goal_state) + ) diff --git a/ros_sugar/launch/launcher.py b/ros_sugar/launch/launcher.py index d8d324d..40b5cb7 100644 --- a/ros_sugar/launch/launcher.py +++ b/ros_sugar/launch/launcher.py @@ -20,7 +20,6 @@ import msgpack import msgpack_numpy as m_pack import launch -import launch_ros import rclpy import setproctitle from launch import LaunchDescription, LaunchIntrospector, LaunchService @@ -32,7 +31,6 @@ OpaqueFunction, Shutdown, ) -from launch.some_entities_type import SomeEntitiesType from launch_ros.actions import LifecycleNode as LifecycleNodeLaunchAction from launch_ros.actions import Node as NodeLaunchAction from launch_ros.actions import PushRosNamespace @@ -48,7 +46,16 @@ from ..core.monitor import Monitor from ..core.event import OnInternalEvent, Event from .launch_actions import ComponentLaunchAction -from ..utils import InvalidAction, action_handler, has_decorator +from ..utils import InvalidAction, action_handler, has_decorator, SomeEntitiesType + +# Get ROS distro +__installed_distro = os.environ.get("ROS_DISTRO", "").lower() + +if __installed_distro in ["humble", "galactic", "foxy"]: + # Get local copy for older distributions + from ._lifecycle_transition import LifecycleTransition +else: + from launch_ros.actions import LifecycleTransition # patch msgpack for numpy arrays m_pack.patch() @@ -304,7 +311,7 @@ def start(self, node_name: str, **_) -> SomeEntitiesType: :rtype: List[SomeEntitiesType] """ actions = [ - launch_ros.actions.LifecycleTransition( + LifecycleTransition( lifecycle_node_names=[node_name], transition_ids=[ Transition.TRANSITION_CONFIGURE, @@ -325,7 +332,7 @@ def stop(self, node_name: str, **_) -> SomeEntitiesType: :rtype: List[SomeEntitiesType] """ actions = [ - launch_ros.actions.LifecycleTransition( + LifecycleTransition( lifecycle_node_names=[node_name], transition_ids=[Transition.TRANSITION_DEACTIVATE], ) @@ -343,7 +350,7 @@ def restart(self, node_name: str, **_) -> SomeEntitiesType: :rtype: List[SomeEntitiesType] """ actions = [ - launch_ros.actions.LifecycleTransition( + LifecycleTransition( lifecycle_node_names=[node_name], transition_ids=[ Transition.TRANSITION_DEACTIVATE, diff --git a/ros_sugar/utils.py b/ros_sugar/utils.py index 73791d0..b8da96a 100644 --- a/ros_sugar/utils.py +++ b/ros_sugar/utils.py @@ -7,7 +7,16 @@ from rclpy.lifecycle import Node as LifecycleNode from launch import LaunchContext from launch.actions import OpaqueFunction -from launch.some_entities_type import SomeEntitiesType +import os + +# Get ROS distro +__installed_distro = os.environ.get("ROS_DISTRO", "").lower() + +if __installed_distro in ["humble", "galactic", "foxy"]: + # Get some_action_type for older distributions + from launch.some_actions_type import SomeActionsType as SomeEntitiesType +else: + from launch.some_entities_type import SomeEntitiesType class IncompatibleSetup(Exception):