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")