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}')