Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
88 changes: 63 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
7 changes: 6 additions & 1 deletion jig/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.28)
cmake_minimum_required(VERSION 3.22)
project(jig)

find_package(ament_cmake_auto REQUIRED)
Expand All @@ -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)
3 changes: 3 additions & 0 deletions jig/package.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@

<exec_depend>ament_index_python</exec_depend>

<test_depend>ament_cmake_pytest</test_depend>
<test_depend>python3-pytest</test_depend>

<export>
<build_type>ament_cmake</build_type>
</export>
Expand Down
2 changes: 1 addition & 1 deletion jig/scripts/generate_node_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# auto-generated DO NOT EDIT

from . import interface
from . import parameters

__all__ = ["interface", "parameters"]
124 changes: 124 additions & 0 deletions jig/tests/fixtures/action_clients_mixed/expected_python/_parameters.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading