diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7b49609 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci + +on: + pull_request: + push: + branches: [main, humble, jazzy, kilted] + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + distros: ${{ steps.set-distros.outputs.distros }} + steps: + - id: set-distros + run: | + # For PRs, use the target branch; for pushes, use the branch name + BRANCH="${{ github.base_ref || github.ref_name }}" + if [[ "$BRANCH" == "humble" ]]; then + echo 'distros=["humble"]' >> "$GITHUB_OUTPUT" + elif [[ "$BRANCH" == "jazzy" ]]; then + echo 'distros=["jazzy"]' >> "$GITHUB_OUTPUT" + elif [[ "$BRANCH" == "kilted" ]]; then + echo 'distros=["kilted"]' >> "$GITHUB_OUTPUT" + else + echo 'distros=["humble","jazzy","kilted"]' >> "$GITHUB_OUTPUT" + fi + + build-and-test: + needs: setup + runs-on: ubuntu-latest + strategy: + matrix: + ros_distro: ${{ fromJson(needs.setup.outputs.distros) }} + include: + - ros_distro: humble + docker_image: ros:humble-ros-base + - ros_distro: jazzy + docker_image: ros:jazzy-ros-base + - ros_distro: kilted + docker_image: ros:kilted-ros-base + fail-fast: false + name: build (${{ matrix.ros_distro }}) + container: + image: ${{ matrix.docker_image }} + steps: + - uses: actions/checkout@v4 + + - uses: ros-tooling/action-ros-ci@v0.4 + with: + target-ros2-distro: ${{ matrix.ros_distro }} + package-name: jig jig_example + coverage-result: false diff --git a/README.md b/README.md index 2ccccf1..1cec78a 100644 --- a/README.md +++ b/README.md @@ -1402,49 +1402,63 @@ cd jig/tests ## Examples -The `jig_example` package demonstrates usage with: -- **Multiple nodes**: C++ node (`my_node`) and Python node (`python_node`) -- **Interface examples**: Publishers, subscribers, services, actions, and parameters -- **Synchronous calls**: `my_node` uses `jig::call_sync` and `jig::send_goal_sync` in `on_configure` to call `python_node`'s service and action synchronously — demonstrating deadlock-free sync calls from lifecycle callbacks -- **Cascading deactivation**: `my_node` publishes a heartbeat; `python_node` subscribes with a 1 s deadline — if `my_node` deactivates, the default QoS handler automatically deactivates `python_node` too +The `jig_example` package demonstrates a range of Jig features across five nodes in both C++ and Python: + +- **`echo_node`** (C++) — Publishers, subscribers, services, service clients, timers, and parameterized QoS. A comprehensive example showing most Jig features in one node. +- **`py_echo_node`** (Python) — Python equivalent of the echo node with timer creation via `jig.create_timer()` and service request handlers. +- **`action_node`** (C++) — Action servers with goal validation and feedback, including single-goal and goal-replacement modes. Also demonstrates action clients and periodic timers. +- **`lifecycle_node`** (Python) — Full lifecycle callbacks (`on_configure`, `on_activate`, `on_deactivate`, `on_cleanup`) with advanced QoS settings including deadline monitoring, liveliness detection, and transient-local durability. +- **`for_each_node`** (Python) — Dynamic subscriber creation using `${for_each_param:...}` to aggregate status from a configurable list of target nodes. + +Additional highlights: - **Minimal CMakeLists.txt**: Just 3 lines using `jig_auto_package()` -- **Component registration**: Automatic component plugin setup -- **Package-level interfaces**: Optional `interfaces/` directory for shared definitions +- **Component registration**: Automatic component plugin setup for C++ nodes +- **Integration tests**: Comprehensive test suite covering pub/sub, services, actions, parameters, lifecycle transitions, QoS handlers, and cross-language communication Structure: ``` jig_example/ ├── nodes/ -│ ├── my_node/ +│ ├── action_node/ # C++ action server/client example +│ │ ├── interface.yaml +│ │ ├── action_node.cpp +│ │ └── action_node.hpp +│ ├── echo_node/ # C++ pub/sub/service/timer example +│ │ ├── interface.yaml +│ │ ├── echo_node.cpp +│ │ └── echo_node.hpp +│ ├── for_each_node/ # Python dynamic collections example │ │ ├── interface.yaml -│ │ ├── my_node.cpp -│ │ └── my_node.hpp -│ └── python_node/ +│ │ └── for_each_node.py +│ ├── lifecycle_node/ # Python lifecycle + advanced QoS example +│ │ ├── interface.yaml +│ │ └── lifecycle_node.py +│ └── py_echo_node/ # Python pub/sub/service/timer example │ ├── interface.yaml -│ └── python_node.py -├── interfaces/ -│ ├── external_node.yaml -│ └── transition_node.yaml +│ └── py_echo_node.py ├── launch/ -│ └── test.launch.py -├── CMakeLists.txt # Just jig_auto_package()! +│ └── test.launch.yaml +├── test/ # Integration tests +├── CMakeLists.txt # Just jig_auto_package()! └── package.xml ``` -Build and run the example: +Build and run the examples: ```bash colcon build --packages-select jig_example source install/setup.bash -# Run C++ node -ros2 run jig_example my_node - -# Run Python node -ros2 run jig_example python_node +# Run individual nodes +ros2 run jig_example echo_node +ros2 run jig_example py_echo_node +ros2 run jig_example action_node +ros2 run jig_example lifecycle_node +ros2 run jig_example for_each_node -# Load as component -ros2 component standalone jig_example jig_example::MyNode +# Load C++ nodes as components +ros2 component standalone jig_example jig_example::EchoNode +ros2 component standalone jig_example jig_example::ActionNode ``` ## Contributing @@ -1466,6 +1480,30 @@ The `prepare-commit-msg` hook will automatically add the `Signed-off-by` line to All pull requests are checked for DCO sign-off via CI. Commits without a `Signed-off-by` line will fail the check. +### Branching Strategy + +Development happens on `main`. Each supported ROS distro has a dedicated branch (e.g., `humble`, `jazzy`) that is **continuously rebased** onto `main`. + +``` +main: A --- B --- C --- D + \ +humble: D --- H1 --- H2 (distro-specific patches) +jazzy: D --- J1 (distro-specific patches) +``` + +**How it works:** + +- All new features and bug fixes are developed against `main` via pull requests. +- Distro branches carry a small number of distro-specific patches (e.g., API compatibility shims, version pins) as commits on top of `main`. +- After `main` advances, distro branches are rebased onto it, keeping the patches at the tip. +- Distro branches are **force-pushed** after each rebase. + +**Guidelines for contributors:** + +- For general features and fixes, base your branch on `main` and open your PR against `main`. +- For distro-specific fixes, base your branch on the target distro branch and open your PR directly against it. Distro PRs are **squash-merged** to keep the patch stack clean for rebasing. +- Do not merge distro branches into `main` or vice versa — the relationship is always rebase, never merge. + ## License Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details. diff --git a/jig/CMakeLists.txt b/jig/CMakeLists.txt index 9759c77..e8a9005 100644 --- a/jig/CMakeLists.txt +++ b/jig/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.28) +cmake_minimum_required(VERSION 3.22) project(jig) find_package(ament_cmake_auto REQUIRED) @@ -21,4 +21,9 @@ list(APPEND ${PROJECT_NAME}_CONFIG_EXTRAS cmake/jig_auto_package.cmake cmake/jig ament_python_install_package(${PROJECT_NAME}) +if(BUILD_TESTING) + find_package(ament_cmake_pytest REQUIRED) + ament_add_pytest_test(test_generate_node_interface tests/test_generate_node_interface.py) +endif() + ament_auto_package(USE_SCOPED_HEADER_INSTALL_DIR INSTALL_TO_SHARE schemas) diff --git a/jig/package.xml b/jig/package.xml index 9e30b1f..cdc6f68 100644 --- a/jig/package.xml +++ b/jig/package.xml @@ -29,6 +29,9 @@ ament_index_python + ament_cmake_pytest + python3-pytest + ament_cmake diff --git a/jig/scripts/generate_node_interface.py b/jig/scripts/generate_node_interface.py index 2f86cf7..03d41d2 100755 --- a/jig/scripts/generate_node_interface.py +++ b/jig/scripts/generate_node_interface.py @@ -43,7 +43,7 @@ class EntityConfig: # Constants -DUMMY_PARAM_NAME = "__jig_dummy" +DUMMY_PARAM_NAME = "_jig_dummy" # Implicit interfaces that all jig lifecycle nodes expose at runtime. # These are added to the generated output YAML to create a complete runtime manifest. diff --git a/jig/tests/fixtures/action_clients_mixed/expected_cpp/action_clients_mixed_interface.params.yaml b/jig/tests/fixtures/action_clients_mixed/expected_cpp/action_clients_mixed_interface.params.yaml index 408f123..be8f79d 100644 --- a/jig/tests/fixtures/action_clients_mixed/expected_cpp/action_clients_mixed_interface.params.yaml +++ b/jig/tests/fixtures/action_clients_mixed/expected_cpp/action_clients_mixed_interface.params.yaml @@ -1,5 +1,5 @@ test_package::action_clients_mixed: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/action_clients_mixed/expected_python/__init__.py b/jig/tests/fixtures/action_clients_mixed/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/action_clients_mixed/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/action_clients_mixed/expected_python/_parameters.py b/jig/tests/fixtures/action_clients_mixed/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/action_clients_mixed/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/action_clients_mixed/expected_python/action_clients_mixed.yaml b/jig/tests/fixtures/action_clients_mixed/expected_python/action_clients_mixed.yaml new file mode 100644 index 0000000..650c31e --- /dev/null +++ b/jig/tests/fixtures/action_clients_mixed/expected_python/action_clients_mixed.yaml @@ -0,0 +1,67 @@ +node: + name: action_clients_mixed + package: test_package +publishers: +- topic: /status + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log +subscribers: +- topic: /command + type: std_msgs/msg/Bool + qos: + history: 5 + reliability: BEST_EFFORT +services: +- name: /reset + type: std_srvs/srv/Trigger +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription +service_clients: +- name: /compute + type: example_interfaces/srv/AddTwoInts +actions: +- name: fibonacci_server + type: example_interfaces/action/Fibonacci +action_clients: +- name: /navigate + type: nav2_msgs/action/NavigateToPose +- name: compute_path + type: nav2_msgs/action/ComputePathToPose +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true diff --git a/jig/tests/fixtures/action_clients_mixed/expected_python/interface.py b/jig/tests/fixtures/action_clients_mixed/expected_python/interface.py new file mode 100644 index 0000000..801e5f7 --- /dev/null +++ b/jig/tests/fixtures/action_clients_mixed/expected_python/interface.py @@ -0,0 +1,206 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from rclpy.client import Client +from rclpy.action import ActionClient +from rclpy.qos import ( + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, +) +from example_interfaces.action import Fibonacci +from example_interfaces.srv import AddTwoInts +from nav2_msgs.action import ComputePathToPose +from nav2_msgs.action import NavigateToPose +from std_msgs.msg import Bool +from std_msgs.msg import String +from std_srvs.srv import Trigger + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + status: jig.Publisher[String] = field(default_factory=jig.Publisher[String]) + + +@dataclass +class Subscribers: + command: jig.Subscriber[Bool] = field(default_factory=jig.Subscriber[Bool]) + + +@dataclass +class Services: + reset: jig.Service[Trigger, Trigger.Request, Trigger.Response] = field(default_factory=jig.Service[Trigger, Trigger.Request, Trigger.Response]) + + +@dataclass +class ServiceClients: + compute: Client # srv_type: example_interfaces/srv/AddTwoInts + + +@dataclass +class Actions: + fibonacci_server: jig.SingleGoalActionServer[Fibonacci, Fibonacci.Goal, Fibonacci.Result, Fibonacci.Feedback] + + +@dataclass +class ActionClients: + navigate: ActionClient # action_type: nav2_msgs/action/NavigateToPose + compute_path: ActionClient # action_type: nav2_msgs/action/ComputePathToPose + + +@dataclass +class ActionClientsMixedSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=ActionClientsMixedSession) + + +class _ActionClientsMixedNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "action_clients_mixed", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients( + compute=node.create_client(AddTwoInts, "/compute"), + ) + + # initialise actions + actions = Actions( + fibonacci_server=jig.SingleGoalActionServer[Fibonacci, Fibonacci.Goal, Fibonacci.Result, Fibonacci.Feedback](node, Fibonacci, "fibonacci_server"), + ) + + # initialise action clients + action_clients = ActionClients( + navigate=ActionClient(node, NavigateToPose, "/navigate"), + compute_path=ActionClient(node, ComputePathToPose, "compute_path"), + ) + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + sn.publishers.status._initialise(sn, String, "/status", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE)) + + # initialise subscribers + sn.subscribers.command._initialise(sn, Bool, "/command", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=5, reliability=ReliabilityPolicy.BEST_EFFORT)) + jig.attach_default_qos_handlers(sn.subscribers.command) + + # initialise services + sn.services.reset._initialise(sn, Trigger, "/reset") + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + sn.actions.fibonacci_server.deactivate() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.publishers.status._destroy(sn.node) + sn.subscribers.command._destroy(sn.node) + sn.services.reset._destroy(sn.node) + sn.node.destroy_client(sn.service_clients.compute) + sn.actions.fibonacci_server._destroy(sn.node) + sn.action_clients.navigate.destroy() + sn.action_clients.compute_path.destroy() + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _ActionClientsMixedNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/action_clients_mixed/expected_python/parameters.py b/jig/tests/fixtures/action_clients_mixed/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/action_clients_mixed/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/action_clients_only/expected_cpp/action_clients_only_interface.params.yaml b/jig/tests/fixtures/action_clients_only/expected_cpp/action_clients_only_interface.params.yaml index bba76a4..ffde36b 100644 --- a/jig/tests/fixtures/action_clients_only/expected_cpp/action_clients_only_interface.params.yaml +++ b/jig/tests/fixtures/action_clients_only/expected_cpp/action_clients_only_interface.params.yaml @@ -1,5 +1,5 @@ test_package::action_clients_only: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/action_clients_only/expected_python/_parameters.py b/jig/tests/fixtures/action_clients_only/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/action_clients_only/expected_python/_parameters.py +++ b/jig/tests/fixtures/action_clients_only/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/action_servers_mixed/expected_cpp/action_servers_mixed_interface.params.yaml b/jig/tests/fixtures/action_servers_mixed/expected_cpp/action_servers_mixed_interface.params.yaml index 628b0b2..c5984e4 100644 --- a/jig/tests/fixtures/action_servers_mixed/expected_cpp/action_servers_mixed_interface.params.yaml +++ b/jig/tests/fixtures/action_servers_mixed/expected_cpp/action_servers_mixed_interface.params.yaml @@ -1,5 +1,5 @@ test_package::action_servers_mixed: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/action_servers_mixed/expected_python/__init__.py b/jig/tests/fixtures/action_servers_mixed/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/action_servers_mixed/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/action_servers_mixed/expected_python/_parameters.py b/jig/tests/fixtures/action_servers_mixed/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/action_servers_mixed/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/action_servers_mixed/expected_python/action_servers_mixed.yaml b/jig/tests/fixtures/action_servers_mixed/expected_python/action_servers_mixed.yaml new file mode 100644 index 0000000..0336f5c --- /dev/null +++ b/jig/tests/fixtures/action_servers_mixed/expected_python/action_servers_mixed.yaml @@ -0,0 +1,59 @@ +node: + name: action_servers_mixed + package: test_package +publishers: +- topic: /status + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log +subscribers: +- topic: /cmd + type: std_msgs/msg/Bool + qos: + history: 10 + reliability: BEST_EFFORT +services: +- name: /reset + type: std_srvs/srv/Trigger +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription +actions: +- name: navigate + type: example_interfaces/action/Fibonacci +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true diff --git a/jig/tests/fixtures/action_servers_mixed/expected_python/interface.py b/jig/tests/fixtures/action_servers_mixed/expected_python/interface.py new file mode 100644 index 0000000..9993d20 --- /dev/null +++ b/jig/tests/fixtures/action_servers_mixed/expected_python/interface.py @@ -0,0 +1,192 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from rclpy.qos import ( + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, +) +from example_interfaces.action import Fibonacci +from std_msgs.msg import Bool +from std_msgs.msg import String +from std_srvs.srv import Trigger + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + status: jig.Publisher[String] = field(default_factory=jig.Publisher[String]) + + +@dataclass +class Subscribers: + cmd: jig.Subscriber[Bool] = field(default_factory=jig.Subscriber[Bool]) + + +@dataclass +class Services: + reset: jig.Service[Trigger, Trigger.Request, Trigger.Response] = field(default_factory=jig.Service[Trigger, Trigger.Request, Trigger.Response]) + + +@dataclass +class ServiceClients: + pass + + +@dataclass +class Actions: + navigate: jig.SingleGoalActionServer[Fibonacci, Fibonacci.Goal, Fibonacci.Result, Fibonacci.Feedback] + + +@dataclass +class ActionClients: + pass + + +@dataclass +class ActionServersMixedSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=ActionServersMixedSession) + + +class _ActionServersMixedNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "action_servers_mixed", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients() + + # initialise actions + actions = Actions( + navigate=jig.SingleGoalActionServer[Fibonacci, Fibonacci.Goal, Fibonacci.Result, Fibonacci.Feedback](node, Fibonacci, "navigate"), + ) + + # initialise action clients + action_clients = ActionClients() + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + sn.publishers.status._initialise(sn, String, "/status", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE)) + + # initialise subscribers + sn.subscribers.cmd._initialise(sn, Bool, "/cmd", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.BEST_EFFORT)) + jig.attach_default_qos_handlers(sn.subscribers.cmd) + + # initialise services + sn.services.reset._initialise(sn, Trigger, "/reset") + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + sn.actions.navigate.deactivate() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.publishers.status._destroy(sn.node) + sn.subscribers.cmd._destroy(sn.node) + sn.services.reset._destroy(sn.node) + sn.actions.navigate._destroy(sn.node) + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _ActionServersMixedNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/action_servers_mixed/expected_python/parameters.py b/jig/tests/fixtures/action_servers_mixed/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/action_servers_mixed/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/action_servers_only/expected_cpp/action_servers_only_interface.params.yaml b/jig/tests/fixtures/action_servers_only/expected_cpp/action_servers_only_interface.params.yaml index 4174ccd..1ecfe8b 100644 --- a/jig/tests/fixtures/action_servers_only/expected_cpp/action_servers_only_interface.params.yaml +++ b/jig/tests/fixtures/action_servers_only/expected_cpp/action_servers_only_interface.params.yaml @@ -1,5 +1,5 @@ test_package::action_servers_only: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/action_servers_only/expected_python/_parameters.py b/jig/tests/fixtures/action_servers_only/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/action_servers_only/expected_python/_parameters.py +++ b/jig/tests/fixtures/action_servers_only/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/complex_types/expected_cpp/complex_types_interface.params.yaml b/jig/tests/fixtures/complex_types/expected_cpp/complex_types_interface.params.yaml index e893616..a5ac1c6 100644 --- a/jig/tests/fixtures/complex_types/expected_cpp/complex_types_interface.params.yaml +++ b/jig/tests/fixtures/complex_types/expected_cpp/complex_types_interface.params.yaml @@ -1,5 +1,5 @@ test_package::complex_types: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/complex_types/expected_python/__init__.py b/jig/tests/fixtures/complex_types/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/complex_types/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/complex_types/expected_python/_parameters.py b/jig/tests/fixtures/complex_types/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/complex_types/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/complex_types/expected_python/complex_types.yaml b/jig/tests/fixtures/complex_types/expected_python/complex_types.yaml new file mode 100644 index 0000000..a9fca4d --- /dev/null +++ b/jig/tests/fixtures/complex_types/expected_python/complex_types.yaml @@ -0,0 +1,64 @@ +node: + name: complex_types + package: test_package +publishers: +- topic: pose + type: geometry_msgs/msg/PoseStamped + qos: + history: 10 + reliability: RELIABLE +- topic: path + type: nav_msgs/msg/Path + qos: + history: 5 + reliability: RELIABLE +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log +subscribers: +- topic: joint_states + type: sensor_msgs/msg/JointState + qos: + history: 10 + reliability: BEST_EFFORT +- topic: point_cloud + type: sensor_msgs/msg/PointCloud2 + qos: + history: 1 + reliability: BEST_EFFORT +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true +services: +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription diff --git a/jig/tests/fixtures/complex_types/expected_python/interface.py b/jig/tests/fixtures/complex_types/expected_python/interface.py new file mode 100644 index 0000000..1373951 --- /dev/null +++ b/jig/tests/fixtures/complex_types/expected_python/interface.py @@ -0,0 +1,193 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from rclpy.qos import ( + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, +) +from geometry_msgs.msg import PoseStamped +from nav_msgs.msg import Path +from sensor_msgs.msg import JointState +from sensor_msgs.msg import PointCloud2 + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + pose: jig.Publisher[PoseStamped] = field(default_factory=jig.Publisher[PoseStamped]) + path: jig.Publisher[Path] = field(default_factory=jig.Publisher[Path]) + + +@dataclass +class Subscribers: + joint_states: jig.Subscriber[JointState] = field(default_factory=jig.Subscriber[JointState]) + point_cloud: jig.Subscriber[PointCloud2] = field(default_factory=jig.Subscriber[PointCloud2]) + + +@dataclass +class Services: + pass + + +@dataclass +class ServiceClients: + pass + + +@dataclass +class Actions: + pass + + +@dataclass +class ActionClients: + pass + + +@dataclass +class ComplexTypesSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=ComplexTypesSession) + + +class _ComplexTypesNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "complex_types", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients() + + # initialise actions + actions = Actions() + + # initialise action clients + action_clients = ActionClients() + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + sn.publishers.pose._initialise(sn, PoseStamped, "pose", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE)) + sn.publishers.path._initialise(sn, Path, "path", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=5, reliability=ReliabilityPolicy.RELIABLE)) + + # initialise subscribers + sn.subscribers.joint_states._initialise(sn, JointState, "joint_states", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.BEST_EFFORT)) + jig.attach_default_qos_handlers(sn.subscribers.joint_states) + sn.subscribers.point_cloud._initialise(sn, PointCloud2, "point_cloud", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=1, reliability=ReliabilityPolicy.BEST_EFFORT)) + jig.attach_default_qos_handlers(sn.subscribers.point_cloud) + + # initialise services + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.publishers.pose._destroy(sn.node) + sn.publishers.path._destroy(sn.node) + sn.subscribers.joint_states._destroy(sn.node) + sn.subscribers.point_cloud._destroy(sn.node) + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _ComplexTypesNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/complex_types/expected_python/parameters.py b/jig/tests/fixtures/complex_types/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/complex_types/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/empty_node/expected_cpp/empty_node_interface.params.yaml b/jig/tests/fixtures/empty_node/expected_cpp/empty_node_interface.params.yaml index 1f6dcb5..1b87f70 100644 --- a/jig/tests/fixtures/empty_node/expected_cpp/empty_node_interface.params.yaml +++ b/jig/tests/fixtures/empty_node/expected_cpp/empty_node_interface.params.yaml @@ -1,5 +1,5 @@ test_package::empty_node: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/empty_node/expected_python/_parameters.py b/jig/tests/fixtures/empty_node/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/empty_node/expected_python/_parameters.py +++ b/jig/tests/fixtures/empty_node/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/manually_created/expected_cpp/manually_created_interface.params.yaml b/jig/tests/fixtures/manually_created/expected_cpp/manually_created_interface.params.yaml index e5419c1..0756edd 100644 --- a/jig/tests/fixtures/manually_created/expected_cpp/manually_created_interface.params.yaml +++ b/jig/tests/fixtures/manually_created/expected_cpp/manually_created_interface.params.yaml @@ -1,5 +1,5 @@ test_package::manually_created: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/manually_created/expected_python/__init__.py b/jig/tests/fixtures/manually_created/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/manually_created/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/manually_created/expected_python/_parameters.py b/jig/tests/fixtures/manually_created/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/manually_created/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/manually_created/expected_python/interface.py b/jig/tests/fixtures/manually_created/expected_python/interface.py new file mode 100644 index 0000000..759363c --- /dev/null +++ b/jig/tests/fixtures/manually_created/expected_python/interface.py @@ -0,0 +1,184 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from rclpy.qos import ( + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, +) +from std_msgs.msg import Bool +from std_msgs.msg import String + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + auto_topic: jig.Publisher[String] = field(default_factory=jig.Publisher[String]) + + +@dataclass +class Subscribers: + auto_sub: jig.Subscriber[Bool] = field(default_factory=jig.Subscriber[Bool]) + + +@dataclass +class Services: + pass + + +@dataclass +class ServiceClients: + pass + + +@dataclass +class Actions: + pass + + +@dataclass +class ActionClients: + pass + + +@dataclass +class ManuallyCreatedSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=ManuallyCreatedSession) + + +class _ManuallyCreatedNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "manually_created", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients() + + # initialise actions + actions = Actions() + + # initialise action clients + action_clients = ActionClients() + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + sn.publishers.auto_topic._initialise(sn, String, "auto_topic", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE)) + + # initialise subscribers + sn.subscribers.auto_sub._initialise(sn, Bool, "auto_sub", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.BEST_EFFORT)) + jig.attach_default_qos_handlers(sn.subscribers.auto_sub) + + # initialise services + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.publishers.auto_topic._destroy(sn.node) + sn.subscribers.auto_sub._destroy(sn.node) + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _ManuallyCreatedNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/manually_created/expected_python/manually_created.yaml b/jig/tests/fixtures/manually_created/expected_python/manually_created.yaml new file mode 100644 index 0000000..4f7c939 --- /dev/null +++ b/jig/tests/fixtures/manually_created/expected_python/manually_created.yaml @@ -0,0 +1,64 @@ +node: + name: manually_created + package: test_package +publishers: +- topic: auto_topic + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE +- topic: manual_topic + type: std_msgs/msg/Int32 + qos: + history: 10 + reliability: RELIABLE +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log +subscribers: +- topic: auto_sub + type: std_msgs/msg/Bool + qos: + history: 10 + reliability: BEST_EFFORT +- topic: manual_sub + type: std_msgs/msg/Float32 + qos: + history: 10 + reliability: BEST_EFFORT +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true +services: +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription diff --git a/jig/tests/fixtures/manually_created/expected_python/parameters.py b/jig/tests/fixtures/manually_created/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/manually_created/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/no_node_section/expected_cpp/no_node_section_interface.params.yaml b/jig/tests/fixtures/no_node_section/expected_cpp/no_node_section_interface.params.yaml index ca199ac..f4d9078 100644 --- a/jig/tests/fixtures/no_node_section/expected_cpp/no_node_section_interface.params.yaml +++ b/jig/tests/fixtures/no_node_section/expected_cpp/no_node_section_interface.params.yaml @@ -1,5 +1,5 @@ test_package::no_node_section: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/no_node_section/expected_python/_parameters.py b/jig/tests/fixtures/no_node_section/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/no_node_section/expected_python/_parameters.py +++ b/jig/tests/fixtures/no_node_section/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/publishers_only/expected_cpp/publishers_only_interface.params.yaml b/jig/tests/fixtures/publishers_only/expected_cpp/publishers_only_interface.params.yaml index 9432c08..3409226 100644 --- a/jig/tests/fixtures/publishers_only/expected_cpp/publishers_only_interface.params.yaml +++ b/jig/tests/fixtures/publishers_only/expected_cpp/publishers_only_interface.params.yaml @@ -1,5 +1,5 @@ test_package::publishers_only: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/publishers_only/expected_python/_parameters.py b/jig/tests/fixtures/publishers_only/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/publishers_only/expected_python/_parameters.py +++ b/jig/tests/fixtures/publishers_only/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/qos_custom/expected_cpp/qos_custom_interface.params.yaml b/jig/tests/fixtures/qos_custom/expected_cpp/qos_custom_interface.params.yaml index 9763336..9299b09 100644 --- a/jig/tests/fixtures/qos_custom/expected_cpp/qos_custom_interface.params.yaml +++ b/jig/tests/fixtures/qos_custom/expected_cpp/qos_custom_interface.params.yaml @@ -1,5 +1,5 @@ test_package::qos_custom: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/qos_custom/expected_python/__init__.py b/jig/tests/fixtures/qos_custom/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/qos_custom/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/qos_custom/expected_python/_parameters.py b/jig/tests/fixtures/qos_custom/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/qos_custom/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/qos_custom/expected_python/interface.py b/jig/tests/fixtures/qos_custom/expected_python/interface.py new file mode 100644 index 0000000..9d2a58b --- /dev/null +++ b/jig/tests/fixtures/qos_custom/expected_python/interface.py @@ -0,0 +1,192 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from rclpy.qos import ( + DurabilityPolicy, + Duration, + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, +) +from std_msgs.msg import String + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + reliable_topic: jig.Publisher[String] = field(default_factory=jig.Publisher[String]) + best_effort_topic: jig.Publisher[String] = field(default_factory=jig.Publisher[String]) + + +@dataclass +class Subscribers: + keep_all_topic: jig.Subscriber[String] = field(default_factory=jig.Subscriber[String]) + deadline_topic: jig.Subscriber[String] = field(default_factory=jig.Subscriber[String]) + + +@dataclass +class Services: + pass + + +@dataclass +class ServiceClients: + pass + + +@dataclass +class Actions: + pass + + +@dataclass +class ActionClients: + pass + + +@dataclass +class QosCustomSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=QosCustomSession) + + +class _QosCustomNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "qos_custom", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients() + + # initialise actions + actions = Actions() + + # initialise action clients + action_clients = ActionClients() + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + sn.publishers.reliable_topic._initialise(sn, String, "reliable_topic", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE, durability=DurabilityPolicy.VOLATILE)) + sn.publishers.best_effort_topic._initialise(sn, String, "best_effort_topic", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=5, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL)) + + # initialise subscribers + sn.subscribers.keep_all_topic._initialise(sn, String, "keep_all_topic", QoSProfile(history=HistoryPolicy.KEEP_ALL, reliability=ReliabilityPolicy.RELIABLE)) + jig.attach_default_qos_handlers(sn.subscribers.keep_all_topic) + sn.subscribers.deadline_topic._initialise(sn, String, "deadline_topic", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=20, reliability=ReliabilityPolicy.RELIABLE, deadline=Duration(nanoseconds=1000000000), lifespan=Duration(nanoseconds=500000000))) + jig.attach_default_qos_handlers(sn.subscribers.deadline_topic) + + # initialise services + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.publishers.reliable_topic._destroy(sn.node) + sn.publishers.best_effort_topic._destroy(sn.node) + sn.subscribers.keep_all_topic._destroy(sn.node) + sn.subscribers.deadline_topic._destroy(sn.node) + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _QosCustomNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/qos_custom/expected_python/parameters.py b/jig/tests/fixtures/qos_custom/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/qos_custom/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/qos_custom/expected_python/qos_custom.yaml b/jig/tests/fixtures/qos_custom/expected_python/qos_custom.yaml new file mode 100644 index 0000000..668b19c --- /dev/null +++ b/jig/tests/fixtures/qos_custom/expected_python/qos_custom.yaml @@ -0,0 +1,68 @@ +node: + name: qos_custom + package: test_package +publishers: +- topic: reliable_topic + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE + durability: VOLATILE +- topic: best_effort_topic + type: std_msgs/msg/String + qos: + history: 5 + reliability: BEST_EFFORT + durability: TRANSIENT_LOCAL +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log +subscribers: +- topic: keep_all_topic + type: std_msgs/msg/String + qos: + history: ALL + reliability: RELIABLE +- topic: deadline_topic + type: std_msgs/msg/String + qos: + history: 20 + reliability: RELIABLE + deadline_ms: 1000 + lifespan_ms: 500 +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true +services: +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription diff --git a/jig/tests/fixtures/service_clients_mixed/expected_cpp/service_clients_mixed_interface.params.yaml b/jig/tests/fixtures/service_clients_mixed/expected_cpp/service_clients_mixed_interface.params.yaml index 2d07d72..5309b50 100644 --- a/jig/tests/fixtures/service_clients_mixed/expected_cpp/service_clients_mixed_interface.params.yaml +++ b/jig/tests/fixtures/service_clients_mixed/expected_cpp/service_clients_mixed_interface.params.yaml @@ -1,5 +1,5 @@ test_package::service_clients_mixed: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/service_clients_mixed/expected_python/__init__.py b/jig/tests/fixtures/service_clients_mixed/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/service_clients_mixed/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/service_clients_mixed/expected_python/_parameters.py b/jig/tests/fixtures/service_clients_mixed/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/service_clients_mixed/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/service_clients_mixed/expected_python/interface.py b/jig/tests/fixtures/service_clients_mixed/expected_python/interface.py new file mode 100644 index 0000000..6f92dfa --- /dev/null +++ b/jig/tests/fixtures/service_clients_mixed/expected_python/interface.py @@ -0,0 +1,195 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from rclpy.client import Client +from rclpy.qos import ( + HistoryPolicy, + QoSProfile, + ReliabilityPolicy, +) +from example_interfaces.srv import AddTwoInts +from std_msgs.msg import Bool +from std_msgs.msg import String +from std_srvs.srv import Trigger + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + status: jig.Publisher[String] = field(default_factory=jig.Publisher[String]) + + +@dataclass +class Subscribers: + command: jig.Subscriber[Bool] = field(default_factory=jig.Subscriber[Bool]) + + +@dataclass +class Services: + reset: jig.Service[Trigger, Trigger.Request, Trigger.Response] = field(default_factory=jig.Service[Trigger, Trigger.Request, Trigger.Response]) + + +@dataclass +class ServiceClients: + add_two_ints: Client # srv_type: example_interfaces/srv/AddTwoInts + compute: Client # srv_type: example_interfaces/srv/AddTwoInts + + +@dataclass +class Actions: + pass + + +@dataclass +class ActionClients: + pass + + +@dataclass +class ServiceClientsMixedSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=ServiceClientsMixedSession) + + +class _ServiceClientsMixedNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "service_clients_mixed", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients( + add_two_ints=node.create_client(AddTwoInts, "/add_two_ints"), + compute=node.create_client(AddTwoInts, "compute"), + ) + + # initialise actions + actions = Actions() + + # initialise action clients + action_clients = ActionClients() + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + sn.publishers.status._initialise(sn, String, "/status", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=10, reliability=ReliabilityPolicy.RELIABLE)) + + # initialise subscribers + sn.subscribers.command._initialise(sn, Bool, "/command", QoSProfile(history=HistoryPolicy.KEEP_LAST, depth=5, reliability=ReliabilityPolicy.BEST_EFFORT)) + jig.attach_default_qos_handlers(sn.subscribers.command) + + # initialise services + sn.services.reset._initialise(sn, Trigger, "/reset") + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.publishers.status._destroy(sn.node) + sn.subscribers.command._destroy(sn.node) + sn.services.reset._destroy(sn.node) + sn.node.destroy_client(sn.service_clients.add_two_ints) + sn.node.destroy_client(sn.service_clients.compute) + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _ServiceClientsMixedNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/service_clients_mixed/expected_python/parameters.py b/jig/tests/fixtures/service_clients_mixed/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/service_clients_mixed/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/service_clients_mixed/expected_python/service_clients_mixed.yaml b/jig/tests/fixtures/service_clients_mixed/expected_python/service_clients_mixed.yaml new file mode 100644 index 0000000..5188259 --- /dev/null +++ b/jig/tests/fixtures/service_clients_mixed/expected_python/service_clients_mixed.yaml @@ -0,0 +1,61 @@ +node: + name: service_clients_mixed + package: test_package +publishers: +- topic: /status + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log +subscribers: +- topic: /command + type: std_msgs/msg/Bool + qos: + history: 5 + reliability: BEST_EFFORT +services: +- name: /reset + type: std_srvs/srv/Trigger +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription +service_clients: +- name: /add_two_ints + type: example_interfaces/srv/AddTwoInts +- name: compute + type: example_interfaces/srv/AddTwoInts +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true diff --git a/jig/tests/fixtures/service_clients_only/expected_cpp/service_clients_only_interface.params.yaml b/jig/tests/fixtures/service_clients_only/expected_cpp/service_clients_only_interface.params.yaml index e1c6f05..5f3a003 100644 --- a/jig/tests/fixtures/service_clients_only/expected_cpp/service_clients_only_interface.params.yaml +++ b/jig/tests/fixtures/service_clients_only/expected_cpp/service_clients_only_interface.params.yaml @@ -1,5 +1,5 @@ test_package::service_clients_only: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/service_clients_only/expected_python/_parameters.py b/jig/tests/fixtures/service_clients_only/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/service_clients_only/expected_python/_parameters.py +++ b/jig/tests/fixtures/service_clients_only/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/services_default_qos/expected_cpp/services_default_qos_interface.params.yaml b/jig/tests/fixtures/services_default_qos/expected_cpp/services_default_qos_interface.params.yaml index 7bc73d4..21925d3 100644 --- a/jig/tests/fixtures/services_default_qos/expected_cpp/services_default_qos_interface.params.yaml +++ b/jig/tests/fixtures/services_default_qos/expected_cpp/services_default_qos_interface.params.yaml @@ -1,5 +1,5 @@ test_package::services_default_qos: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/services_default_qos/expected_python/__init__.py b/jig/tests/fixtures/services_default_qos/expected_python/__init__.py new file mode 100644 index 0000000..a135215 --- /dev/null +++ b/jig/tests/fixtures/services_default_qos/expected_python/__init__.py @@ -0,0 +1,6 @@ +# auto-generated DO NOT EDIT + +from . import interface +from . import parameters + +__all__ = ["interface", "parameters"] diff --git a/jig/tests/fixtures/services_default_qos/expected_python/_parameters.py b/jig/tests/fixtures/services_default_qos/expected_python/_parameters.py new file mode 100644 index 0000000..616e215 --- /dev/null +++ b/jig/tests/fixtures/services_default_qos/expected_python/_parameters.py @@ -0,0 +1,124 @@ +# flake8: noqa + +# auto-generated DO NOT EDIT + +from rcl_interfaces.msg import ParameterDescriptor +from rcl_interfaces.msg import SetParametersResult +from rcl_interfaces.msg import FloatingPointRange, IntegerRange +from rclpy.clock import Clock +from rclpy.exceptions import InvalidParameterValueException +from rclpy.time import Time +import copy +import rclpy +import rclpy.parameter +from generate_parameter_library_py.python_validators import ParameterValidators + + + +class parameters: + + class Params: + # for detecting if the parameter struct has been updated + stamp_ = Time() + + _jig_dummy = True + + + + class ParamListener: + def __init__(self, node, prefix=""): + self.prefix_ = prefix + self.params_ = parameters.Params() + self.node_ = node + self.logger_ = rclpy.logging.get_logger("parameters." + prefix) + + self.declare_params() + + self.node_.add_on_set_parameters_callback(self.update) + self.user_callback = None + self.clock_ = Clock() + + def get_params(self): + tmp = self.params_.stamp_ + self.params_.stamp_ = None + paramCopy = copy.deepcopy(self.params_) + paramCopy.stamp_ = tmp + self.params_.stamp_ = tmp + return paramCopy + + def is_old(self, other_param): + return self.params_.stamp_ != other_param.stamp_ + + def unpack_parameter_dict(self, namespace: str, parameter_dict: dict): + """ + Flatten a parameter dictionary recursively. + + :param namespace: The namespace to prepend to the parameter names. + :param parameter_dict: A dictionary of parameters keyed by the parameter names + :return: A list of rclpy Parameter objects + """ + parameters = [] + for param_name, param_value in parameter_dict.items(): + full_param_name = namespace + param_name + # Unroll nested parameters + if isinstance(param_value, dict): + nested_params = self.unpack_parameter_dict( + namespace=full_param_name + rclpy.parameter.PARAMETER_SEPARATOR_STRING, + parameter_dict=param_value) + parameters.extend(nested_params) + else: + parameters.append(rclpy.parameter.Parameter(full_param_name, value=param_value)) + return parameters + + def set_params_from_dict(self, param_dict): + params_to_set = self.unpack_parameter_dict('', param_dict) + self.update(params_to_set) + + def set_user_callback(self, callback): + self.user_callback = callback + + def clear_user_callback(self): + self.user_callback = None + + def refresh_dynamic_parameters(self): + updated_params = self.get_params() + # TODO remove any destroyed dynamic parameters + + # declare any new dynamic parameters + + + def update(self, parameters): + updated_params = self.get_params() + + for param in parameters: + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + + + + updated_params.stamp_ = self.clock_.now() + self.update_internal_params(updated_params) + if self.user_callback: + self.user_callback(self.get_params()) + return SetParametersResult(successful=True) + + def update_internal_params(self, updated_params): + self.params_ = updated_params + + def declare_params(self): + updated_params = self.get_params() + # declare all parameters and give default values to non-required ones + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): + descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) + + # TODO: need validation + # get parameters and fill struct fields + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") + self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) + updated_params._jig_dummy = param.value + + + self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/services_default_qos/expected_python/interface.py b/jig/tests/fixtures/services_default_qos/expected_python/interface.py new file mode 100644 index 0000000..83e6c6a --- /dev/null +++ b/jig/tests/fixtures/services_default_qos/expected_python/interface.py @@ -0,0 +1,179 @@ +# auto-generated DO NOT EDIT + +from __future__ import annotations + +from dataclasses import dataclass, field + +import rclpy +from example_interfaces.srv import AddTwoInts +from std_srvs.srv import Trigger + +import jig + +from typing import Callable, TypeVar + +from .parameters import Params, ParamListener + + +@dataclass +class Publishers: + pass + + +@dataclass +class Subscribers: + pass + + +@dataclass +class Services: + trigger_service: jig.Service[Trigger, Trigger.Request, Trigger.Response] = field(default_factory=jig.Service[Trigger, Trigger.Request, Trigger.Response]) + compute: jig.Service[AddTwoInts, AddTwoInts.Request, AddTwoInts.Response] = field(default_factory=jig.Service[AddTwoInts, AddTwoInts.Request, AddTwoInts.Response]) + + +@dataclass +class ServiceClients: + pass + + +@dataclass +class Actions: + pass + + +@dataclass +class ActionClients: + pass + + +@dataclass +class ServicesDefaultQosSession(jig.Session): + publishers: Publishers + subscribers: Subscribers + services: Services + service_clients: ServiceClients + actions: Actions + action_clients: ActionClients + + param_listener: ParamListener + params: Params + + +T = TypeVar("T", bound=ServicesDefaultQosSession) + + +class _ServicesDefaultQosNode(jig.BaseNode[T]): + + def __init__( + self, + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, + ) -> None: + super().__init__( + "services_default_qos", + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + def _create_session(self, node) -> T: + # init parameters (must be before publishers/subscribers for param refs in names) + param_listener = ParamListener(node) + params = param_listener.get_params() + + # create publishers - using default constructors + publishers = Publishers() + + # create subscribers - using default constructors + subscribers = Subscribers() + + # create services - using default constructors + services = Services() + + # initialise service clients + service_clients = ServiceClients() + + # initialise actions + actions = Actions() + + # initialise action clients + action_clients = ActionClients() + + sn = self._session_type( + node=node, + publishers=publishers, + subscribers=subscribers, + services=services, + service_clients=service_clients, + actions=actions, + action_clients=action_clients, + param_listener=param_listener, + params=params, + ) + + # initialise publishers + + # initialise subscribers + + # initialise services + sn.services.trigger_service._initialise(sn, Trigger, "/trigger_service") + sn.services.compute._initialise(sn, AddTwoInts, "compute") + + return sn + + def _activate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.reset() + + def _deactivate_entities(self, sn: T) -> None: + for timer in sn.timers: + timer.cancel() + + def _destroy_entities(self, sn: T) -> None: + for timer in sn.timers: + sn.node.destroy_timer(timer) + sn.timers.clear() + sn.services.trigger_service._destroy(sn.node) + sn.services.compute._destroy(sn.node) + + +def run( + session_type: type[T], + on_configure: Callable[[T], jig.TransitionCallbackReturn], + *, + on_activate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_deactivate: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_cleanup: Callable[[T], jig.TransitionCallbackReturn] | None = None, + on_shutdown: Callable[[T], None] | None = None, +): + + rclpy.init() + + wrapper = _ServicesDefaultQosNode( + session_type, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + on_shutdown=on_shutdown, + ) + + try: + rclpy.spin(wrapper.node) + except KeyboardInterrupt: + # note rclpy installs signal handlers during rclpy.init() that respond to SIGINT (Ctrl+C) and shutdown the + # context so no logging or anything should be done here. + pass + finally: + wrapper.node.destroy_node() + if rclpy.ok(): + # since the context is _probably_ shutdown already here, we are doing this just to be certain + rclpy.shutdown() diff --git a/jig/tests/fixtures/services_default_qos/expected_python/parameters.py b/jig/tests/fixtures/services_default_qos/expected_python/parameters.py new file mode 100644 index 0000000..8b295e4 --- /dev/null +++ b/jig/tests/fixtures/services_default_qos/expected_python/parameters.py @@ -0,0 +1,9 @@ +# auto-generated DO NOT EDIT + +from ._parameters import parameters + +# Flatten the nested structure for cleaner API +Params = parameters.Params +ParamListener = parameters.ParamListener + +__all__ = ["Params", "ParamListener"] diff --git a/jig/tests/fixtures/services_default_qos/expected_python/services_default_qos.yaml b/jig/tests/fixtures/services_default_qos/expected_python/services_default_qos.yaml new file mode 100644 index 0000000..b270ac0 --- /dev/null +++ b/jig/tests/fixtures/services_default_qos/expected_python/services_default_qos.yaml @@ -0,0 +1,47 @@ +node: + name: services_default_qos + package: test_package +services: +- name: /trigger_service + type: std_srvs/srv/Trigger +- name: compute + type: example_interfaces/srv/AddTwoInts +- name: ~/change_state + type: lifecycle_msgs/srv/ChangeState +- name: ~/get_state + type: lifecycle_msgs/srv/GetState +- name: ~/get_available_states + type: lifecycle_msgs/srv/GetAvailableStates +- name: ~/get_available_transitions + type: lifecycle_msgs/srv/GetAvailableTransitions +- name: ~/get_transition_graph + type: lifecycle_msgs/srv/GetTransitionGraph +- name: ~/describe_parameters + type: rcl_interfaces/srv/DescribeParameters +- name: ~/get_parameters + type: rcl_interfaces/srv/GetParameters +- name: ~/get_parameter_types + type: rcl_interfaces/srv/GetParameterTypes +- name: ~/list_parameters + type: rcl_interfaces/srv/ListParameters +- name: ~/set_parameters + type: rcl_interfaces/srv/SetParameters +- name: ~/set_parameters_atomically + type: rcl_interfaces/srv/SetParametersAtomically +- name: ~/get_type_description + type: type_description_interfaces/srv/GetTypeDescription +parameters: + autostart: + type: bool + default_value: true + description: Automatically configure and activate the node on startup + read_only: true +publishers: +- topic: ~/transition_event + type: lifecycle_msgs/msg/TransitionEvent +- topic: ~/state + type: lifecycle_msgs/msg/State +- topic: /parameter_events + type: rcl_interfaces/msg/ParameterEvent +- topic: /rosout + type: rcl_interfaces/msg/Log diff --git a/jig/tests/fixtures/services_only/expected_cpp/services_only_interface.params.yaml b/jig/tests/fixtures/services_only/expected_cpp/services_only_interface.params.yaml index 6708a57..84d699f 100644 --- a/jig/tests/fixtures/services_only/expected_cpp/services_only_interface.params.yaml +++ b/jig/tests/fixtures/services_only/expected_cpp/services_only_interface.params.yaml @@ -1,5 +1,5 @@ test_package::services_only: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/services_only/expected_python/_parameters.py b/jig/tests/fixtures/services_only/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/services_only/expected_python/_parameters.py +++ b/jig/tests/fixtures/services_only/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/services_with_pubsub/expected_cpp/services_with_pubsub_interface.params.yaml b/jig/tests/fixtures/services_with_pubsub/expected_cpp/services_with_pubsub_interface.params.yaml index 5ec8ab0..555d9fe 100644 --- a/jig/tests/fixtures/services_with_pubsub/expected_cpp/services_with_pubsub_interface.params.yaml +++ b/jig/tests/fixtures/services_with_pubsub/expected_cpp/services_with_pubsub_interface.params.yaml @@ -1,5 +1,5 @@ test_package::services_with_pubsub: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/services_with_pubsub/expected_python/_parameters.py b/jig/tests/fixtures/services_with_pubsub/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/services_with_pubsub/expected_python/_parameters.py +++ b/jig/tests/fixtures/services_with_pubsub/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/simple_node/expected_cpp/simple_node_interface.params.yaml b/jig/tests/fixtures/simple_node/expected_cpp/simple_node_interface.params.yaml index f7299ae..3b439ab 100644 --- a/jig/tests/fixtures/simple_node/expected_cpp/simple_node_interface.params.yaml +++ b/jig/tests/fixtures/simple_node/expected_cpp/simple_node_interface.params.yaml @@ -1,5 +1,5 @@ test_package::simple_node: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/simple_node/expected_python/_parameters.py b/jig/tests/fixtures/simple_node/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/simple_node/expected_python/_parameters.py +++ b/jig/tests/fixtures/simple_node/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/fixtures/subscribers_only/expected_cpp/subscribers_only_interface.params.yaml b/jig/tests/fixtures/subscribers_only/expected_cpp/subscribers_only_interface.params.yaml index ff9657a..57e62b8 100644 --- a/jig/tests/fixtures/subscribers_only/expected_cpp/subscribers_only_interface.params.yaml +++ b/jig/tests/fixtures/subscribers_only/expected_cpp/subscribers_only_interface.params.yaml @@ -1,5 +1,5 @@ test_package::subscribers_only: - __jig_dummy: + _jig_dummy: type: bool default_value: true description: Dummy parameter (jig generates this when no parameters are defined) diff --git a/jig/tests/fixtures/subscribers_only/expected_python/_parameters.py b/jig/tests/fixtures/subscribers_only/expected_python/_parameters.py index 5806fd1..616e215 100644 --- a/jig/tests/fixtures/subscribers_only/expected_python/_parameters.py +++ b/jig/tests/fixtures/subscribers_only/expected_python/_parameters.py @@ -21,7 +21,7 @@ class Params: # for detecting if the parameter struct has been updated stamp_ = Time() - __jig_dummy = True + _jig_dummy = True @@ -91,8 +91,8 @@ def update(self, parameters): updated_params = self.get_params() for param in parameters: - if param.name == self.prefix_ + "__jig_dummy": - updated_params.__jig_dummy = param.value + if param.name == self.prefix_ + "_jig_dummy": + updated_params._jig_dummy = param.value self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) @@ -109,16 +109,16 @@ def update_internal_params(self, updated_params): def declare_params(self): updated_params = self.get_params() # declare all parameters and give default values to non-required ones - if not self.node_.has_parameter(self.prefix_ + "__jig_dummy"): + if not self.node_.has_parameter(self.prefix_ + "_jig_dummy"): descriptor = ParameterDescriptor(description="Dummy parameter (jig generates this when no parameters are defined)", read_only = True) - parameter = updated_params.__jig_dummy - self.node_.declare_parameter(self.prefix_ + "__jig_dummy", parameter, descriptor) + parameter = updated_params._jig_dummy + self.node_.declare_parameter(self.prefix_ + "_jig_dummy", parameter, descriptor) # TODO: need validation # get parameters and fill struct fields - param = self.node_.get_parameter(self.prefix_ + "__jig_dummy") + param = self.node_.get_parameter(self.prefix_ + "_jig_dummy") self.logger_.debug(param.name + ": " + param.type_.name + " = " + str(param.value)) - updated_params.__jig_dummy = param.value + updated_params._jig_dummy = param.value self.update_internal_params(updated_params) diff --git a/jig/tests/test_generate_node_interface.py b/jig/tests/test_generate_node_interface.py index d4e3e16..b885f2e 100755 --- a/jig/tests/test_generate_node_interface.py +++ b/jig/tests/test_generate_node_interface.py @@ -459,7 +459,7 @@ def test_no_parameters_generates_dummy(tmp_path): # Should have namespace and dummy parameter assert "test_package::test_node:" in params_content - assert "__jig_dummy:" in params_content + assert "_jig_dummy:" in params_content def get_python_test_cases(): diff --git a/jig_example/CMakeLists.txt b/jig_example/CMakeLists.txt index 3ca8d42..fcf3365 100644 --- a/jig_example/CMakeLists.txt +++ b/jig_example/CMakeLists.txt @@ -9,3 +9,21 @@ find_package(jig REQUIRED) # Automatically handle all package setup jig_auto_package() + +if(BUILD_TESTING) + find_package(launch_testing_ament_cmake REQUIRED) + + # APPEND_ENV adds the test/ dir to PYTHONPATH so launch tests can import helpers.py. This is needed because + # add_launch_test runs from the build dir, not the source dir. + set(_test_helpers "PYTHONPATH=${CMAKE_CURRENT_SOURCE_DIR}/test") + + add_launch_test(test/test_lifecycle.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_pub_sub.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_services.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_actions.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_timers.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_parameters.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_cross_language.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_for_each_param.py APPEND_ENV ${_test_helpers}) + add_launch_test(test/test_qos_handlers.py APPEND_ENV ${_test_helpers}) +endif() diff --git a/jig_example/interfaces/external_node.yaml b/jig_example/interfaces/external_node.yaml deleted file mode 100644 index a570753..0000000 --- a/jig_example/interfaces/external_node.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# An example of documenting the interface of an external node that you reference in a launch file from this package, -# that isn't documented in your build tree -node: - name: external_node - package: external_package - -parameters: - custom_param: - type: string - default_value: "custom value" - description: "A custom parameter" - -publishers: - - topic: custom_topic - type: std_msgs/msg/String - qos: - history: 10 - reliability: RELIABLE diff --git a/jig_example/interfaces/transition_node.yaml b/jig_example/interfaces/transition_node.yaml deleted file mode 100644 index c9a9706..0000000 --- a/jig_example/interfaces/transition_node.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# An example of documenting the interface of a node implemented without jig (this allows static ros graph detection -# without rewriting the node) -node: - name: transition_node - package: jig_example - -parameters: - custom_param: - type: string - default_value: "custom value" - description: "A custom parameter" - -publishers: - - topic: custom_topic - type: std_msgs/msg/String - qos: - history: 10 - reliability: RELIABLE diff --git a/jig_example/launch/test.launch.py b/jig_example/launch/test.launch.py deleted file mode 100644 index 99c53a4..0000000 --- a/jig_example/launch/test.launch.py +++ /dev/null @@ -1,11 +0,0 @@ -import clingwrap as cw - - -def generate_launch_description(): - l = cw.LaunchBuilder() - - with l.namespace("cleaning"): - l.node("jig_example", "my_node") - l.node("jig_example", "python_node") - - return l diff --git a/jig_example/launch/test.launch.yaml b/jig_example/launch/test.launch.yaml new file mode 100644 index 0000000..453f552 --- /dev/null +++ b/jig_example/launch/test.launch.yaml @@ -0,0 +1,23 @@ +launch: + - node: + pkg: jig_example + exec: echo_node + + - node: + pkg: jig_example + exec: action_node + + - node: + pkg: jig_example + exec: py_echo_node + + - node: + pkg: jig_example + exec: lifecycle_node + param: + - name: autostart + value: false + + - node: + pkg: jig_example + exec: for_each_node diff --git a/jig_example/nodes/action_node/action_node.cpp b/jig_example/nodes/action_node/action_node.cpp new file mode 100644 index 0000000..9175aaf --- /dev/null +++ b/jig_example/nodes/action_node/action_node.cpp @@ -0,0 +1,67 @@ +#include "action_node.hpp" + +#include +#include +#include + +#include "example_interfaces/action/fibonacci.hpp" + +#include + +using namespace std::chrono_literals; +using Fibonacci = example_interfaces::action::Fibonacci; + +namespace jig_example::action_node { + +void process_goal( + std::shared_ptr> action_server, std::shared_ptr /*sn*/ +) { + auto goal = action_server->get_active_goal(); + if (!goal) { + return; + } + + // Compute full Fibonacci sequence + std::vector sequence; + sequence.push_back(0); + if (goal->order > 0) { + sequence.push_back(1); + } + for (int32_t i = 2; i < goal->order; ++i) { + sequence.push_back(sequence[i - 1] + sequence[i - 2]); + } + + // Publish feedback with partial sequence + auto feedback = std::make_shared(); + feedback->sequence = sequence; + action_server->publish_feedback(feedback); + + // Succeed with final result + auto result = std::make_shared(); + result->sequence = sequence; + action_server->succeed(result); +} + +CallbackReturn on_configure(std::shared_ptr sn) { + // compute: single goal, rejects invalid orders, no replacement + sn->actions.compute->set_options({ + .new_goals_replace_current_goal = false, + .goal_validator = [](const Fibonacci::Goal &goal) -> bool { return goal.order >= 0; }, + }); + + // compute_replace: allows goal replacement + sn->actions.compute_replace->set_options({ + .new_goals_replace_current_goal = true, + .goal_validator = [](const Fibonacci::Goal &goal) -> bool { return goal.order >= 0; }, + }); + + // Timer to process active goals every 100ms + jig::create_timer(sn, 100ms, [](std::shared_ptr sn) { + process_goal(sn->actions.compute, sn); + process_goal(sn->actions.compute_replace, sn); + }); + + return CallbackReturn::SUCCESS; +} + +} // namespace jig_example::action_node diff --git a/jig_example/nodes/action_node/action_node.hpp b/jig_example/nodes/action_node/action_node.hpp new file mode 100644 index 0000000..4daf2d7 --- /dev/null +++ b/jig_example/nodes/action_node/action_node.hpp @@ -0,0 +1,15 @@ +#include + +#include + +namespace jig_example::action_node { + +struct Session : ActionNodeSession { + using ActionNodeSession::ActionNodeSession; +}; + +CallbackReturn on_configure(std::shared_ptr sn); + +using ActionNode = ActionNodeBase; + +} // namespace jig_example::action_node diff --git a/jig_example/nodes/action_node/interface.yaml b/jig_example/nodes/action_node/interface.yaml new file mode 100644 index 0000000..21aaa79 --- /dev/null +++ b/jig_example/nodes/action_node/interface.yaml @@ -0,0 +1,9 @@ +actions: + - name: compute + type: example_interfaces/action/Fibonacci + - name: compute_replace + type: example_interfaces/action/Fibonacci + +action_clients: + - name: compute + type: example_interfaces/action/Fibonacci diff --git a/jig_example/nodes/echo_node/echo_node.cpp b/jig_example/nodes/echo_node/echo_node.cpp new file mode 100644 index 0000000..8715012 --- /dev/null +++ b/jig_example/nodes/echo_node/echo_node.cpp @@ -0,0 +1,61 @@ +#include "echo_node.hpp" + +#include +#include +#include + +#include "example_interfaces/srv/add_two_ints.hpp" +#include "example_interfaces/srv/trigger.hpp" +#include "std_msgs/msg/string.hpp" + +#include + +using namespace std::chrono_literals; + +namespace jig_example::echo_node { + +void input_callback(std::shared_ptr sn, std_msgs::msg::String::ConstSharedPtr msg) { + auto out = std_msgs::msg::String(); + out.data = sn->params.message_prefix + ": " + msg->data; + sn->publishers.output->publish(out); + + auto prefixed = std_msgs::msg::String(); + prefixed.data = out.data; + sn->publishers.prefixed_output->publish(prefixed); +} + +void add_two_ints_handler( + std::shared_ptr /*sn*/, + example_interfaces::srv::AddTwoInts::Request::SharedPtr request, + example_interfaces::srv::AddTwoInts::Response::SharedPtr response +) { + response->sum = request->a + request->b; +} + +void get_counter_handler( + std::shared_ptr sn, + example_interfaces::srv::Trigger::Request::SharedPtr /*request*/, + example_interfaces::srv::Trigger::Response::SharedPtr response +) { + response->success = true; + response->message = sn->params.message_prefix + ": counter=" + std::to_string(sn->counter); +} + +void timer_callback(std::shared_ptr sn) { + sn->counter++; + auto msg = std_msgs::msg::String(); + msg.data = sn->params.message_prefix + ": " + std::to_string(sn->counter); + sn->publishers.output->publish(msg); +} + +CallbackReturn on_configure(std::shared_ptr sn) { + sn->subscribers.input->set_callback(input_callback); + sn->services.add_two_ints->set_request_handler(add_two_ints_handler); + sn->services.get_counter->set_request_handler(get_counter_handler); + + jig::create_timer(sn, std::chrono::milliseconds(sn->params.publish_rate_ms), timer_callback); + + return CallbackReturn::SUCCESS; +} + +} // namespace jig_example::echo_node diff --git a/jig_example/nodes/echo_node/echo_node.hpp b/jig_example/nodes/echo_node/echo_node.hpp new file mode 100644 index 0000000..cda16af --- /dev/null +++ b/jig_example/nodes/echo_node/echo_node.hpp @@ -0,0 +1,16 @@ +#include + +#include + +namespace jig_example::echo_node { + +struct Session : EchoNodeSession { + using EchoNodeSession::EchoNodeSession; + int counter = 0; +}; + +CallbackReturn on_configure(std::shared_ptr sn); + +using EchoNode = EchoNodeBase; + +} // namespace jig_example::echo_node diff --git a/jig_example/nodes/echo_node/interface.yaml b/jig_example/nodes/echo_node/interface.yaml new file mode 100644 index 0000000..24c78f0 --- /dev/null +++ b/jig_example/nodes/echo_node/interface.yaml @@ -0,0 +1,46 @@ +parameters: + message_prefix: + type: string + default_value: "echo" + description: "Prefix prepended to all echoed messages" + read_only: true + publish_rate_ms: + type: int + default_value: 100 + description: "Timer publish rate in milliseconds" + read_only: true + queue_depth: + type: int + default_value: 10 + description: "Queue depth for publishers/subscribers" + read_only: true + +publishers: + - topic: output + type: std_msgs/msg/String + qos: + history: ${param:queue_depth} + reliability: RELIABLE + - topic: "${param:message_prefix}/prefixed_output" + field_name: prefixed_output + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE + +subscribers: + - topic: input + type: std_msgs/msg/String + qos: + history: ${param:queue_depth} + reliability: RELIABLE + +services: + - name: add_two_ints + type: example_interfaces/srv/AddTwoInts + - name: get_counter + type: example_interfaces/srv/Trigger + +service_clients: + - name: remote_trigger + type: example_interfaces/srv/Trigger diff --git a/jig_example/nodes/for_each_node/for_each_node.py b/jig_example/nodes/for_each_node/for_each_node.py new file mode 100644 index 0000000..8b09ec4 --- /dev/null +++ b/jig_example/nodes/for_each_node/for_each_node.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field + +from jig_example.for_each_node.interface import ForEachNodeSession, run + +from std_msgs.msg import String + +from jig import TransitionCallbackReturn + + +@dataclass +class MySession(ForEachNodeSession): + latest_status: dict = field(default_factory=dict) + + +def make_status_callback(target_name: str): + def status_callback(sn: MySession, msg: String): + sn.latest_status[target_name] = msg.data + # Aggregate and publish + parts = [f"{k}={v}" for k, v in sorted(sn.latest_status.items())] + out = String() + out.data = "; ".join(parts) + sn.publishers.aggregated_status.publish(out) + + return status_callback + + +def on_configure(sn: MySession) -> TransitionCallbackReturn: + for name, sub in sn.subscribers.target_status.items(): + sub.set_callback(make_status_callback(name)) + return TransitionCallbackReturn.SUCCESS + + +if __name__ == "__main__": + run(MySession, on_configure) diff --git a/jig_example/nodes/for_each_node/interface.yaml b/jig_example/nodes/for_each_node/interface.yaml new file mode 100644 index 0000000..2ea96be --- /dev/null +++ b/jig_example/nodes/for_each_node/interface.yaml @@ -0,0 +1,27 @@ +parameters: + target_nodes: + type: string_array + default_value: ["alpha", "beta"] + description: "List of target node names to subscribe to" + read_only: true + robot_id: + type: string + default_value: "bot1" + description: "Robot identifier used in publisher topic name" + read_only: true + +subscribers: + - topic: "${for_each_param:target_nodes}/status" + field_name: target_status + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE + +publishers: + - topic: "${param:robot_id}/aggregated_status" + field_name: aggregated_status + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE diff --git a/jig_example/nodes/lifecycle_node/interface.yaml b/jig_example/nodes/lifecycle_node/interface.yaml new file mode 100644 index 0000000..ec882c2 --- /dev/null +++ b/jig_example/nodes/lifecycle_node/interface.yaml @@ -0,0 +1,17 @@ +subscribers: + - topic: heartbeat + type: std_msgs/msg/Bool + qos: + history: 1 + reliability: RELIABLE + deadline_ms: 500 + liveliness: AUTOMATIC + lease_duration_ms: 500 + +publishers: + - topic: state_report + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE + durability: TRANSIENT_LOCAL diff --git a/jig_example/nodes/lifecycle_node/lifecycle_node.py b/jig_example/nodes/lifecycle_node/lifecycle_node.py new file mode 100644 index 0000000..9c87eb5 --- /dev/null +++ b/jig_example/nodes/lifecycle_node/lifecycle_node.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass + +from jig_example.lifecycle_node.interface import LifecycleNodeSession, run + +from std_msgs.msg import Bool, String + +from jig import TransitionCallbackReturn + + +@dataclass +class MySession(LifecycleNodeSession): + pass + + +def heartbeat_callback(sn: MySession, msg: Bool): + sn.logger.info(f"Heartbeat received: {msg.data}") + + +def on_configure(sn: MySession) -> TransitionCallbackReturn: + sn.subscribers.heartbeat.set_callback(heartbeat_callback) + sn.publishers.state_report.publish(String(data="configured")) + return TransitionCallbackReturn.SUCCESS + + +def on_activate(sn: MySession) -> TransitionCallbackReturn: + sn.publishers.state_report.publish(String(data="active")) + return TransitionCallbackReturn.SUCCESS + + +def on_deactivate(sn: MySession) -> TransitionCallbackReturn: + sn.publishers.state_report.publish(String(data="inactive")) + return TransitionCallbackReturn.SUCCESS + + +def on_cleanup(sn: MySession) -> TransitionCallbackReturn: + sn.publishers.state_report.publish(String(data="unconfigured")) + return TransitionCallbackReturn.SUCCESS + + +if __name__ == "__main__": + run( + MySession, + on_configure, + on_activate=on_activate, + on_deactivate=on_deactivate, + on_cleanup=on_cleanup, + ) diff --git a/jig_example/nodes/my_node/interface.yaml b/jig_example/nodes/my_node/interface.yaml deleted file mode 100644 index 4be38ae..0000000 --- a/jig_example/nodes/my_node/interface.yaml +++ /dev/null @@ -1,83 +0,0 @@ -parameters: - spicy_param: - type: string - default_value: "oh hi mark" - description: "A real spicy one" - pub_queue_depth: - type: int - default_value: 10 - read_only: true - description: "Queue depth for publisher" - sub_reliability: - type: string - default_value: "BEST_EFFORT" - read_only: true - description: "Reliability policy for subscriber" - robot_name: - type: string - default_value: "robot_1" - description: "A parameter used in topic a topic name" - read_only: true - -publishers: - - topic: some_topic - type: std_msgs/msg/String - qos: - history: ${param:pub_queue_depth} - reliability: RELIABLE - - topic: "${param: robot_name}/test_pub" - field_name: robot_name_test_pub - type: std_msgs/msg/String - qos: - history: 5 - reliability: RELIABLE - - topic: heartbeat - type: std_msgs/msg/Bool - qos: - history: 1 - reliability: RELIABLE - deadline_ms: 1000 - liveliness: AUTOMATIC - lease_duration_ms: 1000 - - -subscribers: - - topic: other_topic - type: std_msgs/msg/Bool - qos: - history: 5 - reliability: ${param:sub_reliability} - - topic: "/abs_sub/${param: robot_name}/test" - field_name: robot_name_test_sub - type: std_msgs/msg/Bool - qos: - history: 1 - reliability: BEST_EFFORT - -services: - - name: my_service - type: example_interfaces/srv/AddTwoInts - - name: bing_bong - type: example_interfaces/srv/Trigger - - name: "robot_service/${param: robot_name}" - field_name: robot_service - type: example_interfaces/srv/Trigger - -service_clients: - - name: bing_bong - type: example_interfaces/srv/Trigger - - name: my_other_service - type: example_interfaces/srv/AddTwoInts - -actions: - - name: my_action - type: example_interfaces/action/Fibonacci - - name: "/robot_action/${param: robot_name}" - field_name: robot_action - type: example_interfaces/action/Fibonacci - -action_clients: - - name: my_action - type: example_interfaces/action/Fibonacci - - name: my_action_two - type: example_interfaces/action/Fibonacci diff --git a/jig_example/nodes/my_node/my_node.cpp b/jig_example/nodes/my_node/my_node.cpp deleted file mode 100644 index 28769c8..0000000 --- a/jig_example/nodes/my_node/my_node.cpp +++ /dev/null @@ -1,139 +0,0 @@ -#include "my_node.hpp" - -#include -#include - -#include "example_interfaces/action/fibonacci.hpp" -#include "example_interfaces/srv/add_two_ints.hpp" -#include "example_interfaces/srv/trigger.hpp" -#include "std_msgs/msg/bool.hpp" -#include "std_msgs/msg/string.hpp" - -#include -#include - -using namespace std::chrono_literals; - -namespace jig_example::my_node { - -void msg_callback(std::shared_ptr sn, std_msgs::msg::Bool::ConstSharedPtr msg) { - RCLCPP_INFO( - sn->node.get_logger(), - "Got a bool: {%d}. BTW the very important number is: {%d}", - msg->data, - sn->very_important_number - ); - - sn->very_important_number++; -} - -void addition_request_handler( - std::shared_ptr sn, - example_interfaces::srv::AddTwoInts::Request::SharedPtr request, - example_interfaces::srv::AddTwoInts::Response::SharedPtr response -) { - response->sum = request->a + request->b; - RCLCPP_INFO( - sn->node.get_logger(), - "Incoming request: a=%ld, b=%ld. Responding with sum=%ld", - request->a, - request->b, - response->sum - ); -} - -void bing_bong_request_handler( - std::shared_ptr sn, - example_interfaces::srv::Trigger::Request::SharedPtr /*request*/, - example_interfaces::srv::Trigger::Response::SharedPtr response -) { - response->success = true; - response->message = "bing bong!"; - RCLCPP_INFO(sn->node.get_logger(), "Bing bong requested!"); -} - -void action_callback(std::shared_ptr sn) { - auto active_goal = sn->actions.my_action->get_active_goal(); - if (!active_goal) { - sn->count = 0; - } - - if (active_goal) { - RCLCPP_INFO(sn->node.get_logger(), "Got goal! Order: %d", active_goal->order); - sn->count++; - } - - if (sn->count > 10) { - auto result = std::make_shared(); - result->sequence.push_back(1); - result->sequence.push_back(2); - result->sequence.push_back(3); - result->sequence.push_back(4); - sn->actions.my_action->succeed(result); - } -} - -CallbackReturn on_configure(std::shared_ptr sn) { - RCLCPP_INFO(sn->node.get_logger(), "Hello from the test range! This is **my_node**."); - RCLCPP_INFO(sn->node.get_logger(), "spicy_param value: %s", sn->params.spicy_param.c_str()); - - auto msg = std_msgs::msg::String(); - msg.data = sn->params.spicy_param; - sn->publishers.some_topic->publish(msg); - - sn->subscribers.other_topic->set_callback(msg_callback); - sn->services.my_service->set_request_handler(addition_request_handler); - sn->services.bing_bong->set_request_handler(bing_bong_request_handler); - - jig::create_timer(sn, 1000ms, [](std::shared_ptr sn) { - sn->service_clients.bing_bong->async_send_request(std::make_shared() - ); - }); - - sn->actions.my_action->set_options({.new_goals_replace_current_goal = true}); // accept defaults - jig::create_timer(sn, 1000ms, action_callback); - - // Publish heartbeat every 200ms — python_node subscribes with a 1s deadline, - // so if this node deactivates the subscriber will miss its deadline and also deactivate. - jig::create_timer(sn, 200ms, [](std::shared_ptr sn) { - auto msg = std_msgs::msg::Bool(); - msg.data = true; - sn->publishers.heartbeat->publish(msg); - }); - - // Sync service call — calls python_node's my_other_service from on_configure without - // deadlocking, because jig puts service client responses on an isolated background executor. - if (sn->service_clients.my_other_service->wait_for_service(2s)) { - auto req = std::make_shared(); - req->a = 3; - req->b = 4; - auto resp = jig::call_sync(sn->service_clients.my_other_service, req, 5s); - if (resp) { - RCLCPP_INFO(sn->node.get_logger(), "Sync service call: 3 + 4 = %ld", resp->sum); - } else { - RCLCPP_WARN(sn->node.get_logger(), "Sync service call timed out"); - } - } else { - RCLCPP_INFO(sn->node.get_logger(), "my_other_service not available yet, skipping sync call"); - } - - // Sync action goal — calls python_node's my_action_two. send_goal_sync blocks until the - // goal is accepted (or times out), which is safe from on_configure for the same reason. - if (sn->action_clients.my_action_two->wait_for_action_server(2s)) { - example_interfaces::action::Fibonacci::Goal goal; - goal.order = 5; - auto goal_handle = - jig::send_goal_sync(sn->action_clients.my_action_two, goal, {}, 5s); - if (goal_handle) { - RCLCPP_INFO(sn->node.get_logger(), "Sync action goal accepted"); - } else { - RCLCPP_WARN(sn->node.get_logger(), "Sync action goal rejected or timed out"); - } - } else { - RCLCPP_INFO(sn->node.get_logger(), "my_action_two not available yet, skipping sync goal"); - } - - return CallbackReturn::SUCCESS; -} - -} // namespace jig_example::my_node diff --git a/jig_example/nodes/my_node/my_node.hpp b/jig_example/nodes/my_node/my_node.hpp deleted file mode 100644 index 9dc9808..0000000 --- a/jig_example/nodes/my_node/my_node.hpp +++ /dev/null @@ -1,18 +0,0 @@ -#include - -#include - -namespace jig_example::my_node { - -struct Session : MyNodeSession { - using MyNodeSession::MyNodeSession; // required — inherited constructors aren't transitive in C++ - int very_important_number = 5; - int count = 0; -}; - -CallbackReturn on_configure(std::shared_ptr sn); - -// IMPORTANT - this _must_ match the node name. Jig expects the node to be defined at pkg_name::node_name::NodeName -using MyNode = MyNodeBase; - -} // namespace jig_example::my_node diff --git a/jig_example/nodes/py_echo_node/interface.yaml b/jig_example/nodes/py_echo_node/interface.yaml new file mode 100644 index 0000000..ad3edbb --- /dev/null +++ b/jig_example/nodes/py_echo_node/interface.yaml @@ -0,0 +1,31 @@ +parameters: + message_prefix: + type: string + default_value: "py" + description: "Prefix prepended to all echoed messages" + read_only: true + publish_rate_sec: + type: double + default_value: 0.1 + description: "Timer publish rate in seconds" + read_only: true + +publishers: + - topic: output + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE + +subscribers: + - topic: input + type: std_msgs/msg/String + qos: + history: 10 + reliability: RELIABLE + +services: + - name: add_two_ints + type: example_interfaces/srv/AddTwoInts + - name: get_counter + type: example_interfaces/srv/Trigger diff --git a/jig_example/nodes/py_echo_node/py_echo_node.py b/jig_example/nodes/py_echo_node/py_echo_node.py new file mode 100644 index 0000000..856f898 --- /dev/null +++ b/jig_example/nodes/py_echo_node/py_echo_node.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass + +from jig_example.py_echo_node.interface import PyEchoNodeSession, run + +from std_msgs.msg import String + +from example_interfaces.srv import AddTwoInts, Trigger + +import jig +from jig import TransitionCallbackReturn + + +@dataclass +class MySession(PyEchoNodeSession): + counter: int = 0 + + +def input_callback(sn: MySession, msg: String): + out = String() + out.data = f"{sn.params.message_prefix}: {msg.data}" + sn.publishers.output.publish(out) + + +def add_two_ints_handler( + sn: MySession, request: AddTwoInts.Request, response: AddTwoInts.Response +) -> AddTwoInts.Response: + response.sum = request.a + request.b + return response + + +def get_counter_handler(sn: MySession, request: Trigger.Request, response: Trigger.Response) -> Trigger.Response: + response.success = True + response.message = f"{sn.params.message_prefix}: counter={sn.counter}" + return response + + +def timer_callback(sn: MySession): + sn.counter += 1 + msg = String() + msg.data = f"{sn.params.message_prefix}: {sn.counter}" + sn.publishers.output.publish(msg) + + +def on_configure(sn: MySession) -> TransitionCallbackReturn: + sn.subscribers.input.set_callback(input_callback) + sn.services.add_two_ints.set_request_handler(add_two_ints_handler) + sn.services.get_counter.set_request_handler(get_counter_handler) + jig.create_timer(sn, sn.params.publish_rate_sec, timer_callback) + return TransitionCallbackReturn.SUCCESS + + +if __name__ == "__main__": + run(MySession, on_configure) diff --git a/jig_example/nodes/python_node/interface.yaml b/jig_example/nodes/python_node/interface.yaml deleted file mode 100644 index b4fe25b..0000000 --- a/jig_example/nodes/python_node/interface.yaml +++ /dev/null @@ -1,55 +0,0 @@ -parameters: - special_number: - type: int - default_value: 420 - description: "42 * 10" - topic_queue_depth: - type: int - default_value: 10 - read_only: true - description: "Queue depth for pub/sub" - topic_reliability: - type: string - default_value: "RELIABLE" - read_only: true - description: "Reliability policy for pub/sub" - -publishers: - - topic: a_topic - type: std_msgs/msg/String - qos: - history: ${param:topic_queue_depth} - reliability: ${param:topic_reliability} - -subscribers: - - topic: another_topic - type: std_msgs/msg/Bool - qos: - history: ${param:topic_queue_depth} - reliability: BEST_EFFORT - - topic: heartbeat - type: std_msgs/msg/Bool - qos: - history: 1 - reliability: RELIABLE - deadline_ms: 1000 - liveliness: AUTOMATIC - lease_duration_ms: 1000 - -services: - - name: my_other_service - type: example_interfaces/srv/AddTwoInts - - name: bing_bong - type: example_interfaces/srv/Trigger - -service_clients: - - name: bing_bong_the_second - type: example_interfaces/srv/Trigger - -actions: - - name: my_action_two - type: example_interfaces/action/Fibonacci - -action_clients: - - name: my_action_two - type: example_interfaces/action/Fibonacci diff --git a/jig_example/nodes/python_node/python_node.py b/jig_example/nodes/python_node/python_node.py deleted file mode 100644 index b74fe54..0000000 --- a/jig_example/nodes/python_node/python_node.py +++ /dev/null @@ -1,60 +0,0 @@ -from dataclasses import dataclass - -from jig_example.python_node.interface import PythonNodeSession, run - -from std_msgs.msg import Bool, String - -from example_interfaces.action import Fibonacci - -import jig -from jig import TransitionCallbackReturn - -from typing import cast - - -@dataclass -class MySession(PythonNodeSession): - important_number: float = 6.7 - count: int = 0 - - -def topic_callback(sn: MySession, msg: Bool): - response = f"Got message: {msg.data}" - sn.logger.info(response) - sn.publishers.a_topic.publish(String(data=response)) - - -def action_func(sn: MySession): - sn.logger.info("Checking for action!") - active_goal = sn.actions.my_action_two.get_active_goal() - if active_goal is None: - sn.count = 0 - - if active_goal is not None: - active_goal = cast(Fibonacci.Goal, active_goal) - sn.logger.info(f"Got goal: {active_goal.order}") - sn.count += 1 - - if sn.count > 10: - result = Fibonacci.Result(sequence=[1, 2, 3, 4]) - sn.actions.my_action_two.succeed(result) - - -def heartbeat_callback(sn: MySession, msg: Bool): - sn.logger.info("Heartbeat received") - - -def on_configure(sn: MySession) -> TransitionCallbackReturn: - sn.logger.info("Hello from python jig!") - sn.logger.info(f"The parameter is: {sn.params.special_number}. The session value is: {sn.important_number}") - sn.subscribers.another_topic.set_callback(topic_callback) - # Subscribes to heartbeat with a 1s deadline — if my_node stops publishing, - # the default QoS handler will deactivate this node. - sn.subscribers.heartbeat.set_callback(heartbeat_callback) - sn.actions.my_action_two.set_options(jig.SingleGoalActionServerOptions(new_goals_replace_current_goal=True)) - jig.create_timer(sn, 1, action_func) - return TransitionCallbackReturn.SUCCESS - - -if __name__ == "__main__": - run(MySession, on_configure) diff --git a/jig_example/package.xml b/jig_example/package.xml index 2844461..a2cdbff 100644 --- a/jig_example/package.xml +++ b/jig_example/package.xml @@ -14,8 +14,11 @@ std_msgs example_interfaces - - clingwrap + launch_testing + launch_testing_ament_cmake + launch_ros + lifecycle_msgs + rcl_interfaces ament_cmake diff --git a/jig_example/test/helpers.py b/jig_example/test/helpers.py new file mode 100644 index 0000000..735e4f2 --- /dev/null +++ b/jig_example/test/helpers.py @@ -0,0 +1,156 @@ +"""Shared test utilities for jig_example launch tests.""" + +import rclpy +from rclpy.node import Node +from rclpy.qos import DurabilityPolicy, HistoryPolicy, QoSProfile, ReliabilityPolicy + +from lifecycle_msgs.msg import State, Transition + +from lifecycle_msgs.srv import ChangeState, GetState + +TIMEOUT = 10.0 + + +def wait_for_node_state(node: Node, name: str, target_state: int, timeout: float = TIMEOUT) -> bool: + """Wait until a lifecycle node reaches the target state by polling ~/get_state.""" + client = node.create_client(GetState, f"{name}/get_state") + if not client.wait_for_service(timeout_sec=timeout): + node.destroy_client(client) + return False + + end_time = node.get_clock().now() + rclpy.duration.Duration(seconds=timeout) + while node.get_clock().now() < end_time: + future = client.call_async(GetState.Request()) + rclpy.spin_until_future_complete(node, future, timeout_sec=2.0) + if future.result() is not None and future.result().current_state.id == target_state: + node.destroy_client(client) + return True + rclpy.spin_once(node, timeout_sec=0.1) + + node.destroy_client(client) + return False + + +def transition_node(node: Node, name: str, transition_id: int, timeout: float = TIMEOUT) -> bool: + """Call ~/change_state to drive a lifecycle transition. Returns True on success.""" + client = node.create_client(ChangeState, f"{name}/change_state") + if not client.wait_for_service(timeout_sec=timeout): + node.destroy_client(client) + return False + + request = ChangeState.Request() + request.transition = Transition(id=transition_id) + future = client.call_async(request) + rclpy.spin_until_future_complete(node, future, timeout_sec=timeout) + result = future.result() + node.destroy_client(client) + return result is not None and result.success + + +def wait_for_topic_message(node: Node, topic: str, msg_type, timeout: float = TIMEOUT, qos=10, predicate=None): + """Subscribe to a topic and return the first message matching predicate (or first message).""" + result = [None] + + def callback(msg): + if result[0] is None and (predicate is None or predicate(msg)): + result[0] = msg + + sub = node.create_subscription(msg_type, topic, callback, qos) + end_time = node.get_clock().now() + rclpy.duration.Duration(seconds=timeout) + while result[0] is None and node.get_clock().now() < end_time: + rclpy.spin_once(node, timeout_sec=0.1) + + node.destroy_subscription(sub) + return result[0] + + +def collect_topic_messages(node: Node, topic: str, msg_type, duration: float, qos=10): + """Collect all messages on a topic for the given duration.""" + messages = [] + + def callback(msg): + messages.append(msg) + + sub = node.create_subscription(msg_type, topic, callback, qos) + end_time = node.get_clock().now() + rclpy.duration.Duration(seconds=duration) + while node.get_clock().now() < end_time: + rclpy.spin_once(node, timeout_sec=0.05) + + node.destroy_subscription(sub) + return messages + + +def call_service(node: Node, name: str, srv_type, request, timeout: float = TIMEOUT): + """Create a temporary service client, call the service, return the response.""" + client = node.create_client(srv_type, name) + if not client.wait_for_service(timeout_sec=timeout): + node.destroy_client(client) + return None + + future = client.call_async(request) + rclpy.spin_until_future_complete(node, future, timeout_sec=timeout) + result = future.result() + node.destroy_client(client) + return result + + +def send_action_goal(node: Node, name: str, action_type, goal, timeout: float = TIMEOUT): + """Create a temporary action client, send goal, return the goal handle.""" + from rclpy.action import ActionClient + + action_client = ActionClient(node, action_type, name) + if not action_client.wait_for_server(timeout_sec=timeout): + action_client.destroy() + return None + + future = action_client.send_goal_async(goal) + rclpy.spin_until_future_complete(node, future, timeout_sec=timeout) + goal_handle = future.result() + # Don't destroy client yet - caller needs it for result + return goal_handle, action_client + + +def send_action_goal_with_feedback(node: Node, name: str, action_type, goal, timeout: float = TIMEOUT): + """Send a goal and collect feedback. Returns (goal_handle, action_client, feedback_list).""" + from rclpy.action import ActionClient + + feedback_list = [] + action_client = ActionClient(node, action_type, name) + if not action_client.wait_for_server(timeout_sec=timeout): + action_client.destroy() + return None, None, [] + + future = action_client.send_goal_async( + goal, + feedback_callback=lambda fb: feedback_list.append(fb.feedback), + ) + rclpy.spin_until_future_complete(node, future, timeout_sec=timeout) + goal_handle = future.result() + return goal_handle, action_client, feedback_list + + +def get_action_result(node: Node, goal_handle, timeout: float = TIMEOUT): + """Wait for an action result from an accepted goal handle.""" + future = goal_handle.get_result_async() + rclpy.spin_until_future_complete(node, future, timeout_sec=timeout) + return future.result() + + +def publish_message(node: Node, topic: str, msg_type, msg, qos=10): + """Create a temporary publisher, publish a message, then destroy it.""" + pub = node.create_publisher(msg_type, topic, qos) + # Brief spin to allow discovery + rclpy.spin_once(node, timeout_sec=0.1) + pub.publish(msg) + rclpy.spin_once(node, timeout_sec=0.1) + node.destroy_publisher(pub) + + +def state_qos(): + """QoS profile matching the lifecycle ~/state publisher (transient local).""" + return QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=1, + reliability=ReliabilityPolicy.RELIABLE, + durability=DurabilityPolicy.TRANSIENT_LOCAL, + ) diff --git a/jig_example/test/test_actions.py b/jig_example/test/test_actions.py new file mode 100644 index 0000000..54686e1 --- /dev/null +++ b/jig_example/test/test_actions.py @@ -0,0 +1,167 @@ +"""Tests for action server functionality.""" + +import unittest + +from helpers import ( + TIMEOUT, + get_action_result, + send_action_goal, + send_action_goal_with_feedback, + transition_node, + wait_for_node_state, +) +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy + +from action_msgs.msg import GoalStatus +from lifecycle_msgs.msg import State, Transition + +from example_interfaces.action import Fibonacci +from rclpy.action import ActionClient + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="action_node", + name="action_node", + namespace="action_node", + output="screen", + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestActions(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_actions") + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def _wait_active(self): + self.assertTrue( + wait_for_node_state(self.node, "/action_node/action_node", State.PRIMARY_STATE_ACTIVE), + "action_node did not reach ACTIVE", + ) + + def test_goal_accepted_and_succeeds(self): + """Fibonacci(order=5) should succeed with correct result.""" + self._wait_active() + + goal = Fibonacci.Goal(order=5) + gh, client = send_action_goal(self.node, "/action_node/compute", Fibonacci, goal) + self.assertIsNotNone(gh) + self.assertTrue(gh.accepted, "Goal should be accepted") + + result = get_action_result(self.node, gh) + self.assertIsNotNone(result) + self.assertEqual(result.status, GoalStatus.STATUS_SUCCEEDED) + # Fibonacci(5) = [0, 1, 1, 2, 3] + self.assertEqual(list(result.result.sequence), [0, 1, 1, 2, 3]) + client.destroy() + + def test_goal_feedback_published(self): + """Feedback messages should arrive before the result.""" + self._wait_active() + + goal = Fibonacci.Goal(order=5) + gh, client, feedback = send_action_goal_with_feedback(self.node, "/action_node/compute", Fibonacci, goal) + self.assertIsNotNone(gh) + self.assertTrue(gh.accepted) + + result = get_action_result(self.node, gh) + self.assertIsNotNone(result) + self.assertEqual(result.status, GoalStatus.STATUS_SUCCEEDED) + self.assertGreater(len(feedback), 0, "Expected at least one feedback message") + client.destroy() + + def test_goal_rejected_when_inactive(self): + """Deactivate action_node, send goal -> should be rejected.""" + self._wait_active() + + self.assertTrue(transition_node(self.node, "/action_node/action_node", Transition.TRANSITION_DEACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/action_node/action_node", State.PRIMARY_STATE_INACTIVE)) + + goal = Fibonacci.Goal(order=5) + gh, client = send_action_goal(self.node, "/action_node/compute", Fibonacci, goal) + self.assertIsNotNone(gh) + self.assertFalse(gh.accepted, "Goal should be rejected when inactive") + client.destroy() + + # Reactivate + self.assertTrue(transition_node(self.node, "/action_node/action_node", Transition.TRANSITION_ACTIVATE)) + wait_for_node_state(self.node, "/action_node/action_node", State.PRIMARY_STATE_ACTIVE) + + def test_goal_validation_rejects_invalid(self): + """order=-1 should be rejected by goal validator.""" + self._wait_active() + + goal = Fibonacci.Goal(order=-1) + gh, client = send_action_goal(self.node, "/action_node/compute", Fibonacci, goal) + self.assertIsNotNone(gh) + self.assertFalse(gh.accepted, "Negative order should be rejected") + client.destroy() + + def test_goal_replacement(self): + """Send two goals to compute_replace — first aborted, second succeeds.""" + self._wait_active() + + # Send first goal with a large order to keep it busy + goal1 = Fibonacci.Goal(order=5) + gh1, client1 = send_action_goal(self.node, "/action_node/compute_replace", Fibonacci, goal1) + self.assertIsNotNone(gh1) + self.assertTrue(gh1.accepted) + + # Send second goal which should replace the first + goal2 = Fibonacci.Goal(order=3) + gh2, client2 = send_action_goal(self.node, "/action_node/compute_replace", Fibonacci, goal2) + self.assertIsNotNone(gh2) + self.assertTrue(gh2.accepted) + + # Second goal should succeed + result2 = get_action_result(self.node, gh2) + self.assertIsNotNone(result2) + self.assertEqual(result2.status, GoalStatus.STATUS_SUCCEEDED) + + # First goal should have been aborted + result1 = get_action_result(self.node, gh1) + self.assertIsNotNone(result1) + self.assertEqual(result1.status, GoalStatus.STATUS_ABORTED) + + client1.destroy() + client2.destroy() + + def test_cancel_goal(self): + """Send goal, cancel it, verify CANCELED status.""" + self._wait_active() + + goal = Fibonacci.Goal(order=5) + gh, client = send_action_goal(self.node, "/action_node/compute_replace", Fibonacci, goal) + self.assertIsNotNone(gh) + self.assertTrue(gh.accepted) + + # Cancel + cancel_future = gh.cancel_goal_async() + rclpy.spin_until_future_complete(self.node, cancel_future, timeout_sec=TIMEOUT) + + result = get_action_result(self.node, gh) + self.assertIsNotNone(result) + self.assertIn( + result.status, + [GoalStatus.STATUS_CANCELED, GoalStatus.STATUS_SUCCEEDED], + "Goal should be canceled or already succeeded", + ) + client.destroy() diff --git a/jig_example/test/test_cross_language.py b/jig_example/test/test_cross_language.py new file mode 100644 index 0000000..1db06d9 --- /dev/null +++ b/jig_example/test/test_cross_language.py @@ -0,0 +1,133 @@ +"""Tests for cross-language interop: C++ pub -> Python sub and vice versa.""" + +import unittest + +from helpers import TIMEOUT, wait_for_node_state, wait_for_topic_message +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy +from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy + +from lifecycle_msgs.msg import State +from std_msgs.msg import String + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="echo_node", + name="echo_node", + namespace="echo_node", + output="screen", + remappings=[("input", "/cross/cpp_input")], + ), + launch_ros.actions.Node( + package="jig_example", + executable="py_echo_node", + name="py_echo_node", + namespace="py_echo_node", + output="screen", + remappings=[("input", "/cross/py_input")], + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestCrossLanguage(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_cross_language") + cls.qos = QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=10, + reliability=ReliabilityPolicy.RELIABLE, + ) + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def _wait_for_pub_match(self, pub, timeout=5.0): + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=timeout) + while pub.get_subscription_count() == 0 and self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + + def test_cpp_pub_to_python_sub(self): + """Publish to C++ node's input, have Python node subscribe to C++ output.""" + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE)) + self.assertTrue(wait_for_node_state(self.node, "/py_echo_node/py_echo_node", State.PRIMARY_STATE_ACTIVE)) + + # Subscribe first to avoid race + received = [] + sub = self.node.create_subscription( + String, + "/echo_node/output", + lambda msg: received.append(msg), + self.qos, + ) + + # C++ echo_node publishes on /echo_node/output — wire that to py_echo_node's input + # For this test, we publish on cpp's remapped input and verify output + pub = self.node.create_publisher(String, "/cross/cpp_input", self.qos) + self._wait_for_pub_match(pub) + + # Publish multiple times to handle potential message drops + for _ in range(3): + pub.publish(String(data="cross_test_cpp")) + rclpy.spin_once(self.node, timeout_sec=0.1) + + # Wait for echo + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=TIMEOUT) + while self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + if any("cross_test_cpp" in m.data for m in received): + break + + matching = [m for m in received if "cross_test_cpp" in m.data] + self.assertGreater(len(matching), 0, "Expected C++ echo of cross_test_cpp") + + self.node.destroy_publisher(pub) + self.node.destroy_subscription(sub) + + def test_python_pub_to_cpp_sub(self): + """Publish to Python node's input, verify Python echo on output.""" + self.assertTrue(wait_for_node_state(self.node, "/py_echo_node/py_echo_node", State.PRIMARY_STATE_ACTIVE)) + + # Subscribe first to avoid race + received = [] + sub = self.node.create_subscription( + String, + "/py_echo_node/output", + lambda msg: received.append(msg), + self.qos, + ) + + pub = self.node.create_publisher(String, "/cross/py_input", self.qos) + self._wait_for_pub_match(pub) + + # Publish multiple times to handle potential message drops + for _ in range(3): + pub.publish(String(data="cross_test_py")) + rclpy.spin_once(self.node, timeout_sec=0.1) + + # Wait for echo + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=TIMEOUT) + while self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + if any("cross_test_py" in m.data for m in received): + break + + matching = [m for m in received if "cross_test_py" in m.data] + self.assertGreater(len(matching), 0, "Expected Python echo of cross_test_py") + + self.node.destroy_publisher(pub) + self.node.destroy_subscription(sub) diff --git a/jig_example/test/test_for_each_param.py b/jig_example/test/test_for_each_param.py new file mode 100644 index 0000000..d66cb46 --- /dev/null +++ b/jig_example/test/test_for_each_param.py @@ -0,0 +1,119 @@ +"""Tests for for_each_param dynamic entity creation.""" + +import unittest + +from helpers import TIMEOUT, wait_for_node_state, wait_for_topic_message +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy +from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy + +from lifecycle_msgs.msg import State +from std_msgs.msg import String + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="for_each_node", + name="for_each_node", + namespace="for_each_node", + output="screen", + parameters=[{"target_nodes": ["alpha", "beta"]}], + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestForEachParam(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_for_each_param") + cls.qos = QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=10, + reliability=ReliabilityPolicy.RELIABLE, + ) + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def _wait_active(self): + self.assertTrue( + wait_for_node_state(self.node, "/for_each_node/for_each_node", State.PRIMARY_STATE_ACTIVE), + "for_each_node did not reach ACTIVE", + ) + + def test_dynamic_subscribers_created(self): + """Topics /alpha/status and /beta/status should have subscribers.""" + self._wait_active() + + # Check that the topics have subscribers by looking at subscription count + pub_alpha = self.node.create_publisher(String, "/for_each_node/alpha/status", self.qos) + pub_beta = self.node.create_publisher(String, "/for_each_node/beta/status", self.qos) + + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=5.0) + while ( + pub_alpha.get_subscription_count() == 0 or pub_beta.get_subscription_count() == 0 + ) and self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + + self.assertGreater( + pub_alpha.get_subscription_count(), + 0, + "Expected subscriber on alpha/status", + ) + self.assertGreater( + pub_beta.get_subscription_count(), + 0, + "Expected subscriber on beta/status", + ) + self.node.destroy_publisher(pub_alpha) + self.node.destroy_publisher(pub_beta) + + def test_dynamic_subscriber_receives(self): + """Publish on /alpha/status, verify aggregated output is published.""" + self._wait_active() + + # Subscribe first to avoid race + received = [] + sub = self.node.create_subscription( + String, + "/for_each_node/bot1/aggregated_status", + lambda msg: received.append(msg), + self.qos, + ) + + pub = self.node.create_publisher(String, "/for_each_node/alpha/status", self.qos) + + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=5.0) + while pub.get_subscription_count() == 0 and self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + + # Publish multiple times to handle potential message drops + for _ in range(3): + pub.publish(String(data="ok")) + rclpy.spin_once(self.node, timeout_sec=0.1) + + # Wait for aggregated message + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=TIMEOUT) + while self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + if any("alpha=ok" in m.data for m in received): + break + + matching = [m for m in received if "alpha=ok" in m.data] + self.assertGreater(len(matching), 0, "Expected aggregated status containing alpha=ok") + + self.node.destroy_publisher(pub) + self.node.destroy_subscription(sub) diff --git a/jig_example/test/test_lifecycle.py b/jig_example/test/test_lifecycle.py new file mode 100644 index 0000000..cc6ebd3 --- /dev/null +++ b/jig_example/test/test_lifecycle.py @@ -0,0 +1,187 @@ +"""Tests for lifecycle management: autostart, manual transitions, state heartbeat, session reset.""" + +import time +import unittest + +from helpers import ( + TIMEOUT, + call_service, + collect_topic_messages, + state_qos, + transition_node, + wait_for_node_state, + wait_for_topic_message, +) +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy + +from lifecycle_msgs.msg import State, Transition +from std_msgs.msg import String + +from lifecycle_msgs.srv import GetState + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="echo_node", + name="echo_node", + namespace="echo_node", + output="screen", + ), + launch_ros.actions.Node( + package="jig_example", + executable="lifecycle_node", + name="lifecycle_node", + namespace="lifecycle_node", + output="screen", + parameters=[{"autostart": False}], + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +def _reset_to_unconfigured(node, name): + """Drive a lifecycle node back to UNCONFIGURED from whatever state it's in.""" + resp = call_service(node, f"{name}/get_state", GetState, GetState.Request()) + if resp is None: + return + state_id = resp.current_state.id + + if state_id == State.PRIMARY_STATE_ACTIVE: + transition_node(node, name, Transition.TRANSITION_DEACTIVATE) + wait_for_node_state(node, name, State.PRIMARY_STATE_INACTIVE, timeout=5.0) + transition_node(node, name, Transition.TRANSITION_CLEANUP) + wait_for_node_state(node, name, State.PRIMARY_STATE_UNCONFIGURED, timeout=5.0) + elif state_id == State.PRIMARY_STATE_INACTIVE: + transition_node(node, name, Transition.TRANSITION_CLEANUP) + wait_for_node_state(node, name, State.PRIMARY_STATE_UNCONFIGURED, timeout=5.0) + + +class TestLifecycle(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_lifecycle") + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def tearDown(self): + # Reset lifecycle_node to UNCONFIGURED so tests are independent + _reset_to_unconfigured(self.node, "/lifecycle_node/lifecycle_node") + + def test_autostart_activates(self): + """echo_node (autostart=true) should reach ACTIVE.""" + self.assertTrue( + wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE), + "echo_node did not reach ACTIVE state", + ) + + def test_no_autostart_stays_unconfigured(self): + """lifecycle_node (autostart=false) should be UNCONFIGURED.""" + self.assertTrue( + wait_for_node_state( + self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_UNCONFIGURED, timeout=5.0 + ), + "lifecycle_node should be UNCONFIGURED", + ) + + def test_manual_configure_activate(self): + """Drive lifecycle_node through configure -> activate manually.""" + self.assertTrue( + wait_for_node_state( + self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_UNCONFIGURED, timeout=5.0 + ) + ) + + # Configure + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_CONFIGURE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE)) + + # Verify state_report topic exists and has content (TRANSIENT_LOCAL) + received = [] + sub = self.node.create_subscription( + String, + "/lifecycle_node/state_report", + lambda msg: received.append(msg.data), + state_qos(), + ) + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=3.0) + while self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + if received: + break + self.assertGreater(len(received), 0, "Expected messages on state_report") + self.node.destroy_subscription(sub) + + # Activate + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_ACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_ACTIVE)) + + def test_deactivate_cleanup_cycle(self): + """Full lifecycle: configure -> activate -> deactivate -> cleanup.""" + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_CONFIGURE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE)) + + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_ACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_ACTIVE)) + + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_DEACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE)) + + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_CLEANUP)) + self.assertTrue( + wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_UNCONFIGURED) + ) + + def test_state_heartbeat_published(self): + """echo_node should publish state heartbeat on ~/state.""" + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE)) + + messages = collect_topic_messages( + self.node, + "/echo_node/echo_node/state", + State, + duration=2.0, + qos=state_qos(), + ) + self.assertGreater(len(messages), 0, "Expected state heartbeat messages") + for msg in messages: + self.assertEqual(msg.id, State.PRIMARY_STATE_ACTIVE) + + def test_session_reset_on_cleanup(self): + """Full lifecycle cycle twice to verify session is properly destroyed and recreated.""" + for _ in range(2): + self.assertTrue( + transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_CONFIGURE) + ) + self.assertTrue( + wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE) + ) + self.assertTrue( + transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_ACTIVATE) + ) + self.assertTrue( + wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_ACTIVE) + ) + self.assertTrue( + transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_DEACTIVATE) + ) + self.assertTrue( + wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE) + ) + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_CLEANUP)) + self.assertTrue( + wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_UNCONFIGURED) + ) diff --git a/jig_example/test/test_parameters.py b/jig_example/test/test_parameters.py new file mode 100644 index 0000000..9a7f808 --- /dev/null +++ b/jig_example/test/test_parameters.py @@ -0,0 +1,113 @@ +"""Tests for parameter functionality: overrides, read-only enforcement, substitution.""" + +import unittest + +from helpers import TIMEOUT, call_service, wait_for_node_state, wait_for_topic_message +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy + +from lifecycle_msgs.msg import State +from rcl_interfaces.msg import Parameter, ParameterType, ParameterValue +from std_msgs.msg import String + +from example_interfaces.srv import Trigger +from rcl_interfaces.srv import SetParameters + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="echo_node", + name="echo_node", + namespace="echo_node", + output="screen", + parameters=[{"message_prefix": "TEST"}], + ), + launch_ros.actions.Node( + package="jig_example", + executable="py_echo_node", + name="py_echo_node", + namespace="py_echo_node", + output="screen", + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestParameters(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_parameters") + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def test_parameter_override_applied(self): + """get_counter response should contain 'TEST' prefix.""" + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE)) + + resp = call_service(self.node, "/echo_node/get_counter", Trigger, Trigger.Request()) + self.assertIsNotNone(resp) + self.assertIn("TEST", resp.message, "Expected TEST prefix in counter response") + + def test_read_only_param_rejected(self): + """Setting a read-only parameter should fail.""" + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE)) + + req = SetParameters.Request() + req.parameters = [ + Parameter( + name="message_prefix", + value=ParameterValue( + type=ParameterType.PARAMETER_STRING, + string_value="CHANGED", + ), + ) + ] + resp = call_service(self.node, "/echo_node/echo_node/set_parameters", SetParameters, req) + self.assertIsNotNone(resp) + # Read-only params should reject the change + self.assertFalse( + resp.results[0].successful, + "Setting read-only param should fail", + ) + + def test_param_substitution_in_topic_name(self): + """Topic TEST/prefixed_output should exist (resolved from ${param:message_prefix}).""" + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE)) + + msg = wait_for_topic_message( + self.node, + "/echo_node/TEST/prefixed_output", + String, + timeout=5.0, + ) + # The topic should exist — timer publishes on output, and input callback + # also publishes on prefixed_output. Just verify the topic is reachable. + # We may or may not get a message depending on timing, but at minimum + # the topic should be discoverable. Let's check via node graph. + topic_names = [t[0] for t in self.node.get_topic_names_and_types()] + self.assertIn( + "/echo_node/TEST/prefixed_output", + topic_names, + "Expected param-substituted topic to exist", + ) + + def test_param_substitution_in_qos(self): + """Node configures successfully with param-driven QoS (queue_depth).""" + # If the node reached ACTIVE, param substitution in QoS worked + self.assertTrue( + wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE), + "Node should configure successfully with param-substituted QoS", + ) diff --git a/jig_example/test/test_pub_sub.py b/jig_example/test/test_pub_sub.py new file mode 100644 index 0000000..007b198 --- /dev/null +++ b/jig_example/test/test_pub_sub.py @@ -0,0 +1,166 @@ +"""Tests for publisher/subscriber functionality.""" + +import unittest + +from helpers import TIMEOUT, collect_topic_messages, transition_node, wait_for_node_state +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy +from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy + +from lifecycle_msgs.msg import State, Transition +from std_msgs.msg import String + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="echo_node", + name="echo_node", + namespace="echo_node", + output="screen", + ), + launch_ros.actions.Node( + package="jig_example", + executable="py_echo_node", + name="py_echo_node", + namespace="py_echo_node", + output="screen", + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestPubSub(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_pub_sub") + cls.qos = QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=10, + reliability=ReliabilityPolicy.RELIABLE, + ) + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def tearDown(self): + # Ensure echo_node is active for other tests + wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE, timeout=3.0) + + def _wait_active(self, name): + self.assertTrue( + wait_for_node_state(self.node, name, State.PRIMARY_STATE_ACTIVE), + f"{name} did not reach ACTIVE", + ) + + def _wait_for_pub_match(self, pub, timeout=5.0): + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=timeout) + while pub.get_subscription_count() == 0 and self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + + def _publish_and_wait_echo(self, input_topic, output_topic, data, prefix): + """Publish on input, wait for echo on output. Subscribe first to avoid race.""" + received = [] + sub = self.node.create_subscription( + String, + output_topic, + lambda msg: received.append(msg), + self.qos, + ) + + pub = self.node.create_publisher(String, input_topic, self.qos) + self._wait_for_pub_match(pub) + + # Publish multiple times to handle potential message drops + for _ in range(3): + pub.publish(String(data=data)) + rclpy.spin_once(self.node, timeout_sec=0.1) + + # Wait for echo + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=TIMEOUT) + while self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + if any(data in m.data for m in received): + break + + self.node.destroy_publisher(pub) + self.node.destroy_subscription(sub) + + matching = [m for m in received if data in m.data] + self.assertGreater(len(matching), 0, f"Expected echo of '{data}' on {output_topic}") + self.assertIn(prefix, matching[0].data) + + def test_cpp_publish_and_receive(self): + """Publish on echo_node/input, verify echo on echo_node/output.""" + self._wait_active("/echo_node/echo_node") + self._publish_and_wait_echo("/echo_node/input", "/echo_node/output", "hello", "echo") + + def test_subscriber_drops_when_inactive(self): + """Deactivate echo_node, publish, verify no echo, reactivate, verify echo resumes.""" + self._wait_active("/echo_node/echo_node") + + # Subscribe to output first + received = [] + sub = self.node.create_subscription( + String, + "/echo_node/output", + lambda msg: received.append(msg), + self.qos, + ) + pub = self.node.create_publisher(String, "/echo_node/input", self.qos) + self._wait_for_pub_match(pub) + + # Deactivate + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_DEACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_INACTIVE)) + + # Clear received and publish while inactive + received.clear() + pub.publish(String(data="dropped")) + msgs = collect_topic_messages( + self.node, + "/echo_node/output", + String, + duration=2.0, + qos=self.qos, + ) + echo_msgs = [m for m in msgs if "dropped" in m.data] + self.assertEqual(len(echo_msgs), 0, "Should not receive echo while inactive") + + # Reactivate + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_ACTIVATE)) + self._wait_active("/echo_node/echo_node") + self._wait_for_pub_match(pub) + + received.clear() + pub.publish(String(data="resumed")) + + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=TIMEOUT) + while self.node.get_clock().now() < end: + rclpy.spin_once(self.node, timeout_sec=0.1) + if any("resumed" in m.data for m in received): + break + + self.assertTrue( + any("resumed" in m.data for m in received), + "Expected echo after reactivation", + ) + + self.node.destroy_publisher(pub) + self.node.destroy_subscription(sub) + + def test_python_publish_and_receive(self): + """Publish on py_echo_node/input, verify echo on py_echo_node/output.""" + self._wait_active("/py_echo_node/py_echo_node") + self._publish_and_wait_echo("/py_echo_node/input", "/py_echo_node/output", "hello_py", "py") diff --git a/jig_example/test/test_qos_handlers.py b/jig_example/test/test_qos_handlers.py new file mode 100644 index 0000000..3b5bff6 --- /dev/null +++ b/jig_example/test/test_qos_handlers.py @@ -0,0 +1,79 @@ +"""Tests for default QoS handlers: deadline miss triggers deactivation.""" + +import time +import unittest + +from helpers import TIMEOUT, transition_node, wait_for_node_state +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy +from rclpy.qos import Duration, HistoryPolicy, LivelinessPolicy, QoSProfile, ReliabilityPolicy + +from lifecycle_msgs.msg import State, Transition +from std_msgs.msg import Bool + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="lifecycle_node", + name="lifecycle_node", + namespace="lifecycle_node", + output="screen", + parameters=[{"autostart": False}], + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestQosHandlers(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_qos_handlers") + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def test_deadline_miss_deactivates(self): + """Activate lifecycle_node, publish heartbeats, stop, wait for deadline miss -> INACTIVE.""" + # Configure and activate + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_CONFIGURE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE)) + self.assertTrue(transition_node(self.node, "/lifecycle_node/lifecycle_node", Transition.TRANSITION_ACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_ACTIVE)) + + # Publish heartbeats matching the subscriber's QoS (deadline 500ms) + qos = QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=1, + reliability=ReliabilityPolicy.RELIABLE, + deadline=Duration(nanoseconds=500_000_000), + liveliness=LivelinessPolicy.AUTOMATIC, + liveliness_lease_duration=Duration(nanoseconds=500_000_000), + ) + pub = self.node.create_publisher(Bool, "/lifecycle_node/heartbeat", qos) + + # Send heartbeats for 1 second + end = self.node.get_clock().now() + rclpy.duration.Duration(seconds=1.0) + while self.node.get_clock().now() < end: + pub.publish(Bool(data=True)) + rclpy.spin_once(self.node, timeout_sec=0.1) + + # Stop publishing — deadline miss should trigger after 500ms + self.node.destroy_publisher(pub) + + # Wait for the node to deactivate due to deadline miss + self.assertTrue( + wait_for_node_state(self.node, "/lifecycle_node/lifecycle_node", State.PRIMARY_STATE_INACTIVE, timeout=5.0), + "lifecycle_node should deactivate on deadline miss", + ) diff --git a/jig_example/test/test_services.py b/jig_example/test/test_services.py new file mode 100644 index 0000000..b0624cd --- /dev/null +++ b/jig_example/test/test_services.py @@ -0,0 +1,108 @@ +"""Tests for service server functionality.""" + +import unittest + +from helpers import TIMEOUT, call_service, transition_node, wait_for_node_state +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy + +from lifecycle_msgs.msg import State, Transition + +from example_interfaces.srv import AddTwoInts, Trigger + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="echo_node", + name="echo_node", + namespace="echo_node", + output="screen", + ), + launch_ros.actions.Node( + package="jig_example", + executable="py_echo_node", + name="py_echo_node", + namespace="py_echo_node", + output="screen", + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestServices(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_services") + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def _wait_active(self, name): + self.assertTrue( + wait_for_node_state(self.node, name, State.PRIMARY_STATE_ACTIVE), + f"{name} did not reach ACTIVE", + ) + + def test_add_two_ints_cpp(self): + """Call echo_node/add_two_ints(3, 7), verify sum=10.""" + self._wait_active("/echo_node/echo_node") + + req = AddTwoInts.Request() + req.a = 3 + req.b = 7 + resp = call_service(self.node, "/echo_node/add_two_ints", AddTwoInts, req) + self.assertIsNotNone(resp) + self.assertEqual(resp.sum, 10) + + def test_get_counter_cpp(self): + """Call echo_node/get_counter, verify response contains counter.""" + self._wait_active("/echo_node/echo_node") + + resp = call_service(self.node, "/echo_node/get_counter", Trigger, Trigger.Request()) + self.assertIsNotNone(resp) + self.assertTrue(resp.success) + self.assertIn("counter=", resp.message) + + def test_service_rejected_when_inactive(self): + """Deactivate echo_node, call service, verify default/empty response.""" + self._wait_active("/echo_node/echo_node") + + # Deactivate + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_DEACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_INACTIVE)) + + # Call service while inactive — jig returns default response + req = AddTwoInts.Request() + req.a = 3 + req.b = 7 + resp = call_service(self.node, "/echo_node/add_two_ints", AddTwoInts, req) + self.assertIsNotNone(resp) + # Default response has sum=0 (service handler not called) + self.assertEqual(resp.sum, 0) + + # Reactivate for other tests + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_ACTIVATE)) + self._wait_active("/echo_node/echo_node") + + def test_add_two_ints_python(self): + """Call py_echo_node/add_two_ints(5, 8), verify sum=13.""" + self._wait_active("/py_echo_node/py_echo_node") + + req = AddTwoInts.Request() + req.a = 5 + req.b = 8 + resp = call_service(self.node, "/py_echo_node/add_two_ints", AddTwoInts, req) + self.assertIsNotNone(resp) + self.assertEqual(resp.sum, 13) diff --git a/jig_example/test/test_timers.py b/jig_example/test/test_timers.py new file mode 100644 index 0000000..37dea41 --- /dev/null +++ b/jig_example/test/test_timers.py @@ -0,0 +1,107 @@ +"""Tests for timer functionality.""" + +import unittest + +from helpers import TIMEOUT, collect_topic_messages, transition_node, wait_for_node_state +import launch +import launch_ros.actions +import launch_testing +import launch_testing.actions +import pytest +import rclpy +from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy + +from lifecycle_msgs.msg import State, Transition +from std_msgs.msg import String + + +@pytest.mark.launch_test +def generate_test_description(): + return launch.LaunchDescription( + [ + launch_ros.actions.Node( + package="jig_example", + executable="echo_node", + name="echo_node", + namespace="echo_node", + output="screen", + ), + launch_testing.actions.ReadyToTest(), + ] + ) + + +class TestTimers(unittest.TestCase): + @classmethod + def setUpClass(cls): + rclpy.init() + cls.node = rclpy.create_node("test_timers") + cls.qos = QoSProfile( + history=HistoryPolicy.KEEP_LAST, + depth=10, + reliability=ReliabilityPolicy.RELIABLE, + ) + + @classmethod + def tearDownClass(cls): + cls.node.destroy_node() + rclpy.shutdown() + + def _ensure_active(self): + """Ensure echo_node is active (may have been deactivated by a previous test).""" + self.assertTrue( + wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE), + "echo_node did not reach ACTIVE", + ) + + def test_timer_fires_when_active(self): + """Output topic receives periodic timer messages when active.""" + self._ensure_active() + + messages = collect_topic_messages( + self.node, + "/echo_node/output", + String, + duration=2.0, + qos=self.qos, + ) + # At 100ms rate, expect ~20 messages in 2s (allow some slack) + self.assertGreater(len(messages), 5, "Expected periodic timer messages") + + def test_timer_stops_on_deactivate(self): + """Deactivate echo_node, verify no new timer messages.""" + self._ensure_active() + + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_DEACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_INACTIVE)) + + messages = collect_topic_messages( + self.node, + "/echo_node/output", + String, + duration=1.0, + qos=self.qos, + ) + self.assertEqual(len(messages), 0, "Timer should not fire when inactive") + + # Reactivate for other tests + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_ACTIVATE)) + + def test_timer_resumes_on_reactivate(self): + """Deactivate then reactivate — timer messages should resume.""" + self._ensure_active() + + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_DEACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_INACTIVE)) + + self.assertTrue(transition_node(self.node, "/echo_node/echo_node", Transition.TRANSITION_ACTIVATE)) + self.assertTrue(wait_for_node_state(self.node, "/echo_node/echo_node", State.PRIMARY_STATE_ACTIVE)) + + messages = collect_topic_messages( + self.node, + "/echo_node/output", + String, + duration=2.0, + qos=self.qos, + ) + self.assertGreater(len(messages), 5, "Timer should resume after reactivation")