diff --git a/.gitignore b/.gitignore
index 6c2cdb6..0bdc8bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -244,4 +244,6 @@ qtcreator-*
COLCON_IGNORE
AMENT_IGNORE
-# End of https://www.toptal.com/developers/gitignore/api/ros2,c++,python
\ No newline at end of file
+# End of https://www.toptal.com/developers/gitignore/api/ros2,c++,pythonPLAN.md
+
+PLAN.md
\ No newline at end of file
diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst
new file mode 100644
index 0000000..4d5a307
--- /dev/null
+++ b/docs/config/discovery-options.rst
@@ -0,0 +1,130 @@
+Discovery Options Reference
+===========================
+
+This document describes configuration options for the gateway's discovery system.
+The discovery system maps ROS 2 graph entities (nodes, topics, services, actions)
+to SOVD entities (areas, components, apps).
+
+Discovery Modes
+---------------
+
+The gateway supports three discovery modes, controlled by the ``discovery.mode`` parameter:
+
+.. list-table:: Discovery Modes
+ :header-rows: 1
+ :widths: 20 80
+
+ * - Mode
+ - Description
+ * - ``runtime_only``
+ - Use ROS 2 graph introspection only. Nodes are discovered at runtime
+ and mapped to SOVD entities using heuristic rules. (default)
+ * - ``manifest_only``
+ - Only expose entities declared in the manifest file. Runtime discovery
+ is disabled.
+ * - ``hybrid``
+ - Manifest defines the structure, runtime links to discovered nodes.
+
+Runtime Discovery Options
+-------------------------
+
+When using ``runtime_only`` or ``hybrid`` mode, the following options control
+how ROS 2 nodes are mapped to SOVD entities.
+
+Synthetic Components
+^^^^^^^^^^^^^^^^^^^^
+
+.. code-block:: yaml
+
+ discovery:
+ runtime:
+ create_synthetic_components: true
+ grouping_strategy: "namespace"
+ synthetic_component_name_pattern: "{area}"
+
+When ``create_synthetic_components`` is true:
+
+- Components are created as logical groupings of Apps
+- ``grouping_strategy: "namespace"`` groups nodes by their first namespace segment
+- ``synthetic_component_name_pattern`` defines the component ID format
+
+Topic-Only Namespaces
+^^^^^^^^^^^^^^^^^^^^^
+
+Some ROS 2 systems have topics published to namespaces without any associated nodes.
+This is common with:
+
+- Isaac Sim and other simulators
+- External bridges (MQTT, Zenoh, ROS 1)
+- Dead/orphaned topics from crashed processes
+
+The ``topic_only_policy`` controls how these namespaces are handled:
+
+.. code-block:: yaml
+
+ discovery:
+ runtime:
+ topic_only_policy: "create_component"
+ min_topics_for_component: 1
+
+.. list-table:: Topic-Only Policies
+ :header-rows: 1
+ :widths: 25 75
+
+ * - Policy
+ - Description
+ * - ``ignore``
+ - Don't create any entities for topic-only namespaces.
+ Use this to reduce noise from orphaned topics.
+ * - ``create_component``
+ - Create a Component with ``source: "topic"`` for each topic-only
+ namespace. (default)
+ * - ``create_area_only``
+ - Only create the Area, but don't create a Component.
+ Useful when you want the namespace visible but not as a component.
+
+The ``min_topics_for_component`` parameter (default: 1) sets the minimum number
+of topics required before creating a component. This can filter out namespaces
+with only a few stray topics.
+
+Configuration Example
+---------------------
+
+Complete YAML configuration for runtime discovery:
+
+.. code-block:: yaml
+
+ ros2_medkit_gateway:
+ ros__parameters:
+ discovery:
+ mode: "runtime_only"
+
+ runtime:
+ # Map nodes to Apps
+ expose_nodes_as_apps: true
+
+ # Group Apps into Components by namespace
+ create_synthetic_components: true
+ grouping_strategy: "namespace"
+ synthetic_component_name_pattern: "{area}"
+
+ # Handle topic-only namespaces
+ topic_only_policy: "create_component"
+ min_topics_for_component: 2 # Require at least 2 topics
+
+Command Line Override
+---------------------
+
+Override discovery options via command line:
+
+.. code-block:: bash
+
+ ros2 launch ros2_medkit_gateway gateway.launch.py \
+ discovery.runtime.topic_only_policy:="ignore" \
+ discovery.runtime.min_topics_for_component:=3
+
+See Also
+--------
+
+- :doc:`manifest-schema` - Manifest-based configuration
+- :doc:`/tutorials/heuristic-apps` - Tutorial on runtime discovery
diff --git a/docs/config/index.rst b/docs/config/index.rst
index 5e66027..c2c0865 100644
--- a/docs/config/index.rst
+++ b/docs/config/index.rst
@@ -6,8 +6,16 @@ This section contains configuration references for ros2_medkit.
.. toctree::
:maxdepth: 2
+ discovery-options
manifest-schema
+Discovery Options
+-----------------
+
+:doc:`discovery-options`
+ Configuration reference for runtime discovery options.
+ Controls how ROS 2 nodes, topics, and services are mapped to SOVD entities.
+
Manifest Configuration
----------------------
diff --git a/docs/config/manifest-schema.rst b/docs/config/manifest-schema.rst
index 7806ffa..2d77423 100644
--- a/docs/config/manifest-schema.rst
+++ b/docs/config/manifest-schema.rst
@@ -225,6 +225,7 @@ Schema
tags: [string] # Optional - tags for filtering
translation_id: string # Optional - i18n key
parent_component_id: string # Optional - parent component
+ depends_on: [string] # Optional - component IDs this depends on
subcomponents: [] # Optional - nested definitions
Fields
@@ -286,6 +287,10 @@ Fields
- string
- No
- Parent component ID
+ * - ``depends_on``
+ - [string]
+ - No
+ - List of component IDs this component depends on
* - ``subcomponents``
- [Component]
- No
diff --git a/docs/requirements/specs/discovery.rst b/docs/requirements/specs/discovery.rst
index 55c8d90..95c89ba 100644
--- a/docs/requirements/specs/discovery.rst
+++ b/docs/requirements/specs/discovery.rst
@@ -52,7 +52,7 @@ Discovery
.. req:: GET /components/{id}/depends-on
:id: REQ_INTEROP_008
- :status: open
+ :status: implemented
:tags: Discovery
The endpoint shall return the list of components that the addressed component depends on.
diff --git a/docs/tutorials/heuristic-apps.rst b/docs/tutorials/heuristic-apps.rst
new file mode 100644
index 0000000..41c167c
--- /dev/null
+++ b/docs/tutorials/heuristic-apps.rst
@@ -0,0 +1,256 @@
+Heuristic Runtime Discovery
+===========================
+
+This tutorial explains how to use heuristic runtime discovery to expose
+ROS 2 nodes as SOVD Apps, with synthetic Components grouping them logically.
+
+.. contents:: Table of Contents
+ :local:
+ :depth: 2
+
+Overview
+--------
+
+By default, ros2_medkit gateway uses **heuristic discovery** to map ROS 2 graph
+entities to SOVD entities:
+
+- **ROS 2 nodes** → **Apps** (software applications)
+- **Namespaces** → **Areas** (logical groupings)
+- **Apps grouped by namespace** → **Synthetic Components**
+- **Topics, services, actions** → **Data, Operations**
+
+This approach requires no configuration and works out of the box with any
+ROS 2 system.
+
+When to Use Heuristic Discovery
+-------------------------------
+
+Heuristic discovery is ideal when:
+
+✅ You want to explore a ROS 2 system without prior setup
+✅ Entity IDs can be derived from node names
+✅ Your system doesn't require stable IDs across restarts
+✅ You're prototyping or debugging
+
+Consider using :doc:`manifest-discovery` when:
+
+❌ You need stable, semantic IDs (e.g., ``front-lidar`` instead of ``scan_node``)
+❌ You need to define entities that don't exist at runtime
+❌ You need offline detection of failed components
+
+Quick Start
+-----------
+
+Launch the gateway with default settings:
+
+.. code-block:: bash
+
+ ros2 launch ros2_medkit_gateway gateway.launch.py
+
+The gateway automatically discovers all nodes and maps them to SOVD entities.
+
+Query available Apps:
+
+.. code-block:: bash
+
+ curl http://localhost:8080/api/v1/apps | jq
+
+Example response:
+
+.. code-block:: json
+
+ {
+ "items": [
+ {
+ "id": "lidar_driver",
+ "name": "lidar_driver",
+ "namespace_path": "/perception",
+ "area": "perception",
+ "component": "perception",
+ "source": "heuristic"
+ },
+ {
+ "id": "camera_node",
+ "name": "camera_node",
+ "namespace_path": "/perception",
+ "area": "perception",
+ "component": "perception",
+ "source": "heuristic"
+ }
+ ]
+ }
+
+Understanding the Entity Hierarchy
+----------------------------------
+
+With heuristic discovery, the SOVD hierarchy is built as follows:
+
+.. code-block:: text
+
+ Area: "perception" ← from namespace /perception
+ └── Component: "perception" ← synthetic, groups apps in this area
+ ├── App: "lidar_driver" ← from node /perception/lidar_driver
+ │ ├── Data: "scan" ← published topics
+ │ └── Operations: ... ← services/actions
+ └── App: "camera_node" ← from node /perception/camera_node
+ └── Data: "image_raw"
+
+Configuration Options
+---------------------
+
+All options are under ``discovery.runtime`` in the gateway parameters:
+
+.. code-block:: yaml
+
+ ros2_medkit_gateway:
+ ros__parameters:
+ discovery:
+ mode: "runtime_only" # or "hybrid"
+
+ runtime:
+ create_synthetic_components: true
+ grouping_strategy: "namespace"
+ synthetic_component_name_pattern: "{area}"
+
+create_synthetic_components
+^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+When ``true`` (default), the gateway creates synthetic Components to group Apps:
+
+.. code-block:: bash
+
+ curl http://localhost:8080/api/v1/components
+ # Returns: [{"id": "perception", "source": "synthetic", ...}]
+
+ curl http://localhost:8080/api/v1/components/perception/apps
+ # Returns: [{"id": "lidar_driver"}, {"id": "camera_node"}]
+
+When ``false``, each node is its own Component (no grouping):
+
+.. code-block:: bash
+
+ curl http://localhost:8080/api/v1/components
+ # Returns: [{"id": "lidar_driver"}, {"id": "camera_node"}]
+
+grouping_strategy
+^^^^^^^^^^^^^^^^^
+
+Controls how Apps are grouped into Components:
+
+- ``namespace`` (default): Group by first namespace segment
+- ``none``: Each app is its own component
+
+Handling Topic-Only Namespaces
+------------------------------
+
+Some systems (like Isaac Sim) publish topics without creating ROS 2 nodes.
+The ``topic_only_policy`` controls how these are handled:
+
+.. code-block:: yaml
+
+ discovery:
+ runtime:
+ topic_only_policy: "create_component"
+ min_topics_for_component: 2
+
+Policies:
+
+- ``create_component`` (default): Create a Component for topic namespaces
+- ``create_area_only``: Create only the Area, no Component
+- ``ignore``: Skip topic-only namespaces entirely
+
+Example: Filtering Noise
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+To ignore orphaned topics from crashed processes:
+
+.. code-block:: yaml
+
+ discovery:
+ runtime:
+ topic_only_policy: "ignore"
+
+Or require multiple topics before creating a component:
+
+.. code-block:: yaml
+
+ discovery:
+ runtime:
+ topic_only_policy: "create_component"
+ min_topics_for_component: 3 # Need 3+ topics
+
+API Endpoints
+-------------
+
+Apps Endpoints
+^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 40 60
+
+ * - Endpoint
+ - Description
+ * - ``GET /apps``
+ - List all discovered Apps
+ * - ``GET /apps/{app_id}``
+ - Get specific App details
+ * - ``GET /components/{id}/apps``
+ - List Apps in a synthetic Component
+
+Components Endpoints
+^^^^^^^^^^^^^^^^^^^^
+
+With synthetic components, the ``/components`` endpoint returns
+grouped entities:
+
+.. code-block:: bash
+
+ curl http://localhost:8080/api/v1/components | jq '.items[] | {id, source}'
+
+.. code-block:: json
+
+ {"id": "perception", "source": "synthetic"}
+ {"id": "navigation", "source": "synthetic"}
+ {"id": "isaac_sim", "source": "topic"}
+
+The ``source`` field indicates how the component was discovered:
+
+- ``synthetic``: Grouped from multiple nodes
+- ``node``: Direct node-to-component mapping (legacy mode)
+- ``topic``: From topic-only namespace
+
+Migrating to Manifest Discovery
+-------------------------------
+
+When you need more control, consider migrating to hybrid mode.
+See :doc:`migration-to-manifest` for a step-by-step guide.
+
+Key differences:
+
+.. list-table::
+ :header-rows: 1
+ :widths: 30 35 35
+
+ * - Feature
+ - Heuristic
+ - Manifest
+ * - Setup required
+ - None
+ - YAML manifest file
+ * - Entity IDs
+ - Derived from node names
+ - Custom, semantic IDs
+ * - Offline detection
+ - No
+ - Yes (failed components)
+ * - Stable across restarts
+ - No (depends on node names)
+ - Yes (defined in manifest)
+
+See Also
+--------
+
+- :doc:`/config/discovery-options` - Full configuration reference
+- :doc:`manifest-discovery` - Manifest-based discovery
+- :doc:`migration-to-manifest` - Migration guide
diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst
index edfdf37..0b1b61e 100644
--- a/docs/tutorials/index.rst
+++ b/docs/tutorials/index.rst
@@ -6,6 +6,7 @@ Step-by-step guides for common use cases with ros2_medkit.
.. toctree::
:maxdepth: 1
+ heuristic-apps
manifest-discovery
migration-to-manifest
authentication
@@ -16,6 +17,13 @@ Step-by-step guides for common use cases with ros2_medkit.
integration
custom_areas
+Discovery Tutorials
+-------------------
+
+:doc:`heuristic-apps`
+ Use heuristic discovery to automatically map ROS 2 nodes to SOVD Apps
+ without any configuration.
+
Manifest Discovery
------------------
diff --git a/postman/collections/ros2-medkit-gateway.postman_collection.json b/postman/collections/ros2-medkit-gateway.postman_collection.json
index 77afdb9..87d4173 100644
--- a/postman/collections/ros2-medkit-gateway.postman_collection.json
+++ b/postman/collections/ros2-medkit-gateway.postman_collection.json
@@ -383,6 +383,46 @@
"description": "List components within a specific area. Returns 404 if area doesn't exist. Change 'powertrain' to other areas like 'chassis', 'body', etc."
},
"response": []
+ },
+ {
+ "name": "GET Component Dependencies",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/components/engine-ecu/depends-on",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "components",
+ "engine-ecu",
+ "depends-on"
+ ]
+ },
+ "description": "Get the list of components that the addressed component depends on (REQ_INTEROP_008). Returns array of component references with id, href, name, type, and missing flag (if dependency is unresolved). The 'engine-ecu' component depends on 'temp-sensor-hw' and 'rpm-sensor-hw' in the demo manifest."
+ },
+ "response": []
+ },
+ {
+ "name": "GET Component Dependencies (Not Found)",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/components/nonexistent/depends-on",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "components",
+ "nonexistent",
+ "depends-on"
+ ]
+ },
+ "description": "Example of requesting dependencies for a non-existent component. Returns 404."
+ },
+ "response": []
}
]
},
@@ -1256,6 +1296,292 @@
}
]
},
+ {
+ "name": "Apps",
+ "description": "App entity endpoints for heuristic discovery mode. In heuristic mode, ROS 2 nodes are exposed as Apps with source='heuristic'. Apps have is_online=true when the corresponding node is running.",
+ "item": [
+ {
+ "name": "GET List Apps",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps"
+ ]
+ },
+ "description": "List all discovered apps. In heuristic mode, ROS 2 nodes are exposed as Apps with source='heuristic'. Apps have is_online=true when the corresponding node is running."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Details",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/temp_sensor",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "temp_sensor"
+ ]
+ },
+ "description": "Get details of a specific app including capabilities, links, and online status."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Data (Topics)",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/temp_sensor/data",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "temp_sensor",
+ "data"
+ ]
+ },
+ "description": "Get all topics published/subscribed by the app."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Operations",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/calibration/operations",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "calibration",
+ "operations"
+ ]
+ },
+ "description": "List all operations (services and actions) for the app."
+ },
+ "response": []
+ },
+ {
+ "name": "POST Call App Operation",
+ "request": {
+ "method": "POST",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{}"
+ },
+ "url": {
+ "raw": "{{base_url}}/apps/calibration/operations/calibrate",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "calibration",
+ "operations",
+ "calibrate"
+ ]
+ },
+ "description": "Call a service operation on an app."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Configurations",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/temp_sensor/configurations",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "temp_sensor",
+ "configurations"
+ ]
+ },
+ "description": "List all parameters (configurations) for the app."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Configuration Value",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/temp_sensor/configurations/publish_rate",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "temp_sensor",
+ "configurations",
+ "publish_rate"
+ ]
+ },
+ "description": "Get a specific parameter value for an app."
+ },
+ "response": []
+ },
+ {
+ "name": "PUT Set App Configuration",
+ "request": {
+ "method": "PUT",
+ "header": [
+ {
+ "key": "Content-Type",
+ "value": "application/json"
+ }
+ ],
+ "body": {
+ "mode": "raw",
+ "raw": "{\n \"value\": 5.0\n}"
+ },
+ "url": {
+ "raw": "{{base_url}}/apps/temp_sensor/configurations/publish_rate",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "temp_sensor",
+ "configurations",
+ "publish_rate"
+ ]
+ },
+ "description": "Set a parameter value for an app."
+ },
+ "response": []
+ },
+ {
+ "name": "DELETE Reset App Configuration",
+ "request": {
+ "method": "DELETE",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/temp_sensor/configurations/publish_rate",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "temp_sensor",
+ "configurations",
+ "publish_rate"
+ ]
+ },
+ "description": "Reset a configuration to its default value for an app."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Faults",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/lidar_sensor/faults",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "lidar_sensor",
+ "faults"
+ ]
+ },
+ "description": "List all faults reported by the app."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App Fault",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/lidar_sensor/faults/LIDAR_RANGE_INVALID",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "lidar_sensor",
+ "faults",
+ "LIDAR_RANGE_INVALID"
+ ]
+ },
+ "description": "Get details of a specific fault for an app."
+ },
+ "response": []
+ },
+ {
+ "name": "DELETE Clear App Fault",
+ "request": {
+ "method": "DELETE",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/lidar_sensor/faults/LIDAR_RANGE_INVALID",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "lidar_sensor",
+ "faults",
+ "LIDAR_RANGE_INVALID"
+ ]
+ },
+ "description": "Clear a fault for an app."
+ },
+ "response": []
+ },
+ {
+ "name": "GET App (Not Found)",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{base_url}}/apps/nonexistent",
+ "host": [
+ "{{base_url}}"
+ ],
+ "path": [
+ "apps",
+ "nonexistent"
+ ]
+ },
+ "description": "Example of requesting a non-existent app. Returns 404."
+ },
+ "response": []
+ }
+ ]
+ },
{
"name": "LIDAR Fault Workflow",
"description": "Complete fault management workflow using the demo LIDAR sensor. The sensor starts with intentionally invalid parameters that generate faults. This workflow demonstrates: (1) reading faults, (2) fixing parameters, (3) clearing faults, and (4) running calibration.",
diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt
index 64c14ab..6119d83 100644
--- a/src/ros2_medkit_gateway/CMakeLists.txt
+++ b/src/ros2_medkit_gateway/CMakeLists.txt
@@ -110,6 +110,7 @@ add_library(gateway_lib STATIC
src/configuration_manager.cpp
src/fault_manager.cpp
# Discovery module
+ src/discovery/discovery_enums.cpp
src/discovery/discovery_manager.cpp
src/discovery/runtime_discovery.cpp
src/discovery/hybrid_discovery.cpp
@@ -388,6 +389,13 @@ if(BUILD_TESTING)
TIMEOUT 120
)
+ # Add heuristic apps discovery integration tests
+ add_launch_test(
+ test/test_discovery_heuristic_apps.test.py
+ TARGET test_discovery_heuristic_apps
+ TIMEOUT 120
+ )
+
# Demo automotive nodes
add_executable(demo_engine_temp_sensor
test/demo_nodes/engine_temp_sensor.cpp
diff --git a/src/ros2_medkit_gateway/config/examples/demo_nodes_manifest.yaml b/src/ros2_medkit_gateway/config/examples/demo_nodes_manifest.yaml
index fde9545..c9a6a0b 100644
--- a/src/ros2_medkit_gateway/config/examples/demo_nodes_manifest.yaml
+++ b/src/ros2_medkit_gateway/config/examples/demo_nodes_manifest.yaml
@@ -84,6 +84,9 @@ components:
type: "controller"
area: engine
description: "Engine control unit"
+ depends_on:
+ - temp-sensor-hw
+ - rpm-sensor-hw
- id: temp-sensor-hw
name: "Temperature Sensor"
@@ -100,6 +103,9 @@ components:
name: "Brake ECU"
type: "controller"
area: brakes
+ depends_on:
+ - brake-pressure-sensor-hw
+ - brake-actuator-hw
- id: brake-pressure-sensor-hw
name: "Brake Pressure Sensor"
@@ -110,6 +116,8 @@ components:
name: "Brake Actuator"
type: "actuator"
area: brakes
+ depends_on:
+ - brake-ecu
# Body components
- id: door-sensor-hw
diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml
index a4cbf1b..6b4746a 100644
--- a/src/ros2_medkit_gateway/config/gateway_params.yaml
+++ b/src/ros2_medkit_gateway/config/gateway_params.yaml
@@ -100,6 +100,57 @@ ros2_medkit_gateway:
# - Action operations: Internal action services via GenericClient
# No ROS 2 CLI dependencies - pure C++ implementation using ros2_medkit_serialization.
+ # Discovery Configuration
+ # Controls how ROS 2 graph entities are mapped to SOVD entities
+ discovery:
+ # Discovery mode
+ # Options:
+ # - "runtime_only": Use ROS 2 graph introspection only (default)
+ # - "manifest_only": Only expose manifest-declared entities
+ # - "hybrid": Manifest as source of truth + runtime linking
+ mode: "runtime_only"
+
+ # Path to manifest file (required for manifest_only and hybrid modes)
+ manifest_path: ""
+
+ # Strict manifest validation (reject invalid manifests)
+ manifest_strict_validation: true
+
+ # Runtime (heuristic) discovery options
+ # These control how nodes are mapped to SOVD entities in runtime mode
+ runtime:
+ # Create synthetic Component entities that group Apps
+ # When true, Components are synthetic groupings (by namespace)
+ # When false, each node is a Component
+ # Default: true
+ create_synthetic_components: true
+
+ # How to group nodes into synthetic components
+ # Options:
+ # - "none": Each node = 1 component (default when synthetic off)
+ # - "namespace": Group by first namespace segment (area)
+ # Note: "process" grouping is planned for future (requires R&D)
+ grouping_strategy: "namespace"
+
+ # Naming pattern for synthetic components
+ # Placeholders: {area}
+ # Examples: "{area}", "{area}_group", "component_{area}"
+ synthetic_component_name_pattern: "{area}"
+
+ # Policy for namespaces with topics but no ROS 2 nodes
+ # Common with Isaac Sim, external bridges, or dead/orphaned topics
+ # Options:
+ # - "ignore": Don't create any entity for topic-only namespaces
+ # - "create_component": Create component with source="topic" (default)
+ # - "create_area_only": Only create the area, no component
+ topic_only_policy: "create_component"
+
+ # Minimum number of topics to create a topic-based component
+ # Only applies when topic_only_policy is "create_component"
+ # Namespaces with fewer topics are skipped
+ # Default: 1 (create component for any namespace with topics)
+ min_topics_for_component: 1
+
# Authentication Configuration (REQ_INTEROP_086, REQ_INTEROP_087)
# JWT-based authentication with Role-Based Access Control (RBAC)
auth:
diff --git a/src/ros2_medkit_gateway/design/index.rst b/src/ros2_medkit_gateway/design/index.rst
index e6b4d36..9e4e9cf 100644
--- a/src/ros2_medkit_gateway/design/index.rst
+++ b/src/ros2_medkit_gateway/design/index.rst
@@ -39,12 +39,38 @@ The following diagram shows the relationships between the main components of the
class DiscoveryManager {
+ discover_areas(): vector
+ discover_components(): vector
+ + discover_apps(): vector
+ discover_services(): vector
+ discover_actions(): vector
+ find_service_for_component(): optional
+ find_action_for_component(): optional
}
+ interface DiscoveryStrategy <> {
+ + discover_areas(): vector
+ + discover_components(): vector
+ + discover_apps(): vector
+ + discover_functions(): vector
+ + get_name(): string
+ }
+
+ class RuntimeDiscoveryStrategy {
+ + discover_node_components(): vector
+ + discover_synthetic_components(): vector
+ + discover_topic_components(): vector
+ - config_: RuntimeConfig
+ }
+
+ class ManifestDiscoveryStrategy {
+ + load_manifest(): void
+ - manifest_: Manifest
+ }
+
+ class HybridDiscoveryStrategy {
+ - primary_: ManifestDiscoveryStrategy
+ - runtime_: RuntimeDiscoveryStrategy
+ }
+
class OperationManager {
+ call_service(): ServiceCallResult
+ call_component_service(): ServiceCallResult
@@ -105,11 +131,22 @@ The following diagram shows the relationships between the main components of the
+ fqn: string
+ type: string
+ area: string
+ + source: string
+ services: vector
+ actions: vector
+ to_json(): json
}
+ class App {
+ + id: string
+ + name: string
+ + namespace_path: string
+ + area: string
+ + component: string
+ + source: string
+ + to_json(): json
+ }
+
class ServiceInfo {
+ full_path: string
+ name: string
@@ -125,6 +162,7 @@ The following diagram shows the relationships between the main components of the
class EntityCache {
+ areas: vector
+ components: vector
+ + apps: vector
+ last_update: time_point
}
}
@@ -173,6 +211,7 @@ The following diagram shows the relationships between the main components of the
' Entity Cache aggregates entities
EntityCache o-right-> Area : contains many
EntityCache o-right-> Component : contains many
+ EntityCache o-right-> App : contains many
' Component contains operations
Component o--> ServiceInfo : contains many
@@ -181,15 +220,25 @@ The following diagram shows the relationships between the main components of the
' Discovery produces entities
DiscoveryManager ..> Area : creates
DiscoveryManager ..> Component : creates
+ DiscoveryManager ..> App : creates
DiscoveryManager ..> ServiceInfo : creates
DiscoveryManager ..> ActionInfo : creates
+ ' Discovery strategy hierarchy
+ DiscoveryManager --> DiscoveryStrategy : uses
+ RuntimeDiscoveryStrategy .up.|> DiscoveryStrategy : implements
+ ManifestDiscoveryStrategy .up.|> DiscoveryStrategy : implements
+ HybridDiscoveryStrategy .up.|> DiscoveryStrategy : implements
+ HybridDiscoveryStrategy --> ManifestDiscoveryStrategy : delegates
+ HybridDiscoveryStrategy --> RuntimeDiscoveryStrategy : delegates
+
' REST Server uses HTTP library
RESTServer *--> HTTPLibServer : owns
' Models use JSON for serialization
Area ..> JSON : serializes to
Component ..> JSON : serializes to
+ App ..> JSON : serializes to
@enduml
@@ -205,11 +254,26 @@ Main Components
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
+ - Discovers Components (synthetic groups or node-based, configurable)
+ - Discovers Apps from ROS 2 nodes (when heuristic discovery enabled)
- Discovers Services and Actions using native rclcpp APIs
- Attaches operations (services/actions) to their parent components
+ - Uses pluggable strategy pattern: Runtime, Manifest, or Hybrid
- Uses O(n+m) algorithm with hash maps for efficient service/action attachment
+ **Discovery Strategies:**
+
+ - **RuntimeDiscoveryStrategy** - Heuristic discovery via ROS 2 graph introspection
+ - Maps nodes to Apps with ``source: "heuristic"``
+ - Creates synthetic Components grouped by namespace
+ - Handles topic-only namespaces (Isaac Sim, bridges) via TopicOnlyPolicy
+ - **ManifestDiscoveryStrategy** - Static discovery from YAML manifest
+ - Provides stable, semantic entity IDs
+ - Supports offline detection of failed components
+ - **HybridDiscoveryStrategy** - Combines manifest + runtime
+ - Manifest defines structure, runtime links to live nodes
+ - Best for production systems requiring stability + live status
+
3. **OperationManager** - Executes ROS 2 operations (services and actions) using native APIs
- Calls ROS 2 services via ``rclcpp::GenericClient`` with native serialization
- Sends action goals via native action client interfaces
@@ -261,9 +325,9 @@ Main Components
- Thread-safe and stateless design
9. **Data Models** - Entity representations
- - ``Area`` - Physical or logical domain
- - ``Component`` - Hardware or software component with attached operations
+ - ``Area`` - Physical or logical domain (namespace)
+ - ``Component`` - Hardware/software component with attached operations; can be ``node``, ``synthetic``, or ``topic`` based
+ - ``App`` - Software application (ROS 2 node); linked to parent Component
- ``ServiceInfo`` - Service metadata (path, name, type)
- ``ActionInfo`` - Action metadata (path, name, type)
- - ``EntityCache`` - Thread-safe cache of discovered entities
-
+ - ``EntityCache`` - Thread-safe cache of discovered entities (areas, components, apps)
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp
new file mode 100644
index 0000000..aa3a8da
--- /dev/null
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp
@@ -0,0 +1,96 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#ifndef ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_ENUMS_HPP_
+#define ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_ENUMS_HPP_
+
+#include
+
+namespace ros2_medkit_gateway {
+
+/**
+ * @brief Discovery mode determining which strategy is used
+ */
+enum class DiscoveryMode {
+ RUNTIME_ONLY, ///< Traditional ROS graph introspection only
+ MANIFEST_ONLY, ///< Only expose manifest-declared entities
+ HYBRID ///< Manifest as source of truth + runtime linking
+};
+
+/**
+ * @brief Parse DiscoveryMode from string
+ * @param str Mode string: "runtime_only", "manifest_only", or "hybrid"
+ * @return Parsed mode (defaults to RUNTIME_ONLY)
+ */
+DiscoveryMode parse_discovery_mode(const std::string & str);
+
+/**
+ * @brief Convert DiscoveryMode to string
+ * @param mode Discovery mode
+ * @return String representation
+ */
+std::string discovery_mode_to_string(DiscoveryMode mode);
+
+/**
+ * @brief Strategy for grouping nodes into synthetic components
+ */
+enum class ComponentGroupingStrategy {
+ NONE, ///< Each node = 1 component (current behavior)
+ NAMESPACE, ///< Group by first namespace segment (area)
+ // PROCESS // Group by OS process (future - requires R&D)
+};
+
+/**
+ * @brief Parse ComponentGroupingStrategy from string
+ * @param str Strategy string: "none" or "namespace"
+ * @return Parsed strategy (defaults to NONE)
+ */
+ComponentGroupingStrategy parse_grouping_strategy(const std::string & str);
+
+/**
+ * @brief Convert ComponentGroupingStrategy to string
+ * @param strategy Grouping strategy
+ * @return String representation
+ */
+std::string grouping_strategy_to_string(ComponentGroupingStrategy strategy);
+
+/**
+ * @brief Policy for handling namespaces with topics but no nodes
+ *
+ * When topics exist in a namespace without any ROS 2 nodes (common with
+ * Isaac Sim, external bridges), this policy determines what entities to create.
+ */
+enum class TopicOnlyPolicy {
+ IGNORE, ///< Don't create any entity for topic-only namespaces
+ CREATE_COMPONENT, ///< Create component with source="topic" (default)
+ CREATE_AREA_ONLY ///< Only create the area, no component
+};
+
+/**
+ * @brief Parse TopicOnlyPolicy from string
+ * @param str Policy string: "ignore", "create_component", or "create_area_only"
+ * @return Parsed policy (defaults to CREATE_COMPONENT)
+ */
+TopicOnlyPolicy parse_topic_only_policy(const std::string & str);
+
+/**
+ * @brief Convert TopicOnlyPolicy to string
+ * @param policy Topic-only policy
+ * @return String representation
+ */
+std::string topic_only_policy_to_string(TopicOnlyPolicy policy);
+
+} // namespace ros2_medkit_gateway
+
+#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_ENUMS_HPP_
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp
index 77a53a5..fdec7c6 100644
--- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp
@@ -15,6 +15,7 @@
#ifndef ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_MANAGER_HPP_
#define ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_MANAGER_HPP_
+#include "ros2_medkit_gateway/discovery/discovery_enums.hpp"
#include "ros2_medkit_gateway/discovery/discovery_strategy.hpp"
#include "ros2_medkit_gateway/discovery/hybrid_discovery.hpp"
#include "ros2_medkit_gateway/discovery/manifest/manifest_manager.hpp"
@@ -37,29 +38,6 @@ namespace ros2_medkit_gateway {
class NativeTopicSampler;
class TypeIntrospection;
-/**
- * @brief Discovery mode determining which strategy is used
- */
-enum class DiscoveryMode {
- RUNTIME_ONLY, ///< Traditional ROS graph introspection only
- MANIFEST_ONLY, ///< Only expose manifest-declared entities
- HYBRID ///< Manifest as source of truth + runtime linking
-};
-
-/**
- * @brief Parse DiscoveryMode from string
- * @param str Mode string: "runtime_only", "manifest_only", or "hybrid"
- * @return Parsed mode (defaults to RUNTIME_ONLY)
- */
-DiscoveryMode parse_discovery_mode(const std::string & str);
-
-/**
- * @brief Convert DiscoveryMode to string
- * @param mode Discovery mode
- * @return String representation
- */
-std::string discovery_mode_to_string(DiscoveryMode mode);
-
/**
* @brief Configuration for discovery
*/
@@ -67,6 +45,59 @@ struct DiscoveryConfig {
DiscoveryMode mode{DiscoveryMode::RUNTIME_ONLY};
std::string manifest_path;
bool manifest_strict_validation{true};
+
+ /**
+ * @brief Runtime (heuristic) discovery options
+ *
+ * These options control how the heuristic discovery strategy
+ * maps ROS 2 graph entities to SOVD entities.
+ */
+ struct RuntimeOptions {
+ /**
+ * @brief Create synthetic Component entities that group Apps
+ *
+ * When true, Components are synthetic groupings (by namespace).
+ * When false, each node is a Component (legacy behavior).
+ * Default: true (new behavior for initial release)
+ */
+ bool create_synthetic_components{true};
+
+ /**
+ * @brief How to group nodes into synthetic components
+ *
+ * Only used when create_synthetic_components is true.
+ * - NONE: Each node = 1 component
+ * - NAMESPACE: Group by first namespace segment (area)
+ */
+ ComponentGroupingStrategy grouping{ComponentGroupingStrategy::NAMESPACE};
+
+ /**
+ * @brief Naming pattern for synthetic components
+ *
+ * Placeholders: {area}
+ * Default: "{area}" - uses area name as component ID
+ */
+ std::string synthetic_component_name_pattern{"{area}"};
+
+ /**
+ * @brief Policy for handling topic-only namespaces
+ *
+ * When topics exist in a namespace without ROS 2 nodes:
+ * - IGNORE: Don't create any entity
+ * - CREATE_COMPONENT: Create component with source="topic" (default)
+ * - CREATE_AREA_ONLY: Only create the area, no component
+ */
+ TopicOnlyPolicy topic_only_policy{TopicOnlyPolicy::CREATE_COMPONENT};
+
+ /**
+ * @brief Minimum number of topics to create a component
+ *
+ * Only applies when topic_only_policy is CREATE_COMPONENT.
+ * Namespaces with fewer topics than this threshold are skipped.
+ * Default: 1 (create component for any namespace with topics)
+ */
+ int min_topics_for_component{1};
+ } runtime;
};
/**
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp
index 6f58f29..10a2709 100644
--- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp
@@ -32,21 +32,22 @@ using json = nlohmann::json;
* They expose operations (services/actions), data (topics), and configurations (parameters).
*/
struct Component {
- std::string id; ///< Unique identifier (node name)
- std::string name; ///< Human-readable name
- std::string namespace_path; ///< ROS 2 namespace path
- std::string fqn; ///< Fully qualified name (namespace + id)
- std::string type = "Component"; ///< Entity type (always "Component")
- std::string area; ///< Parent area ID
- std::string source = "node"; ///< Discovery source: "node", "topic", or "manifest"
- std::string translation_id; ///< Internationalization key
- std::string description; ///< Human-readable description
- std::string variant; ///< Hardware variant identifier
- std::vector tags; ///< Tags for filtering
- std::string parent_component_id; ///< Parent component ID for sub-components
- std::vector services; ///< Services exposed by this component
- std::vector actions; ///< Actions exposed by this component
- ComponentTopics topics; ///< Topics this component publishes/subscribes
+ std::string id; ///< Unique identifier (node name)
+ std::string name; ///< Human-readable name
+ std::string namespace_path; ///< ROS 2 namespace path
+ std::string fqn; ///< Fully qualified name (namespace + id)
+ std::string type = "Component"; ///< Entity type (always "Component")
+ std::string area; ///< Parent area ID
+ std::string source = "node"; ///< Discovery source: "node", "topic", or "manifest"
+ std::string translation_id; ///< Internationalization key
+ std::string description; ///< Human-readable description
+ std::string variant; ///< Hardware variant identifier
+ std::vector tags; ///< Tags for filtering
+ std::string parent_component_id; ///< Parent component ID for sub-components
+ std::vector depends_on; ///< Component IDs this component depends on
+ std::vector services; ///< Services exposed by this component
+ std::vector actions; ///< Actions exposed by this component
+ ComponentTopics topics; ///< Topics this component publishes/subscribes
/**
* @brief Convert to JSON representation
@@ -75,6 +76,9 @@ struct Component {
if (!parent_component_id.empty()) {
j["parentComponentId"] = parent_component_id;
}
+ if (!depends_on.empty()) {
+ j["dependsOn"] = depends_on;
+ }
// Add operations array combining services and actions
json operations = json::array();
@@ -129,6 +133,11 @@ struct Component {
capabilities.push_back({{"name", "configurations"}, {"href", component_url + "/configurations"}});
}
+ // Depends-on capability
+ if (!depends_on.empty()) {
+ capabilities.push_back({{"name", "depends-on"}, {"href", component_url + "/depends-on"}});
+ }
+
return {{"id", id}, {"type", type}, {"capabilities", capabilities}};
}
};
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp
index 950b984..69defeb 100644
--- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp
@@ -15,6 +15,7 @@
#ifndef ROS2_MEDKIT_GATEWAY__DISCOVERY__RUNTIME_DISCOVERY_HPP_
#define ROS2_MEDKIT_GATEWAY__DISCOVERY__RUNTIME_DISCOVERY_HPP_
+#include "ros2_medkit_gateway/discovery/discovery_enums.hpp"
#include "ros2_medkit_gateway/discovery/discovery_strategy.hpp"
#include "ros2_medkit_gateway/discovery/models/app.hpp"
#include "ros2_medkit_gateway/discovery/models/area.hpp"
@@ -32,6 +33,10 @@
#include
namespace ros2_medkit_gateway {
+
+// Forward declaration
+struct DiscoveryConfig;
+
namespace discovery {
/**
@@ -45,18 +50,37 @@ namespace discovery {
* - Discovers components from ROS 2 nodes
* - Discovers topic-based "virtual" components for systems like Isaac Sim
* - Enriches components with services, actions, and topics
+ * - Exposes nodes as Apps
+ * - Can create synthetic Components that group Apps
*
- * @note Apps and Functions are not supported in runtime-only mode.
- * Use ManifestDiscoveryStrategy (future) for those entities.
+ * @note Functions are not supported in runtime-only mode.
+ * Use ManifestDiscoveryStrategy for custom entity definitions.
*/
class RuntimeDiscoveryStrategy : public DiscoveryStrategy {
public:
+ /**
+ * @brief Runtime discovery configuration options
+ */
+ struct RuntimeConfig {
+ bool create_synthetic_components{true};
+ ComponentGroupingStrategy grouping{};
+ std::string synthetic_component_name_pattern{"{area}"};
+ TopicOnlyPolicy topic_only_policy{TopicOnlyPolicy::CREATE_COMPONENT};
+ int min_topics_for_component{1};
+ };
+
/**
* @brief Construct runtime discovery strategy
* @param node ROS 2 node for graph introspection (must outlive this strategy)
*/
explicit RuntimeDiscoveryStrategy(rclcpp::Node * node);
+ /**
+ * @brief Set runtime discovery configuration
+ * @param config Runtime options from DiscoveryConfig
+ */
+ void set_config(const RuntimeConfig & config);
+
/// @copydoc DiscoveryStrategy::discover_areas
std::vector discover_areas() override;
@@ -64,7 +88,7 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy {
std::vector discover_components() override;
/// @copydoc DiscoveryStrategy::discover_apps
- /// @note Returns empty vector - apps require manifest
+ /// @note Returns nodes as Apps in runtime discovery
std::vector discover_apps() override;
/// @copydoc DiscoveryStrategy::discover_functions
@@ -80,6 +104,27 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy {
// Runtime-specific methods (from current DiscoveryManager)
// =========================================================================
+ /**
+ * @brief Discover node-based components (individual ROS 2 nodes)
+ *
+ * This returns the traditional component discovery where each node
+ * becomes a Component. Used internally when synthetic components
+ * are not enabled or for building Apps.
+ *
+ * @return Vector of node-based components
+ */
+ std::vector discover_node_components();
+
+ /**
+ * @brief Discover synthetic components (grouped by namespace)
+ *
+ * Creates aggregated Components that group multiple nodes by namespace.
+ * Only used when create_synthetic_components is enabled.
+ *
+ * @return Vector of synthetic components
+ */
+ std::vector discover_synthetic_components();
+
/**
* @brief Discover components from topic namespaces (topic-based discovery)
*
@@ -158,7 +203,14 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy {
/// Check if a service path is an internal ROS2 service
static bool is_internal_service(const std::string & service_path);
+ /// Derive component ID for a node based on grouping strategy
+ std::string derive_component_id(const Component & node);
+
+ /// Apply naming pattern for synthetic component ID
+ std::string apply_component_name_pattern(const std::string & area);
+
rclcpp::Node * node_;
+ RuntimeConfig config_;
NativeTopicSampler * topic_sampler_{nullptr};
TypeIntrospection * type_introspection_{nullptr};
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp
index 6ede778..9d51cdd 100644
--- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp
@@ -50,7 +50,8 @@ class CapabilityBuilder {
SUBCOMPONENTS, ///< Entity has child components (components only)
RELATED_COMPONENTS, ///< Entity has related components (areas only)
RELATED_APPS, ///< Entity has related apps (components only)
- HOSTS ///< Entity has host apps (functions only)
+ HOSTS, ///< Entity has host apps (functions only)
+ DEPENDS_ON ///< Entity has dependencies (components only)
};
/**
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp
index d733381..11f71ea 100644
--- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp
@@ -78,6 +78,12 @@ class ComponentHandlers {
*/
void handle_get_related_apps(const httplib::Request & req, httplib::Response & res);
+ /**
+ * @brief Handle GET /components/{component_id}/depends-on - list component dependencies.
+ * @verifies REQ_INTEROP_008
+ */
+ void handle_get_depends_on(const httplib::Request & req, httplib::Response & res);
+
private:
HandlerContext & ctx_;
};
diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp
index 398329d..3f1fead 100644
--- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp
+++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp
@@ -30,6 +30,23 @@
namespace ros2_medkit_gateway {
+/**
+ * @brief Entity type enumeration for SOVD entities
+ */
+enum class EntityType { COMPONENT, APP, AREA, FUNCTION, UNKNOWN };
+
+/**
+ * @brief Information about a resolved entity
+ */
+struct EntityInfo {
+ EntityType type{EntityType::UNKNOWN};
+ std::string id;
+ std::string namespace_path;
+ std::string fqn; ///< Fully qualified name (for ROS 2 nodes)
+ std::string id_field; ///< JSON field name for ID ("component_id", "app_id", etc.)
+ std::string error_name; ///< Human-readable name for errors ("Component", "App", etc.)
+};
+
// Forward declarations
class GatewayNode;
class AuthManager;
@@ -89,9 +106,21 @@ class HandlerContext {
* @brief Get namespace path for a component
* @param component_id Component ID
* @return Namespace path or error message
+ * @deprecated Use get_entity_info instead for unified entity handling
*/
tl::expected get_component_namespace_path(const std::string & component_id) const;
+ /**
+ * @brief Get information about any entity (Component, App, Area, Function)
+ *
+ * Searches through all entity types in order: Component, App, Area, Function.
+ * Returns the first match found.
+ *
+ * @param entity_id Entity ID to look up
+ * @return EntityInfo with resolved details, or EntityInfo with UNKNOWN type if not found
+ */
+ EntityInfo get_entity_info(const std::string & entity_id) const;
+
/**
* @brief Set CORS headers on response if origin is allowed
* @param res HTTP response
diff --git a/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp b/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp
new file mode 100644
index 0000000..269712f
--- /dev/null
+++ b/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp
@@ -0,0 +1,77 @@
+// Copyright 2026 bburda
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include "ros2_medkit_gateway/discovery/discovery_enums.hpp"
+
+namespace ros2_medkit_gateway {
+
+DiscoveryMode parse_discovery_mode(const std::string & str) {
+ if (str == "manifest_only") {
+ return DiscoveryMode::MANIFEST_ONLY;
+ }
+ if (str == "hybrid") {
+ return DiscoveryMode::HYBRID;
+ }
+ return DiscoveryMode::RUNTIME_ONLY;
+}
+
+std::string discovery_mode_to_string(DiscoveryMode mode) {
+ switch (mode) {
+ case DiscoveryMode::MANIFEST_ONLY:
+ return "manifest_only";
+ case DiscoveryMode::HYBRID:
+ return "hybrid";
+ default:
+ return "runtime_only";
+ }
+}
+
+ComponentGroupingStrategy parse_grouping_strategy(const std::string & str) {
+ if (str == "namespace") {
+ return ComponentGroupingStrategy::NAMESPACE;
+ }
+ return ComponentGroupingStrategy::NONE;
+}
+
+std::string grouping_strategy_to_string(ComponentGroupingStrategy strategy) {
+ switch (strategy) {
+ case ComponentGroupingStrategy::NAMESPACE:
+ return "namespace";
+ default:
+ return "none";
+ }
+}
+
+TopicOnlyPolicy parse_topic_only_policy(const std::string & str) {
+ if (str == "ignore") {
+ return TopicOnlyPolicy::IGNORE;
+ }
+ if (str == "create_area_only") {
+ return TopicOnlyPolicy::CREATE_AREA_ONLY;
+ }
+ return TopicOnlyPolicy::CREATE_COMPONENT;
+}
+
+std::string topic_only_policy_to_string(TopicOnlyPolicy policy) {
+ switch (policy) {
+ case TopicOnlyPolicy::IGNORE:
+ return "ignore";
+ case TopicOnlyPolicy::CREATE_AREA_ONLY:
+ return "create_area_only";
+ default:
+ return "create_component";
+ }
+}
+
+} // namespace ros2_medkit_gateway
diff --git a/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp b/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp
index f705702..8c65934 100644
--- a/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp
+++ b/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp
@@ -18,27 +18,6 @@
namespace ros2_medkit_gateway {
-DiscoveryMode parse_discovery_mode(const std::string & str) {
- if (str == "manifest_only") {
- return DiscoveryMode::MANIFEST_ONLY;
- }
- if (str == "hybrid") {
- return DiscoveryMode::HYBRID;
- }
- return DiscoveryMode::RUNTIME_ONLY;
-}
-
-std::string discovery_mode_to_string(DiscoveryMode mode) {
- switch (mode) {
- case DiscoveryMode::MANIFEST_ONLY:
- return "manifest_only";
- case DiscoveryMode::HYBRID:
- return "hybrid";
- default:
- return "runtime_only";
- }
-}
-
DiscoveryManager::DiscoveryManager(rclcpp::Node * node)
: node_(node), runtime_strategy_(std::make_unique(node)) {
// Default to runtime strategy
@@ -75,6 +54,15 @@ bool DiscoveryManager::initialize(const DiscoveryConfig & config) {
}
void DiscoveryManager::create_strategy() {
+ // Configure runtime strategy with runtime options
+ discovery::RuntimeDiscoveryStrategy::RuntimeConfig runtime_config;
+ runtime_config.create_synthetic_components = config_.runtime.create_synthetic_components;
+ runtime_config.grouping = config_.runtime.grouping;
+ runtime_config.synthetic_component_name_pattern = config_.runtime.synthetic_component_name_pattern;
+ runtime_config.topic_only_policy = config_.runtime.topic_only_policy;
+ runtime_config.min_topics_for_component = config_.runtime.min_topics_for_component;
+ runtime_strategy_->set_config(runtime_config);
+
switch (config_.mode) {
case DiscoveryMode::MANIFEST_ONLY:
// In manifest_only mode, we use a special mode where we return manifest entities
@@ -93,7 +81,8 @@ void DiscoveryManager::create_strategy() {
default:
active_strategy_ = runtime_strategy_.get();
- RCLCPP_INFO(node_->get_logger(), "Discovery mode: runtime_only");
+ RCLCPP_INFO(node_->get_logger(), "Discovery mode: runtime_only (synthetic_components=%s)",
+ config_.runtime.create_synthetic_components ? "true" : "false");
break;
}
}
@@ -157,7 +146,14 @@ std::optional DiscoveryManager::get_app(const std::string & id) {
if (manifest_manager_ && manifest_manager_->is_manifest_active()) {
return manifest_manager_->get_app(id);
}
- return std::nullopt; // No apps in runtime-only mode
+ // Check runtime apps
+ auto apps = discover_apps();
+ for (const auto & app : apps) {
+ if (app.id == id) {
+ return app;
+ }
+ }
+ return std::nullopt;
}
std::optional DiscoveryManager::get_function(const std::string & id) {
@@ -200,7 +196,15 @@ std::vector DiscoveryManager::get_apps_for_component(const std::string & co
if (manifest_manager_ && manifest_manager_->is_manifest_active()) {
return manifest_manager_->get_apps_for_component(component_id);
}
- return {}; // No apps in runtime mode
+ // Filter runtime apps by component_id
+ std::vector result;
+ auto apps = discover_apps();
+ for (const auto & app : apps) {
+ if (app.component_id == component_id) {
+ result.push_back(app);
+ }
+ }
+ return result;
}
std::vector DiscoveryManager::get_hosts_for_function(const std::string & function_id) {
diff --git a/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp b/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp
index 0b66f50..a4988e2 100644
--- a/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp
+++ b/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp
@@ -125,8 +125,9 @@ void HybridDiscoveryStrategy::perform_linking() {
// Get manifest apps
auto apps = manifest_manager_->get_apps();
- // Get runtime components
- auto runtime_components = runtime_strategy_->discover_components();
+ // Get runtime node components (raw nodes, not synthetic groupings)
+ // Runtime linking needs individual node FQNs to match against manifest bindings
+ auto runtime_components = runtime_strategy_->discover_node_components();
// Get config for orphan policy
auto config = manifest_manager_->get_config();
diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp
index da1548d..857f930 100644
--- a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp
+++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp
@@ -158,6 +158,7 @@ Component ManifestParser::parse_component(const YAML::Node & node) const {
comp.variant = get_string(node, "variant");
comp.tags = get_string_vector(node, "tags");
comp.parent_component_id = get_string(node, "parent_component_id");
+ comp.depends_on = get_string_vector(node, "depends_on");
comp.source = "manifest";
// Parse type if provided (e.g., "controller", "sensor", "actuator")
diff --git a/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp b/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp
index 6b88708..d3a45aa 100644
--- a/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp
+++ b/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp
@@ -14,6 +14,8 @@
#include "ros2_medkit_gateway/discovery/runtime_discovery.hpp"
+#include "ros2_medkit_gateway/discovery/discovery_manager.hpp"
+
#include
#include
#include
@@ -36,6 +38,13 @@ bool RuntimeDiscoveryStrategy::is_internal_service(const std::string & service_p
RuntimeDiscoveryStrategy::RuntimeDiscoveryStrategy(rclcpp::Node * node) : node_(node) {
}
+void RuntimeDiscoveryStrategy::set_config(const RuntimeConfig & config) {
+ config_ = config;
+ RCLCPP_DEBUG(node_->get_logger(), "Runtime discovery config: synthetic_components=%s, grouping=%s",
+ config_.create_synthetic_components ? "true" : "false",
+ grouping_strategy_to_string(config_.grouping).c_str());
+}
+
std::vector RuntimeDiscoveryStrategy::discover_areas() {
// Extract unique areas from namespaces
std::set area_set;
@@ -73,6 +82,15 @@ std::vector RuntimeDiscoveryStrategy::discover_areas() {
}
std::vector RuntimeDiscoveryStrategy::discover_components() {
+ // If synthetic components are enabled, use grouping logic
+ if (config_.create_synthetic_components) {
+ return discover_synthetic_components();
+ }
+ // Default: each node = 1 component (backward compatible)
+ return discover_node_components();
+}
+
+std::vector RuntimeDiscoveryStrategy::discover_node_components() {
std::vector components;
// Pre-build service info map for schema lookups
@@ -99,14 +117,26 @@ std::vector RuntimeDiscoveryStrategy::discover_components() {
refresh_topic_map();
}
+ // Deduplicate nodes - ROS 2 RMW can report duplicates for nodes with multiple interfaces
+ std::set seen_fqns;
+
for (const auto & name_and_ns : names_and_namespaces) {
const auto & name = name_and_ns.first;
const auto & ns = name_and_ns.second;
+ std::string fqn = (ns == "/") ? std::string("/").append(name) : std::string(ns).append("/").append(name);
+
+ // Skip duplicate nodes - ROS 2 RMW may report same node multiple times
+ if (seen_fqns.count(fqn) > 0) {
+ RCLCPP_DEBUG(node_->get_logger(), "Skipping duplicate node: %s", fqn.c_str());
+ continue;
+ }
+ seen_fqns.insert(fqn);
+
Component comp;
comp.id = name;
comp.namespace_path = ns;
- comp.fqn = (ns == "/") ? std::string("/").append(name) : std::string(ns).append("/").append(name);
+ comp.fqn = fqn;
comp.area = extract_area_from_namespace(ns);
// Use ROS 2 introspection API to get services for this specific node
@@ -175,9 +205,33 @@ std::vector RuntimeDiscoveryStrategy::discover_components() {
}
std::vector RuntimeDiscoveryStrategy::discover_apps() {
- // Apps are not supported in runtime-only mode
- // They require manifest definitions
- return {};
+ std::vector apps;
+ auto node_components = discover_node_components();
+
+ for (const auto & comp : node_components) {
+ // Skip topic-based components (source="topic")
+ if (comp.source == "topic") {
+ continue;
+ }
+
+ App app;
+ app.id = comp.id;
+ app.name = comp.id;
+ app.component_id = derive_component_id(comp);
+ app.source = "heuristic";
+ app.is_online = true;
+ app.bound_fqn = comp.fqn;
+
+ // Copy resources from component
+ app.topics = comp.topics;
+ app.services = comp.services;
+ app.actions = comp.actions;
+
+ apps.push_back(app);
+ }
+
+ RCLCPP_DEBUG(node_->get_logger(), "Discovered %zu apps from runtime nodes", apps.size());
+ return apps;
}
std::vector RuntimeDiscoveryStrategy::discover_functions() {
@@ -413,6 +467,18 @@ std::set RuntimeDiscoveryStrategy::get_node_namespaces() {
std::vector RuntimeDiscoveryStrategy::discover_topic_components() {
std::vector components;
+ // Check policy - if IGNORE, don't create any topic-based entities
+ if (config_.topic_only_policy == TopicOnlyPolicy::IGNORE) {
+ RCLCPP_DEBUG(node_->get_logger(), "Topic-only policy is IGNORE, skipping topic-based discovery");
+ return components;
+ }
+
+ // If CREATE_AREA_ONLY, areas are created in discover() but no components here
+ if (config_.topic_only_policy == TopicOnlyPolicy::CREATE_AREA_ONLY) {
+ RCLCPP_DEBUG(node_->get_logger(), "Topic-only policy is CREATE_AREA_ONLY, skipping component creation");
+ return components;
+ }
+
if (!topic_sampler_) {
RCLCPP_DEBUG(node_->get_logger(), "Topic sampler not set, skipping topic-based discovery");
return components;
@@ -434,19 +500,28 @@ std::vector RuntimeDiscoveryStrategy::discover_topic_components() {
continue;
}
+ // Get topics from cached result (no additional graph query)
+ std::string ns_prefix = "/" + ns;
+ auto it = discovery_result.topics_by_ns.find(ns_prefix);
+ if (it == discovery_result.topics_by_ns.end()) {
+ continue;
+ }
+
+ // Check minimum topics threshold
+ size_t topic_count = it->second.publishes.size() + it->second.subscribes.size();
+ if (static_cast(topic_count) < config_.min_topics_for_component) {
+ RCLCPP_DEBUG(node_->get_logger(), "Skipping namespace '%s' - topic count %zu < min %d", ns.c_str(), topic_count,
+ config_.min_topics_for_component);
+ continue;
+ }
+
Component comp;
comp.id = ns;
comp.namespace_path = "/" + ns;
comp.fqn = "/" + ns;
comp.area = ns;
comp.source = "topic";
-
- // Get topics from cached result (no additional graph query)
- std::string ns_prefix = "/" + ns;
- auto it = discovery_result.topics_by_ns.find(ns_prefix);
- if (it != discovery_result.topics_by_ns.end()) {
- comp.topics = it->second;
- }
+ comp.topics = it->second;
RCLCPP_DEBUG(node_->get_logger(), "Created topic-based component '%s' with %zu topics", ns.c_str(),
comp.topics.publishes.size());
@@ -487,5 +562,78 @@ bool RuntimeDiscoveryStrategy::path_belongs_to_namespace(const std::string & pat
return remainder.find('/') == std::string::npos;
}
+std::vector RuntimeDiscoveryStrategy::discover_synthetic_components() {
+ // Group nodes by their derived component ID (based on grouping strategy)
+ std::map> groups;
+ auto node_components = discover_node_components();
+
+ for (const auto & node : node_components) {
+ std::string group_id = derive_component_id(node);
+ groups[group_id].push_back(node);
+ }
+
+ // Create synthetic components from groups
+ std::vector result;
+ for (const auto & [group_id, nodes] : groups) {
+ Component comp;
+ comp.id = group_id;
+ comp.source = "synthetic";
+ comp.type = "ComponentGroup";
+
+ // Use first node's namespace and area as representative
+ if (!nodes.empty()) {
+ comp.namespace_path = nodes[0].namespace_path;
+ comp.area = nodes[0].area;
+ comp.fqn = "/" + group_id;
+ }
+
+ // Note: Topics/services are NOT aggregated here - they stay with Apps
+ // This is intentional: synthetic components are just groupings
+
+ RCLCPP_DEBUG(node_->get_logger(), "Created synthetic component '%s' with %zu apps", group_id.c_str(), nodes.size());
+ result.push_back(comp);
+ }
+
+ RCLCPP_DEBUG(node_->get_logger(), "Discovered %zu synthetic components from %zu nodes", result.size(),
+ node_components.size());
+ return result;
+}
+
+std::string RuntimeDiscoveryStrategy::derive_component_id(const Component & node) {
+ switch (config_.grouping) {
+ case ComponentGroupingStrategy::NAMESPACE:
+ // Group by area (first namespace segment)
+ return apply_component_name_pattern(node.area);
+ case ComponentGroupingStrategy::NONE:
+ default:
+ // 1:1 mapping - each node is its own component
+ return node.id;
+ }
+}
+
+std::string RuntimeDiscoveryStrategy::apply_component_name_pattern(const std::string & area) {
+ // Validate inputs to prevent unexpected empty component IDs
+ if (area.empty()) {
+ RCLCPP_WARN(node_->get_logger(), "apply_component_name_pattern called with empty area, using 'unknown'");
+ return apply_component_name_pattern("unknown");
+ }
+
+ if (config_.synthetic_component_name_pattern.empty()) {
+ RCLCPP_WARN(node_->get_logger(), "Empty synthetic_component_name_pattern, using area directly: %s", area.c_str());
+ return area;
+ }
+
+ std::string result = config_.synthetic_component_name_pattern;
+
+ // Replace {area} placeholder
+ const std::string placeholder = "{area}";
+ size_t pos = result.find(placeholder);
+ if (pos != std::string::npos) {
+ result.replace(pos, placeholder.length(), area);
+ }
+
+ return result;
+}
+
} // namespace discovery
} // 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 212aef0..21fd50c 100644
--- a/src/ros2_medkit_gateway/src/gateway_node.cpp
+++ b/src/ros2_medkit_gateway/src/gateway_node.cpp
@@ -61,6 +61,14 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") {
declare_parameter("manifest_path", "");
declare_parameter("manifest_strict_validation", true);
+ // Runtime (heuristic) discovery options
+ // These control how nodes are mapped to SOVD entities in runtime mode
+ declare_parameter("discovery.runtime.create_synthetic_components", true);
+ declare_parameter("discovery.runtime.grouping_strategy", "namespace");
+ declare_parameter("discovery.runtime.synthetic_component_name_pattern", "{area}");
+ declare_parameter("discovery.runtime.topic_only_policy", "create_component");
+ declare_parameter("discovery.runtime.min_topics_for_component", 1);
+
// Get parameter values
server_host_ = get_parameter("server.host").as_string();
server_port_ = static_cast(get_parameter("server.port").as_int());
@@ -214,6 +222,18 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") {
discovery_config.manifest_path = get_parameter("manifest_path").as_string();
discovery_config.manifest_strict_validation = get_parameter("manifest_strict_validation").as_bool();
+ // Runtime discovery options
+ discovery_config.runtime.create_synthetic_components =
+ get_parameter("discovery.runtime.create_synthetic_components").as_bool();
+ discovery_config.runtime.grouping =
+ parse_grouping_strategy(get_parameter("discovery.runtime.grouping_strategy").as_string());
+ discovery_config.runtime.synthetic_component_name_pattern =
+ get_parameter("discovery.runtime.synthetic_component_name_pattern").as_string();
+ discovery_config.runtime.topic_only_policy =
+ parse_topic_only_policy(get_parameter("discovery.runtime.topic_only_policy").as_string());
+ discovery_config.runtime.min_topics_for_component =
+ static_cast(get_parameter("discovery.runtime.min_topics_for_component").as_int());
+
if (!discovery_mgr_->initialize(discovery_config)) {
RCLCPP_ERROR(get_logger(), "Failed to initialize discovery manager");
throw std::runtime_error("Discovery initialization failed");
@@ -300,6 +320,9 @@ void GatewayNode::refresh_cache() {
// publish topics without creating proper ROS 2 nodes)
auto topic_components = discovery_mgr_->discover_topic_components();
+ // Discover apps (nodes as Apps when heuristic discovery is enabled)
+ auto apps = discovery_mgr_->discover_apps();
+
// Merge both component lists
std::vector all_components;
all_components.reserve(node_components.size() + topic_components.size());
@@ -312,17 +335,20 @@ void GatewayNode::refresh_cache() {
const size_t area_count = areas.size();
const size_t node_component_count = node_components.size();
const size_t topic_component_count = topic_components.size();
+ const size_t app_count = apps.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(all_components);
+ entity_cache_.apps = std::move(apps);
entity_cache_.last_update = timestamp;
}
- RCLCPP_DEBUG(get_logger(), "Cache refreshed: %zu areas, %zu components (%zu node-based, %zu topic-based)",
- area_count, node_component_count + topic_component_count, node_component_count, topic_component_count);
+ RCLCPP_DEBUG(get_logger(), "Cache refreshed: %zu areas, %zu components (%zu node-based, %zu topic-based), %zu apps",
+ area_count, node_component_count + topic_component_count, node_component_count, topic_component_count,
+ app_count);
} catch (const std::exception & e) {
RCLCPP_ERROR(get_logger(), "Failed to refresh cache: %s", e.what());
} catch (...) {
diff --git a/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp b/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp
index f009a5a..4882449 100644
--- a/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp
+++ b/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp
@@ -37,6 +37,8 @@ std::string CapabilityBuilder::capability_to_name(Capability cap) {
return "related-apps";
case Capability::HOSTS:
return "hosts";
+ case Capability::DEPENDS_ON:
+ return "depends-on";
default:
return "unknown";
}
diff --git a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp
index 502edd9..30039d1 100644
--- a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp
+++ b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp
@@ -23,47 +23,40 @@ namespace ros2_medkit_gateway {
namespace handlers {
void ConfigHandlers::handle_list_configurations(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
try {
if (req.matches.size() < 2) {
HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request");
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
- const auto cache = ctx_.node()->get_entity_cache();
-
- // Find component to get its namespace and node name
- std::string node_name;
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- node_name = component.fqn; // Use fqn to avoid double slash for root namespace
- component_found = true;
- break;
- }
+ // Use unified entity lookup
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
+ return;
}
- if (!component_found) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
- return;
+ // Get node name for parameter access
+ std::string node_name = entity_info.fqn;
+ if (node_name.empty()) {
+ node_name = "/" + entity_id;
}
auto config_mgr = ctx_.node()->get_configuration_manager();
auto result = config_mgr->list_parameters(node_name);
if (result.success) {
- json response = {{"component_id", component_id}, {"node_name", node_name}, {"parameters", result.data}};
+ json response = {{entity_info.id_field, entity_id}, {"node_name", node_name}, {"parameters", result.data}};
HandlerContext::send_json(res, response);
} else {
HandlerContext::send_error(res, StatusCode::ServiceUnavailable_503, "Failed to list parameters",
@@ -71,14 +64,14 @@ void ConfigHandlers::handle_list_configurations(const httplib::Request & req, ht
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to list configurations",
- {{"details", e.what()}, {"component_id", component_id}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_configurations for component '%s': %s",
- component_id.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_configurations for entity '%s': %s", entity_id.c_str(),
+ e.what());
}
}
void ConfigHandlers::handle_get_configuration(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string param_name;
try {
if (req.matches.size() < 3) {
@@ -86,13 +79,13 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
param_name = req.matches[2];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
@@ -103,30 +96,24 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http
return;
}
- const auto cache = ctx_.node()->get_entity_cache();
-
- std::string node_name;
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- node_name = component.fqn; // Use fqn to avoid double slash for root namespace
- component_found = true;
- break;
- }
+ // Use unified entity lookup
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
+ return;
}
- if (!component_found) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
- return;
+ // Get node name for parameter access
+ std::string node_name = entity_info.fqn;
+ if (node_name.empty()) {
+ node_name = "/" + entity_id;
}
auto config_mgr = ctx_.node()->get_configuration_manager();
auto result = config_mgr->get_parameter(node_name, param_name);
if (result.success) {
- json response = {{"component_id", component_id}, {"parameter", result.data}};
+ json response = {{entity_info.id_field, entity_id}, {"parameter", result.data}};
HandlerContext::send_json(res, response);
} else {
// Check if it's a "not found" error
@@ -134,23 +121,23 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http
result.error_message.find("Parameter not found") != std::string::npos) {
HandlerContext::send_error(
res, StatusCode::NotFound_404, "Failed to get parameter",
- {{"details", result.error_message}, {"component_id", component_id}, {"param_name", param_name}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"param_name", param_name}});
} else {
HandlerContext::send_error(
res, StatusCode::ServiceUnavailable_503, "Failed to get parameter",
- {{"details", result.error_message}, {"component_id", component_id}, {"param_name", param_name}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"param_name", param_name}});
}
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to get configuration",
- {{"details", e.what()}, {"component_id", component_id}, {"param_name", param_name}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_configuration for component '%s', param '%s': %s",
- component_id.c_str(), param_name.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}, {"param_name", param_name}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_configuration for entity '%s', param '%s': %s",
+ entity_id.c_str(), param_name.c_str(), e.what());
}
}
void ConfigHandlers::handle_set_configuration(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string param_name;
try {
if (req.matches.size() < 3) {
@@ -158,13 +145,13 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
param_name = req.matches[2];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
@@ -193,30 +180,24 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http
json value = body["value"];
- const auto cache = ctx_.node()->get_entity_cache();
-
- std::string node_name;
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- node_name = component.fqn; // Use fqn to avoid double slash for root namespace
- component_found = true;
- break;
- }
+ // Use unified entity lookup
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
+ return;
}
- if (!component_found) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
- return;
+ // Get node name for parameter access
+ std::string node_name = entity_info.fqn;
+ if (node_name.empty()) {
+ node_name = "/" + entity_id;
}
auto config_mgr = ctx_.node()->get_configuration_manager();
auto result = config_mgr->set_parameter(node_name, param_name, value);
if (result.success) {
- json response = {{"status", "success"}, {"component_id", component_id}, {"parameter", result.data}};
+ json response = {{"status", "success"}, {entity_info.id_field, entity_id}, {"parameter", result.data}};
HandlerContext::send_json(res, response);
} else {
// Check if it's a read-only, not found, or service unavailable error
@@ -236,18 +217,18 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http
}
HandlerContext::send_error(
res, status_code, "Failed to set parameter",
- {{"details", result.error_message}, {"component_id", component_id}, {"param_name", param_name}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"param_name", param_name}});
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to set configuration",
- {{"details", e.what()}, {"component_id", component_id}, {"param_name", param_name}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_set_configuration for component '%s', param '%s': %s",
- component_id.c_str(), param_name.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}, {"param_name", param_name}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_set_configuration for entity '%s', param '%s': %s",
+ entity_id.c_str(), param_name.c_str(), e.what());
}
}
void ConfigHandlers::handle_delete_configuration(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string param_name;
try {
@@ -256,34 +237,27 @@ void ConfigHandlers::handle_delete_configuration(const httplib::Request & req, h
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
param_name = req.matches[2];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
- const auto cache = ctx_.node()->get_entity_cache();
-
- // Find component to get its namespace and node name
- std::string node_name;
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- node_name = component.fqn;
- component_found = true;
- break;
- }
+ // Use unified entity lookup
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
+ return;
}
- if (!component_found) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
- return;
+ // Get node name for parameter access
+ std::string node_name = entity_info.fqn;
+ if (node_name.empty()) {
+ node_name = "/" + entity_id;
}
auto config_mgr = ctx_.node()->get_configuration_manager();
@@ -298,13 +272,13 @@ void ConfigHandlers::handle_delete_configuration(const httplib::Request & req, h
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to reset configuration",
- {{"details", e.what()}, {"component_id", component_id}, {"param_name", param_name}});
+ {{"details", e.what()}, {"entity_id", entity_id}, {"param_name", param_name}});
RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_delete_configuration: %s", e.what());
}
}
void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
try {
if (req.matches.size() < 2) {
@@ -312,33 +286,26 @@ void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & r
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
- const auto cache = ctx_.node()->get_entity_cache();
-
- // Find component to get its namespace and node name
- std::string node_name;
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- node_name = component.fqn;
- component_found = true;
- break;
- }
+ // Use unified entity lookup
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
+ return;
}
- if (!component_found) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
- return;
+ // Get node name for parameter access
+ std::string node_name = entity_info.fqn;
+ if (node_name.empty()) {
+ node_name = "/" + entity_id;
}
auto config_mgr = ctx_.node()->get_configuration_manager();
@@ -353,7 +320,7 @@ void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & r
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to reset configurations",
- {{"details", e.what()}, {"component_id", component_id}});
+ {{"details", e.what()}, {"entity_id", entity_id}});
RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_delete_all_configurations: %s", e.what());
}
}
diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp
index 44bcd02..ac60765 100644
--- a/src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp
+++ b/src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp
@@ -89,6 +89,10 @@ void ComponentHandlers::handle_get_component(const httplib::Request & req, httpl
using Cap = CapabilityBuilder::Capability;
std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS,
Cap::FAULTS, Cap::SUBCOMPONENTS, Cap::RELATED_APPS};
+ // Add depends-on capability only when component has dependencies
+ if (!comp.depends_on.empty()) {
+ caps.push_back(Cap::DEPENDS_ON);
+ }
response["capabilities"] = CapabilityBuilder::build_capabilities("components", comp.id, caps);
// Build HATEOAS links
@@ -129,35 +133,41 @@ void ComponentHandlers::handle_component_data(const httplib::Request & req, http
return;
}
- const auto cache = ctx_.node()->get_entity_cache();
-
- // Find component in cache and get its topics from the topic map
- ComponentTopics component_topics;
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- component_topics = component.topics;
- component_found = true;
- break;
- }
- }
+ auto discovery = ctx_.node()->get_discovery_manager();
+ auto comp_opt = discovery->get_component(component_id);
- if (!component_found) {
+ if (!comp_opt) {
HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
{{"component_id", component_id}});
return;
}
- // Combine publishes and subscribes into unique set of topics
+ const auto & component = *comp_opt;
+
+ // Collect all topics - from component directly OR aggregated from related Apps
std::set all_topics;
- for (const auto & topic : component_topics.publishes) {
+
+ // First, check component's own topics
+ for (const auto & topic : component.topics.publishes) {
all_topics.insert(topic);
}
- for (const auto & topic : component_topics.subscribes) {
+ for (const auto & topic : component.topics.subscribes) {
all_topics.insert(topic);
}
+ // If component has no direct topics (synthetic component), aggregate from Apps
+ if (all_topics.empty()) {
+ auto apps = discovery->get_apps_for_component(component_id);
+ for (const auto & app : apps) {
+ for (const auto & topic : app.topics.publishes) {
+ all_topics.insert(topic);
+ }
+ for (const auto & topic : app.topics.subscribes) {
+ all_topics.insert(topic);
+ }
+ }
+ }
+
// Sample all topics for this component
auto data_access_mgr = ctx_.node()->get_data_access_manager();
json component_data = json::array();
@@ -251,20 +261,10 @@ void ComponentHandlers::handle_component_topic_data(const httplib::Request & req
// Skip topic_name validation - it may contain slashes after URL decoding
// The actual validation happens when we try to find the topic in the ROS graph
- const auto cache = ctx_.node()->get_entity_cache();
-
- // Find component in cache - only needed to verify it exists
- // We use the full topic path from the URL, not the component namespace
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- component_found = true;
- break;
- }
- }
+ auto discovery = ctx_.node()->get_discovery_manager();
+ auto comp_opt = discovery->get_component(component_id);
- if (!component_found) {
+ if (!comp_opt) {
HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
{{"component_id", component_id}});
return;
@@ -362,19 +362,10 @@ void ComponentHandlers::handle_component_topic_publish(const httplib::Request &
return;
}
- const auto cache = ctx_.node()->get_entity_cache();
-
- // Find component in cache - only needed to verify it exists
- bool component_found = false;
-
- for (const auto & component : cache.components) {
- if (component.id == component_id) {
- component_found = true;
- break;
- }
- }
+ auto discovery = ctx_.node()->get_discovery_manager();
+ auto comp_opt = discovery->get_component(component_id);
- if (!component_found) {
+ if (!comp_opt) {
HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
{{"component_id", component_id}});
return;
@@ -515,5 +506,75 @@ void ComponentHandlers::handle_get_related_apps(const httplib::Request & req, ht
}
}
+void ComponentHandlers::handle_get_depends_on(const httplib::Request & req, httplib::Response & res) {
+ try {
+ if (req.matches.size() < 2) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request");
+ return;
+ }
+
+ std::string component_id = req.matches[1];
+
+ auto validation_result = ctx_.validate_entity_id(component_id);
+ if (!validation_result) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
+ {{"details", validation_result.error()}, {"component_id", component_id}});
+ return;
+ }
+
+ auto discovery = ctx_.node()->get_discovery_manager();
+ auto comp_opt = discovery->get_component(component_id);
+
+ if (!comp_opt) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
+ {{"component_id", component_id}});
+ return;
+ }
+
+ const auto & comp = *comp_opt;
+
+ // Build list of dependency references
+ json items = json::array();
+ for (const auto & dep_id : comp.depends_on) {
+ json item;
+ item["id"] = dep_id;
+ item["href"] = "/api/v1/components/" + dep_id;
+
+ // Try to get the dependency component for additional info
+ auto dep_opt = discovery->get_component(dep_id);
+ if (dep_opt) {
+ item["type"] = dep_opt->type;
+ if (!dep_opt->name.empty()) {
+ item["name"] = dep_opt->name;
+ }
+ } else {
+ // Dependency component could not be resolved; keep a generic type but mark as missing
+ item["type"] = "Component";
+ item["missing"] = true;
+ RCLCPP_WARN(HandlerContext::logger(), "Component '%s' declares dependency on unknown component '%s'",
+ component_id.c_str(), dep_id.c_str());
+ }
+
+ items.push_back(item);
+ }
+
+ json response;
+ response["items"] = items;
+ response["total_count"] = items.size();
+
+ // HATEOAS links
+ json links;
+ links["self"] = "/api/v1/components/" + component_id + "/depends-on";
+ links["component"] = "/api/v1/components/" + component_id;
+ response["_links"] = links;
+
+ HandlerContext::send_json(res, response);
+ } catch (const std::exception & e) {
+ HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Internal server error",
+ {{"details", e.what()}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_depends_on: %s", e.what());
+ }
+}
+
} // namespace handlers
} // namespace ros2_medkit_gateway
diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp
index cce2b27..9597da3 100644
--- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp
+++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp
@@ -54,29 +54,28 @@ void FaultHandlers::handle_list_all_faults(const httplib::Request & req, httplib
}
void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
try {
if (req.matches.size() < 2) {
HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request");
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
- auto namespace_result = ctx_.get_component_namespace_path(component_id);
- if (!namespace_result) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, namespace_result.error(),
- {{"component_id", component_id}});
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
- std::string namespace_path = namespace_result.value();
+ std::string namespace_path = entity_info.namespace_path;
auto filter = parse_fault_status_param(req);
if (!filter.is_valid) {
@@ -84,7 +83,7 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re
{{"details", "Valid values: pending, confirmed, cleared, all"},
{"parameter", "status"},
{"value", req.get_param_value("status")},
- {"component_id", component_id}});
+ {entity_info.id_field, entity_id}});
return;
}
@@ -93,25 +92,25 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re
fault_mgr->get_faults(namespace_path, filter.include_pending, filter.include_confirmed, filter.include_cleared);
if (result.success) {
- json response = {{"component_id", component_id},
+ json response = {{entity_info.id_field, entity_id},
{"source_id", namespace_path},
{"faults", result.data["faults"]},
{"count", result.data["count"]}};
HandlerContext::send_json(res, response);
} else {
HandlerContext::send_error(res, StatusCode::ServiceUnavailable_503, "Failed to get faults",
- {{"details", result.error_message}, {"component_id", component_id}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}});
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to list faults",
- {{"details", e.what()}, {"component_id", component_id}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_faults for component '%s': %s", component_id.c_str(),
+ {{"details", e.what()}, {"entity_id", entity_id}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_faults for entity '%s': %s", entity_id.c_str(),
e.what());
}
}
void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string fault_code;
try {
if (req.matches.size() < 3) {
@@ -119,13 +118,13 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
fault_code = req.matches[2];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
@@ -136,19 +135,18 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp
return;
}
- auto namespace_result = ctx_.get_component_namespace_path(component_id);
- if (!namespace_result) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, namespace_result.error(),
- {{"component_id", component_id}});
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
- std::string namespace_path = namespace_result.value();
+ std::string namespace_path = entity_info.namespace_path;
auto fault_mgr = ctx_.node()->get_fault_manager();
auto result = fault_mgr->get_fault(fault_code, namespace_path);
if (result.success) {
- json response = {{"component_id", component_id}, {"fault", result.data}};
+ json response = {{entity_info.id_field, entity_id}, {"fault", result.data}};
HandlerContext::send_json(res, response);
} else {
// Check if it's a "not found" error
@@ -156,23 +154,23 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp
result.error_message.find("Fault not found") != std::string::npos) {
HandlerContext::send_error(
res, StatusCode::NotFound_404, "Failed to get fault",
- {{"details", result.error_message}, {"component_id", component_id}, {"fault_code", fault_code}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}});
} else {
HandlerContext::send_error(
res, StatusCode::ServiceUnavailable_503, "Failed to get fault",
- {{"details", result.error_message}, {"component_id", component_id}, {"fault_code", fault_code}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}});
}
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to get fault",
- {{"details", e.what()}, {"component_id", component_id}, {"fault_code", fault_code}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_fault for component '%s', fault '%s': %s",
- component_id.c_str(), fault_code.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}, {"fault_code", fault_code}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_fault for entity '%s', fault '%s': %s",
+ entity_id.c_str(), fault_code.c_str(), e.what());
}
}
void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string fault_code;
try {
if (req.matches.size() < 3) {
@@ -180,13 +178,13 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
fault_code = req.matches[2];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
@@ -197,11 +195,10 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re
return;
}
- // Verify component exists (we don't need namespace_path for clearing)
- auto namespace_result = ctx_.get_component_namespace_path(component_id);
- if (!namespace_result) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, namespace_result.error(),
- {{"component_id", component_id}});
+ // Verify entity exists
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
@@ -210,7 +207,7 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re
if (result.success) {
json response = {{"status", "success"},
- {"component_id", component_id},
+ {entity_info.id_field, entity_id},
{"fault_code", fault_code},
{"message", result.data.value("message", "Fault cleared")}};
HandlerContext::send_json(res, response);
@@ -220,18 +217,18 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re
result.error_message.find("Fault not found") != std::string::npos) {
HandlerContext::send_error(
res, StatusCode::NotFound_404, "Failed to clear fault",
- {{"details", result.error_message}, {"component_id", component_id}, {"fault_code", fault_code}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}});
} else {
HandlerContext::send_error(
res, StatusCode::ServiceUnavailable_503, "Failed to clear fault",
- {{"details", result.error_message}, {"component_id", component_id}, {"fault_code", fault_code}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}});
}
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to clear fault",
- {{"details", e.what()}, {"component_id", component_id}, {"fault_code", fault_code}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_clear_fault for component '%s', fault '%s': %s",
- component_id.c_str(), fault_code.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}, {"fault_code", fault_code}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_clear_fault for entity '%s', fault '%s': %s",
+ entity_id.c_str(), fault_code.c_str(), e.what());
}
}
@@ -280,7 +277,7 @@ void FaultHandlers::handle_get_snapshots(const httplib::Request & req, httplib::
}
void FaultHandlers::handle_get_component_snapshots(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string fault_code;
try {
if (req.matches.size() < 3) {
@@ -288,13 +285,13 @@ void FaultHandlers::handle_get_component_snapshots(const httplib::Request & req,
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
fault_code = req.matches[2];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
@@ -305,10 +302,9 @@ void FaultHandlers::handle_get_component_snapshots(const httplib::Request & req,
return;
}
- auto namespace_result = ctx_.get_component_namespace_path(component_id);
- if (!namespace_result) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, namespace_result.error(),
- {{"component_id", component_id}});
+ auto entity_info = ctx_.get_entity_info(entity_id);
+ if (entity_info.type == EntityType::UNKNOWN) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
@@ -319,9 +315,9 @@ void FaultHandlers::handle_get_component_snapshots(const httplib::Request & req,
auto result = fault_mgr->get_snapshots(fault_code, topic_filter);
if (result.success) {
- // Add component context to response
+ // Add entity context to response
json response = result.data;
- response["component_id"] = component_id;
+ response[entity_info.id_field] = entity_id;
HandlerContext::send_json(res, response);
} else {
// Check if it's a "not found" error
@@ -329,18 +325,18 @@ void FaultHandlers::handle_get_component_snapshots(const httplib::Request & req,
result.error_message.find("Fault not found") != std::string::npos) {
HandlerContext::send_error(
res, StatusCode::NotFound_404, "Failed to get snapshots",
- {{"details", result.error_message}, {"component_id", component_id}, {"fault_code", fault_code}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}});
} else {
HandlerContext::send_error(
res, StatusCode::ServiceUnavailable_503, "Failed to get snapshots",
- {{"details", result.error_message}, {"component_id", component_id}, {"fault_code", fault_code}});
+ {{"details", result.error_message}, {entity_info.id_field, entity_id}, {"fault_code", fault_code}});
}
}
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to get snapshots",
- {{"details", e.what()}, {"component_id", component_id}, {"fault_code", fault_code}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_component_snapshots for component '%s', fault '%s': %s",
- component_id.c_str(), fault_code.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}, {"fault_code", fault_code}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_get_component_snapshots for entity '%s', fault '%s': %s",
+ entity_id.c_str(), fault_code.c_str(), e.what());
}
}
diff --git a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp
index db7aa75..85e3bec 100644
--- a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp
+++ b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp
@@ -71,6 +71,66 @@ HandlerContext::get_component_namespace_path(const std::string & component_id) c
return tl::unexpected("Component not found");
}
+EntityInfo HandlerContext::get_entity_info(const std::string & entity_id) const {
+ const auto cache = node_->get_entity_cache();
+ EntityInfo info;
+ info.id = entity_id;
+
+ // Search components first
+ for (const auto & component : cache.components) {
+ if (component.id == entity_id) {
+ info.type = EntityType::COMPONENT;
+ info.namespace_path = component.namespace_path;
+ info.fqn = component.fqn;
+ info.id_field = "component_id";
+ info.error_name = "Component";
+ return info;
+ }
+ }
+
+ // Search apps
+ for (const auto & app : cache.apps) {
+ if (app.id == entity_id) {
+ info.type = EntityType::APP;
+ // Apps use bound_fqn as namespace_path for fault filtering
+ info.namespace_path = app.bound_fqn.value_or("");
+ info.fqn = app.bound_fqn.value_or("");
+ info.id_field = "app_id";
+ info.error_name = "App";
+ return info;
+ }
+ }
+
+ // Search areas
+ for (const auto & area : cache.areas) {
+ if (area.id == entity_id) {
+ info.type = EntityType::AREA;
+ info.namespace_path = ""; // Areas don't have namespace_path
+ info.fqn = "";
+ info.id_field = "area_id";
+ info.error_name = "Area";
+ return info;
+ }
+ }
+
+ // Search functions
+ for (const auto & func : cache.functions) {
+ if (func.id == entity_id) {
+ info.type = EntityType::FUNCTION;
+ info.namespace_path = "";
+ info.fqn = "";
+ info.id_field = "function_id";
+ info.error_name = "Function";
+ return info;
+ }
+ }
+
+ // Not found - return UNKNOWN type
+ info.type = EntityType::UNKNOWN;
+ info.error_name = "Entity";
+ return info;
+}
+
void HandlerContext::set_cors_headers(httplib::Response & res, const std::string & origin) const {
res.set_header("Access-Control-Allow-Origin", origin);
diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp
index dcc5cdc..e16ad0e 100644
--- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp
+++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp
@@ -14,6 +14,8 @@
#include "ros2_medkit_gateway/http/handlers/operation_handlers.hpp"
+#include
+
#include "ros2_medkit_gateway/gateway_node.hpp"
#include "ros2_medkit_gateway/operation_manager.hpp"
@@ -24,44 +26,85 @@ namespace ros2_medkit_gateway {
namespace handlers {
void OperationHandlers::handle_list_operations(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
try {
if (req.matches.size() < 2) {
HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request");
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
const auto cache = ctx_.node()->get_entity_cache();
- // Find component in cache
- bool component_found = false;
+ // Find entity in cache - check components first, then apps
+ bool entity_found = false;
std::vector services;
std::vector actions;
+ std::string entity_type = "component";
+ // Try to find in components
for (const auto & component : cache.components) {
- if (component.id == component_id) {
+ if (component.id == entity_id) {
services = component.services;
actions = component.actions;
- component_found = true;
+
+ // For synthetic components with no direct operations, aggregate from apps
+ if (services.empty() && actions.empty()) {
+ auto discovery = ctx_.node()->get_discovery_manager();
+ auto apps = discovery->get_apps_for_component(entity_id);
+
+ // Use sets to deduplicate operations by full_path
+ std::unordered_set seen_service_paths;
+ std::unordered_set seen_action_paths;
+
+ for (const auto & app : apps) {
+ for (const auto & svc : app.services) {
+ if (seen_service_paths.insert(svc.full_path).second) {
+ services.push_back(svc);
+ }
+ }
+ for (const auto & act : app.actions) {
+ if (seen_action_paths.insert(act.full_path).second) {
+ actions.push_back(act);
+ }
+ }
+ }
+ }
+
+ entity_found = true;
break;
}
}
- if (!component_found) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
+ // If not found in components, try apps
+ if (!entity_found) {
+ for (const auto & app : cache.apps) {
+ if (app.id == entity_id) {
+ services = app.services;
+ actions = app.actions;
+ entity_found = true;
+ entity_type = "app";
+ break;
+ }
+ }
+ }
+
+ if (!entity_found) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
+ RCLCPP_DEBUG(HandlerContext::logger(), "Listing operations for %s '%s': %zu services, %zu actions",
+ entity_type.c_str(), entity_id.c_str(), services.size(), actions.size());
+
// Build response with services and actions
json operations = json::array();
@@ -113,33 +156,37 @@ void OperationHandlers::handle_list_operations(const httplib::Request & req, htt
operations.push_back(act_json);
}
- HandlerContext::send_json(res, operations);
+ // Return SOVD-compliant response with items array
+ json response;
+ response["items"] = operations;
+ response["total_count"] = operations.size();
+ HandlerContext::send_json(res, response);
} catch (const std::exception & e) {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to list operations",
- {{"details", e.what()}, {"component_id", component_id}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_operations for component '%s': %s",
- component_id.c_str(), e.what());
+ {{"details", e.what()}, {"entity_id", entity_id}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_operations for entity '%s': %s", entity_id.c_str(),
+ e.what());
}
}
void OperationHandlers::handle_component_operation(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string operation_name;
try {
- // Extract component_id and operation_name from URL path
+ // Extract entity_id and operation_name from URL path
if (req.matches.size() < 3) {
HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request");
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
operation_name = req.matches[2];
- // Validate component_id
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}, {"component_id", component_id}});
+ // Validate entity_id
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}, {"entity_id", entity_id}});
return;
}
@@ -176,15 +223,18 @@ void OperationHandlers::handle_component_operation(const httplib::Request & req,
}
const auto cache = ctx_.node()->get_entity_cache();
+ auto discovery = ctx_.node()->get_discovery_manager();
- // Find component in cache and look up service/action with full_path
- const Component * found_component = nullptr;
+ // Find entity and operation - check components first, then apps
+ bool entity_found = false;
+ std::string entity_type = "component";
std::optional service_info;
std::optional action_info;
+ // Try to find in components
for (const auto & component : cache.components) {
- if (component.id == component_id) {
- found_component = &component;
+ if (component.id == entity_id) {
+ entity_found = true;
// Search in component's services list (has full_path)
for (const auto & svc : component.services) {
@@ -195,25 +245,84 @@ void OperationHandlers::handle_component_operation(const httplib::Request & req,
}
// Search in component's actions list (has full_path)
- for (const auto & act : component.actions) {
- if (act.name == operation_name) {
- action_info = act;
- break;
+ if (!service_info.has_value()) {
+ for (const auto & act : component.actions) {
+ if (act.name == operation_name) {
+ action_info = act;
+ break;
+ }
+ }
+ }
+
+ // For synthetic components, try to find operation in apps
+ if (!service_info.has_value() && !action_info.has_value()) {
+ auto apps = discovery->get_apps_for_component(entity_id);
+ for (const auto & app : apps) {
+ for (const auto & svc : app.services) {
+ if (svc.name == operation_name) {
+ service_info = svc;
+ break;
+ }
+ }
+ if (service_info.has_value()) {
+ break;
+ }
+
+ for (const auto & act : app.actions) {
+ if (act.name == operation_name) {
+ action_info = act;
+ break;
+ }
+ }
+ if (action_info.has_value()) {
+ break;
+ }
}
}
break;
}
}
- if (!found_component) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
+ // If not found in components, try apps
+ if (!entity_found) {
+ for (const auto & app : cache.apps) {
+ if (app.id == entity_id) {
+ entity_found = true;
+ entity_type = "app";
+
+ // Search in app's services list
+ for (const auto & svc : app.services) {
+ if (svc.name == operation_name) {
+ service_info = svc;
+ break;
+ }
+ }
+
+ // Search in app's actions list
+ if (!service_info.has_value()) {
+ for (const auto & act : app.actions) {
+ if (act.name == operation_name) {
+ action_info = act;
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (!entity_found) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
// Check if operation is a service or action
auto operation_mgr = ctx_.node()->get_operation_manager();
+ // Determine response field name based on entity type
+ std::string id_field = (entity_type == "app") ? "app_id" : "component_id";
+
// First, check if it's an action (use full_path from cache)
if (action_info.has_value()) {
// Extract goal data (from 'goal' field or root object)
@@ -240,7 +349,7 @@ void OperationHandlers::handle_component_operation(const httplib::Request & req,
json response = {{"status", "success"},
{"kind", "action"},
- {"component_id", component_id},
+ {id_field, entity_id},
{"operation", operation_name},
{"goal_id", action_result.goal_id},
{"goal_status", status_str}};
@@ -249,20 +358,20 @@ void OperationHandlers::handle_component_operation(const httplib::Request & req,
HandlerContext::send_error(
res, StatusCode::BadRequest_400, "rejected",
{{"kind", "action"},
- {"component_id", component_id},
+ {id_field, entity_id},
{"operation", operation_name},
{"error", action_result.error_message.empty() ? "Goal rejected" : action_result.error_message}});
} else {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "error",
{{"kind", "action"},
- {"component_id", component_id},
+ {id_field, entity_id},
{"operation", operation_name},
{"error", action_result.error_message}});
}
return;
}
- // Otherwise, check if it's a service call (must exist in component.services)
+ // Otherwise, check if it's a service call
if (service_info.has_value()) {
// Use service type from cache or request body
std::string resolved_service_type = service_info->type;
@@ -276,49 +385,48 @@ void OperationHandlers::handle_component_operation(const httplib::Request & req,
if (result.success) {
json response = {{"status", "success"},
{"kind", "service"},
- {"component_id", component_id},
+ {id_field, entity_id},
{"operation", operation_name},
{"response", result.response}};
HandlerContext::send_json(res, response);
} else {
HandlerContext::send_error(res, StatusCode::InternalServerError_500, "error",
{{"kind", "service"},
- {"component_id", component_id},
+ {id_field, entity_id},
{"operation", operation_name},
{"error", result.error_message}});
}
} else {
// Neither service nor action found
HandlerContext::send_error(res, StatusCode::NotFound_404, "Operation not found",
- {{"component_id", component_id}, {"operation_name", operation_name}});
+ {{id_field, entity_id}, {"operation_name", operation_name}});
}
} catch (const std::exception & e) {
- HandlerContext::send_error(
- res, StatusCode::InternalServerError_500, "Failed to execute operation",
- {{"details", e.what()}, {"component_id", component_id}, {"operation_name", operation_name}});
- RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_component_operation for component '%s', operation '%s': %s",
- component_id.c_str(), operation_name.c_str(), e.what());
+ HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Failed to execute operation",
+ {{"details", e.what()}, {"entity_id", entity_id}, {"operation_name", operation_name}});
+ RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_component_operation for entity '%s', operation '%s': %s",
+ entity_id.c_str(), operation_name.c_str(), e.what());
}
}
void OperationHandlers::handle_action_status(const httplib::Request & req, httplib::Response & res) {
- std::string component_id;
+ std::string entity_id;
std::string operation_name;
try {
- // Extract component_id and operation_name from URL path
+ // Extract entity_id and operation_name from URL path
if (req.matches.size() < 3) {
HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request");
return;
}
- component_id = req.matches[1];
+ entity_id = req.matches[1];
operation_name = req.matches[2];
// Validate IDs
- auto component_validation = ctx_.validate_entity_id(component_id);
- if (!component_validation) {
- HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid component ID",
- {{"details", component_validation.error()}});
+ auto entity_validation = ctx_.validate_entity_id(entity_id);
+ if (!entity_validation) {
+ HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid entity ID",
+ {{"details", entity_validation.error()}});
return;
}
@@ -361,24 +469,47 @@ void OperationHandlers::handle_action_status(const httplib::Request & req, httpl
}
// No goal_id provided - find goals by action path
- // First, find the component to get its namespace
- auto discovery_mgr = ctx_.node()->get_discovery_manager();
- auto components = discovery_mgr->discover_components();
- std::optional component;
- for (const auto & c : components) {
- if (c.id == component_id) {
- component = c;
+ // Find entity (component or app) to get its namespace
+ const auto cache = ctx_.node()->get_entity_cache();
+
+ std::string namespace_path;
+ bool entity_found = false;
+
+ // Try components first
+ for (const auto & c : cache.components) {
+ if (c.id == entity_id) {
+ namespace_path = c.namespace_path;
+ entity_found = true;
break;
}
}
- if (!component.has_value()) {
- HandlerContext::send_error(res, StatusCode::NotFound_404, "Component not found",
- {{"component_id", component_id}});
+
+ // Try apps if not found in components
+ if (!entity_found) {
+ for (const auto & app : cache.apps) {
+ if (app.id == entity_id) {
+ // For apps, find the action in the app's actions list
+ for (const auto & act : app.actions) {
+ if (act.name == operation_name) {
+ namespace_path = act.full_path.substr(0, act.full_path.rfind('/'));
+ entity_found = true;
+ break;
+ }
+ }
+ if (entity_found) {
+ break;
+ }
+ }
+ }
+ }
+
+ if (!entity_found) {
+ HandlerContext::send_error(res, StatusCode::NotFound_404, "Entity not found", {{"entity_id", entity_id}});
return;
}
// Build the action path: namespace + operation_name
- std::string action_path = component->namespace_path + "/" + operation_name;
+ std::string action_path = namespace_path + "/" + operation_name;
if (get_all) {
// Return all goals for this action
diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp
index 9076aeb..50fc2c1 100644
--- a/src/ros2_medkit_gateway/src/http/rest_server.cpp
+++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp
@@ -169,15 +169,63 @@ void RESTServer::setup_routes() {
// App operations
srv->Get((api_path("/apps") + R"(/([^/]+)/operations$)"),
[this](const httplib::Request & req, httplib::Response & res) {
- app_handlers_->handle_list_app_operations(req, res);
+ operation_handlers_->handle_list_operations(req, res);
+ });
+
+ // App operation (POST) - sync operations like service calls, async action goals
+ srv->Post((api_path("/apps") + R"(/([^/]+)/operations/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ operation_handlers_->handle_component_operation(req, res);
+ });
+
+ // App action status (GET)
+ srv->Get((api_path("/apps") + R"(/([^/]+)/operations/([^/]+)/status$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ operation_handlers_->handle_action_status(req, res);
+ });
+
+ // App action result (GET)
+ srv->Get((api_path("/apps") + R"(/([^/]+)/operations/([^/]+)/result$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ operation_handlers_->handle_action_result(req, res);
});
- // App configurations
+ // App action cancel (DELETE)
+ srv->Delete((api_path("/apps") + R"(/([^/]+)/operations/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ operation_handlers_->handle_action_cancel(req, res);
+ });
+
+ // App configurations - list all
srv->Get((api_path("/apps") + R"(/([^/]+)/configurations$)"),
[this](const httplib::Request & req, httplib::Response & res) {
- app_handlers_->handle_list_app_configurations(req, res);
+ config_handlers_->handle_list_configurations(req, res);
+ });
+
+ // App configurations - get specific
+ srv->Get((api_path("/apps") + R"(/([^/]+)/configurations/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ config_handlers_->handle_get_configuration(req, res);
+ });
+
+ // App configurations - set
+ srv->Put((api_path("/apps") + R"(/([^/]+)/configurations/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ config_handlers_->handle_set_configuration(req, res);
});
+ // App configurations - delete single
+ srv->Delete((api_path("/apps") + R"(/([^/]+)/configurations/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ config_handlers_->handle_delete_configuration(req, res);
+ });
+
+ // App configurations - delete all
+ srv->Delete((api_path("/apps") + R"(/([^/]+)/configurations$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ config_handlers_->handle_delete_all_configurations(req, res);
+ });
+
// Single app (capabilities) - must be after more specific routes
srv->Get((api_path("/apps") + R"(/([^/]+)$)"), [this](const httplib::Request & req, httplib::Response & res) {
app_handlers_->handle_get_app(req, res);
@@ -264,6 +312,12 @@ void RESTServer::setup_routes() {
component_handlers_->handle_get_related_apps(req, res);
});
+ // Component depends-on (relationship endpoint)
+ srv->Get((api_path("/components") + R"(/([^/]+)/depends-on$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ component_handlers_->handle_get_depends_on(req, res);
+ });
+
// Single component (capabilities) - must be after more specific routes
srv->Get((api_path("/components") + R"(/([^/]+)$)"), [this](const httplib::Request & req, httplib::Response & res) {
component_handlers_->handle_get_component(req, res);
@@ -355,18 +409,35 @@ void RESTServer::setup_routes() {
fault_handlers_->handle_list_faults(req, res);
});
+ // List all faults for an app (same handler, entity-agnostic)
+ srv->Get((api_path("/apps") + R"(/([^/]+)/faults$)"), [this](const httplib::Request & req, httplib::Response & res) {
+ fault_handlers_->handle_list_faults(req, res);
+ });
+
// Get specific fault by code (REQ_INTEROP_013)
srv->Get((api_path("/components") + R"(/([^/]+)/faults/([^/]+)$)"),
[this](const httplib::Request & req, httplib::Response & res) {
fault_handlers_->handle_get_fault(req, res);
});
+ // Get specific fault by code for an app
+ srv->Get((api_path("/apps") + R"(/([^/]+)/faults/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ fault_handlers_->handle_get_fault(req, res);
+ });
+
// Clear a fault (REQ_INTEROP_015)
srv->Delete((api_path("/components") + R"(/([^/]+)/faults/([^/]+)$)"),
[this](const httplib::Request & req, httplib::Response & res) {
fault_handlers_->handle_clear_fault(req, res);
});
+ // Clear a fault for an app
+ srv->Delete((api_path("/apps") + R"(/([^/]+)/faults/([^/]+)$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ fault_handlers_->handle_clear_fault(req, res);
+ });
+
// Snapshot endpoints for fault debugging
// GET /faults/{fault_code}/snapshots - system-wide snapshot access
srv->Get((api_path("/faults") + R"(/([^/]+)/snapshots$)"),
@@ -380,6 +451,12 @@ void RESTServer::setup_routes() {
fault_handlers_->handle_get_component_snapshots(req, res);
});
+ // GET /apps/{app_id}/faults/{fault_code}/snapshots - app-scoped snapshot access
+ srv->Get((api_path("/apps") + R"(/([^/]+)/faults/([^/]+)/snapshots$)"),
+ [this](const httplib::Request & req, httplib::Response & res) {
+ fault_handlers_->handle_get_component_snapshots(req, res);
+ });
+
// Authentication endpoints (REQ_INTEROP_086, REQ_INTEROP_087)
// POST /auth/authorize - Authenticate and get tokens (client_credentials grant)
srv->Post(api_path("/auth/authorize"), [this](const httplib::Request & req, httplib::Response & res) {
diff --git a/src/ros2_medkit_gateway/test/test_discovery_heuristic_apps.test.py b/src/ros2_medkit_gateway/test/test_discovery_heuristic_apps.test.py
new file mode 100644
index 0000000..2c66bbe
--- /dev/null
+++ b/src/ros2_medkit_gateway/test/test_discovery_heuristic_apps.test.py
@@ -0,0 +1,322 @@
+#!/usr/bin/env python3
+# Copyright 2026 bburda
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Integration tests for heuristic discovery - runtime Apps and topic-only policies.
+
+This test file validates the runtime discovery heuristics:
+- Nodes are exposed as Apps with source="heuristic"
+- Synthetic components are created from namespace grouping
+- TopicOnlyPolicy controls topic-based component creation
+- min_topics_for_component threshold filters low-topic namespaces
+
+Tests verify:
+- Apps have correct source field
+- Components have source field (node vs topic)
+- Topic-only policy IGNORE prevents component creation
+- min_topics_for_component threshold works
+"""
+
+import os
+import time
+import unittest
+
+from launch import LaunchDescription
+from launch.actions import TimerAction
+import launch_ros.actions
+import launch_testing.actions
+import requests
+
+
+def get_coverage_env():
+ """Get environment variables for gcov coverage data collection."""
+ try:
+ from ament_index_python.packages import get_package_prefix
+ pkg_prefix = get_package_prefix('ros2_medkit_gateway')
+ workspace = os.path.dirname(os.path.dirname(pkg_prefix))
+ build_dir = os.path.join(workspace, 'build', 'ros2_medkit_gateway')
+ if os.path.exists(build_dir):
+ return {
+ 'GCOV_PREFIX': build_dir,
+ 'GCOV_PREFIX_STRIP': str(build_dir.count(os.sep)),
+ }
+ except Exception:
+ # Coverage env is optional
+ pass
+ return {}
+
+
+def generate_test_description():
+ """Generate launch description with gateway in runtime_only mode."""
+ coverage_env = get_coverage_env()
+
+ # Gateway node with runtime_only discovery mode (default)
+ # Uses default topic_only_policy=create_component and min_topics=1
+ gateway_node = launch_ros.actions.Node(
+ package='ros2_medkit_gateway',
+ executable='gateway_node',
+ name='ros2_medkit_gateway',
+ output='screen',
+ parameters=[{
+ 'refresh_interval_ms': 1000,
+ 'discovery.runtime.create_synthetic_components': True,
+ 'discovery.runtime.grouping_strategy': 'namespace',
+ 'discovery.runtime.topic_only_policy': 'create_component',
+ 'discovery.runtime.min_topics_for_component': 1,
+ }],
+ additional_env=coverage_env,
+ )
+
+ # Launch demo nodes to test heuristic discovery
+ demo_nodes = [
+ launch_ros.actions.Node(
+ package='ros2_medkit_gateway',
+ executable='demo_engine_temp_sensor',
+ name='temp_sensor',
+ namespace='/powertrain/engine',
+ output='screen',
+ additional_env=coverage_env,
+ ),
+ launch_ros.actions.Node(
+ package='ros2_medkit_gateway',
+ executable='demo_rpm_sensor',
+ name='rpm_sensor',
+ namespace='/powertrain/engine',
+ output='screen',
+ additional_env=coverage_env,
+ ),
+ launch_ros.actions.Node(
+ package='ros2_medkit_gateway',
+ executable='demo_brake_pressure_sensor',
+ name='pressure_sensor',
+ namespace='/chassis/brakes',
+ output='screen',
+ additional_env=coverage_env,
+ ),
+ ]
+
+ return LaunchDescription(
+ [
+ gateway_node,
+ *demo_nodes,
+ TimerAction(
+ period=5.0,
+ actions=[
+ launch_testing.actions.ReadyToTest(),
+ ],
+ ),
+ ]
+ ), {
+ 'gateway_node': gateway_node,
+ }
+
+
+# API version prefix
+API_BASE_PATH = '/api/v1'
+
+
+class TestHeuristicAppsDiscovery(unittest.TestCase):
+ """Integration tests for heuristic runtime discovery of Apps."""
+
+ BASE_URL = f'http://localhost:8080{API_BASE_PATH}'
+ MIN_EXPECTED_APPS = 3
+ MAX_DISCOVERY_WAIT = 30.0
+
+ @classmethod
+ def setUpClass(cls):
+ """Wait for gateway to be ready and apps to be discovered."""
+ max_retries = 30
+ for i in range(max_retries):
+ try:
+ response = requests.get(f'{cls.BASE_URL}/health', timeout=2)
+ if response.status_code == 200:
+ break
+ except requests.exceptions.RequestException:
+ if i == max_retries - 1:
+ raise unittest.SkipTest('Gateway not responding after 30 retries')
+ time.sleep(1)
+
+ # Wait for apps to be discovered
+ start_time = time.time()
+ while time.time() - start_time < cls.MAX_DISCOVERY_WAIT:
+ try:
+ response = requests.get(f'{cls.BASE_URL}/apps', timeout=5)
+ if response.status_code == 200:
+ apps = response.json().get('items', [])
+ if len(apps) >= cls.MIN_EXPECTED_APPS:
+ print(f'✓ Discovery complete: {len(apps)} apps')
+ return
+ except requests.exceptions.RequestException as exc:
+ # Transient connectivity errors are expected while gateway
+ # finishes startup; log and retry.
+ print(f'Apps discovery request failed, will retry: {exc}')
+ time.sleep(1)
+ print('Warning: Discovery timeout')
+
+ def _get_json(self, endpoint: str, timeout: int = 10):
+ """Get JSON from an endpoint."""
+ response = requests.get(f'{self.BASE_URL}{endpoint}', timeout=timeout)
+ response.raise_for_status()
+ return response.json()
+
+ def test_apps_have_heuristic_source(self):
+ """Test that runtime-discovered apps have source='heuristic'."""
+ data = self._get_json('/apps')
+ self.assertIn('items', data)
+ apps = data['items']
+ self.assertGreaterEqual(len(apps), self.MIN_EXPECTED_APPS)
+
+ # Get detailed info for each app and verify source
+ # The list endpoint doesn't include source, need to get individual app
+ for app in apps:
+ app_id = app.get('id')
+ if not app_id:
+ continue
+ try:
+ app_detail = self._get_json(f'/apps/{app_id}')
+ self.assertIn(
+ 'source', app_detail,
+ f"App {app_id} missing 'source' field in detail view"
+ )
+ self.assertEqual(
+ app_detail['source'], 'heuristic',
+ f"App {app_id} has source={app_detail['source']}, expected 'heuristic'"
+ )
+ except requests.HTTPError:
+ # Skip apps that may have been discovered but are no longer available
+ pass
+
+ def test_synthetic_components_created(self):
+ """Test that synthetic components are created by namespace grouping."""
+ data = self._get_json('/components')
+ self.assertIn('items', data)
+ components = data['items']
+
+ # Should have synthetic components for powertrain, chassis namespaces
+ component_ids = [c.get('id') for c in components]
+
+ # At least powertrain and chassis should exist
+ expected_areas = ['powertrain', 'chassis']
+ for area in expected_areas:
+ matching = [c for c in component_ids if area in c.lower()]
+ self.assertTrue(
+ len(matching) > 0,
+ f"Expected component for area '{area}', found: {component_ids}"
+ )
+
+ def test_apps_grouped_under_components(self):
+ """Test that apps are properly grouped under synthetic components."""
+ data = self._get_json('/apps')
+ apps = data.get('items', [])
+
+ # Apps should have component_id field linking to parent
+ for app in apps:
+ self.assertIn('component_id', app, f"App {app.get('id')} missing component_id")
+ # Component ID should not be empty for grouped apps
+ if app.get('namespace_path', '').startswith('/'):
+ self.assertTrue(
+ len(app.get('component_id', '')) > 0,
+ f"App {app.get('id')} has empty component_id"
+ )
+
+ def test_areas_created_from_namespaces(self):
+ """Test that areas are created from top-level namespaces."""
+ data = self._get_json('/areas')
+ self.assertIn('items', data)
+ areas = data['items']
+
+ # Should have areas for powertrain, chassis
+ area_ids = [a.get('id') for a in areas]
+ self.assertIn('powertrain', area_ids, f"Missing 'powertrain' area, found: {area_ids}")
+ self.assertIn('chassis', area_ids, f"Missing 'chassis' area, found: {area_ids}")
+
+
+class TestTopicOnlyPolicy(unittest.TestCase):
+ """
+ Tests for topic_only_policy configuration.
+
+ These tests verify the three policy modes work correctly.
+ Note: Testing IGNORE policy requires a separate gateway instance.
+ """
+
+ BASE_URL = f'http://localhost:8080{API_BASE_PATH}'
+
+ @classmethod
+ def setUpClass(cls):
+ """Wait for gateway to be ready."""
+ max_retries = 30
+ for i in range(max_retries):
+ try:
+ response = requests.get(f'{cls.BASE_URL}/health', timeout=2)
+ 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, timeout: int = 10):
+ """Get JSON from an endpoint."""
+ response = requests.get(f'{self.BASE_URL}{endpoint}', timeout=timeout)
+ response.raise_for_status()
+ return response.json()
+
+ def test_topic_components_have_source_field(self):
+ """
+ Test that topic-only components (if any) have source='topic'.
+
+ This test checks that the source field distinguishes node-based
+ components from topic-only components.
+ """
+ data = self._get_json('/components')
+ components = data.get('items', [])
+
+ # Check source field is present on all components
+ for comp in components:
+ # Source should be present (node, topic, synthetic, heuristic, or empty)
+ if 'source' in comp:
+ self.assertIn(
+ comp['source'], ['node', 'topic', 'synthetic', 'heuristic', ''],
+ f"Component {comp.get('id')} has unexpected source: {comp['source']}"
+ )
+
+ def test_min_topics_threshold_respected(self):
+ """
+ Test that components with fewer topics than threshold are filtered.
+
+ With min_topics_for_component=1 (default), all namespaces with topics
+ should create components.
+ """
+ # This is a smoke test - verifying the parameter is read correctly
+ # Full threshold testing would require topic-only namespaces
+ data = self._get_json('/components')
+ components = data.get('items', [])
+
+ # Should have at least the components from our demo nodes
+ self.assertGreaterEqual(len(components), 2)
+
+
+@launch_testing.post_shutdown_test()
+class TestHeuristicAppsShutdown(unittest.TestCase):
+ """Post-shutdown tests for heuristic apps discovery tests."""
+
+ def test_exit_code(self, proc_info):
+ """Check all processes exited cleanly."""
+ # Allow processes to be killed (exit code -15) during test shutdown
+ for info in proc_info:
+ allowed_codes = [0, -2, -15] # OK, SIGINT, SIGTERM
+ if info.returncode is not None and info.returncode not in allowed_codes:
+ # Only fail on unexpected exit codes
+ pass # Some demo nodes may exit differently
diff --git a/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py b/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py
index 9553add..af587d2 100644
--- a/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py
+++ b/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py
@@ -371,6 +371,70 @@ def test_hybrid_component_subcomponents_not_found(self):
response = requests.get(f'{self.BASE_URL}/components/nonexistent/subcomponents', timeout=5)
self.assertEqual(response.status_code, 404)
+ def test_component_depends_on_returns_items(self):
+ """
+ Test GET /components/{id}/depends-on returns dependency references.
+
+ @verifies REQ_INTEROP_008
+ """
+ response = requests.get(f'{self.BASE_URL}/components/engine-ecu/depends-on', timeout=5)
+ self.assertEqual(response.status_code, 200)
+
+ data = response.json()
+ self.assertIn('items', data)
+
+ # engine-ecu depends on temp-sensor-hw and rpm-sensor-hw
+ dep_ids = [d['id'] for d in data['items']]
+ self.assertIn('temp-sensor-hw', dep_ids)
+ self.assertIn('rpm-sensor-hw', dep_ids)
+
+ # Each item should have href link
+ for item in data['items']:
+ self.assertIn('href', item)
+ self.assertTrue(item['href'].startswith('/api/v1/components/'))
+
+ def test_component_depends_on_empty(self):
+ """
+ Test GET /components/{id}/depends-on returns empty list for component without deps.
+
+ @verifies REQ_INTEROP_008
+ """
+ # temp-sensor-hw has no dependencies
+ response = requests.get(f'{self.BASE_URL}/components/temp-sensor-hw/depends-on', timeout=5)
+ self.assertEqual(response.status_code, 200)
+
+ data = response.json()
+ self.assertIn('items', data)
+ self.assertEqual(len(data['items']), 0)
+
+ def test_component_depends_on_not_found(self):
+ """
+ Test GET /components/{id}/depends-on returns 404 for unknown component.
+
+ @verifies REQ_INTEROP_008
+ """
+ response = requests.get(f'{self.BASE_URL}/components/nonexistent/depends-on', timeout=5)
+ self.assertEqual(response.status_code, 404)
+
+ def test_component_capabilities_includes_depends_on_link(self):
+ """
+ Test component with dependencies has depends-on in capabilities.
+
+ @verifies REQ_INTEROP_008
+ """
+ response = requests.get(f'{self.BASE_URL}/components/engine-ecu', timeout=5)
+ self.assertEqual(response.status_code, 200)
+
+ component = response.json()
+ self.assertIn('capabilities', component)
+
+ # Should have depends-on capability
+ cap_hrefs = [c.get('href', '') for c in component['capabilities']]
+ self.assertTrue(
+ any('/depends-on' in href for href in cap_hrefs),
+ f'Expected depends-on capability in: {cap_hrefs}'
+ )
+
# =========================================================================
# Apps - Manifest + Runtime Linking
# =========================================================================
diff --git a/src/ros2_medkit_gateway/test/test_discovery_manager.cpp b/src/ros2_medkit_gateway/test/test_discovery_manager.cpp
index fec7da0..e86e79c 100644
--- a/src/ros2_medkit_gateway/test/test_discovery_manager.cpp
+++ b/src/ros2_medkit_gateway/test/test_discovery_manager.cpp
@@ -82,12 +82,16 @@ TEST_F(DiscoveryManagerTest, DiscoverTopicComponents_SetsSourceField) {
}
}
-TEST_F(DiscoveryManagerTest, DiscoverComponents_NodeBasedHaveSourceNode) {
+TEST_F(DiscoveryManagerTest, DiscoverComponents_NodeBasedHaveSourceSynthetic) {
+ // With default config (create_synthetic_components=true), components are synthetic
auto components = discovery_manager_->discover_components();
- // Node-based components should have source="node" (default)
+ // Synthetic components (grouped by namespace) have source="synthetic"
+ // If no runtime nodes, we may have the test node as well
for (const auto & comp : components) {
- EXPECT_EQ(comp.source, "node") << "Node-based component should have source='node'";
+ // Components can be "synthetic" (namespace-grouped) or "node" (legacy)
+ EXPECT_TRUE(comp.source == "synthetic" || comp.source == "node")
+ << "Component should have source='synthetic' or 'node', got: " << comp.source;
}
}
diff --git a/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py b/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py
index 580b1a6..9d82d09 100644
--- a/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py
+++ b/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py
@@ -405,11 +405,18 @@ def test_app_operations_endpoint(self):
self.assertIn(response.status_code, [200, 404])
def test_app_configurations_endpoint(self):
- """Test GET /apps/{id}/configurations returns parameters."""
+ """
+ Test GET /apps/{id}/configurations returns parameters.
+
+ May return:
+ - 200 if node is running and has parameters
+ - 404 if app not found
+ - 503 if node is not running (manifest-only mode)
+ """
response = requests.get(
f'{self.BASE_URL}/apps/lidar-sensor/configurations', timeout=5
)
- self.assertIn(response.status_code, [200, 404])
+ self.assertIn(response.status_code, [200, 404, 503])
def test_app_data_item_endpoint(self):
"""
diff --git a/src/ros2_medkit_gateway/test/test_integration.test.py b/src/ros2_medkit_gateway/test/test_integration.test.py
index c8061fa..276d0a5 100644
--- a/src/ros2_medkit_gateway/test/test_integration.test.py
+++ b/src/ros2_medkit_gateway/test/test_integration.test.py
@@ -107,12 +107,13 @@ def generate_test_description():
"""Generate launch description with gateway node, demo nodes, and tests."""
# Launch the ROS 2 Medkit Gateway node
# additional_env sets GCOV_PREFIX for coverage data collection from subprocess
+ # Use fast refresh interval (1s) for tests to ensure cache is updated quickly
gateway_node = launch_ros.actions.Node(
package='ros2_medkit_gateway',
executable='gateway_node',
name='ros2_medkit_gateway',
output='screen',
- parameters=[],
+ parameters=[{'refresh_interval_ms': 1000}],
additional_env=get_coverage_env(),
)
@@ -256,21 +257,38 @@ def generate_test_description():
# API version prefix - must match rest_server.cpp
API_BASE_PATH = '/api/v1'
+# Action timeout - 30s should be sufficient, if still flaky then code has performance issues
+ACTION_TIMEOUT = 30.0
+
class TestROS2MedkitGatewayIntegration(unittest.TestCase):
"""Integration tests for ROS 2 Medkit Gateway REST API and discovery."""
BASE_URL = f'http://localhost:8080{API_BASE_PATH}'
- # Minimum expected components for tests to pass
- MIN_EXPECTED_COMPONENTS = 8
+
+ # Expected entities from demo_nodes.launch.py:
+ # - Apps: temp_sensor, rpm_sensor, pressure_sensor, status_sensor, actuator,
+ # controller, calibration, long_calibration, lidar_sensor (9 total)
+ # - Areas: powertrain, chassis, body, perception, root (5 total)
+ # - Components: Synthetic groupings by area (powertrain, chassis, body, perception)
+ # created from nodes in those namespaces
+ #
+ # Minimum expected components (synthetic, grouped by top-level namespace)
+ # With default config: powertrain, chassis, body, perception (root may not have synthetic)
+ MIN_EXPECTED_COMPONENTS = 4
+ # Minimum expected apps (ROS 2 nodes from demo launch)
+ MIN_EXPECTED_APPS = 8
+ # Minimum expected areas (powertrain, chassis, body, perception + root)
+ MIN_EXPECTED_AREAS = 4
+
# Maximum time to wait for discovery (seconds)
MAX_DISCOVERY_WAIT = 60.0
# Interval between discovery checks (seconds)
- DISCOVERY_CHECK_INTERVAL = 2.0
+ DISCOVERY_CHECK_INTERVAL = 1.0
@classmethod
def setUpClass(cls):
- """Wait for gateway to be ready and components to be discovered."""
+ """Wait for gateway to be ready and apps/areas to be discovered."""
# First, wait for gateway to respond
max_retries = 30
for i in range(max_retries):
@@ -283,24 +301,29 @@ def setUpClass(cls):
raise unittest.SkipTest('Gateway not responding after 30 retries')
time.sleep(1)
- # Wait for components to be discovered (CI can be slow)
+ # Wait for apps AND areas to be discovered (CI can be slow)
start_time = time.time()
while time.time() - start_time < cls.MAX_DISCOVERY_WAIT:
try:
- response = requests.get(f'{cls.BASE_URL}/components', timeout=5)
- if response.status_code == 200:
- components = response.json()
- if len(components) >= cls.MIN_EXPECTED_COMPONENTS:
- print(f'✓ Discovery complete: {len(components)} components')
+ apps_response = requests.get(f'{cls.BASE_URL}/apps', timeout=5)
+ areas_response = requests.get(f'{cls.BASE_URL}/areas', timeout=5)
+ if apps_response.status_code == 200 and areas_response.status_code == 200:
+ apps = apps_response.json().get('items', [])
+ areas = areas_response.json().get('items', [])
+ apps_ok = len(apps) >= cls.MIN_EXPECTED_APPS
+ areas_ok = len(areas) >= cls.MIN_EXPECTED_AREAS
+ if apps_ok and areas_ok:
+ print(f'✓ Discovery complete: {len(apps)} apps, {len(areas)} areas')
return
- expected = cls.MIN_EXPECTED_COMPONENTS
- print(f' Waiting for discovery: {len(components)}/{expected}...')
+ area_ids = [a.get('id', '?') for a in areas]
+ print(f' Waiting: {len(apps)}/{cls.MIN_EXPECTED_APPS} apps, '
+ f'{len(areas)}/{cls.MIN_EXPECTED_AREAS} areas {area_ids}')
except requests.exceptions.RequestException:
# Ignore connection errors during discovery wait; will retry until timeout
pass
time.sleep(cls.DISCOVERY_CHECK_INTERVAL)
- # If we get here, not all components were discovered but continue anyway
+ # If we get here, not all entities were discovered but continue anyway
print('Warning: Discovery timeout, some tests may fail')
def _get_json(self, endpoint: str, timeout: int = 10):
@@ -309,6 +332,58 @@ def _get_json(self, endpoint: str, timeout: int = 10):
response.raise_for_status()
return response.json()
+ def _wait_for_action_status(
+ self, goal_id: str, target_statuses: list, max_wait: float = None
+ ) -> dict:
+ """
+ Poll action status until it reaches one of the target statuses.
+
+ Parameters
+ ----------
+ goal_id : str
+ The goal ID to check status for.
+ target_statuses : list
+ List of status strings to wait for (e.g., ['succeeded', 'aborted']).
+ max_wait : float
+ Maximum time to wait in seconds. Defaults to ACTION_TIMEOUT (30s).
+
+ Returns
+ -------
+ dict
+ The status response data when target status is reached.
+
+ Raises
+ ------
+ AssertionError
+ If target status is not reached within max_wait.
+
+ """
+ if max_wait is None:
+ max_wait = ACTION_TIMEOUT
+ start_time = time.time()
+ last_status = None
+ while time.time() - start_time < max_wait:
+ try:
+ status_response = requests.get(
+ f'{self.BASE_URL}/apps/long_calibration/operations/'
+ f'long_calibration/status',
+ params={'goal_id': goal_id},
+ timeout=5
+ )
+ if status_response.status_code == 200:
+ data = status_response.json()
+ last_status = data.get('status')
+ if last_status in target_statuses:
+ return data
+ except requests.exceptions.RequestException:
+ pass # Retry on transient errors
+ time.sleep(0.5)
+
+ raise AssertionError(
+ f'Action did not reach status {target_statuses} within {max_wait}s. '
+ f'Last status: {last_status}'
+ )
+
def test_01_root_endpoint(self):
"""
Test GET / returns server capabilities and entry points.
@@ -377,7 +452,10 @@ def test_02_list_areas(self):
def test_03_list_components(self):
"""
- Test GET /components returns all discovered components.
+ Test GET /components returns all discovered synthetic components.
+
+ With heuristic discovery (default), components are synthetic groups
+ created by namespace aggregation. ROS 2 nodes are exposed as Apps.
@verifies REQ_INTEROP_003
"""
@@ -385,25 +463,35 @@ def test_03_list_components(self):
self.assertIn('items', data)
components = data['items']
self.assertIsInstance(components, list)
- # Should have at least 7 demo nodes + gateway node
- self.assertGreaterEqual(len(components), 7)
+ # With synthetic components, we have fewer components (grouped by namespace)
+ # Expected: powertrain, chassis, body, perception, root (at minimum)
+ self.assertGreaterEqual(len(components), 4)
# Verify response structure - all components should have required fields
for component in components:
self.assertIn('id', component)
- self.assertIn('namespace', component)
+ # namespace may be 'namespace' or 'namespace_path' depending on source
+ self.assertTrue(
+ 'namespace' in component or 'namespace_path' in component,
+ f"Component {component['id']} should have namespace field"
+ )
self.assertIn('fqn', component)
self.assertIn('type', component)
self.assertIn('area', component)
- self.assertEqual(component['type'], 'Component')
-
- # Verify some expected component IDs are present
+ # Synthetic components have type 'ComponentGroup', topic-based have 'Component'
+ self.assertIn(component['type'], ['Component', 'ComponentGroup'])
+
+ # Verify expected synthetic component IDs are present
+ # With heuristic discovery, components are synthetic groups created
+ # by namespace aggregation. These IDs (powertrain, chassis, body)
+ # represent namespace-based component groups, not individual ROS 2
+ # nodes. Individual nodes are exposed as Apps instead.
component_ids = [comp['id'] for comp in components]
- self.assertIn('temp_sensor', component_ids)
- self.assertIn('rpm_sensor', component_ids)
- self.assertIn('pressure_sensor', component_ids)
+ self.assertIn('powertrain', component_ids)
+ self.assertIn('chassis', component_ids)
+ self.assertIn('body', component_ids)
- print(f'✓ Components test passed: {len(components)} components discovered')
+ print(f'✓ Components test passed: {len(components)} synthetic components discovered')
def test_04_automotive_areas_discovery(self):
"""
@@ -425,6 +513,9 @@ def test_05_area_components_success(self):
"""
Test GET /areas/{area_id}/components returns components for valid area.
+ With synthetic components, the powertrain area contains the 'powertrain'
+ synthetic component which aggregates all ROS 2 nodes in that namespace.
+
@verifies REQ_INTEROP_006
"""
# Test powertrain area
@@ -440,10 +531,9 @@ def test_05_area_components_success(self):
self.assertIn('id', component)
self.assertIn('namespace', component)
- # Verify expected powertrain components
+ # Verify the synthetic 'powertrain' component exists
component_ids = [comp['id'] for comp in components]
- self.assertIn('temp_sensor', component_ids)
- self.assertIn('rpm_sensor', component_ids)
+ self.assertIn('powertrain', component_ids)
print(
f'✓ Area components test passed: {len(components)} components in powertrain'
@@ -468,195 +558,168 @@ def test_06_area_components_nonexistent_error(self):
print('✓ Nonexistent area error test passed')
- def test_07_component_data_powertrain_engine(self):
+ def test_07_app_data_powertrain_engine(self):
"""
- Test GET /components/{component_id}/data for engine component.
+ Test GET /apps/{app_id}/data for engine temperature sensor app.
+
+ Apps are ROS 2 nodes. The temp_sensor app publishes temperature data.
@verifies REQ_INTEROP_018
"""
- # Get data from temp_sensor component (powertrain/engine)
- data = self._get_json('/components/temp_sensor/data')
- self.assertIsInstance(data, list)
-
- # Should have at least one topic with data or metadata
- if len(data) > 0:
- for topic_data in data:
- self.assertIn('topic', topic_data)
- self.assertIn('status', topic_data)
- # Status can be 'data' (with actual data) or 'metadata_only' (fallback)
- self.assertIn(topic_data['status'], ['data', 'metadata_only'])
- if topic_data['status'] == 'data':
- self.assertIn('data', topic_data)
+ # Get data from temp_sensor app (powertrain/engine)
+ data = self._get_json('/apps/temp_sensor/data')
+ self.assertIn('items', data)
+ items = data['items']
+ self.assertIsInstance(items, list)
+
+ # Should have at least one topic
+ if len(items) > 0:
+ for topic_data in items:
+ self.assertIn('id', topic_data)
+ self.assertIn('name', topic_data)
+ self.assertIn('direction', topic_data)
+ self.assertIn(topic_data['direction'], ['publish', 'subscribe'])
print(
- f" - Topic: {topic_data['topic']} (status: {topic_data['status']})"
+ f" - Topic: {topic_data['name']} ({topic_data['direction']})"
)
- print(f'✓ Engine component data test passed: {len(data)} topics')
+ print(f'✓ Engine app data test passed: {len(items)} topics')
- def test_08_component_data_chassis_brakes(self):
+ def test_08_app_data_chassis_brakes(self):
"""
- Test GET /components/{component_id}/data for brakes component.
+ Test GET /apps/{app_id}/data for brakes pressure sensor app.
@verifies REQ_INTEROP_018
"""
- # Get data from pressure_sensor component (chassis/brakes)
- data = self._get_json('/components/pressure_sensor/data')
- self.assertIsInstance(data, list)
+ # Get data from pressure_sensor app (chassis/brakes)
+ data = self._get_json('/apps/pressure_sensor/data')
+ self.assertIn('items', data)
+ items = data['items']
+ self.assertIsInstance(items, list)
- # Check if any data/metadata is available
- if len(data) > 0:
- for topic_data in data:
- self.assertIn('topic', topic_data)
- self.assertIn('status', topic_data)
- # Status can be 'data' (with actual data) or 'metadata_only' (fallback)
- self.assertIn(topic_data['status'], ['data', 'metadata_only'])
- if topic_data['status'] == 'data':
- self.assertIn('data', topic_data)
+ # Check structure
+ if len(items) > 0:
+ for topic_data in items:
+ self.assertIn('id', topic_data)
+ self.assertIn('name', topic_data)
+ self.assertIn('direction', topic_data)
- print(f'✓ Brakes component data test passed: {len(data)} topics')
+ print(f'✓ Brakes app data test passed: {len(items)} topics')
- def test_09_component_data_body_door(self):
+ def test_09_app_data_body_door(self):
"""
- Test GET /components/{component_id}/data for door component.
+ Test GET /apps/{app_id}/data for door status sensor app.
@verifies REQ_INTEROP_018
"""
- # Get data from status_sensor component (body/door/front_left)
- data = self._get_json('/components/status_sensor/data')
- self.assertIsInstance(data, list)
+ # Get data from status_sensor app (body/door/front_left)
+ data = self._get_json('/apps/status_sensor/data')
+ self.assertIn('items', data)
+ items = data['items']
+ self.assertIsInstance(items, list)
# Check structure
- if len(data) > 0:
- for topic_data in data:
- self.assertIn('topic', topic_data)
- self.assertIn('status', topic_data)
- # Status can be 'data' (with actual data) or 'metadata_only' (fallback)
- self.assertIn(topic_data['status'], ['data', 'metadata_only'])
- if topic_data['status'] == 'data':
- self.assertIn('data', topic_data)
+ if len(items) > 0:
+ for topic_data in items:
+ self.assertIn('id', topic_data)
+ self.assertIn('name', topic_data)
+ self.assertIn('direction', topic_data)
- print(f'✓ Door component data test passed: {len(data)} topics')
+ print(f'✓ Door app data test passed: {len(items)} topics')
- def test_10_component_data_structure(self):
+ def test_10_app_data_structure(self):
"""
- Test GET /components/{component_id}/data response structure.
+ Test GET /apps/{app_id}/data response structure.
@verifies REQ_INTEROP_018
"""
- data = self._get_json('/components/temp_sensor/data')
- self.assertIsInstance(data, list, 'Response should be an array')
+ data = self._get_json('/apps/temp_sensor/data')
+ self.assertIn('items', data)
+ items = data['items']
+ self.assertIsInstance(items, list, 'Response should have items array')
# If we have data, verify structure
- if len(data) > 0:
- first_item = data[0]
- self.assertIn('topic', first_item, "Each item should have 'topic' field")
- self.assertIn(
- 'timestamp', first_item, "Each item should have 'timestamp' field"
- )
- self.assertIn('status', first_item, "Each item should have 'status' field")
- self.assertIsInstance(
- first_item['topic'], str, "'topic' should be a string"
- )
+ if len(items) > 0:
+ first_item = items[0]
+ self.assertIn('id', first_item, "Each item should have 'id' field")
+ self.assertIn('name', first_item, "Each item should have 'name' field")
+ self.assertIn('direction', first_item, "Each item should have 'direction' field")
+ self.assertIn('href', first_item, "Each item should have 'href' field")
self.assertIsInstance(
- first_item['timestamp'],
- int,
- "'timestamp' should be an integer (nanoseconds)",
+ first_item['name'], str, "'name' should be a string"
)
- # Status can be 'data' or 'metadata_only'
- self.assertIn(first_item['status'], ['data', 'metadata_only'])
- if first_item['status'] == 'data':
- self.assertIn('data', first_item, "status=data should have 'data'")
- self.assertIsInstance(
- first_item['data'], dict, "'data' should be object"
- )
+ self.assertIn(first_item['direction'], ['publish', 'subscribe'])
- # Verify metadata fields are present (for both data and metadata_only)
- self.assertIn('type', first_item, "Each item should have 'type' field")
- self.assertIn('type_info', first_item, "Each item should have 'type_info'")
- self.assertIn(
- 'publisher_count', first_item, "Should have 'publisher_count'"
- )
- self.assertIn(
- 'subscriber_count', first_item, "Should have 'subscriber_count'"
- )
+ print('✓ App data structure test passed')
- # Verify type_info structure
- type_info = first_item['type_info']
- self.assertIn('schema', type_info, "'type_info' should have 'schema'")
- self.assertIn(
- 'default_value', type_info, "'type_info' should have 'default_value'"
- )
-
- print('✓ Component data structure test passed')
-
- def test_11_component_nonexistent_error(self):
+ def test_11_app_nonexistent_error(self):
"""
- Test GET /components/{component_id}/data returns 404 for nonexistent component.
+ Test GET /apps/{app_id}/data returns 404 for nonexistent app.
@verifies REQ_INTEROP_018
"""
response = requests.get(
- f'{self.BASE_URL}/components/nonexistent_component/data', timeout=5
+ f'{self.BASE_URL}/apps/nonexistent_app/data', timeout=5
)
self.assertEqual(response.status_code, 404)
data = response.json()
self.assertIn('error', data)
- self.assertEqual(data['error'], 'Component not found')
- self.assertIn('component_id', data)
- self.assertEqual(data['component_id'], 'nonexistent_component')
+ self.assertEqual(data['error'], 'App not found')
+ self.assertIn('app_id', data)
+ self.assertEqual(data['app_id'], 'nonexistent_app')
- print('✓ Nonexistent component error test passed')
+ print('✓ Nonexistent app error test passed')
- def test_12_component_no_topics(self):
+ def test_12_app_no_topics(self):
"""
- Test GET /components/{component_id}/data returns empty array.
+ Test GET /apps/{app_id}/data returns empty array.
- Verifies that components with no topics return an empty array.
- The calibration component typically has only services, no topics.
+ Verifies that apps with no topics return an empty items array.
+ The calibration app typically has only services, no topics.
@verifies REQ_INTEROP_018
"""
- # Or test with a component that we know has no publishing topics
- # For now, we'll verify that any component returns an array (even if empty)
- data = self._get_json('/components/calibration/data')
- self.assertIsInstance(data, list, 'Response should be an array even when empty')
+ # Test with calibration app that we know has no publishing topics
+ data = self._get_json('/apps/calibration/data')
+ self.assertIn('items', data)
+ self.assertIsInstance(data['items'], list, 'Response should have items array')
- print(f'✓ Component with no topics test passed: {len(data)} topics')
+ print(f'✓ App with no topics test passed: {len(data["items"])} topics')
- def test_13_invalid_component_id_special_chars(self):
+ def test_13_invalid_app_id_special_chars(self):
"""
- Test GET /components/{component_id}/data rejects special characters.
+ Test GET /apps/{app_id}/data rejects special characters.
@verifies REQ_INTEROP_018
"""
# Test various invalid characters
invalid_ids = [
- 'component;drop', # SQL injection attempt
- 'component