diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 238ff0935..c60d239d4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ **ros2_medkit** is a ROS 2 diagnostics gateway that exposes ROS 2 system information via a RESTful HTTP API. It models robots as a **diagnostic entity tree** (Area → Component → Function → App) aligned with the SOVD (Service-Oriented Vehicle Diagnostics) specification. -**Tech Stack**: C++17, ROS 2 Jazzy, Ubuntu 24.04 +**Tech Stack**: C++17, ROS 2 Jazzy/Humble/Rolling, Ubuntu 24.04/22.04 ## Architecture @@ -25,7 +25,7 @@ GatewayNode (src/ros2_medkit_gateway/src/gateway_node.cpp) ## Code Style & Conventions - **C++ Standard**: C++17 with `-Wall -Wextra -Wpedantic -Wshadow -Wconversion` -- **ROS 2 Distribution**: Jazzy (Ubuntu 24.04) +- **ROS 2 Distribution**: Jazzy (Ubuntu 24.04), Humble (Ubuntu 22.04), or Rolling (Ubuntu 24.04, experimental) - **Formatting**: Google-based clang-format with 120 column limit, 2-space indent - **Pointer style**: Middle alignment (`Type * ptr`) per ROS 2 conventions - **Namespace**: All gateway code lives in `ros2_medkit_gateway` namespace @@ -65,7 +65,7 @@ When adding a new endpoint or feature: ## Key Dependencies -- **ROS 2 Jazzy** (Ubuntu 24.04) +- **ROS 2 Jazzy** (Ubuntu 24.04) / **ROS 2 Humble** (Ubuntu 22.04) / **ROS 2 Rolling** (Ubuntu 24.04, best-effort) - **cpp-httplib**: HTTP server (found via pkg-config) - **nlohmann_json**: JSON serialization - **yaml-cpp**: Configuration parsing diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b4a33c4fb..1a07cca6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,19 @@ on: jobs: build-and-test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - ros_distro: jazzy + os_image: ubuntu:noble + - ros_distro: humble + os_image: ubuntu:jammy + - ros_distro: rolling + os_image: ubuntu:noble + continue-on-error: ${{ matrix.ros_distro == 'rolling' }} container: - image: ubuntu:noble + image: ${{ matrix.os_image }} timeout-minutes: 60 defaults: run: @@ -25,29 +36,53 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up ROS 2 Jazzy + - name: Set up ROS 2 ${{ matrix.ros_distro }} uses: ros-tooling/setup-ros@v0.7 with: - required-ros-distributions: jazzy + required-ros-distributions: ${{ matrix.ros_distro }} + + - name: Install cpp-httplib from source (Humble) + if: matrix.ros_distro == 'humble' + run: | + apt-get update + apt-get install -y cmake g++ libssl-dev pkg-config + git clone --depth 1 --branch v0.14.3 https://github.com/yhirose/cpp-httplib.git /tmp/cpp-httplib + cd /tmp/cpp-httplib + mkdir build && cd build + cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DHTTPLIB_REQUIRE_OPENSSL=ON + make install + # Verify installation — cpp-httplib from source installs cmake config (not pkg-config .pc) + test -f /usr/include/httplib.h && echo "cpp-httplib installed successfully" || exit 1 - name: Install dependencies run: | apt-get update - apt-get install -y clang-format clang-tidy ros-jazzy-test-msgs - source /opt/ros/jazzy/setup.bash + apt-get install -y ros-${{ matrix.ros_distro }}-test-msgs + # Linter tools only needed on Jazzy (clang versions differ across Ubuntu releases) + if [ "${{ matrix.ros_distro }}" = "jazzy" ]; then + apt-get install -y clang-format clang-tidy + fi + source /opt/ros/${{ matrix.ros_distro }}/setup.bash rosdep update - rosdep install --from-paths src --ignore-src -r -y + # On Humble, skip the libcpp-httplib-dev rosdep key — the apt version (0.10.3) + # is too old; cpp-httplib v0.14.3 is built from source in an earlier step. + if [ "${{ matrix.ros_distro }}" = "humble" ]; then + rosdep install --from-paths src --ignore-src -r -y --skip-keys="libcpp-httplib-dev" + else + rosdep install --from-paths src --ignore-src -y + fi - name: Build packages run: | - source /opt/ros/jazzy/setup.bash + source /opt/ros/${{ matrix.ros_distro }}/setup.bash colcon build --symlink-install \ --cmake-args -DCMAKE_BUILD_TYPE=Release \ --event-handlers console_direct+ - name: Run linters (clang-format, clang-tidy, etc.) + if: matrix.ros_distro == 'jazzy' run: | - source /opt/ros/jazzy/setup.bash + source /opt/ros/${{ matrix.ros_distro }}/setup.bash source install/setup.bash colcon test --return-code-on-test-failure \ --ctest-args -L linter \ @@ -56,7 +91,7 @@ jobs: - name: Run unit and integration tests timeout-minutes: 15 run: | - source /opt/ros/jazzy/setup.bash + source /opt/ros/${{ matrix.ros_distro }}/setup.bash source install/setup.bash colcon test --return-code-on-test-failure \ --ctest-args -LE linter \ @@ -70,7 +105,7 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: test-results + name: test-results-${{ matrix.ros_distro }} path: | log/ build/*/test_results/ diff --git a/QUALITY_DECLARATION.md b/QUALITY_DECLARATION.md index 54d96268d..1ad573aa2 100644 --- a/QUALITY_DECLARATION.md +++ b/QUALITY_DECLARATION.md @@ -86,7 +86,7 @@ GitHub Copilot code review is used in addition to human review. All pull requests must pass CI before merging: -- **Build & Test job:** Full build + linter tests + unit/integration tests on Ubuntu Noble / ROS 2 Jazzy +- **Build & Test job:** Full build + unit/integration tests on Ubuntu Noble / ROS 2 Jazzy, Ubuntu Jammy / ROS 2 Humble, and Ubuntu Noble / ROS 2 Rolling (best-effort, allow-failure). Linter tests on Jazzy only - **Coverage job:** Debug build with coverage. Reports are generated for all PRs as artifacts and uploaded to [Codecov](https://codecov.io/gh/selfpatch/ros2_medkit) on pushes to `main` - Linting enforced: `clang-format`, `clang-tidy` via `ament_lint_auto` @@ -201,9 +201,11 @@ Linter tests are enforced in CI on every pull request. `ros2_medkit` is tested and supported on: -- **Ubuntu 24.04 (Noble)** with **ROS 2 Jazzy** +- **Ubuntu 24.04 (Noble)** with **ROS 2 Jazzy** (primary) +- **Ubuntu 22.04 (Jammy)** with **ROS 2 Humble** +- **Ubuntu 24.04 (Noble)** with **ROS 2 Rolling** (experimental, best-effort) -This is the primary Tier 1 platform for ROS 2 Jazzy per [REP-2000](https://www.ros.org/reps/rep-2000.html). +Jazzy and Humble are the Tier 1 platforms per [REP-2000](https://www.ros.org/reps/rep-2000.html) and are tested in CI. Rolling is tested as best-effort (allow-failure) for forward-compatibility. --- @@ -232,7 +234,7 @@ Security issues can be reported via GitHub Security Advisories on the | Feature tests | Met | 65 tests across unit + integration | | Coverage | Met | 75% line coverage | | Linting | Met | clang-format, clang-tidy, ament_lint | -| Platform support | Met | Ubuntu Noble / ROS 2 Jazzy | +| Platform support | Met | Ubuntu Noble / ROS 2 Jazzy + Ubuntu Jammy / ROS 2 Humble + Rolling (best-effort) | | Security policy | Met | REP-2006 compliant | **Caveat:** Version is 0.2.0 (pre-1.0.0, requirement 1.ii). The REST API is versioned (`/api/v1/`) diff --git a/README.md b/README.md index 3b4bad549..be3bf1dbd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ [![codecov](https://codecov.io/gh/selfpatch/ros2_medkit/branch/main/graph/badge.svg)](https://codecov.io/gh/selfpatch/ros2_medkit) [![Docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://selfpatch.github.io/ros2_medkit/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -[![ROS 2](https://img.shields.io/badge/ROS%202-Jazzy-blue)](https://docs.ros.org/en/jazzy/) +[![ROS 2 Jazzy](https://img.shields.io/badge/ROS%202-Jazzy-blue)](https://docs.ros.org/en/jazzy/) +[![ROS 2 Humble](https://img.shields.io/badge/ROS%202-Humble-blue)](https://docs.ros.org/en/humble/) +[![ROS 2 Rolling](https://img.shields.io/badge/ROS%202-Rolling-orange)](https://docs.ros.org/en/rolling/) [![Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/6CXPMApAyq) [![Quality Level 3](https://img.shields.io/badge/Quality-Level%203-yellow)](QUALITY_DECLARATION.md) @@ -32,7 +34,7 @@ cd selfpatch_demos/demos/turtlebot3_integration # → API: http://localhost:8080/api/v1/ Web UI: http://localhost:3000 ``` -**Build from source** (ROS 2 Jazzy): +**Build from source** (ROS 2 Jazzy, Humble, or Rolling): ```bash git clone --recurse-submodules https://github.com/selfpatch/ros2_medkit.git @@ -52,7 +54,7 @@ For more examples, see our [Postman collection](postman/). - **🔗 SOVD Compatible** — Align with Service-Oriented Vehicle Diagnostics standards - **🌐 REST API Gateway** — HTTP interface for integration with external tools and UIs - **📊 Health Modeling** — Track health state per entity for fleet-level observability -- **🔧 Easy Integration** — Works with existing ROS 2 Jazzy nodes out of the box +- **🔧 Easy Integration** — Works with existing ROS 2 nodes out of the box (Jazzy, Humble & Rolling) ## 📖 Overview @@ -69,9 +71,9 @@ Compatible with the **SOVD (Service-Oriented Vehicle Diagnostics)** model — sa ## 📋 Requirements -- **OS:** Ubuntu 24.04 LTS -- **ROS 2:** Jazzy Jalisco -- **Compiler:** GCC 13+ (C++17 support) +- **OS:** Ubuntu 24.04 LTS (Jazzy / Rolling) or Ubuntu 22.04 LTS (Humble) +- **ROS 2:** Jazzy Jalisco, Humble Hawksbill, or Rolling (experimental) +- **Compiler:** GCC 11+ (C++17 support) - **Build System:** colcon + ament_cmake ## 📚 Documentation @@ -192,14 +194,15 @@ Then open `coverage_html/index.html` in your browser. ### CI/CD All pull requests and pushes to main are automatically built and tested using GitHub Actions. -The CI workflow runs on Ubuntu 24.04 with ROS 2 Jazzy and consists of two parallel jobs: +The CI workflow runs a build matrix across **ROS 2 Jazzy** (Ubuntu 24.04), **ROS 2 Humble** (Ubuntu 22.04), and **ROS 2 Rolling** (Ubuntu 24.04, allow-failure) and consists of the following jobs: -**build-and-test:** +**build-and-test** (matrix: Jazzy + Humble + Rolling): -- Code linting and formatting checks (clang-format, clang-tidy) -- Unit tests and integration tests with demo automotive nodes +- Full build and unit/integration tests on all distros +- Rolling jobs are allowed to fail (best-effort forward-compatibility) +- Code linting and formatting checks (clang-format, clang-tidy) — Jazzy only -**coverage:** +**coverage** (Jazzy only): - Builds with coverage instrumentation (Debug mode) - Runs unit tests only (for stable coverage metrics) diff --git a/cmake/ROS2MedkitCompat.cmake b/cmake/ROS2MedkitCompat.cmake new file mode 100644 index 000000000..30be2198f --- /dev/null +++ b/cmake/ROS2MedkitCompat.cmake @@ -0,0 +1,143 @@ +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ============================================================================= +# ROS2MedkitCompat.cmake — Multi-distro compatibility module for ros2_medkit +# ============================================================================= +# +# Centralizes all dependency resolution workarounds for supporting multiple +# ROS 2 distributions (Humble, Jazzy, Rolling) in a single place. +# +# Usage (in each package's CMakeLists.txt, after find_package(ament_cmake)): +# list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake") +# include(ROS2MedkitCompat) +# +# Macros provided: +# medkit_find_yaml_cpp() — Find yaml-cpp, ensure yaml-cpp::yaml-cpp target +# medkit_find_cpp_httplib() — Find cpp-httplib, create cpp_httplib_target alias +# medkit_detect_compat_defs() — Detect rclcpp/rosbag2 versions, set compat variables +# medkit_apply_compat_defs(target) — Apply compile definitions to a target +# +# Variables set by medkit_detect_compat_defs(): +# MEDKIT_RCLCPP_VERSION_MAJOR — integer (e.g., 16 for Humble, 28 for Jazzy) +# MEDKIT_ROSBAG2_OLD_TIMESTAMP — ON if rosbag2_storage < 0.22.0 (Humble) +# + +include_guard(GLOBAL) + +# --------------------------------------------------------------------------- +# medkit_find_yaml_cpp() +# --------------------------------------------------------------------------- +# Jazzy's yaml_cpp_vendor exports a namespaced yaml-cpp::yaml-cpp cmake target. +# Humble's yaml_cpp_vendor bundles yaml-cpp but does NOT export the cmake target. +# This macro creates an IMPORTED INTERFACE target when find_package doesn't. +# +# Prerequisite: find_package(yaml_cpp_vendor REQUIRED) must be called before. +# --------------------------------------------------------------------------- +macro(medkit_find_yaml_cpp) + find_package(yaml-cpp QUIET) + if(NOT TARGET yaml-cpp::yaml-cpp) + find_library(_MEDKIT_YAML_CPP_LIB yaml-cpp) + find_path(_MEDKIT_YAML_CPP_INCLUDE yaml-cpp/yaml.h) + if(_MEDKIT_YAML_CPP_LIB AND _MEDKIT_YAML_CPP_INCLUDE) + add_library(yaml-cpp::yaml-cpp IMPORTED INTERFACE) + set_target_properties(yaml-cpp::yaml-cpp PROPERTIES + INTERFACE_LINK_LIBRARIES "${_MEDKIT_YAML_CPP_LIB}" + INTERFACE_INCLUDE_DIRECTORIES "${_MEDKIT_YAML_CPP_INCLUDE}" + ) + message(STATUS "[MedkitCompat] yaml-cpp: created target from system library (${_MEDKIT_YAML_CPP_LIB})") + else() + message(FATAL_ERROR "[MedkitCompat] Could not find yaml-cpp library. " + "Ensure yaml_cpp_vendor is installed: apt install ros-${ROS_DISTRO}-yaml-cpp-vendor") + endif() + unset(_MEDKIT_YAML_CPP_LIB) + unset(_MEDKIT_YAML_CPP_INCLUDE) + else() + message(STATUS "[MedkitCompat] yaml-cpp: using native cmake target") + endif() +endmacro() + +# --------------------------------------------------------------------------- +# medkit_find_cpp_httplib() +# --------------------------------------------------------------------------- +# On Jazzy/Noble, libcpp-httplib-dev is available via apt and provides a +# pkg-config .pc file. On Humble/Jammy, cpp-httplib must be built from +# source, which installs a CMake config file (httplibConfig.cmake). +# +# Creates a unified alias target `cpp_httplib_target` for consumers. +# --------------------------------------------------------------------------- +macro(medkit_find_cpp_httplib) + find_package(PkgConfig QUIET) + if(PkgConfig_FOUND) + pkg_check_modules(cpp_httplib IMPORTED_TARGET cpp-httplib) + endif() + if(cpp_httplib_FOUND) + add_library(cpp_httplib_target ALIAS PkgConfig::cpp_httplib) + message(STATUS "[MedkitCompat] cpp-httplib: using pkg-config (system package)") + else() + find_package(httplib REQUIRED) + add_library(cpp_httplib_target ALIAS httplib::httplib) + message(STATUS "[MedkitCompat] cpp-httplib: using cmake config (source build)") + endif() +endmacro() + +# --------------------------------------------------------------------------- +# medkit_detect_compat_defs() +# --------------------------------------------------------------------------- +# Detects rclcpp and rosbag2 versions to set compatibility variables. +# Call AFTER find_package(rclcpp) and optionally find_package(rosbag2_storage). +# +# Sets: +# MEDKIT_RCLCPP_VERSION_MAJOR — integer (16=Humble, 21+=Iron, 28+=Jazzy) +# MEDKIT_ROSBAG2_OLD_TIMESTAMP — ON if rosbag2_storage < 0.22.0 (Humble) +# --------------------------------------------------------------------------- +macro(medkit_detect_compat_defs) + # --- rclcpp version --- + if(rclcpp_VERSION) + string(REGEX MATCH "^([0-9]+)" _medkit_rclcpp_major "${rclcpp_VERSION}") + set(MEDKIT_RCLCPP_VERSION_MAJOR ${_medkit_rclcpp_major}) + unset(_medkit_rclcpp_major) + else() + set(MEDKIT_RCLCPP_VERSION_MAJOR 0) + endif() + + if(MEDKIT_RCLCPP_VERSION_MAJOR GREATER_EQUAL 21) + message(STATUS "[MedkitCompat] rclcpp ${rclcpp_VERSION} (Iron+): native GenericClient + BestAvailable QoS") + else() + message(STATUS "[MedkitCompat] rclcpp ${rclcpp_VERSION} (Humble): using compat shim for GenericClient") + endif() + + # --- rosbag2 timestamp API --- + if(rosbag2_storage_VERSION) + if(rosbag2_storage_VERSION VERSION_LESS "0.22.0") + set(MEDKIT_ROSBAG2_OLD_TIMESTAMP ON) + message(STATUS "[MedkitCompat] rosbag2_storage ${rosbag2_storage_VERSION}: using time_stamp field (Humble)") + else() + set(MEDKIT_ROSBAG2_OLD_TIMESTAMP OFF) + message(STATUS "[MedkitCompat] rosbag2_storage ${rosbag2_storage_VERSION}: using recv_timestamp (Iron+)") + endif() + endif() +endmacro() + +# --------------------------------------------------------------------------- +# medkit_apply_compat_defs(target) +# --------------------------------------------------------------------------- +# Applies all relevant compile definitions to a target based on detected +# compatibility state. Call AFTER medkit_detect_compat_defs(). +# --------------------------------------------------------------------------- +function(medkit_apply_compat_defs target) + if(MEDKIT_ROSBAG2_OLD_TIMESTAMP) + target_compile_definitions(${target} PRIVATE ROSBAG2_USE_OLD_TIMESTAMP_FIELD) + endif() +endfunction() diff --git a/docs/installation.rst b/docs/installation.rst index a97b63e57..f994bd02a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -1,7 +1,8 @@ Installation ============ -This guide covers installation of ros2_medkit on Ubuntu 24.04 with ROS 2 Jazzy. +This guide covers installation of ros2_medkit on Ubuntu 24.04 with ROS 2 Jazzy, +Ubuntu 22.04 with ROS 2 Humble, or Ubuntu 24.04 with ROS 2 Rolling. System Requirements ------------------- @@ -13,21 +14,38 @@ System Requirements * - Requirement - Version * - Operating System - - Ubuntu 24.04 LTS (Noble Numbat) + - Ubuntu 24.04 LTS (Noble) or Ubuntu 22.04 LTS (Jammy) * - ROS 2 Distribution - - Jazzy + - Jazzy, Humble, or Rolling * - C++ Compiler - - GCC 13+ (C++17 support required) + - GCC 11+ (C++17 support required) * - CMake - 3.22+ * - Python - - 3.12+ + - 3.10+ (Humble) / 3.12+ (Jazzy / Rolling) Prerequisites ------------- -**ROS 2 Jazzy** must be installed and sourced. Follow the official installation guide: -https://docs.ros.org/en/jazzy/Installation/Ubuntu-Install-Debs.html +**ROS 2 Jazzy, Humble, or Rolling** must be installed and sourced. Follow the official installation guide +for your distribution: + +- Jazzy: https://docs.ros.org/en/jazzy/Installation/Ubuntu-Install-Debs.html +- Humble: https://docs.ros.org/en/humble/Installation/Ubuntu-Install-Debs.html +- Rolling: https://docs.ros.org/en/rolling/Installation/Ubuntu-Install-Debs.html + +.. note:: + + On Ubuntu 22.04 (Humble), the ``libcpp-httplib-dev`` system package is not available. + You must install cpp-httplib from source before building: + + .. code-block:: bash + + sudo apt install cmake g++ libssl-dev + git clone --depth 1 --branch v0.14.3 https://github.com/yhirose/cpp-httplib.git /tmp/cpp-httplib + cd /tmp/cpp-httplib && mkdir build && cd build + cmake .. -DCMAKE_INSTALL_PREFIX=/usr -DHTTPLIB_REQUIRE_OPENSSL=ON + sudo make install Installation from Source ------------------------ diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 3f0b186b5..652c539ac 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -214,7 +214,7 @@ parameter changes) only happen when explicitly requested via the API. **Q: Can I use ros2_medkit with ROS 1?** -No. ros2_medkit requires ROS 2 Jazzy. For ROS 1 systems, consider using +No. ros2_medkit requires ROS 2 (Jazzy, Humble, or Rolling). For ROS 1 systems, consider using the ``ros1_bridge`` and running ros2_medkit on the ROS 2 side. **Q: Is ros2_medkit production-ready?** diff --git a/src/ros2_medkit_diagnostic_bridge/src/diagnostic_bridge_node.cpp b/src/ros2_medkit_diagnostic_bridge/src/diagnostic_bridge_node.cpp index 46c0eed5c..c761a3cef 100644 --- a/src/ros2_medkit_diagnostic_bridge/src/diagnostic_bridge_node.cpp +++ b/src/ros2_medkit_diagnostic_bridge/src/diagnostic_bridge_node.cpp @@ -99,8 +99,10 @@ std::string DiagnosticBridgeNode::map_to_fault_code(const std::string & diagnost } // Log warning and return empty string if no mapping and auto-generate disabled - RCLCPP_WARN_THROTTLE(get_logger(), *get_clock(), 5000, - "No mapping for diagnostic '%s' and auto_generate_codes is disabled", diagnostic_name.c_str()); + // Use a mutable clock copy — Humble's RCLCPP_WARN_THROTTLE requires non-const Clock + rclcpp::Clock clock(*get_clock()); + RCLCPP_WARN_THROTTLE(get_logger(), clock, 5000, "No mapping for diagnostic '%s' and auto_generate_codes is disabled", + diagnostic_name.c_str()); return ""; } diff --git a/src/ros2_medkit_fault_manager/CMakeLists.txt b/src/ros2_medkit_fault_manager/CMakeLists.txt index 175de97b8..0aeb42743 100644 --- a/src/ros2_medkit_fault_manager/CMakeLists.txt +++ b/src/ros2_medkit_fault_manager/CMakeLists.txt @@ -19,6 +19,10 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Multi-distro compatibility (Humble / Jazzy / Rolling) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake") +include(ROS2MedkitCompat) + if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) endif() @@ -38,11 +42,13 @@ find_package(ros2_medkit_serialization REQUIRED) find_package(SQLite3 REQUIRED) find_package(nlohmann_json REQUIRED) # yaml-cpp is required as transitive dependency from ros2_medkit_serialization -find_package(yaml-cpp REQUIRED) +medkit_find_yaml_cpp() # rosbag2 for time-window snapshot recording find_package(rosbag2_cpp REQUIRED) find_package(rosbag2_storage REQUIRED) +medkit_detect_compat_defs() + # Library target (shared between executable and tests) add_library(fault_manager_lib STATIC src/fault_manager_node.cpp @@ -75,6 +81,8 @@ target_link_libraries(fault_manager_lib PUBLIC yaml-cpp::yaml-cpp ) +medkit_apply_compat_defs(fault_manager_lib) + if(ENABLE_COVERAGE) target_compile_options(fault_manager_lib PRIVATE --coverage -O0 -g) target_link_options(fault_manager_lib PRIVATE --coverage) diff --git a/src/ros2_medkit_fault_manager/package.xml b/src/ros2_medkit_fault_manager/package.xml index e2025d5c6..bf8a6dba7 100644 --- a/src/ros2_medkit_fault_manager/package.xml +++ b/src/ros2_medkit_fault_manager/package.xml @@ -27,6 +27,7 @@ launch_testing_ros sensor_msgs std_msgs + rosbag2_storage_mcap ament_cmake diff --git a/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp b/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp index 5f9801682..3d42768fa 100644 --- a/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp +++ b/src/ros2_medkit_fault_manager/src/rosbag_capture.cpp @@ -53,8 +53,13 @@ std::shared_ptr create_bag_message(const const rclcpp::Logger & logger) { auto bag_msg = std::make_shared(); bag_msg->topic_name = topic; + // rosbag2 API changed in Iron: Humble uses time_stamp, Iron+ uses recv_timestamp/send_timestamp +#ifdef ROSBAG2_USE_OLD_TIMESTAMP_FIELD + bag_msg->time_stamp = timestamp_ns; +#else bag_msg->recv_timestamp = timestamp_ns; bag_msg->send_timestamp = timestamp_ns; +#endif // Create serialized_data with custom deleter that calls rcutils_uint8_array_fini auto serialized_data = std::shared_ptr(new rcutils_uint8_array_t(), Uint8ArrayDeleter{}); @@ -361,8 +366,8 @@ void RosbagCapture::message_callback(const std::string & topic, const std::strin } // Memory is automatically cleaned up by RAII when bag_msg goes out of scope } catch (const std::exception & e) { - RCLCPP_WARN_THROTTLE(node_->get_logger(), *node_->get_clock(), 1000, "Failed to write post-fault message: %s", - e.what()); + rclcpp::Clock clock(*node_->get_clock()); + RCLCPP_WARN_THROTTLE(node_->get_logger(), clock, 1000, "Failed to write post-fault message: %s", e.what()); } } return; // Don't buffer during post-fault recording diff --git a/src/ros2_medkit_fault_reporter/include/ros2_medkit_fault_reporter/fault_reporter.hpp b/src/ros2_medkit_fault_reporter/include/ros2_medkit_fault_reporter/fault_reporter.hpp index fb89a6a5a..9e1696696 100644 --- a/src/ros2_medkit_fault_reporter/include/ros2_medkit_fault_reporter/fault_reporter.hpp +++ b/src/ros2_medkit_fault_reporter/include/ros2_medkit_fault_reporter/fault_reporter.hpp @@ -83,13 +83,12 @@ class FaultReporter { private: /// Load filter configuration from ROS parameters - void load_parameters(); + void load_parameters(const rclcpp::Node::SharedPtr & node); /// Send the fault report to FaultManager (async, fire-and-forget) void send_report(const std::string & fault_code, uint8_t event_type, uint8_t severity, const std::string & description); - rclcpp::Node::SharedPtr node_; std::string source_id_; rclcpp::Client::SharedPtr client_; LocalFilter filter_; diff --git a/src/ros2_medkit_fault_reporter/src/fault_reporter.cpp b/src/ros2_medkit_fault_reporter/src/fault_reporter.cpp index 3e12c472a..a84b97cb8 100644 --- a/src/ros2_medkit_fault_reporter/src/fault_reporter.cpp +++ b/src/ros2_medkit_fault_reporter/src/fault_reporter.cpp @@ -18,45 +18,44 @@ namespace ros2_medkit_fault_reporter { FaultReporter::FaultReporter(const rclcpp::Node::SharedPtr & node, const std::string & source_id, const std::string & service_name) - : node_(node), source_id_(source_id), logger_(node->get_logger()) { + : source_id_(source_id), logger_(node->get_logger()) { // Validate source_id if (source_id_.empty()) { RCLCPP_WARN(logger_, "FaultReporter created with empty source_id, fault origins will be difficult to trace"); } - // Create service client - client_ = node_->create_client(service_name); + // Create service client (stored as shared_ptr, independent of node lifetime) + client_ = node->create_client(service_name); - // Load configuration from parameters - load_parameters(); + // Load configuration from parameters (node only needed during construction) + load_parameters(node); RCLCPP_DEBUG(logger_, "FaultReporter initialized for source: %s", source_id_.c_str()); } -void FaultReporter::load_parameters() { +void FaultReporter::load_parameters(const rclcpp::Node::SharedPtr & node) { FilterConfig config; // Declare parameters with defaults if not already declared - if (!node_->has_parameter("fault_reporter.local_filtering.enabled")) { - node_->declare_parameter("fault_reporter.local_filtering.enabled", config.enabled); + if (!node->has_parameter("fault_reporter.local_filtering.enabled")) { + node->declare_parameter("fault_reporter.local_filtering.enabled", config.enabled); } - if (!node_->has_parameter("fault_reporter.local_filtering.default_threshold")) { - node_->declare_parameter("fault_reporter.local_filtering.default_threshold", config.default_threshold); + if (!node->has_parameter("fault_reporter.local_filtering.default_threshold")) { + node->declare_parameter("fault_reporter.local_filtering.default_threshold", config.default_threshold); } - if (!node_->has_parameter("fault_reporter.local_filtering.default_window_sec")) { - node_->declare_parameter("fault_reporter.local_filtering.default_window_sec", config.default_window_sec); + if (!node->has_parameter("fault_reporter.local_filtering.default_window_sec")) { + node->declare_parameter("fault_reporter.local_filtering.default_window_sec", config.default_window_sec); } - if (!node_->has_parameter("fault_reporter.local_filtering.bypass_severity")) { - node_->declare_parameter("fault_reporter.local_filtering.bypass_severity", - static_cast(config.bypass_severity)); + if (!node->has_parameter("fault_reporter.local_filtering.bypass_severity")) { + node->declare_parameter("fault_reporter.local_filtering.bypass_severity", static_cast(config.bypass_severity)); } - config.enabled = node_->get_parameter("fault_reporter.local_filtering.enabled").as_bool(); + config.enabled = node->get_parameter("fault_reporter.local_filtering.enabled").as_bool(); config.default_threshold = - static_cast(node_->get_parameter("fault_reporter.local_filtering.default_threshold").as_int()); - config.default_window_sec = node_->get_parameter("fault_reporter.local_filtering.default_window_sec").as_double(); + static_cast(node->get_parameter("fault_reporter.local_filtering.default_threshold").as_int()); + config.default_window_sec = node->get_parameter("fault_reporter.local_filtering.default_window_sec").as_double(); config.bypass_severity = - static_cast(node_->get_parameter("fault_reporter.local_filtering.bypass_severity").as_int()); + static_cast(node->get_parameter("fault_reporter.local_filtering.bypass_severity").as_int()); filter_.set_config(config); diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index bd03fde27..e731bc2a0 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -5,6 +5,10 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Multi-distro compatibility (Humble / Jazzy / Rolling) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake") +include(ROS2MedkitCompat) + if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) endif() @@ -26,7 +30,7 @@ find_package(sensor_msgs REQUIRED) find_package(rcl_interfaces REQUIRED) find_package(nlohmann_json REQUIRED) find_package(yaml_cpp_vendor REQUIRED) -find_package(yaml-cpp REQUIRED) +medkit_find_yaml_cpp() find_package(ament_index_cpp REQUIRED) find_package(action_msgs REQUIRED) find_package(rclcpp_action REQUIRED) @@ -34,9 +38,12 @@ find_package(example_interfaces REQUIRED) find_package(ros2_medkit_msgs REQUIRED) find_package(ros2_medkit_serialization REQUIRED) -# Find cpp-httplib using pkg-config -find_package(PkgConfig REQUIRED) -pkg_check_modules(cpp_httplib REQUIRED IMPORTED_TARGET cpp-httplib) +# GenericClient compat shim dependencies (needed explicitly for Humble, +# transitively available on Iron+/Jazzy but harmless to declare unconditionally) +find_package(rosidl_typesupport_cpp REQUIRED) +find_package(rosidl_typesupport_introspection_cpp REQUIRED) + +medkit_find_cpp_httplib() # Find OpenSSL (required by jwt-cpp for RS256 and optional TLS support) find_package(OpenSSL REQUIRED) @@ -129,10 +136,12 @@ ament_target_dependencies(gateway_lib action_msgs ros2_medkit_msgs ros2_medkit_serialization + rosidl_typesupport_cpp + rosidl_typesupport_introspection_cpp ) target_link_libraries(gateway_lib - PkgConfig::cpp_httplib + cpp_httplib_target nlohmann_json::nlohmann_json yaml-cpp::yaml-cpp OpenSSL::SSL @@ -241,6 +250,10 @@ if(BUILD_TESTING) ament_add_gtest(test_auth_manager test/test_auth_manager.cpp) target_link_libraries(test_auth_manager gateway_lib) + ament_add_gtest(test_generic_client_compat test/test_generic_client_compat.cpp) + target_link_libraries(test_generic_client_compat gateway_lib) + ament_target_dependencies(test_generic_client_compat rclcpp std_srvs) + ament_add_gtest(test_operation_manager test/test_operation_manager.cpp) target_link_libraries(test_operation_manager gateway_lib) @@ -329,6 +342,7 @@ if(BUILD_TESTING) set(_test_targets test_gateway_node test_auth_manager + test_generic_client_compat test_operation_manager test_configuration_manager test_native_topic_sampler diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/compat/generic_client_compat.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/compat/generic_client_compat.hpp new file mode 100644 index 000000000..fc018cc09 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/compat/generic_client_compat.hpp @@ -0,0 +1,353 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file generic_client_compat.hpp +/// @brief Provides GenericServiceClient — either rclcpp::GenericClient (Iron+) or a shim for Humble. +/// +/// rclcpp::GenericClient was introduced in Iron (rclcpp ≥ 21.x). Humble (rclcpp 16.x) +/// does not have it. This header defines HAS_GENERIC_CLIENT based on the rclcpp version +/// and provides a compatible GenericServiceClient type for OperationManager to use. +/// +/// When HAS_GENERIC_CLIENT is true (Iron/Jazzy), GenericServiceClient is simply +/// rclcpp::GenericClient, and create_generic_service_client() delegates to +/// Node::create_generic_client(). +/// +/// When HAS_GENERIC_CLIENT is false (Humble), GenericServiceClient is a custom class +/// that replicates GenericClient's behavior using rcl C APIs and the same +/// rosidl_typesupport_introspection infrastructure available in all distros. + +#pragma once + +#include + +// Detect whether rclcpp::GenericClient is available. +// rclcpp/version.h exists in all supported distros (Humble, Jazzy, Rolling). +#include + +#if defined(RCLCPP_VERSION_MAJOR) && RCLCPP_VERSION_MAJOR >= 21 +#define HAS_GENERIC_CLIENT 1 +#else +#define HAS_GENERIC_CLIENT 0 +#endif + +#if HAS_GENERIC_CLIENT + +// ============================================================================ +// Iron / Jazzy path — just alias to rclcpp::GenericClient +// ============================================================================ + +#include + +namespace ros2_medkit_gateway { +namespace compat { + +using GenericServiceClient = rclcpp::GenericClient; + +/// Create a GenericServiceClient (delegates to Node::create_generic_client) +inline GenericServiceClient::SharedPtr +create_generic_service_client(rclcpp::Node * node, const std::string & service_name, const std::string & service_type) { + return node->create_generic_client(service_name, service_type); +} + +} // namespace compat +} // namespace ros2_medkit_gateway + +#else // !HAS_GENERIC_CLIENT + +// ============================================================================ +// Humble compatibility shim — replicate GenericClient using rcl C APIs +// ============================================================================ +// +// On Humble (rclcpp 16.x), the following APIs do NOT exist: +// - rclcpp::GenericClient +// - rclcpp::get_service_typesupport_handle() +// - rclcpp::get_message_typesupport_handle() +// +// The following APIs ARE available: +// - rclcpp::get_typesupport_library() — loads the .so +// - rclcpp::get_typesupport_handle() — extracts message TS from .so +// - rclcpp::ClientBase — base class for service clients +// - rcl_client_init / rcl_send_request — C-level service client API +// - rosidl_typesupport_introspection_cpp — message introspection +// +// This shim manually loads the service typesupport symbol from the library +// (the same way GenericClient does on Iron+, but with direct dlsym). + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace compat { + +namespace detail { + +/// Build the mangled symbol name for a service's rosidl_typesupport_cpp handle. +/// Example: "std_srvs/srv/Trigger" with ts_id "rosidl_typesupport_cpp" +/// => "rosidl_typesupport_cpp__get_service_type_support_handle__std_srvs__srv__Trigger" +inline std::string make_service_ts_symbol(const std::string & service_type, + const std::string & typesupport_identifier) { + // service_type format: "package/srv/ServiceName" + std::string symbol = service_type; + // Replace '/' with '__' + std::string result; + result.reserve(typesupport_identifier.size() + 40 + symbol.size()); + result += typesupport_identifier; + result += "__get_service_type_support_handle__"; + for (char c : symbol) { + if (c == '/') { + result += "__"; + } else { + result += c; + } + } + return result; +} + +/// Load the service typesupport handle from a shared library. +/// Equivalent to rclcpp::get_service_typesupport_handle() (available only in Iron+). +inline const rosidl_service_type_support_t * load_service_typesupport(const std::string & service_type, + const std::string & typesupport_identifier, + rcpputils::SharedLibrary & library) { + std::string symbol_name = make_service_ts_symbol(service_type, typesupport_identifier); + + if (!library.has_symbol(symbol_name)) { + throw std::runtime_error("Failed to find service typesupport symbol '" + symbol_name + "' in library"); + } + + // The symbol is a function: const rosidl_service_type_support_t* get_...() + using GetServiceTSFunc = const rosidl_service_type_support_t * (*)(); + auto func = reinterpret_cast(library.get_symbol(symbol_name)); + if (!func) { + throw std::runtime_error("Failed to load service typesupport function for '" + service_type + "'"); + } + + return func(); +} + +/// Get the introspection MessageMembers for the response message of a service. +/// The service_ts coming from "rosidl_typesupport_cpp" is a dispatch handle. +/// We use the dispatch function to obtain the introspection-flavor handle, +/// then cast its data to ServiceMembers to get response_members_. +/// Both rosidl_typesupport_cpp::get_service_typesupport_handle_function() and +/// rosidl_typesupport_introspection_cpp::ServiceMembers exist in Humble. +inline const rosidl_typesupport_introspection_cpp::MessageMembers * +get_response_introspection(const rosidl_service_type_support_t * service_ts) { + // Dispatch from "rosidl_typesupport_cpp" → "rosidl_typesupport_introspection_cpp" + const rosidl_service_type_support_t * intro_ts = rosidl_typesupport_cpp::get_service_typesupport_handle_function( + service_ts, rosidl_typesupport_introspection_cpp::typesupport_identifier); + if (!intro_ts) { + throw std::runtime_error("Failed to get introspection typesupport for service"); + } + + const auto * service_members = + static_cast(intro_ts->data); + return service_members->response_members_; +} + +} // namespace detail + +/// Compatibility GenericClient for ROS 2 Humble. +/// Replicates the subset of rclcpp::GenericClient API used by OperationManager: +/// - async_send_request(void*) +/// - wait_for_service(timeout) +/// - remove_pending_request(request_id) +/// +/// Implemented directly on top of rcl C APIs and dynamically loaded typesupport, +/// which are available in all ROS 2 distros from Humble onward. +class GenericServiceClient : public rclcpp::ClientBase { + public: + using Request = void *; + using Response = void *; + using SharedResponse = std::shared_ptr; + using Promise = std::promise; + using Future = std::future; + + using SharedPtr = std::shared_ptr; + + /// FutureAndRequestId — wraps a std::future and associated request_id, + /// mirroring rclcpp::GenericClient::FutureAndRequestId for API compatibility. + struct FutureAndRequestId { + Future future; + int64_t request_id; + + FutureAndRequestId(Future f, int64_t id) : future(std::move(f)), request_id(id) { + } + + FutureAndRequestId(FutureAndRequestId && other) noexcept = default; + FutureAndRequestId & operator=(FutureAndRequestId && other) noexcept = default; + FutureAndRequestId(const FutureAndRequestId &) = delete; + FutureAndRequestId & operator=(const FutureAndRequestId &) = delete; + + auto get() { + return future.get(); + } + bool valid() const noexcept { + return future.valid(); + } + void wait() const { + future.wait(); + } + + template + std::future_status wait_for(const std::chrono::duration & timeout) const { + return future.wait_for(timeout); + } + }; + + /// Construct a GenericServiceClient. + /// @param node_base Node base interface (for rcl node handle) + /// @param node_graph Node graph interface (for service availability checks) + /// @param service_name Fully-qualified service name + /// @param service_type Service type string, e.g. "std_srvs/srv/Trigger" + /// @param client_options rcl client options + GenericServiceClient(rclcpp::node_interfaces::NodeBaseInterface * node_base, + rclcpp::node_interfaces::NodeGraphInterface::SharedPtr node_graph, + const std::string & service_name, const std::string & service_type, + rcl_client_options_t & client_options) + : ClientBase(node_base, std::move(node_graph)) { + // Step 1: Load the typesupport shared library for this service type + ts_lib_ = rclcpp::get_typesupport_library(service_type, "rosidl_typesupport_cpp"); + + // Step 2: Get the service type support handle from the loaded library + // Note: rclcpp::get_service_typesupport_handle() doesn't exist in Humble, + // so we manually resolve the symbol from the shared library. + const rosidl_service_type_support_t * service_ts = + detail::load_service_typesupport(service_type, "rosidl_typesupport_cpp", *ts_lib_); + + // Step 3: Get the introspection MessageMembers for the response type. + // Used by create_response() to allocate and initialize response messages. + response_members_ = detail::get_response_introspection(service_ts); + + // Step 4: Initialize the rcl client with the dynamically-loaded typesupport + rcl_ret_t ret = rcl_client_init(this->get_client_handle().get(), this->get_rcl_node_handle(), service_ts, + service_name.c_str(), &client_options); + if (ret != RCL_RET_OK) { + rclcpp::exceptions::throw_from_rcl_error(ret, "Failed to create generic service client"); + } + } + + ~GenericServiceClient() override = default; + + /// Create a response message (allocated via introspection) + std::shared_ptr create_response() override { + // Allocate buffer for the response message + auto * response = new uint8_t[response_members_->size_of_]; + // Initialize with default values using the introspection init function + response_members_->init_function(response, rosidl_runtime_cpp::MessageInitialization::ZERO); + // Return with custom deleter that calls fini_function + const auto * members = response_members_; + return std::shared_ptr(response, [members](void * ptr) { + members->fini_function(ptr); + delete[] static_cast(ptr); + }); + } + + /// Create a request header for associating responses + std::shared_ptr create_request_header() override { + return std::make_shared(); + } + + /// Handle incoming response — dispatches to the pending promise + void handle_response(std::shared_ptr request_header, std::shared_ptr response) override { + std::unique_lock lock(pending_requests_mutex_); + auto it = pending_requests_.find(request_header->sequence_number); + if (it != pending_requests_.end()) { + auto promise = std::move(it->second); + pending_requests_.erase(it); + lock.unlock(); + promise.set_value(std::move(response)); + } + } + + /// Send a request asynchronously. Returns a FutureAndRequestId. + /// @param request Pointer to the deserialized request message (void*) + FutureAndRequestId async_send_request(const Request request) { + Promise promise; + auto future = promise.get_future(); + + // Send the request via rcl + int64_t sequence_number; + rcl_ret_t ret = rcl_send_request(get_client_handle().get(), request, &sequence_number); + if (ret != RCL_RET_OK) { + rclcpp::exceptions::throw_from_rcl_error(ret, "Failed to send request"); + } + + // Store promise for later fulfillment when handle_response is called + { + std::lock_guard lock(pending_requests_mutex_); + pending_requests_.emplace(sequence_number, std::move(promise)); + } + + return FutureAndRequestId(std::move(future), sequence_number); + } + + /// Remove a pending request (called on timeout to prevent resource leak) + bool remove_pending_request(int64_t request_id) { + std::lock_guard lock(pending_requests_mutex_); + return pending_requests_.erase(request_id) > 0; + } + + private: + /// Loaded typesupport library (kept alive for duration of client) + std::shared_ptr ts_lib_; + + /// Response type introspection members (for allocating response messages) + const rosidl_typesupport_introspection_cpp::MessageMembers * response_members_; + + /// Map from sequence_number to pending promise + std::mutex pending_requests_mutex_; + std::map pending_requests_; +}; + +/// Create a GenericServiceClient for Humble +inline GenericServiceClient::SharedPtr +create_generic_service_client(rclcpp::Node * node, const std::string & service_name, const std::string & service_type) { + rcl_client_options_t options = rcl_client_get_default_options(); + auto client = std::make_shared( + node->get_node_base_interface().get(), node->get_node_graph_interface(), service_name, service_type, options); + + // Register the client with the node's default callback group so the executor + // polls it for incoming responses. Without this, handle_response() is never + // called and every future hangs until timeout. + node->get_node_services_interface()->add_client(std::dynamic_pointer_cast(client), nullptr); + + return client; +} + +} // namespace compat +} // namespace ros2_medkit_gateway + +#endif // HAS_GENERIC_CLIENT diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp index 0599fef2d..7603ecae7 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp @@ -23,12 +23,12 @@ #include #include #include -#include #include #include #include #include +#include "ros2_medkit_gateway/compat/generic_client_compat.hpp" #include "ros2_medkit_gateway/discovery/discovery_manager.hpp" #include "ros2_medkit_gateway/discovery/models/common.hpp" #include "ros2_medkit_serialization/json_serializer.hpp" @@ -210,9 +210,9 @@ class OperationManager { private: /// Set of clients for an action (internal services) struct ActionClientSet { - rclcpp::GenericClient::SharedPtr send_goal_client; - rclcpp::GenericClient::SharedPtr get_result_client; - rclcpp::GenericClient::SharedPtr cancel_goal_client; + compat::GenericServiceClient::SharedPtr send_goal_client; + compat::GenericServiceClient::SharedPtr get_result_client; + compat::GenericServiceClient::SharedPtr cancel_goal_client; std::string action_type; // Store type for later use }; @@ -228,9 +228,9 @@ class OperationManager { /// Track a new goal void track_goal(const std::string & goal_id, const std::string & action_path, const std::string & action_type); - /// Get or create a cached GenericClient for a service - rclcpp::GenericClient::SharedPtr get_or_create_service_client(const std::string & service_path, - const std::string & service_type); + /// Get or create a cached GenericServiceClient for a service + compat::GenericServiceClient::SharedPtr get_or_create_service_client(const std::string & service_path, + const std::string & service_type); /// Get or create cached action clients for an action ActionClientSet & get_or_create_action_clients(const std::string & action_path, const std::string & action_type); @@ -248,9 +248,9 @@ class OperationManager { /// Native JSON serializer for service calls std::shared_ptr serializer_; - /// Cache for GenericClient instances (key = "service_path|service_type") + /// Cache for GenericServiceClient instances (key = "service_path|service_type") mutable std::shared_mutex clients_mutex_; - std::map generic_clients_; + std::map generic_clients_; /// Cache for action client sets (key = action_path) std::map action_clients_; diff --git a/src/ros2_medkit_gateway/package.xml b/src/ros2_medkit_gateway/package.xml index 1053aa934..8388faf0d 100644 --- a/src/ros2_medkit_gateway/package.xml +++ b/src/ros2_medkit_gateway/package.xml @@ -25,6 +25,8 @@ ament_index_cpp ros2_medkit_msgs ros2_medkit_serialization + rosidl_typesupport_cpp + rosidl_typesupport_introspection_cpp rosidl_runtime_py rosidl_parser diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp index 4eae15a16..a3573c81b 100644 --- a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp +++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp @@ -369,7 +369,7 @@ std::optional ManifestManager::get_capabilities_override(const std::string auto it = manifest_->capabilities.find(entity_id); if (it != manifest_->capabilities.end()) { - return it->second; + return std::optional{it->second}; } return std::nullopt; } diff --git a/src/ros2_medkit_gateway/src/native_topic_sampler.cpp b/src/ros2_medkit_gateway/src/native_topic_sampler.cpp index e41132b4b..3e3e14a35 100644 --- a/src/ros2_medkit_gateway/src/native_topic_sampler.cpp +++ b/src/ros2_medkit_gateway/src/native_topic_sampler.cpp @@ -27,6 +27,8 @@ #include #include +#include + #include "ros2_medkit_serialization/json_serializer.hpp" #include "ros2_medkit_serialization/serialization_error.hpp" @@ -35,7 +37,6 @@ namespace ros2_medkit_gateway { namespace { /// Convert rclcpp ReliabilityPolicy to string -/// Note: BestAvailable policy requires ROS 2 Humble or newer std::string reliability_to_string(rclcpp::ReliabilityPolicy policy) { switch (policy) { case rclcpp::ReliabilityPolicy::Reliable: @@ -44,15 +45,16 @@ std::string reliability_to_string(rclcpp::ReliabilityPolicy policy) { return "best_effort"; case rclcpp::ReliabilityPolicy::SystemDefault: return "system_default"; +#if RCLCPP_VERSION_MAJOR >= 21 case rclcpp::ReliabilityPolicy::BestAvailable: return "best_available"; +#endif default: return "unknown"; } } /// Convert rclcpp DurabilityPolicy to string -/// Note: BestAvailable policy requires ROS 2 Humble or newer std::string durability_to_string(rclcpp::DurabilityPolicy policy) { switch (policy) { case rclcpp::DurabilityPolicy::Volatile: @@ -61,8 +63,10 @@ std::string durability_to_string(rclcpp::DurabilityPolicy policy) { return "transient_local"; case rclcpp::DurabilityPolicy::SystemDefault: return "system_default"; +#if RCLCPP_VERSION_MAJOR >= 21 case rclcpp::DurabilityPolicy::BestAvailable: return "best_available"; +#endif default: return "unknown"; } @@ -83,7 +87,6 @@ std::string history_to_string(rclcpp::HistoryPolicy policy) { } /// Convert rclcpp LivelinessPolicy to string -/// Note: BestAvailable policy requires ROS 2 Humble or newer std::string liveliness_to_string(rclcpp::LivelinessPolicy policy) { switch (policy) { case rclcpp::LivelinessPolicy::Automatic: @@ -92,8 +95,10 @@ std::string liveliness_to_string(rclcpp::LivelinessPolicy policy) { return "manual_by_topic"; case rclcpp::LivelinessPolicy::SystemDefault: return "system_default"; +#if RCLCPP_VERSION_MAJOR >= 21 case rclcpp::LivelinessPolicy::BestAvailable: return "best_available"; +#endif default: return "unknown"; } diff --git a/src/ros2_medkit_gateway/src/operation_manager.cpp b/src/ros2_medkit_gateway/src/operation_manager.cpp index bc9c1075f..9acdb53f2 100644 --- a/src/ros2_medkit_gateway/src/operation_manager.cpp +++ b/src/ros2_medkit_gateway/src/operation_manager.cpp @@ -74,8 +74,8 @@ std::string OperationManager::make_client_key(const std::string & service_path, return service_path + "|" + service_type; } -rclcpp::GenericClient::SharedPtr OperationManager::get_or_create_service_client(const std::string & service_path, - const std::string & service_type) { +compat::GenericServiceClient::SharedPtr +OperationManager::get_or_create_service_client(const std::string & service_path, const std::string & service_type) { const std::string key = make_client_key(service_path, service_type); // Try read lock first (fast path) @@ -97,7 +97,7 @@ rclcpp::GenericClient::SharedPtr OperationManager::get_or_create_service_client( } // Create new client - auto client = node_->create_generic_client(service_path, service_type); + auto client = compat::create_generic_service_client(node_, service_path, service_type); generic_clients_[key] = client; RCLCPP_DEBUG(node_->get_logger(), "Created generic client for %s (%s)", service_path.c_str(), service_type.c_str()); @@ -346,16 +346,17 @@ OperationManager::ActionClientSet & OperationManager::get_or_create_action_clien // Send goal service: {action}/_action/send_goal std::string send_goal_service = action_path + "/_action/send_goal"; std::string send_goal_type = ServiceActionTypes::get_action_send_goal_service_type(action_type); - clients.send_goal_client = node_->create_generic_client(send_goal_service, send_goal_type); + clients.send_goal_client = compat::create_generic_service_client(node_, send_goal_service, send_goal_type); // Get result service: {action}/_action/get_result std::string get_result_service = action_path + "/_action/get_result"; std::string get_result_type = ServiceActionTypes::get_action_get_result_service_type(action_type); - clients.get_result_client = node_->create_generic_client(get_result_service, get_result_type); + clients.get_result_client = compat::create_generic_service_client(node_, get_result_service, get_result_type); // Cancel goal service (standard type for all actions) std::string cancel_service = action_path + "/_action/cancel_goal"; - clients.cancel_goal_client = node_->create_generic_client(cancel_service, "action_msgs/srv/CancelGoal"); + clients.cancel_goal_client = + compat::create_generic_service_client(node_, cancel_service, "action_msgs/srv/CancelGoal"); RCLCPP_DEBUG(node_->get_logger(), "Created action clients for %s (type: %s)", action_path.c_str(), action_type.c_str()); diff --git a/src/ros2_medkit_gateway/test/test_data_access_manager.cpp b/src/ros2_medkit_gateway/test/test_data_access_manager.cpp index ed5aaefb4..600f3f796 100644 --- a/src/ros2_medkit_gateway/test/test_data_access_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_data_access_manager.cpp @@ -14,6 +14,7 @@ #include +#include #include #include #include diff --git a/src/ros2_medkit_gateway/test/test_generic_client_compat.cpp b/src/ros2_medkit_gateway/test/test_generic_client_compat.cpp new file mode 100644 index 000000000..29a7a4e87 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_generic_client_compat.cpp @@ -0,0 +1,184 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// @file test_generic_client_compat.cpp +/// @brief Unit tests for the GenericServiceClient compatibility layer. +/// +/// On Jazzy (rclcpp >= 21), these tests exercise the rclcpp::GenericClient alias path. +/// On Humble (rclcpp < 21), these tests exercise the custom shim implementation. +/// The tests verify the public API contract that OperationManager depends on. +/// +// @verifies REQ_HUMBLE_COMPAT + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/compat/generic_client_compat.hpp" + +using namespace ros2_medkit_gateway; + +class TestGenericClientCompat : public ::testing::Test { + protected: + static void SetUpTestSuite() { + rclcpp::init(0, nullptr); + } + + static void TearDownTestSuite() { + rclcpp::shutdown(); + } + + void SetUp() override { + node_ = std::make_shared("test_generic_client_compat_node"); + } + + void TearDown() override { + node_.reset(); + } + + std::shared_ptr node_; +}; + +// ==================== FACTORY TESTS ==================== + +/// Factory creates a valid non-null client +TEST_F(TestGenericClientCompat, factory_creates_valid_client) { + auto client = compat::create_generic_service_client(node_.get(), "/test/trigger_service", "std_srvs/srv/Trigger"); + ASSERT_NE(client, nullptr); +} + +/// Factory works with different service types +TEST_F(TestGenericClientCompat, factory_works_with_set_bool) { + auto client = compat::create_generic_service_client(node_.get(), "/test/set_bool_service", "std_srvs/srv/SetBool"); + ASSERT_NE(client, nullptr); +} + +/// Multiple clients can be created for different services +TEST_F(TestGenericClientCompat, multiple_clients_for_different_services) { + auto client_a = compat::create_generic_service_client(node_.get(), "/test/service_a", "std_srvs/srv/Trigger"); + auto client_b = compat::create_generic_service_client(node_.get(), "/test/service_b", "std_srvs/srv/Trigger"); + auto client_c = compat::create_generic_service_client(node_.get(), "/test/service_c", "std_srvs/srv/SetBool"); + + ASSERT_NE(client_a, nullptr); + ASSERT_NE(client_b, nullptr); + ASSERT_NE(client_c, nullptr); + + // They should be distinct client instances + EXPECT_NE(client_a.get(), client_b.get()); + EXPECT_NE(client_a.get(), client_c.get()); +} + +/// Multiple clients can be created for the same service (different consumers) +TEST_F(TestGenericClientCompat, multiple_clients_for_same_service) { + auto client1 = compat::create_generic_service_client(node_.get(), "/test/shared_service", "std_srvs/srv/Trigger"); + auto client2 = compat::create_generic_service_client(node_.get(), "/test/shared_service", "std_srvs/srv/Trigger"); + + ASSERT_NE(client1, nullptr); + ASSERT_NE(client2, nullptr); + EXPECT_NE(client1.get(), client2.get()); +} + +// ==================== SERVICE AVAILABILITY TESTS ==================== + +/// Client reports service as unavailable when no server exists +TEST_F(TestGenericClientCompat, service_not_available_for_nonexistent) { + auto client = + compat::create_generic_service_client(node_.get(), "/nonexistent/trigger_service", "std_srvs/srv/Trigger"); + ASSERT_NE(client, nullptr); + + bool available = client->wait_for_service(std::chrono::milliseconds(100)); + EXPECT_FALSE(available); +} + +/// Client detects a running service server +TEST_F(TestGenericClientCompat, detects_running_service) { + // Create a service server on the same node + auto service = node_->create_service( + "/test/live_trigger_service", [](const std::shared_ptr /*req*/, + std::shared_ptr res) { + res->success = true; + res->message = "ok"; + }); + + auto client = + compat::create_generic_service_client(node_.get(), "/test/live_trigger_service", "std_srvs/srv/Trigger"); + ASSERT_NE(client, nullptr); + + // Service should become available + bool available = client->wait_for_service(std::chrono::seconds(2)); + EXPECT_TRUE(available); +} + +/// Client detects a SetBool service server +TEST_F(TestGenericClientCompat, detects_running_set_bool_service) { + auto service = node_->create_service( + "/test/live_set_bool_service", [](const std::shared_ptr /*req*/, + std::shared_ptr res) { + res->success = true; + res->message = "done"; + }); + + auto client = + compat::create_generic_service_client(node_.get(), "/test/live_set_bool_service", "std_srvs/srv/SetBool"); + ASSERT_NE(client, nullptr); + + bool available = client->wait_for_service(std::chrono::seconds(2)); + EXPECT_TRUE(available); +} + +// ==================== TYPE ALIAS VERIFICATION ==================== + +/// GenericServiceClient::SharedPtr is a valid shared_ptr type +TEST_F(TestGenericClientCompat, shared_ptr_type_is_valid) { + compat::GenericServiceClient::SharedPtr client = + compat::create_generic_service_client(node_.get(), "/test/type_alias_service", "std_srvs/srv/Trigger"); + + // Verify it's a proper shared_ptr (use_count should be >= 1) + ASSERT_NE(client, nullptr); + EXPECT_GE(client.use_count(), 1); +} + +/// Client can be stored as a ClientBase shared pointer (polymorphism) +TEST_F(TestGenericClientCompat, can_cast_to_client_base) { + auto client = compat::create_generic_service_client(node_.get(), "/test/cast_service", "std_srvs/srv/Trigger"); + ASSERT_NE(client, nullptr); + + // The compat client (on both paths) should be castable to ClientBase + std::shared_ptr base_ptr = std::dynamic_pointer_cast(client); + EXPECT_NE(base_ptr, nullptr); +} + +// ==================== COMPILE-TIME DETECTION ==================== + +/// Verify that HAS_GENERIC_CLIENT macro is defined consistently +TEST_F(TestGenericClientCompat, has_generic_client_macro_is_defined) { +#if HAS_GENERIC_CLIENT + // On Iron+ / Jazzy: should use rclcpp::GenericClient + SUCCEED() << "Running on Iron+ (HAS_GENERIC_CLIENT=1): using native rclcpp::GenericClient"; +#else + // On Humble: should use custom shim + SUCCEED() << "Running on Humble (HAS_GENERIC_CLIENT=0): using compat shim"; +#endif +} + +int main(int argc, char ** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/ros2_medkit_gateway/test/test_manifest_manager.cpp b/src/ros2_medkit_gateway/test/test_manifest_manager.cpp index 2a176299f..d13629d66 100644 --- a/src/ros2_medkit_gateway/test/test_manifest_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_manifest_manager.cpp @@ -14,6 +14,7 @@ #include +#include #include #include #include diff --git a/src/ros2_medkit_serialization/CMakeLists.txt b/src/ros2_medkit_serialization/CMakeLists.txt index d59bbeef5..4c340096d 100644 --- a/src/ros2_medkit_serialization/CMakeLists.txt +++ b/src/ros2_medkit_serialization/CMakeLists.txt @@ -5,6 +5,10 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +# Multi-distro compatibility (Humble / Jazzy / Rolling) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake") +include(ROS2MedkitCompat) + if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif() @@ -27,7 +31,7 @@ find_package(rosidl_typesupport_introspection_cpp REQUIRED) find_package(rosidl_runtime_cpp REQUIRED) find_package(rcpputils REQUIRED) find_package(yaml_cpp_vendor REQUIRED) -find_package(yaml-cpp REQUIRED) +medkit_find_yaml_cpp() find_package(nlohmann_json REQUIRED) # Library diff --git a/src/ros2_medkit_serialization/test/test_type_cache.cpp b/src/ros2_medkit_serialization/test/test_type_cache.cpp index 886b585a0..7bd7fb3e1 100644 --- a/src/ros2_medkit_serialization/test/test_type_cache.cpp +++ b/src/ros2_medkit_serialization/test/test_type_cache.cpp @@ -14,6 +14,7 @@ #include +#include #include #include