diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0abfe2b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,31 @@ +# ROS 2 Medkit Gateway - Architecture Documentation + +This directory contains design documentation for the ros2_medkit_gateway project. + +## Architecture Diagram + +The `architecture.puml` file contains a PlantUML class diagram showing the relationships between the main components of the gateway. + +### Main Components + +1. **GatewayNode** - The main ROS 2 node that orchestrates the system + - Extends `rclcpp::Node` + - Manages periodic discovery and cache refresh + - Runs the REST server in a separate thread + - Provides thread-safe access to the entity cache + +2. **DiscoveryManager** - Discovers ROS 2 entities and maps them to the SOVD hierarchy + - Discovers Areas from node namespaces + - Discovers Components from nodes, topics, and services + - Extracts the entity hierarchy from the ROS 2 graph + +3. **RESTServer** - Provides the HTTP/REST API + - Serves endpoints: `/health`, `/`, `/areas` + - Retrieves cached entities from the GatewayNode + - Runs on configurable host and port + +4. **Data Models** - Entity representations + - `Area` - Physical or logical domain + - `Component` - Hardware or software component + - `EntityCache` - Thread-safe cache of discovered entities + \ No newline at end of file diff --git a/docs/architecture.puml b/docs/architecture.puml new file mode 100644 index 0000000..1038d6d --- /dev/null +++ b/docs/architecture.puml @@ -0,0 +1,92 @@ +@startuml ros2_medkit_gateway_architecture + +!theme plain +skinparam linetype ortho +skinparam classAttributeIconSize 0 + +title ROS 2 Medkit Gateway - Class Architecture + +package "ROS 2 Framework" { + class rclcpp::Node { + +get_node_names() + +get_topic_names_and_types() + +get_service_names_and_types() + } +} + +package "ros2_medkit_gateway" { + + class GatewayNode { + + get_entity_cache(): EntityCache + } + + class DiscoveryManager { + + discover_areas(): vector + + discover_components(): vector + } + + class RESTServer { + + start(): void + + stop(): void + } + + class Area { + + id: string + + namespace_path: string + + type: string + + to_json(): json + } + + class Component { + + id: string + + namespace_path: string + + fqn: string + + type: string + + area: string + + to_json(): json + } + + class EntityCache { + + areas: vector + + components: vector + + last_update: time_point + } +} + +package "External Libraries" { + class "httplib::Server" as HTTPLibServer + class "nlohmann::json" as JSON +} + +' Relationships + +' Inheritance +GatewayNode -up-|> rclcpp::Node : extends + +' Composition (Gateway owns these) +GatewayNode *-down-> DiscoveryManager : owns +GatewayNode *-down-> RESTServer : owns +GatewayNode *-down-> EntityCache : owns + +' Discovery Manager uses Node interface +DiscoveryManager --> rclcpp::Node : uses + +' REST Server references Gateway +RESTServer --> GatewayNode : uses + +' Entity Cache aggregates entities +EntityCache o-right-> Area : contains many +EntityCache o-right-> Component : contains many + +' Discovery produces entities +DiscoveryManager ..> Area : creates +DiscoveryManager ..> Component : creates + +' REST Server uses HTTP library +RESTServer *--> HTTPLibServer : owns + +' Models use JSON for serialization +Area ..> JSON : serializes to +Component ..> JSON : serializes to + +@enduml diff --git a/postman/README.md b/postman/README.md new file mode 100644 index 0000000..b37b433 --- /dev/null +++ b/postman/README.md @@ -0,0 +1,61 @@ +# ROS 2 Medkit Gateway Postman Collection + +This folder contains Postman collections for testing the ROS 2 Medkit Gateway REST API. + +## Current Collection: + +**Collection:** `ros2-medkit-gateway.postman_collection.json` + +Includes below endpoints: +- ✅ GET `/` - Gateway info +- ✅ GET `/areas` - List all areas + +## Quick Start + +### 1. Import Collection + +**In Postman Desktop:** +1. Click **Import** button (top-left) +2. Select: `postman/collections/ros2-medkit-gateway.postman_collection.json` +3. Click **Import** + +### 2. Import Environment + +1. Click **Environments** icon (left sidebar) +2. Click **Import** +3. Select: `postman/environments/local.postman_environment.json` +4. Activate environment: Select **"ROS 2 Medkit Gateway - Local"** from dropdown (top-right) + +### 3. Start Gateway & Demo Nodes + +**Terminal 1 - Gateway:** +```bash +ros2 launch ros2_medkit_gateway gateway.launch.py +``` + +**Terminal 2 - Demo Nodes:** +```bash +ros2 launch ros2_medkit_gateway demo_nodes.launch.py +``` + +### 4. Test + +1. In Postman, expand **"Discovery"** folder +2. Click **"GET Gateway Info"** +3. Click **Send** +4. You should see: `{"status": "ROS 2 Medkit Gateway running", "version": "0.1.0", ...}` + +5. Click **"GET List Areas"** +6. Click **Send** +7. You should see areas: `[{"id": "powertrain", ...}, {"id": "chassis", ...}, ...]` + +## API Variables + +The environment includes: +- `base_url`: `http://localhost:8080` (default gateway address) + +You can change the port if your gateway runs on a different port. + +--- + +**Happy Testing! 🚀** diff --git a/postman/collections/ros2-medkit-gateway.postman_collection.json b/postman/collections/ros2-medkit-gateway.postman_collection.json new file mode 100644 index 0000000..e2b261f --- /dev/null +++ b/postman/collections/ros2-medkit-gateway.postman_collection.json @@ -0,0 +1,50 @@ +{ + "info": { + "name": "ROS 2 Medkit Gateway API", + "description": "Minimal collection: GET / and GET /areas endpoints only", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "Discovery", + "item": [ + { + "name": "GET Gateway Info", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/", + "host": [ + "{{base_url}}" + ], + "path": [ + "" + ] + }, + "description": "Get gateway status and version information" + }, + "response": [] + }, + { + "name": "GET List Areas", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/areas", + "host": [ + "{{base_url}}" + ], + "path": [ + "areas" + ] + }, + "description": "List all discovered areas (powertrain, chassis, body, root)" + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/postman/environments/local.postman_environment.json b/postman/environments/local.postman_environment.json new file mode 100644 index 0000000..58f92fd --- /dev/null +++ b/postman/environments/local.postman_environment.json @@ -0,0 +1,11 @@ +{ + "name": "ROS 2 Medkit Gateway - Local", + "values": [ + { + "key": "base_url", + "value": "http://localhost:8080", + "type": "default", + "enabled": true + } + ] +} \ No newline at end of file diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 3eb8c6b..173fdae 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -1,6 +1,11 @@ cmake_minimum_required(VERSION 3.8) project(ros2_medkit_gateway) +# Compiler settings +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() + if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif() @@ -8,26 +13,51 @@ endif() # Find dependencies find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) +find_package(std_msgs REQUIRED) +find_package(std_srvs REQUIRED) +find_package(sensor_msgs REQUIRED) +find_package(rcl_interfaces REQUIRED) find_package(nlohmann_json REQUIRED) # Find cpp-httplib using pkg-config find_package(PkgConfig REQUIRED) pkg_check_modules(cpp_httplib REQUIRED IMPORTED_TARGET cpp-httplib) +# Include directories +include_directories(include) + # Gateway node executable -add_executable(gateway_node src/gateway_node.cpp) -ament_target_dependencies(gateway_node rclcpp) -target_link_libraries(gateway_node PkgConfig::cpp_httplib nlohmann_json::nlohmann_json) -target_include_directories(gateway_node PUBLIC - $ - $) -target_compile_features(gateway_node PUBLIC c_std_99 cxx_std_17) - -# Install targets +add_executable(gateway_node + src/main.cpp + src/gateway_node.cpp + src/rest_server.cpp + src/discovery_manager.cpp +) + +ament_target_dependencies(gateway_node + rclcpp + std_msgs + std_srvs + rcl_interfaces +) + +target_link_libraries(gateway_node + PkgConfig::cpp_httplib + nlohmann_json::nlohmann_json +) + +# Install install(TARGETS gateway_node - DESTINATION lib/${PROJECT_NAME}) + DESTINATION lib/${PROJECT_NAME} +) + +install(DIRECTORY launch config + DESTINATION share/${PROJECT_NAME} +) +# Testing if(BUILD_TESTING) + # Linting and code quality checks find_package(ament_lint_auto REQUIRED) # Exclude uncrustify since we use clang-format list(APPEND AMENT_LINT_AUTO_EXCLUDE ament_cmake_uncrustify) @@ -39,6 +69,101 @@ if(BUILD_TESTING) ament_add_gtest(test_gateway_node test/test_gateway_node.cpp) target_link_libraries(test_gateway_node PkgConfig::cpp_httplib nlohmann_json::nlohmann_json) ament_target_dependencies(test_gateway_node rclcpp) + + # Integration testing + find_package(ament_cmake_pytest REQUIRED) + find_package(launch_testing_ament_cmake REQUIRED) + + # Install test directory + install(DIRECTORY test + DESTINATION share/${PROJECT_NAME} + ) + + # Add integration tests + add_launch_test( + test/test_integration.test.py + TARGET test_integration + TIMEOUT 120 + ) + + # Demo automotive nodes + add_executable(demo_engine_temp_sensor + test/demo_nodes/engine_temp_sensor.cpp + ) + ament_target_dependencies(demo_engine_temp_sensor + rclcpp + sensor_msgs + ) + install(TARGETS demo_engine_temp_sensor + DESTINATION lib/${PROJECT_NAME} + ) + + add_executable(demo_rpm_sensor + test/demo_nodes/rpm_sensor.cpp + ) + ament_target_dependencies(demo_rpm_sensor + rclcpp + std_msgs + ) + install(TARGETS demo_rpm_sensor + DESTINATION lib/${PROJECT_NAME} + ) + + add_executable(demo_brake_pressure_sensor + test/demo_nodes/brake_pressure_sensor.cpp + ) + ament_target_dependencies(demo_brake_pressure_sensor + rclcpp + std_msgs + ) + install(TARGETS demo_brake_pressure_sensor + DESTINATION lib/${PROJECT_NAME} + ) + + add_executable(demo_door_status_sensor + test/demo_nodes/door_status_sensor.cpp + ) + ament_target_dependencies(demo_door_status_sensor + rclcpp + std_msgs + ) + install(TARGETS demo_door_status_sensor + DESTINATION lib/${PROJECT_NAME} + ) + + # demo actuators + add_executable(demo_brake_actuator + test/demo_nodes/brake_actuator.cpp + ) + ament_target_dependencies(demo_brake_actuator + rclcpp + std_msgs + ) + install(TARGETS demo_brake_actuator + DESTINATION lib/${PROJECT_NAME} + ) + + add_executable(demo_light_controller + test/demo_nodes/light_controller.cpp + ) + ament_target_dependencies(demo_light_controller + rclcpp + std_msgs + ) + install(TARGETS demo_light_controller + DESTINATION lib/${PROJECT_NAME} + ) + + add_executable(demo_calibration_service + test/demo_nodes/calibration_service.cpp + ) + ament_target_dependencies(demo_calibration_service + rclcpp + std_srvs + ) + install(TARGETS demo_calibration_service + DESTINATION lib/${PROJECT_NAME} + ) endif() ament_package() diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index af4a86d..9bb2bc3 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -4,41 +4,160 @@ HTTP gateway node for the ros2_medkit diagnostics system. ## Overview -This package provides an HTTP server that exposes diagnostic information from the ROS 2 system through REST APIs. +The ROS 2 Medkit Gateway exposes ROS 2 system information and data through a RESTful HTTP API. It automatically discovers nodes in the ROS 2 system, organizes them into areas based on their namespaces, and provides endpoints to query and interact with them. + +**Key Features:** +- **Auto-discovery**: Automatically detects ROS 2 nodes and topics +- **Area-based organization**: Groups nodes by namespace (e.g., `/powertrain`, `/chassis`, `/body`) +- **REST API**: Standard HTTP/JSON interface +- **Real-time updates**: Configurable cache refresh for up-to-date system state ## Endpoints -- `GET /health` - Health check endpoint returning node status -- `GET /` - Service information and available endpoints +Current available endpoints: + +- `GET /health` - Health check endpoint (returns healthy status) +- `GET /` - Gateway status and version information +- `GET /areas` - List all discovered areas (powertrain, chassis, body, root) + +## Quick Start -## Building +### Build ```bash +# From your ROS 2 workspace colcon build --packages-select ros2_medkit_gateway + +# Source the workspace +source install/setup.bash ``` -## Running +### Run +**Start the gateway:** ```bash ros2 run ros2_medkit_gateway gateway_node ``` +**Or use the launch file:** +```bash +ros2 launch ros2_medkit_gateway gateway.launch.py +``` + +**Start demo nodes:** +```bash +ros2 launch ros2_medkit_gateway demo_nodes.launch.py +``` + +**Test the API:** +```bash +curl http://localhost:8080/areas +``` + +## Configuration + +The gateway can be configured via parameters in `config/gateway_params.yaml` or through command-line arguments. + ### Parameters -- `port` (int, default: 8080) - HTTP server port -- `host` (string, default: "0.0.0.0") - HTTP server host address +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `server.host` | string | `127.0.0.1` | Host to bind the REST server (`127.0.0.1` for localhost, `0.0.0.0` for all interfaces) | +| `server.port` | int | `8080` | Port for the REST API (range: 1024-65535) | +| `refresh_interval_ms` | int | `2000` | Cache refresh interval in milliseconds (range: 100-60000) | -### Example with custom port +### Configuration Examples +**Change port via command line:** ```bash -ros2 run ros2_medkit_gateway gateway_node --ros-args -p port:=9090 +ros2 run ros2_medkit_gateway gateway_node --ros-args -p server.port:=9090 ``` -## Dependencies +**Change host via launch file:** +```bash +ros2 launch ros2_medkit_gateway gateway.launch.py server_host:=0.0.0.0 +``` -- ROS 2 (rclcpp) -- cpp-httplib (automatically fetched during build) +## Architecture -## License +### Components + +- **Gateway Node**: Main ROS 2 node that runs the REST server +- **Discovery Manager**: Discovers and caches ROS 2 nodes, organizing them into areas and components +- **REST Server**: HTTP server using cpp-httplib +- **Entity Cache**: In-memory cache of discovered areas and components, updated periodically + +### Area Organization + +The gateway organizes nodes into "areas" based on their namespace: + +``` +/powertrain/engine/temp_sensor → Area: powertrain, Component: temp_sensor +/chassis/brakes/pressure_sensor → Area: chassis, Component: pressure_sensor +/body/lights/controller → Area: body, Component: controller +/standalone_node → Area: root, Component: standalone_node +``` +## Demo Nodes + +The package includes demo automotive nodes for testing: + +| Node | Namespace | Description | +|------|-----------|-------------| +| `temp_sensor` | `/powertrain/engine` | Engine temperature sensor | +| `rpm_sensor` | `/powertrain/engine` | Engine RPM sensor | +| `pressure_sensor` | `/chassis/brakes` | Brake pressure sensor | +| `status_sensor` | `/body/door/front_left` | Door status sensor | +| `actuator` | `/chassis/brakes` | Brake actuator | +| `controller` | `/body/lights` | Light controller | +| `calibration` | `/powertrain/engine` | Calibration service | + +**Launch all demo nodes:** +```bash +ros2 launch ros2_medkit_gateway demo_nodes.launch.py +``` + +## URL Encoding + +Component and topic names in URLs use double underscore (`__`) as a namespace separator: + +| ROS 2 Name | URL Encoding | +|------------|--------------| +| `/powertrain/engine` | `powertrain__engine` | +| `/chassis/brakes` | `chassis__brakes` | +| `/body/door/front_left` | `body__door__front_left` | + +**Example:** +- Component FQN: `/powertrain/engine/temp_sensor` +- URL component ID: `powertrain__engine__temp_sensor` + +## Manual API Testing with Postman + +We provide a Postman collection for easy API testing: + +1. **Import collection:** `postman/collections/ros2-medkit-gateway.postman_collection.json` +2. **Import environment:** `postman/environments/local.postman_environment.json` +3. **Activate environment:** Select "ROS 2 Medkit Gateway - Local" in Postman +4. **Start gateway:** `ros2 launch ros2_medkit_gateway gateway.launch.py` +5. **Start demo nodes:** `ros2 launch ros2_medkit_gateway demo_nodes.launch.py` +6. **Test:** Send requests from Postman! + +See [postman/README.md](postman/README.md) for detailed instructions. + +## Testing + +### Run Integration Tests + +```bash +# Build with tests +colcon build --packages-select ros2_medkit_gateway + +# Run tests +colcon test --packages-select ros2_medkit_gateway --event-handlers console_direct+ + +# View test results +colcon test-result --verbose +``` + +## License Apache-2.0 diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml new file mode 100644 index 0000000..5ac7e08 --- /dev/null +++ b/src/ros2_medkit_gateway/config/gateway_params.yaml @@ -0,0 +1,26 @@ +# ROS 2 Medkit Gateway Configuration Parameters +# +# This file contains default configuration for the ROS 2 Medkit Gateway. +# You can override these values via: +# - Command line: --ros-args -p server.port:=9000 +# - Launch files: parameters=[...] +# - This YAML file + +ros2_medkit_gateway: + ros__parameters: + # Server Configuration + server: + # Host to bind the REST server to + # Options: + # - "127.0.0.1" (localhost only - SECURE, default) + # - "0.0.0.0" (all interfaces - use for Docker/networking) + host: "127.0.0.1" + + # Port for REST API + # Valid range: 1024-65535 + port: 8080 + + # Cache refresh interval in milliseconds + # How often to discover ROS 2 nodes and update the cache + # Valid range: 100-60000 (0.1s to 60s) + refresh_interval_ms: 2000 diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery_manager.hpp new file mode 100644 index 0000000..ecf697e --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery_manager.hpp @@ -0,0 +1,39 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#pragma once + +#include +#include + +#include + +#include "ros2_medkit_gateway/models.hpp" + +namespace ros2_medkit_gateway { + +class DiscoveryManager { +public: + explicit DiscoveryManager(rclcpp::Node* node); + + std::vector discover_areas(); + std::vector discover_components(); + +private: + std::string extract_area_from_namespace(const std::string& ns); + + rclcpp::Node* node_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp new file mode 100644 index 0000000..c2e0c6f --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -0,0 +1,68 @@ +// Copyright 2025 bburda, mfaferek93 +// +// 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. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "ros2_medkit_gateway/models.hpp" +#include "ros2_medkit_gateway/discovery_manager.hpp" +#include "ros2_medkit_gateway/rest_server.hpp" + +namespace ros2_medkit_gateway { + +class GatewayNode : public rclcpp::Node { +public: + GatewayNode(); + ~GatewayNode(); + + // Thread-safe accessors for REST server + EntityCache get_entity_cache() const; + +private: + void refresh_cache(); + void start_rest_server(); + void stop_rest_server(); + + // Configuration parameters + std::string server_host_; + int server_port_; + int refresh_interval_ms_; + + // Managers + std::unique_ptr discovery_mgr_; + std::unique_ptr rest_server_; + + // Cache with thread safety + mutable std::mutex cache_mutex_; + EntityCache entity_cache_; + + // Timer for periodic refresh + rclcpp::TimerBase::SharedPtr refresh_timer_; + + // REST server thread management + std::unique_ptr server_thread_; + std::atomic server_running_{false}; + std::mutex server_mutex_; + std::condition_variable server_cv_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp new file mode 100644 index 0000000..b252d34 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp @@ -0,0 +1,64 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#pragma once + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +struct Area { + std::string id; + std::string namespace_path; + std::string type = "Area"; + + json to_json() const { + return { + {"id", id}, + {"namespace", namespace_path}, + {"type", type} + }; + } +}; + +struct Component { + std::string id; + std::string namespace_path; + std::string fqn; + std::string type = "Component"; + std::string area; + + json to_json() const { + return { + {"id", id}, + {"namespace", namespace_path}, + {"fqn", fqn}, + {"type", type}, + {"area", area} + }; + } +}; + +struct EntityCache { + std::vector areas; + std::vector components; + std::chrono::system_clock::time_point last_update; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp new file mode 100644 index 0000000..1d476a3 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp @@ -0,0 +1,50 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#pragma once + +#include + +#include +#include + +#include + +namespace ros2_medkit_gateway { + +class GatewayNode; + +class RESTServer { +public: + RESTServer(GatewayNode* node, const std::string& host, int port); + ~RESTServer(); + + void start(); + void stop(); + +private: + void setup_routes(); + + // Route handlers + void handle_health(const httplib::Request& req, httplib::Response& res); + void handle_root(const httplib::Request& req, httplib::Response& res); + void handle_list_areas(const httplib::Request& req, httplib::Response& res); + + GatewayNode* node_; + std::string host_; + int port_; + std::unique_ptr server_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/launch/demo_nodes.launch.py b/src/ros2_medkit_gateway/launch/demo_nodes.launch.py new file mode 100644 index 0000000..f58879f --- /dev/null +++ b/src/ros2_medkit_gateway/launch/demo_nodes.launch.py @@ -0,0 +1,108 @@ +# Copyright 2025 mfaferek93 +# +# 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. + +from launch import LaunchDescription +from launch_ros.actions import Node + + +def generate_launch_description(): + """ + Launch all ROS 2 Medkit Gateway demo nodes with proper namespaces. + + This creates the automotive area hierarchy: + - /powertrain (engine sensors + calibration) + - /chassis (brake sensors + actuators) + - /body (door sensors + light controller) + + Sensors: + - Engine temperature sensor → /powertrain/engine + - Engine RPM sensor → /powertrain/engine + - Brake pressure sensor → /chassis/brakes + - Door status sensor → /body/door/front_left + + Actuators: + - Brake actuator → /chassis/brakes + - Light controller → /body/lights + - Calibration service → /powertrain/engine + """ + return LaunchDescription([ + # === POWERTRAIN AREA === + + Node( + package='ros2_medkit_gateway', + executable='demo_engine_temp_sensor', + name='temp_sensor', + namespace='powertrain/engine', + output='log', + emulate_tty=True, + ), + + Node( + package='ros2_medkit_gateway', + executable='demo_rpm_sensor', + name='rpm_sensor', + namespace='powertrain/engine', + output='log', + emulate_tty=True, + ), + + Node( + package='ros2_medkit_gateway', + executable='demo_calibration_service', + name='calibration', + namespace='powertrain/engine', + output='log', + emulate_tty=True, + ), + + # === CHASSIS AREA === + + Node( + package='ros2_medkit_gateway', + executable='demo_brake_pressure_sensor', + name='pressure_sensor', + namespace='chassis/brakes', + output='log', + emulate_tty=True, + ), + + Node( + package='ros2_medkit_gateway', + executable='demo_brake_actuator', + name='actuator', + namespace='chassis/brakes', + output='log', + emulate_tty=True, + ), + + # === BODY AREA === + + Node( + package='ros2_medkit_gateway', + executable='demo_door_status_sensor', + name='status_sensor', + namespace='body/door/front_left', + output='log', + emulate_tty=True, + ), + + Node( + package='ros2_medkit_gateway', + executable='demo_light_controller', + name='controller', + namespace='body/lights', + output='log', + emulate_tty=True, + ), + ]) diff --git a/src/ros2_medkit_gateway/launch/gateway.launch.py b/src/ros2_medkit_gateway/launch/gateway.launch.py new file mode 100644 index 0000000..a13a198 --- /dev/null +++ b/src/ros2_medkit_gateway/launch/gateway.launch.py @@ -0,0 +1,72 @@ +# Copyright 2025 mfaferek93 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from ament_index_python.packages import get_package_share_directory +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + + +def generate_launch_description(): + # Get package directory + pkg_dir = get_package_share_directory('ros2_medkit_gateway') + + # Path to default config file + default_config = os.path.join(pkg_dir, 'config', 'gateway_params.yaml') + + # Declare launch arguments for easy overriding + declare_host_arg = DeclareLaunchArgument( + 'server_host', + default_value='127.0.0.1', + description='Host to bind REST server (127.0.0.1 or 0.0.0.0)' + ) + + declare_port_arg = DeclareLaunchArgument( + 'server_port', + default_value='8080', + description='Port for REST API' + ) + + declare_refresh_arg = DeclareLaunchArgument( + 'refresh_interval_ms', + default_value='2000', + description='Cache refresh interval in milliseconds' + ) + + # Gateway node with parameters + gateway_node = Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='ros2_medkit_gateway', + output='screen', + parameters=[ + default_config, # Load from YAML first + { # Override with launch arguments + 'server.host': LaunchConfiguration('server_host'), + 'server.port': LaunchConfiguration('server_port'), + 'refresh_interval_ms': LaunchConfiguration('refresh_interval_ms'), + } + ], + arguments=['--ros-args', '--log-level', 'info'] + ) + + return LaunchDescription([ + declare_host_arg, + declare_port_arg, + declare_refresh_arg, + gateway_node + ]) diff --git a/src/ros2_medkit_gateway/package.xml b/src/ros2_medkit_gateway/package.xml index 49cd6c2..6fd010c 100644 --- a/src/ros2_medkit_gateway/package.xml +++ b/src/ros2_medkit_gateway/package.xml @@ -11,6 +11,10 @@ ament_cmake rclcpp + std_msgs + std_srvs + sensor_msgs + rcl_interfaces nlohmann-json-dev libcpp-httplib-dev @@ -18,6 +22,8 @@ ament_lint_common ament_clang_format ament_cmake_gtest + ament_cmake_pytest + launch_testing_ament_cmake ament_cmake diff --git a/src/ros2_medkit_gateway/src/discovery_manager.cpp b/src/ros2_medkit_gateway/src/discovery_manager.cpp new file mode 100644 index 0000000..106a2db --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery_manager.cpp @@ -0,0 +1,96 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#include "ros2_medkit_gateway/discovery_manager.hpp" +#include +#include + +namespace ros2_medkit_gateway { + +DiscoveryManager::DiscoveryManager(rclcpp::Node* node) + : node_(node) +{ +} + +std::vector DiscoveryManager::discover_areas() { + // Extract unique areas from namespaces + std::set area_set; + + // Get node graph interface + auto node_graph = node_->get_node_graph_interface(); + + // Iterate through all nodes to find namespaces + auto names_and_namespaces = node_graph->get_node_names_and_namespaces(); + + for (const auto& name_and_ns : names_and_namespaces) { + std::string ns = name_and_ns.second; + std::string area = extract_area_from_namespace(ns); + area_set.insert(area); + } + + // Convert set to vector of Area structs + std::vector areas; + for (const auto& area_name : area_set) { + Area area; + area.id = area_name; + area.namespace_path = (area_name == "root") ? "/" : "/" + area_name; + areas.push_back(area); + } + + return areas; +} + +std::vector DiscoveryManager::discover_components() { + std::vector components; + + auto node_graph = node_->get_node_graph_interface(); + auto names_and_namespaces = node_graph->get_node_names_and_namespaces(); + + for (const auto& name_and_ns : names_and_namespaces) { + const auto& name = name_and_ns.first; + const auto& ns = name_and_ns.second; + + Component comp; + comp.id = name; + comp.namespace_path = ns; + comp.fqn = (ns == "/") ? "/" + name : ns + "/" + name; + comp.area = extract_area_from_namespace(ns); + + components.push_back(comp); + } + + return components; +} + +std::string DiscoveryManager::extract_area_from_namespace(const std::string& ns) { + if (ns == "/" || ns.empty()) { + return "root"; + } + + // Remove leading slash + std::string cleaned = ns; + if (cleaned[0] == '/') { + cleaned = cleaned.substr(1); + } + + // Get first segment + auto pos = cleaned.find('/'); + if (pos != std::string::npos) { + return cleaned.substr(0, pos); + } + + return cleaned; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 906b454..f379be4 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -1,4 +1,4 @@ -// Copyright 2025 bburda +// Copyright 2025 bburda, mfaferek93 // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,92 +12,178 @@ // See the License for the specific language governing permissions and // limitations under the License. +#include "ros2_medkit_gateway/gateway_node.hpp" #include -#include -#include - -#include // NOLINT(build/include_order) -#include -#include using namespace std::chrono_literals; -static constexpr char VERSION[] = "0.1.0"; +namespace ros2_medkit_gateway { + +GatewayNode::GatewayNode() + : Node("ros2_medkit_gateway") +{ + RCLCPP_INFO(get_logger(), "Initializing ROS 2 Medkit Gateway..."); + + // Declare parameters with defaults + declare_parameter("server.host", "127.0.0.1"); + declare_parameter("server.port", 8080); + declare_parameter("refresh_interval_ms", 2000); + + // Get parameter values + server_host_ = get_parameter("server.host").as_string(); + server_port_ = get_parameter("server.port").as_int(); + refresh_interval_ms_ = get_parameter("refresh_interval_ms").as_int(); + + // Validate port range + if (server_port_ < 1024 || server_port_ > 65535) { + RCLCPP_ERROR( + get_logger(), + "Invalid port %d. Must be between 1024-65535. Using default 8080.", + server_port_ + ); + server_port_ = 8080; + } + + // Validate host + if (server_host_.empty()) { + RCLCPP_WARN(get_logger(), "Empty host specified. Using default 127.0.0.1"); + server_host_ = "127.0.0.1"; + } + + // Warn if binding to all interfaces + if (server_host_ == "0.0.0.0") { + RCLCPP_WARN( + get_logger(), + "Binding to 0.0.0.0 - REST API accessible from ALL network interfaces!" + ); + } + + // Validate refresh interval + if (refresh_interval_ms_ < 100 || refresh_interval_ms_ > 60000) { + RCLCPP_WARN( + get_logger(), + "Invalid refresh interval %dms. Must be between 100-60000ms. Using default 2000ms.", + refresh_interval_ms_ + ); + refresh_interval_ms_ = 2000; + } -class GatewayNode : public rclcpp::Node { -public: - GatewayNode() - : Node("gateway_node"), http_server_(std::make_unique()), - node_name_(this->get_name()) { - // Declare parameters - this->declare_parameter("port", 8080); - this->declare_parameter("host", "0.0.0.0"); + // Log configuration + RCLCPP_INFO( + get_logger(), + "Configuration: REST API at %s:%d, refresh interval: %dms", + server_host_.c_str(), + server_port_, + refresh_interval_ms_ + ); + + // Initialize managers + discovery_mgr_ = std::make_unique(this); + + // Initial discovery + refresh_cache(); + + // Setup periodic refresh with configurable interval + refresh_timer_ = create_wall_timer( + std::chrono::milliseconds(refresh_interval_ms_), + std::bind(&GatewayNode::refresh_cache, this) + ); + + // Start REST server with configured host and port + rest_server_ = std::make_unique(this, server_host_, server_port_); + start_rest_server(); + + RCLCPP_INFO( + get_logger(), + "ROS 2 Medkit Gateway ready on %s:%d", + server_host_.c_str(), + server_port_ + ); +} - port_ = this->get_parameter("port").as_int(); - host_ = this->get_parameter("host").as_string(); +GatewayNode::~GatewayNode() { + RCLCPP_INFO(get_logger(), "Shutting down ROS 2 Medkit Gateway..."); + stop_rest_server(); +} - // Setup HTTP endpoints - setup_endpoints(); +EntityCache GatewayNode::get_entity_cache() const { + std::lock_guard lock(cache_mutex_); + return entity_cache_; +} - RCLCPP_INFO(this->get_logger(), "Starting HTTP server on %s:%d", - host_.c_str(), port_); +void GatewayNode::refresh_cache() { + RCLCPP_DEBUG(get_logger(), "Refreshing entity cache..."); + + try { + // Discover data outside the lock to minimize lock time + auto areas = discovery_mgr_->discover_areas(); + auto components = discovery_mgr_->discover_components(); + auto timestamp = std::chrono::system_clock::now(); + + // Capture sizes before move for logging + const size_t area_count = areas.size(); + const size_t component_count = components.size(); + + // Lock only for the actual cache update + { + std::lock_guard lock(cache_mutex_); + entity_cache_.areas = std::move(areas); + entity_cache_.components = std::move(components); + entity_cache_.last_update = timestamp; + } + + RCLCPP_DEBUG( + get_logger(), + "Cache refreshed: %zu areas, %zu components", + area_count, + component_count + ); + } catch (const std::exception& e) { + RCLCPP_ERROR(get_logger(), "Failed to refresh cache: %s", e.what()); + } catch (...) { + RCLCPP_ERROR(get_logger(), "Failed to refresh cache: unknown exception"); + } +} - // Start HTTP server in a separate thread - server_thread_ = - std::thread([this]() { http_server_->listen(host_.c_str(), port_); }); +void GatewayNode::start_rest_server() { + server_thread_ = std::make_unique([this]() { + { + std::lock_guard lock(server_mutex_); + server_running_ = true; + } + server_cv_.notify_all(); + + try { + rest_server_->start(); + } catch (const std::exception& e) { + RCLCPP_ERROR(get_logger(), "REST server failed to start: %s", e.what()); + } catch (...) { + RCLCPP_ERROR(get_logger(), "REST server failed to start: unknown exception"); + } + + { + std::lock_guard lock(server_mutex_); + server_running_ = false; + } + server_cv_.notify_all(); + }); + + // Wait for server to start + std::unique_lock lock(server_mutex_); + server_cv_.wait(lock, [this] { return server_running_.load(); }); +} - RCLCPP_INFO(this->get_logger(), "Gateway node initialized"); - } +void GatewayNode::stop_rest_server() { + if (rest_server_) { + rest_server_->stop(); + } - ~GatewayNode() { - RCLCPP_INFO(this->get_logger(), "Shutting down HTTP server"); - http_server_->stop(); - if (server_thread_.joinable()) { - server_thread_.join(); + // Wait for server thread to finish + if (server_thread_ && server_thread_->joinable()) { + std::unique_lock lock(server_mutex_); + server_cv_.wait(lock, [this] { return !server_running_.load(); }); + server_thread_->join(); } - } - -private: - void setup_endpoints() { - // Health endpoint - http_server_->Get( - "/health", [this](const httplib::Request &req, httplib::Response &res) { - (void)req; // Unused parameter - - nlohmann::json health_json = {{"status", "ok"}, - {"node", node_name_}, - {"timestamp", this->now().seconds()}}; - - res.set_content(health_json.dump(), "application/json"); - res.status = 200; - }); - - // Root endpoint - http_server_->Get( - "/", [this](const httplib::Request &req, httplib::Response &res) { - (void)req; // Unused parameter - - nlohmann::json info_json = { - {"service", "ros2_medkit_gateway"}, - {"version", VERSION}, - {"endpoints", nlohmann::json::array({"/health", "/"})}}; - - res.set_content(info_json.dump(), "application/json"); - res.status = 200; - }); - } - - std::unique_ptr http_server_; - std::thread server_thread_; - int port_; - std::string host_; - std::string node_name_; -}; - -int main(int argc, char *argv[]) { - rclcpp::init(argc, argv); - auto node = std::make_shared(); - rclcpp::spin(node); - rclcpp::shutdown(); - return 0; } + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/main.cpp b/src/ros2_medkit_gateway/src/main.cpp new file mode 100644 index 0000000..f005b4f --- /dev/null +++ b/src/ros2_medkit_gateway/src/main.cpp @@ -0,0 +1,27 @@ +// Copyright 2025 bburda, mfaferek93 +// +// 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. + +#include +#include "ros2_medkit_gateway/gateway_node.hpp" + +int main(int argc, char** argv) { + rclcpp::init(argc, argv); + + auto node = std::make_shared(); + + rclcpp::spin(node); + + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/src/rest_server.cpp b/src/ros2_medkit_gateway/src/rest_server.cpp new file mode 100644 index 0000000..db189cd --- /dev/null +++ b/src/ros2_medkit_gateway/src/rest_server.cpp @@ -0,0 +1,132 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#include "ros2_medkit_gateway/rest_server.hpp" +#include "ros2_medkit_gateway/gateway_node.hpp" +#include + +using json = nlohmann::json; + +namespace ros2_medkit_gateway { + +RESTServer::RESTServer(GatewayNode* node, const std::string& host, int port) + : node_(node), host_(host), port_(port) +{ + server_ = std::make_unique(); + setup_routes(); +} + +RESTServer::~RESTServer() { + stop(); +} + +void RESTServer::setup_routes() { + // Health check + server_->Get("/health", [this](const httplib::Request& req, httplib::Response& res) { + handle_health(req, res); + }); + + // Root + server_->Get("/", [this](const httplib::Request& req, httplib::Response& res) { + handle_root(req, res); + }); + + // Areas + server_->Get("/areas", [this](const httplib::Request& req, httplib::Response& res) { + handle_list_areas(req, res); + }); +} + +void RESTServer::start() { + RCLCPP_INFO( + rclcpp::get_logger("rest_server"), + "Starting REST server on %s:%d...", + host_.c_str(), + port_ + ); + + server_->listen(host_.c_str(), port_); +} + +void RESTServer::stop() { + if (server_ && server_->is_running()) { + RCLCPP_INFO(rclcpp::get_logger("rest_server"), "Stopping REST server..."); + server_->stop(); + } +} + +void RESTServer::handle_health(const httplib::Request& req, httplib::Response& res) { + (void)req; // Unused parameter + + try { + json response = { + {"status", "healthy"}, + {"timestamp", std::chrono::system_clock::now().time_since_epoch().count()} + }; + + res.set_content(response.dump(2), "application/json"); + } catch (const std::exception& e) { + res.status = 500; + res.set_content( + json{{"error", "Internal server error"}}.dump(), + "application/json" + ); + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "Error in handle_health: %s", e.what()); + } +} + +void RESTServer::handle_root(const httplib::Request& req, httplib::Response& res) { + (void)req; // Unused parameter + + try { + json response = { + {"status", "ROS 2 Medkit Gateway running"}, + {"version", "0.1.0"}, + {"timestamp", std::chrono::system_clock::now().time_since_epoch().count()} + }; + + res.set_content(response.dump(2), "application/json"); + } catch (const std::exception& e) { + res.status = 500; + res.set_content( + json{{"error", "Internal server error"}}.dump(), + "application/json" + ); + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "Error in handle_root: %s", e.what()); + } +} + +void RESTServer::handle_list_areas(const httplib::Request& req, httplib::Response& res) { + (void)req; // Unused parameter + + try { + const auto cache = node_->get_entity_cache(); + + json areas_json = json::array(); + for (const auto& area : cache.areas) { + areas_json.push_back(area.to_json()); + } + + res.set_content(areas_json.dump(2), "application/json"); + } catch (const std::exception& e) { + res.status = 500; + res.set_content( + json{{"error", "Internal server error"}}.dump(), + "application/json" + ); + RCLCPP_ERROR(rclcpp::get_logger("rest_server"), "Error in handle_list_areas: %s", e.what()); + } +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/test/demo_nodes/brake_actuator.cpp b/src/ros2_medkit_gateway/test/demo_nodes/brake_actuator.cpp new file mode 100644 index 0000000..7335261 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/brake_actuator.cpp @@ -0,0 +1,99 @@ +// Copyright 2025 mfaferek93 +// +// 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 brake_actuator.cpp + * @brief Demo brake actuator + * + * Simulates brake system that: + * - Subscribes to command topic (Float32) + * - Publishes actual pressure (simulated gradual response) + * - Used to test PUT /data/{topic} endpoint + */ + +#include + +#include +#include + +using namespace std::chrono_literals; + +class BrakeActuator : public rclcpp::Node +{ +public: + BrakeActuator() : Node("brake_actuator"), current_pressure_(0.0f), target_pressure_(0.0f) + { + // Subscribe to command topic + cmd_sub_ = this->create_subscription( + "command", 10, + std::bind(&BrakeActuator::command_callback, this, std::placeholders::_1) + ); + + // Publish pressure topic + pressure_pub_ = this->create_publisher("pressure", 10); + + // Timer to simulate gradual pressure change + timer_ = this->create_wall_timer( + 100ms, + std::bind(&BrakeActuator::timer_callback, this) + ); + + RCLCPP_INFO(this->get_logger(), "Brake actuator started"); + } + +private: + void command_callback(const std_msgs::msg::Float32::SharedPtr msg) + { + target_pressure_ = msg->data; + + // Clamp to valid range (0-100 bar) + if (target_pressure_ < 0.0f) target_pressure_ = 0.0f; + if (target_pressure_ > 100.0f) target_pressure_ = 100.0f; + + RCLCPP_INFO(this->get_logger(), "Received command: %.2f bar", target_pressure_); + } + + void timer_callback() + { + // Simulate gradual pressure change (5 bar/sec = 0.5 bar per 100ms) + const float step = 0.5f; + + if (std::abs(current_pressure_ - target_pressure_) < step) { + current_pressure_ = target_pressure_; + } else if (current_pressure_ < target_pressure_) { + current_pressure_ += step; + } else { + current_pressure_ -= step; + } + + // Publish current pressure + auto msg = std_msgs::msg::Float32(); + msg.data = current_pressure_; + pressure_pub_->publish(msg); + } + + rclcpp::Subscription::SharedPtr cmd_sub_; + rclcpp::Publisher::SharedPtr pressure_pub_; + rclcpp::TimerBase::SharedPtr timer_; + float current_pressure_; + float target_pressure_; +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/demo_nodes/brake_pressure_sensor.cpp b/src/ros2_medkit_gateway/test/demo_nodes/brake_pressure_sensor.cpp new file mode 100644 index 0000000..d7d25d8 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/brake_pressure_sensor.cpp @@ -0,0 +1,60 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#include +#include + +class BrakePressureSensor : public rclcpp::Node +{ +public: + BrakePressureSensor() + : Node("brake_pressure_sensor") + { + pressure_pub_ = this->create_publisher("pressure", 10); + + timer_ = this->create_wall_timer( + std::chrono::milliseconds(500), + std::bind(&BrakePressureSensor::publish_data, this)); + + RCLCPP_INFO(this->get_logger(), "Brake pressure sensor started"); + } + +private: + void publish_data() + { + current_pressure_ += 5.0; + if (current_pressure_ > 100.0) { + current_pressure_ = 0.0; + } + + auto pressure_msg = std_msgs::msg::Float32(); + pressure_msg.data = current_pressure_; + + pressure_pub_->publish(pressure_msg); + + RCLCPP_INFO(this->get_logger(), "Brake Pressure: %.1f bar", current_pressure_); + } + + rclcpp::Publisher::SharedPtr pressure_pub_; + rclcpp::TimerBase::SharedPtr timer_; + double current_pressure_ = 0.0; +}; + +int main(int argc, char** argv) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/demo_nodes/calibration_service.cpp b/src/ros2_medkit_gateway/test/demo_nodes/calibration_service.cpp new file mode 100644 index 0000000..5aff920 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/calibration_service.cpp @@ -0,0 +1,70 @@ +// Copyright 2025 mfaferek93 +// +// 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 calibration_service.cpp + * @brief Demo calibration service + * + * Provides calibration service that: + * - Exposes /powertrain/engine/calibrate service (Trigger type) + * - Returns success with calibration message + * - Used to test POST /services/{service} endpoint + */ + +#include +#include + +class CalibrationService : public rclcpp::Node +{ +public: + CalibrationService() : Node("calibration_service"), calibration_count_(0) + { + // Create calibration service + calibration_srv_ = this->create_service( + "calibrate", + std::bind(&CalibrationService::calibrate_callback, this, + std::placeholders::_1, std::placeholders::_2) + ); + + RCLCPP_INFO(this->get_logger(), "Calibration service started"); + } + +private: + void calibrate_callback( + const std::shared_ptr request, + std::shared_ptr response) + { + (void)request; // Trigger has no request fields + + calibration_count_++; + + // Simulate calibration + response->success = true; + response->message = "Engine calibrated successfully (count: " + + std::to_string(calibration_count_) + ")"; + + RCLCPP_INFO(this->get_logger(), "Calibration requested: %s", response->message.c_str()); + } + + rclcpp::Service::SharedPtr calibration_srv_; + int calibration_count_; +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/demo_nodes/door_status_sensor.cpp b/src/ros2_medkit_gateway/test/demo_nodes/door_status_sensor.cpp new file mode 100644 index 0000000..6f6d8f1 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/door_status_sensor.cpp @@ -0,0 +1,64 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#include +#include +#include + +class DoorStatusSensor : public rclcpp::Node +{ +public: + DoorStatusSensor() + : Node("door_status_sensor") + { + is_open_pub_ = this->create_publisher("is_open", 10); + state_pub_ = this->create_publisher("state", 10); + + timer_ = this->create_wall_timer( + std::chrono::seconds(2), + std::bind(&DoorStatusSensor::publish_data, this)); + + RCLCPP_INFO(this->get_logger(), "Door status sensor started"); + } + +private: + void publish_data() + { + // Toggle door state + is_open_ = !is_open_; + + auto is_open_msg = std_msgs::msg::Bool(); + is_open_msg.data = is_open_; + is_open_pub_->publish(is_open_msg); + + auto state_msg = std_msgs::msg::String(); + state_msg.data = is_open_ ? "open" : "closed"; + state_pub_->publish(state_msg); + + RCLCPP_INFO(this->get_logger(), "Door: %s", state_msg.data.c_str()); + } + + rclcpp::Publisher::SharedPtr is_open_pub_; + rclcpp::Publisher::SharedPtr state_pub_; + rclcpp::TimerBase::SharedPtr timer_; + bool is_open_ = false; +}; + +int main(int argc, char** argv) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/demo_nodes/engine_temp_sensor.cpp b/src/ros2_medkit_gateway/test/demo_nodes/engine_temp_sensor.cpp new file mode 100644 index 0000000..c9b3b04 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/engine_temp_sensor.cpp @@ -0,0 +1,65 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#include +#include + +class EngineTempSensor : public rclcpp::Node +{ +public: + EngineTempSensor() + : Node("engine_temp_sensor") + { + temp_pub_ = this->create_publisher( + "temperature", 10); + + timer_ = this->create_wall_timer( + std::chrono::milliseconds(500), + std::bind(&EngineTempSensor::publish_data, this)); + + RCLCPP_INFO(this->get_logger(), + "Engine temperature sensor started"); + } + +private: + void publish_data() + { + current_temp_ += 0.5; + if (current_temp_ > 95.0) { + current_temp_ = 85.0; + } + + auto temp_msg = sensor_msgs::msg::Temperature(); + temp_msg.header.stamp = this->now(); + temp_msg.header.frame_id = "engine"; + temp_msg.temperature = current_temp_; + temp_msg.variance = 0.5; + + temp_pub_->publish(temp_msg); + + RCLCPP_INFO(this->get_logger(), "Temperature: %.1f°C", current_temp_); + } + + rclcpp::Publisher::SharedPtr temp_pub_; + rclcpp::TimerBase::SharedPtr timer_; + double current_temp_ = 85.0; +}; + +int main(int argc, char** argv) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/demo_nodes/light_controller.cpp b/src/ros2_medkit_gateway/test/demo_nodes/light_controller.cpp new file mode 100644 index 0000000..7e473d1 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/light_controller.cpp @@ -0,0 +1,82 @@ +// Copyright 2025 mfaferek93 +// +// 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 light_controller.cpp + * @brief Demo light controller + * + * Simulates light system that: + * - Subscribes to command topic (Bool - on/off) + * - Publishes status topic (Bool - current state) + * - Used to test PUT /data/{topic} endpoint with boolean values + */ + +#include + +#include +#include + +using namespace std::chrono_literals; + +class LightController : public rclcpp::Node +{ +public: + LightController() : Node("light_controller"), light_on_(false) + { + // Subscribe to command topic + cmd_sub_ = this->create_subscription( + "command", 10, + std::bind(&LightController::command_callback, this, std::placeholders::_1) + ); + + // Publish status topic + status_pub_ = this->create_publisher("status", 10); + + // Timer to periodically publish status + timer_ = this->create_wall_timer( + 100ms, + std::bind(&LightController::timer_callback, this) + ); + + RCLCPP_INFO(this->get_logger(), "Light controller started"); + } + +private: + void command_callback(const std_msgs::msg::Bool::SharedPtr msg) + { + light_on_ = msg->data; + RCLCPP_INFO(this->get_logger(), "Light command: %s", light_on_ ? "ON" : "OFF"); + } + + void timer_callback() + { + // Publish current status + auto msg = std_msgs::msg::Bool(); + msg.data = light_on_; + status_pub_->publish(msg); + } + + rclcpp::Subscription::SharedPtr cmd_sub_; + rclcpp::Publisher::SharedPtr status_pub_; + rclcpp::TimerBase::SharedPtr timer_; + bool light_on_; +}; + +int main(int argc, char * argv[]) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/demo_nodes/rpm_sensor.cpp b/src/ros2_medkit_gateway/test/demo_nodes/rpm_sensor.cpp new file mode 100644 index 0000000..18cfc36 --- /dev/null +++ b/src/ros2_medkit_gateway/test/demo_nodes/rpm_sensor.cpp @@ -0,0 +1,60 @@ +// Copyright 2025 mfaferek93 +// +// 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. + +#include +#include + +class RPMSensor : public rclcpp::Node +{ +public: + RPMSensor() + : Node("rpm_sensor") + { + rpm_pub_ = this->create_publisher("rpm", 10); + + timer_ = this->create_wall_timer( + std::chrono::milliseconds(500), + std::bind(&RPMSensor::publish_data, this)); + + RCLCPP_INFO(this->get_logger(), "RPM sensor started"); + } + +private: + void publish_data() + { + current_rpm_ += 50.0; + if (current_rpm_ > 3000.0) { + current_rpm_ = 1000.0; + } + + auto rpm_msg = std_msgs::msg::Float32(); + rpm_msg.data = current_rpm_; + + rpm_pub_->publish(rpm_msg); + + RCLCPP_INFO(this->get_logger(), "RPM: %.0f", current_rpm_); + } + + rclcpp::Publisher::SharedPtr rpm_pub_; + rclcpp::TimerBase::SharedPtr timer_; + double current_rpm_ = 1000.0; +}; + +int main(int argc, char** argv) +{ + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} diff --git a/src/ros2_medkit_gateway/test/test_integration.test.py b/src/ros2_medkit_gateway/test/test_integration.test.py new file mode 100644 index 0000000..e0d0ce6 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_integration.test.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +# Copyright 2025 bburda, mfaferek93 +# +# 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. + +""" +Launch file for ROS 2 Medkit Gateway integration tests. + +This launch file: +1. Starts the ROS 2 Medkit Gateway node +2. Launches demo nodes in different namespaces (powertrain, chassis, body) +3. Runs integration tests +4. Cleans up all processes +""" + +import time +import unittest + +from launch import LaunchDescription +from launch.actions import TimerAction +import launch_ros.actions +import launch_testing.actions +import requests + + +def generate_test_description(): + """Generate launch description with gateway node, demo nodes, and tests.""" + # Launch the ROS 2 Medkit Gateway node + gateway_node = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='ros2_medkit_gateway', + output='screen', + parameters=[], + ) + + # Launch demo automotive sensor nodes + engine_temp_sensor = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_engine_temp_sensor', + name='temp_sensor', + namespace='/powertrain/engine', + output='screen', + ) + + rpm_sensor = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_rpm_sensor', + name='rpm_sensor', + namespace='/powertrain/engine', + output='screen', + ) + + brake_pressure_sensor = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_brake_pressure_sensor', + name='pressure_sensor', + namespace='/chassis/brakes', + output='screen', + ) + + door_status_sensor = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_door_status_sensor', + name='status_sensor', + namespace='/body/door/front_left', + output='screen', + ) + + brake_actuator = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_brake_actuator', + name='actuator', + namespace='/chassis/brakes', + output='screen', + ) + + light_controller = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_light_controller', + name='controller', + namespace='/body/lights', + output='screen', + ) + + calibration_service = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_calibration_service', + name='calibration', + namespace='/powertrain/engine', + output='screen', + ) + + # Start demo nodes with a delay to ensure gateway starts first + delayed_sensors = TimerAction( + period=2.0, + actions=[ + engine_temp_sensor, rpm_sensor, brake_pressure_sensor, door_status_sensor, + brake_actuator, light_controller, calibration_service + ] + ) + + return ( + LaunchDescription([ + # Launch gateway first + gateway_node, + + # Launch demo nodes with delay + delayed_sensors, + + # Start tests after nodes have time to initialize + launch_testing.actions.ReadyToTest(), + ]), + { + 'gateway_node': gateway_node, + } + ) + + +class TestROS2MedkitGatewayIntegration(unittest.TestCase): + """Integration tests for ROS 2 Medkit Gateway REST API and discovery.""" + + BASE_URL = 'http://localhost:8080' + # Wait for cache refresh + safety margin + # Must be kept in sync with gateway_params.yaml refresh_interval_ms (2000ms) + # Need to wait for at least 2 refresh cycles to ensure all demo nodes are discovered + CACHE_REFRESH_INTERVAL = 5.0 + + @classmethod + def setUpClass(cls): + """Wait for gateway to be ready before any tests.""" + # Wait for gateway and discovery + time.sleep(cls.CACHE_REFRESH_INTERVAL) + + # Verify gateway is responding + max_retries = 5 + for i in range(max_retries): + try: + response = requests.get(f'{cls.BASE_URL}/', timeout=1) + if response.status_code == 200: + return + except requests.exceptions.RequestException: + if i == max_retries - 1: + raise unittest.SkipTest('Gateway not responding') + time.sleep(1) + + def _get_json(self, endpoint: str): + """Get JSON from an endpoint.""" + response = requests.get(f'{self.BASE_URL}{endpoint}', timeout=5) + response.raise_for_status() + return response.json() + + def test_01_root_endpoint(self): + """Test GET / returns gateway status and version.""" + data = self._get_json('/') + self.assertIn('status', data) + self.assertIn('version', data) + self.assertEqual(data['status'], 'ROS 2 Medkit Gateway running') + self.assertEqual(data['version'], '0.1.0') + print('✓ Root endpoint test passed') + + def test_02_list_areas(self): + """Test GET /areas returns all discovered areas.""" + areas = self._get_json('/areas') + self.assertIsInstance(areas, list) + self.assertGreaterEqual(len(areas), 1) + area_ids = [area['id'] for area in areas] + self.assertIn('root', area_ids) + print(f'✓ Areas test passed: {len(areas)} areas discovered') + + def test_21_automotive_areas_discovery(self): + """Test that automotive areas are properly discovered.""" + areas = self._get_json('/areas') + area_ids = [area['id'] for area in areas] + + expected_areas = ['powertrain', 'chassis', 'body'] + for expected in expected_areas: + self.assertIn(expected, area_ids) + + print(f'✓ All automotive areas discovered: {area_ids}')