diff --git a/docs/config/index.rst b/docs/config/index.rst new file mode 100644 index 0000000..5e66027 --- /dev/null +++ b/docs/config/index.rst @@ -0,0 +1,16 @@ +Configuration Reference +======================= + +This section contains configuration references for ros2_medkit. + +.. toctree:: + :maxdepth: 2 + + manifest-schema + +Manifest Configuration +---------------------- + +:doc:`manifest-schema` + Complete YAML schema reference for SOVD system manifests. + Defines areas, components, apps, and functions for your ROS 2 system. diff --git a/docs/config/manifest-schema.rst b/docs/config/manifest-schema.rst new file mode 100644 index 0000000..7806ffa --- /dev/null +++ b/docs/config/manifest-schema.rst @@ -0,0 +1,707 @@ +Manifest Schema Reference +========================= + +This document describes the complete YAML schema for SOVD system manifests. + +.. contents:: Table of Contents + :local: + :depth: 2 + +Top-Level Structure +------------------- + +A manifest file has the following top-level structure: + +.. code-block:: yaml + + manifest_version: "1.0" # Required - manifest schema version + + metadata: # Optional - document metadata + name: string + version: string + description: string + + config: # Optional - discovery behavior settings + unmanifested_nodes: string + inherit_runtime_resources: boolean + allow_manifest_override: boolean + + areas: [] # Optional - area definitions + components: [] # Optional - component definitions + apps: [] # Optional - app definitions + functions: [] # Optional - function definitions + +manifest_version (Required) +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The manifest schema version. Currently must be ``"1.0"``. + +.. code-block:: yaml + + manifest_version: "1.0" + +metadata (Optional) +~~~~~~~~~~~~~~~~~~~ + +Document metadata for identification and documentation. + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Field + - Type + - Description + * - ``name`` + - string + - System/robot name (e.g., "turtlebot3-nav2") + * - ``version`` + - string + - Manifest version (e.g., "1.0.0") + * - ``description`` + - string + - Human-readable description + +.. code-block:: yaml + + metadata: + name: "my-robot" + version: "2.0.0" + description: "Mobile robot with Nav2 navigation stack" + +config (Optional) +~~~~~~~~~~~~~~~~~ + +Discovery behavior configuration. + +.. list-table:: + :header-rows: 1 + :widths: 25 15 60 + + * - Field + - Type + - Description + * - ``unmanifested_nodes`` + - string + - Policy for ROS nodes not in manifest + * - ``inherit_runtime_resources`` + - boolean + - Copy topics/services from runtime nodes (default: true) + * - ``allow_manifest_override`` + - boolean + - Manifest values can override runtime (default: true) + +**unmanifested_nodes options:** + +- ``ignore`` - Don't expose unmanifested nodes +- ``warn`` - Log warning, include as orphans (default) +- ``error`` - Fail startup if orphan nodes detected +- ``include_as_orphan`` - Include with ``source: "orphan"`` + +.. code-block:: yaml + + config: + unmanifested_nodes: warn + inherit_runtime_resources: true + allow_manifest_override: true + +Areas +----- + +Areas represent logical or physical groupings (subsystems, locations, etc.). +In runtime-only mode, areas are derived from ROS 2 namespaces. + +Schema +~~~~~~ + +.. code-block:: yaml + + areas: + - id: string # Required - unique identifier + name: string # Required - human-readable name + namespace: string # Optional - ROS 2 namespace path + category: string # Optional - classification + description: string # Optional - detailed description + tags: [string] # Optional - tags for filtering + translation_id: string # Optional - i18n key + parent_area_id: string # Optional - parent area reference + subareas: [] # Optional - nested area definitions + +Fields +~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 15 10 55 + + * - Field + - Type + - Required + - Description + * - ``id`` + - string + - Yes + - Unique identifier (alphanumeric, hyphens allowed) + * - ``name`` + - string + - Yes + - Human-readable name + * - ``namespace`` + - string + - No + - ROS 2 namespace path (e.g., "/perception") + * - ``category`` + - string + - No + - Classification for filtering + * - ``description`` + - string + - No + - Detailed description + * - ``tags`` + - [string] + - No + - Tags for filtering and grouping + * - ``translation_id`` + - string + - No + - Internationalization key + * - ``parent_area_id`` + - string + - No + - Parent area ID (for flat hierarchy definition) + * - ``subareas`` + - [Area] + - No + - Nested area definitions + +Example +~~~~~~~ + +.. code-block:: yaml + + areas: + - id: perception + name: "Perception Subsystem" + category: "sensor-processing" + description: "Sensor data acquisition and processing" + tags: + - sensors + - realtime + subareas: + - id: lidar-processing + name: "LiDAR Processing" + description: "Point cloud processing pipeline" + + - id: camera-processing + name: "Camera Processing" + description: "Image processing pipeline" + + - id: navigation + name: "Navigation Subsystem" + category: "motion-planning" + +Components +---------- + +Components represent hardware or virtual entities (ECUs, sensors, controllers). +In runtime-only mode, components are derived from ROS 2 nodes. + +Schema +~~~~~~ + +.. code-block:: yaml + + components: + - id: string # Required - unique identifier + name: string # Required - human-readable name + type: string # Optional - component type + category: string # Optional - classification + area: string # Optional - reference to area.id + namespace: string # Optional - ROS 2 namespace + fqn: string # Optional - fully qualified name + variant: string # Optional - hardware variant + description: string # Optional - detailed description + tags: [string] # Optional - tags for filtering + translation_id: string # Optional - i18n key + parent_component_id: string # Optional - parent component + subcomponents: [] # Optional - nested definitions + +Fields +~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 22 15 10 53 + + * - Field + - Type + - Required + - Description + * - ``id`` + - string + - Yes + - Unique identifier + * - ``name`` + - string + - Yes + - Human-readable name + * - ``type`` + - string + - No + - Component type (sensor, actuator, controller, etc.) + * - ``category`` + - string + - No + - Classification for filtering + * - ``area`` + - string + - No + - Parent area ID + * - ``namespace`` + - string + - No + - ROS 2 namespace path + * - ``fqn`` + - string + - No + - Fully qualified name (namespace + id) + * - ``variant`` + - string + - No + - Hardware variant identifier + * - ``description`` + - string + - No + - Detailed description + * - ``tags`` + - [string] + - No + - Tags for filtering + * - ``translation_id`` + - string + - No + - Internationalization key + * - ``parent_component_id`` + - string + - No + - Parent component ID + * - ``subcomponents`` + - [Component] + - No + - Nested component definitions + +Common Component Types +~~~~~~~~~~~~~~~~~~~~~~ + +- ``sensor`` - Sensors (LiDAR, camera, IMU) +- ``actuator`` - Actuators (motors, grippers) +- ``controller`` - Controllers (main computer, ECU) +- ``accelerator`` - Compute accelerators (GPU, TPU) +- ``communication`` - Communication interfaces +- ``power`` - Power management + +Example +~~~~~~~ + +.. code-block:: yaml + + components: + - id: main-computer + name: "Main Computer" + type: "controller" + area: control + description: "Raspberry Pi 4 running ROS 2" + variant: "rpi4-8gb" + subcomponents: + - id: gpu-unit + name: "GPU Processing Unit" + type: "accelerator" + + - id: lidar-sensor + name: "LiDAR Sensor" + type: "sensor" + area: perception + description: "360° laser range finder" + tags: + - safety-critical + - realtime + + - id: imu-sensor + name: "IMU Sensor" + type: "sensor" + area: perception + +Apps +---- + +Apps represent software applications, typically mapping 1:1 to ROS 2 nodes. +Apps exist only in manifest and hybrid modes. + +Schema +~~~~~~ + +.. code-block:: yaml + + apps: + - id: string # Required - unique identifier + name: string # Required - human-readable name + category: string # Optional - classification + is_located_on: string # Optional - component ID + depends_on: [string] # Optional - app IDs this app depends on + description: string # Optional - detailed description + tags: [string] # Optional - tags for filtering + translation_id: string # Optional - i18n key + external: boolean # Optional - not a ROS node (default: false) + + ros_binding: # Required for hybrid mode linking + node_name: string # Required - ROS node name + namespace: string # Optional - namespace (default: /) + topic_namespace: string # Optional - match by topic prefix + +Fields +~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 15 10 55 + + * - Field + - Type + - Required + - Description + * - ``id`` + - string + - Yes + - Unique identifier + * - ``name`` + - string + - Yes + - Human-readable name + * - ``category`` + - string + - No + - Classification for filtering + * - ``is_located_on`` + - string + - No + - Component ID where app runs + * - ``depends_on`` + - [string] + - No + - List of app IDs this app depends on + * - ``description`` + - string + - No + - Detailed description + * - ``tags`` + - [string] + - No + - Tags for filtering + * - ``translation_id`` + - string + - No + - Internationalization key + * - ``external`` + - boolean + - No + - True if not a ROS node (default: false) + +ros_binding Fields +~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 22 15 10 53 + + * - Field + - Type + - Required + - Description + * - ``node_name`` + - string + - Yes* + - ROS 2 node name to bind to + * - ``namespace`` + - string + - No + - Namespace ("*" for wildcard) + * - ``topic_namespace`` + - string + - Yes* + - Alternative: match by topic prefix + +\* Either ``node_name`` or ``topic_namespace`` is required. + +**Matching behavior:** + +1. **Exact match** (default): ``node_name`` and ``namespace`` must match exactly +2. **Wildcard namespace**: Set ``namespace: "*"`` to match node in any namespace +3. **Topic namespace**: Match nodes by their published topic prefix + +Example +~~~~~~~ + +.. code-block:: yaml + + apps: + # Match by exact node name and namespace + - id: lidar-driver + name: "LiDAR Driver" + is_located_on: lidar-sensor + ros_binding: + node_name: velodyne_driver + namespace: /sensors + + # Match node in any namespace + - id: camera-driver + name: "Camera Driver" + ros_binding: + node_name: usb_cam + namespace: "*" + + # Match by topic namespace + - id: perception-pipeline + name: "Perception Pipeline" + ros_binding: + topic_namespace: /perception + + # App with dependencies + - id: slam-node + name: "SLAM Node" + category: "localization" + is_located_on: main-computer + depends_on: + - lidar-driver + - imu-driver + ros_binding: + node_name: slam_toolbox + namespace: /mapping + + # External app (not a ROS node) + - id: cloud-connector + name: "Cloud Connector" + external: true + description: "External cloud service integration" + +Functions +--------- + +Functions represent high-level capabilities spanning multiple apps. +Functions are always manifest-defined and aggregate data from their host apps. + +Schema +~~~~~~ + +.. code-block:: yaml + + functions: + - id: string # Required - unique identifier + name: string # Required - human-readable name + category: string # Optional - classification + hosted_by: [string] # Required - list of app IDs + depends_on: [string] # Optional - function IDs + description: string # Optional - detailed description + tags: [string] # Optional - tags for filtering + translation_id: string # Optional - i18n key + +Fields +~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 15 10 55 + + * - Field + - Type + - Required + - Description + * - ``id`` + - string + - Yes + - Unique identifier + * - ``name`` + - string + - Yes + - Human-readable name + * - ``hosted_by`` + - [string] + - Yes + - List of app IDs that implement this function + * - ``category`` + - string + - No + - Classification for filtering + * - ``depends_on`` + - [string] + - No + - List of function IDs this function depends on + * - ``description`` + - string + - No + - Detailed description + * - ``tags`` + - [string] + - No + - Tags for filtering + * - ``translation_id`` + - string + - No + - Internationalization key + +Function Capabilities +~~~~~~~~~~~~~~~~~~~~~ + +Functions aggregate capabilities from their host apps: + +- **Data**: Combined topics from all host apps +- **Operations**: Combined services/actions from all host apps +- **Faults**: Faults from all host apps + +Example +~~~~~~~ + +.. code-block:: yaml + + functions: + - id: autonomous-navigation + name: "Autonomous Navigation" + category: "mobility" + description: "Complete autonomous navigation capability" + tags: + - safety-critical + - autonomous + hosted_by: + - amcl-node + - planner-server + - controller-server + - bt-navigator + + - id: localization + name: "Localization" + category: "state-estimation" + hosted_by: + - amcl-node + - map-server + + - id: perception + name: "Environment Perception" + category: "sensing" + hosted_by: + - lidar-driver + - camera-driver + - point-cloud-processor + +Complete Example +---------------- + +Here's a complete manifest for a TurtleBot3 robot: + +.. code-block:: yaml + + manifest_version: "1.0" + + metadata: + name: "turtlebot3-nav2" + version: "2.0.0" + description: "TurtleBot3 with Nav2 navigation stack" + + config: + unmanifested_nodes: warn + inherit_runtime_resources: true + + areas: + - id: perception + name: "Perception" + category: "sensor-processing" + + - id: navigation + name: "Navigation" + category: "motion-planning" + + - id: control + name: "Control" + category: "motion-control" + + components: + - id: lidar-sensor + name: "LiDAR Sensor" + type: "sensor" + area: perception + + - id: main-computer + name: "Main Computer" + type: "controller" + area: control + + apps: + - id: lidar-driver + name: "LiDAR Driver" + is_located_on: lidar-sensor + ros_binding: + node_name: ld08_driver + + - id: amcl-node + name: "AMCL Localization" + category: "localization" + is_located_on: main-computer + ros_binding: + node_name: amcl + + - id: planner-server + name: "Planner Server" + category: "navigation" + is_located_on: main-computer + depends_on: + - amcl-node + ros_binding: + node_name: planner_server + + functions: + - id: autonomous-navigation + name: "Autonomous Navigation" + category: "mobility" + hosted_by: + - amcl-node + - planner-server + +Validation +---------- + +Manifests are validated during loading. The validator checks: + +**Required fields:** + +- ``manifest_version`` must be present and equal to "1.0" +- All entities must have ``id`` and ``name`` +- Apps with ``ros_binding`` must have ``node_name`` or ``topic_namespace`` +- Functions must have at least one entry in ``hosted_by`` + +**References:** + +- ``area`` references must point to valid area IDs +- ``is_located_on`` must point to valid component IDs +- ``depends_on`` must point to valid app/function IDs +- ``hosted_by`` must point to valid app IDs + +**Uniqueness:** + +- All entity IDs must be unique within their type +- IDs should be unique across all types (warning) + +**Format:** + +- IDs should contain only alphanumeric characters and hyphens +- IDs should not start with numbers + +Validation errors are reported with the path to the invalid field: + +.. code-block:: text + + Validation error at apps[2].ros_binding: 'node_name' or 'topic_namespace' required + Validation error at functions[0].hosted_by[1]: App 'unknown-app' not found + +.. seealso:: + + - :doc:`/tutorials/manifest-discovery` - User guide for manifest-based discovery + - :doc:`/tutorials/migration-to-manifest` - Migration guide diff --git a/docs/index.rst b/docs/index.rst index 8ca63d1..5ad2d49 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -70,6 +70,12 @@ Community design/index roadmap +.. toctree:: + :maxdepth: 2 + :caption: Configuration + + config/index + .. toctree:: :maxdepth: 2 :caption: API Reference diff --git a/docs/tutorials/custom_areas.rst b/docs/tutorials/custom_areas.rst index 61c5a4e..62c2c46 100644 --- a/docs/tutorials/custom_areas.rst +++ b/docs/tutorials/custom_areas.rst @@ -270,5 +270,4 @@ See Also -------- - :doc:`integration` - Basic integration guide -- `SOVD Standard `_ - AUTOSAR SOVD specification - `REP-149 `_ - ROS 2 package naming conventions diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 5a3273e..edfdf37 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -6,6 +6,8 @@ Step-by-step guides for common use cases with ros2_medkit. .. toctree:: :maxdepth: 1 + manifest-discovery + migration-to-manifest authentication https snapshots @@ -14,6 +16,17 @@ Step-by-step guides for common use cases with ros2_medkit. integration custom_areas +Manifest Discovery +------------------ + +:doc:`manifest-discovery` + Use YAML manifests to define your ROS 2 system structure with stable IDs, + semantic groupings, and offline detection. + +:doc:`migration-to-manifest` + Migrate from runtime-only discovery to hybrid mode for better control + over entity organization. + Basic Tutorials --------------- diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst new file mode 100644 index 0000000..a625192 --- /dev/null +++ b/docs/tutorials/manifest-discovery.rst @@ -0,0 +1,422 @@ +Manifest-Based Discovery +======================== + +This tutorial explains how to use manifest files to define your ROS 2 system +structure for SOVD discovery. + +Introduction +------------ + +The ros2_medkit gateway supports three discovery modes: + +1. **Runtime-Only** (default): Traditional ROS 2 graph introspection +2. **Hybrid** (recommended): Manifest as source of truth + runtime linking +3. **Manifest-Only**: Only expose manifest-declared entities + +Hybrid mode is recommended because it: + +- Provides **stable entity IDs** that don't change across restarts +- Enables **semantic groupings** (areas, functions) for better organization +- Preserves **runtime data** (topic values, service calls) +- Allows **documentation and metadata** in entity definitions +- Supports **offline detection** for apps not currently running + +Discovery Modes Comparison +-------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 30 30 20 + + * - Feature + - Runtime-Only + - Hybrid + - Manifest-Only + * - Entity IDs + - Derived from ROS graph + - Stable (from manifest) + - Stable (from manifest) + * - Live topics/services + - ✅ Yes + - ✅ Yes + - ❌ No + * - Custom areas + - ❌ No + - ✅ Yes + - ✅ Yes + * - Apps entity type + - ❌ No + - ✅ Yes + - ✅ Yes + * - Functions entity type + - ❌ No + - ✅ Yes + - ✅ Yes + * - Offline detection + - ❌ No + - ✅ Yes + - N/A + +Writing Your First Manifest +--------------------------- + +Create a file named ``system_manifest.yaml``: + +.. code-block:: yaml + + # SOVD System Manifest + manifest_version: "1.0" + + metadata: + name: "my-robot" + version: "1.0.0" + description: "My Robot System" + + # Define logical areas (subsystems) + areas: + - id: perception + name: "Perception" + category: "sensor-processing" + description: "Sensor data processing and fusion" + + # Define hardware/virtual components + components: + - id: lidar-sensor + name: "LiDAR Sensor" + type: "sensor" + area: perception + + # Define apps (map to ROS 2 nodes) + apps: + - id: lidar-driver + name: "LiDAR Driver" + is_located_on: lidar-sensor + ros_binding: + node_name: velodyne_driver + namespace: /sensors + + # Define high-level functions + functions: + - id: environment-sensing + name: "Environment Sensing" + hosted_by: + - lidar-driver + +Enabling Manifest Mode +---------------------- + +Option 1: Using Launch File +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from launch import LaunchDescription + from launch_ros.actions import Node + + def generate_launch_description(): + return LaunchDescription([ + Node( + package='ros2_medkit_gateway', + executable='gateway_node', + parameters=[{ + 'manifest.enabled': True, + 'manifest.file_path': '/path/to/system_manifest.yaml', + 'manifest.mode': 'hybrid', + }] + ) + ]) + +Option 2: Using Parameter File +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add to your ``gateway_params.yaml``: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + # ... existing parameters ... + + manifest: + # Enable manifest-based discovery + enabled: true + + # Path to manifest YAML file + file_path: "/path/to/system_manifest.yaml" + + # Discovery mode: "runtime_only", "hybrid", or "manifest_only" + mode: "hybrid" + + # Strict validation: fail on any validation error + strict_validation: true + +Then launch with: + +.. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node \ + --ros-args --params-file /path/to/gateway_params.yaml + +Option 3: Command Line Parameters +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p manifest.enabled:=true \ + -p manifest.file_path:=/path/to/system_manifest.yaml \ + -p manifest.mode:=hybrid + +Verifying the Configuration +--------------------------- + +Once the gateway is running with a manifest, you can verify the configuration +via the REST API. + +Check manifest status: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/manifest/status + +Expected response: + +.. code-block:: json + + { + "status": "active", + "discovery_mode": "hybrid", + "manifest_path": "/path/to/system_manifest.yaml", + "statistics": { + "areas_count": 1, + "components_count": 1, + "apps_count": 1, + "functions_count": 1 + } + } + +List apps: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/apps + +List functions: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/functions + +Understanding Runtime Linking +----------------------------- + +In hybrid mode, manifest apps are automatically linked to running ROS 2 nodes. +The linking process: + +1. **Discovery**: Gateway discovers running ROS 2 nodes +2. **Matching**: For each manifest app, checks ``ros_binding`` configuration +3. **Linking**: If match found, copies runtime resources (topics, services, actions) +4. **Status**: Apps with matched nodes are marked ``is_online: true`` + +ROS Binding Configuration +~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``ros_binding`` section specifies how to match an app to a ROS 2 node: + +.. code-block:: yaml + + ros_binding: + # Match by node name + node_name: "velodyne_driver" + + # Match within a specific namespace (optional) + namespace: "/sensors" + + # Or match by topic namespace prefix (alternative) + topic_namespace: "/sensors/lidar" + +**Match Types:** + +- **Exact match** (default): Node name and namespace must match exactly +- **Wildcard namespace**: Use ``namespace: "*"`` to match any namespace +- **Topic namespace**: Match nodes by their topic prefix + +.. code-block:: yaml + + # Example: Match node in any namespace + apps: + - id: camera-driver + name: "Camera Driver" + ros_binding: + node_name: usb_cam + namespace: "*" + + # Example: Match by topic namespace + apps: + - id: lidar-processing + name: "LiDAR Processing" + ros_binding: + topic_namespace: "/perception/lidar" + +Checking Linking Status +~~~~~~~~~~~~~~~~~~~~~~~ + +Check which apps are online: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/apps | jq '.[] | {id, name, is_online}' + +Example response: + +.. code-block:: json + + {"id": "lidar-driver", "name": "LiDAR Driver", "is_online": true} + {"id": "camera-driver", "name": "Camera Driver", "is_online": false} + +Entity Hierarchy +---------------- + +The manifest defines a hierarchical structure: + +.. code-block:: text + + Areas (logical/physical groupings) + └── Components (hardware/virtual units) + └── Apps (software applications) + └── Data (topics) + └── Operations (services/actions) + └── Configurations (parameters) + + Functions (cross-cutting capabilities) + └── Apps (hosted by) + +**Areas** group related components by function or location: + +.. code-block:: yaml + + areas: + - id: perception + name: "Perception Subsystem" + subareas: + - id: lidar-processing + name: "LiDAR Processing" + +**Components** represent hardware or virtual units: + +.. code-block:: yaml + + components: + - id: main-computer + name: "Main Computer" + type: "controller" + area: perception + subcomponents: + - id: gpu-unit + name: "GPU Processing Unit" + +**Apps** are software applications (typically ROS 2 nodes): + +.. code-block:: yaml + + apps: + - id: slam-node + name: "SLAM Node" + is_located_on: main-computer + depends_on: + - lidar-driver + - imu-driver + ros_binding: + node_name: slam_toolbox + +**Functions** are high-level capabilities spanning multiple apps: + +.. code-block:: yaml + + functions: + - id: autonomous-navigation + name: "Autonomous Navigation" + hosted_by: + - planner-node + - controller-node + - localization-node + +Handling Unmanifested Nodes +--------------------------- + +In hybrid mode, the gateway may discover ROS 2 nodes that aren't declared +in the manifest. The ``config.unmanifested_nodes`` setting controls this: + +.. code-block:: yaml + + config: + # Options: ignore, warn, error, include_as_orphan + unmanifested_nodes: warn + +**Policies:** + +- ``ignore``: Don't expose unmanifested nodes at all +- ``warn`` (default): Log warning, include nodes as orphans +- ``error``: Fail startup if orphan nodes detected +- ``include_as_orphan``: Include with ``source: "orphan"`` + +Hot Reloading +------------- + +Reload the manifest without restarting the gateway: + +.. code-block:: bash + + curl -X POST http://localhost:8080/api/v1/manifest/reload + +This re-parses the manifest file and re-links apps to nodes. + +REST API Endpoints +------------------ + +Manifest mode adds the following endpoints: + +.. list-table:: + :header-rows: 1 + :widths: 30 50 20 + + * - Endpoint + - Description + - Since + * - ``GET /apps`` + - List all apps + - 0.2.0 + * - ``GET /apps/{id}`` + - Get app capabilities + - 0.2.0 + * - ``GET /apps/{id}/data`` + - Get app topic data + - 0.2.0 + * - ``GET /apps/{id}/operations`` + - List app operations + - 0.2.0 + * - ``GET /apps/{id}/configurations`` + - List app configurations + - 0.2.0 + * - ``GET /functions`` + - List all functions + - 0.2.0 + * - ``GET /functions/{id}`` + - Get function capabilities + - 0.2.0 + * - ``GET /functions/{id}/hosts`` + - List function host apps + - 0.2.0 + * - ``GET /functions/{id}/data`` + - Aggregated data from hosts + - 0.2.0 + * - ``GET /functions/{id}/operations`` + - Aggregated operations + - 0.2.0 + +Next Steps +---------- + +- :doc:`/config/manifest-schema` - Complete YAML schema reference +- :doc:`migration-to-manifest` - Migrate from runtime-only mode +- :doc:`/getting_started` - Basic gateway setup diff --git a/docs/tutorials/migration-to-manifest.rst b/docs/tutorials/migration-to-manifest.rst new file mode 100644 index 0000000..e2404bc --- /dev/null +++ b/docs/tutorials/migration-to-manifest.rst @@ -0,0 +1,463 @@ +Migration Guide: Runtime to Hybrid Mode +======================================== + +This guide helps you migrate from runtime-only discovery to hybrid mode, +enabling stable entity IDs, semantic groupings, and offline detection. + +.. contents:: Table of Contents + :local: + :depth: 2 + +Overview +-------- + +**Runtime-only mode** (default) automatically discovers: + +- ROS 2 nodes → Components +- ROS 2 namespaces → Areas +- Topics, services, actions → Data, operations + +**Hybrid mode** adds: + +- Stable, meaningful entity IDs +- Apps entity type (software applications) +- Functions entity type (high-level capabilities) +- Offline detection for apps +- Custom metadata and descriptions + +The migration process creates a manifest that maps your existing ROS 2 nodes +to the SOVD entity model while preserving runtime data access. + +Step 1: Audit Your System +------------------------- + +First, understand your current ROS 2 system structure. + +List all running nodes: + +.. code-block:: bash + + ros2 node list + +Example output: + +.. code-block:: text + + /amcl + /bt_navigator + /controller_server + /ld08_driver + /map_server + /planner_server + /robot_state_publisher + /turtlebot3_node + +For each node, get detailed info: + +.. code-block:: bash + + ros2 node info /amcl + +Note down: + +- Node name and namespace +- Published/subscribed topics +- Provided services and actions +- Purpose and category + +Create a spreadsheet or document with columns: + +.. list-table:: + :header-rows: 1 + :widths: 20 20 20 40 + + * - Node Name + - Namespace + - Category + - Purpose + * - amcl + - / + - localization + - Adaptive Monte Carlo Localization + * - planner_server + - / + - navigation + - Global path planning + * - controller_server + - / + - navigation + - Local trajectory tracking + * - ... + - ... + - ... + - ... + +Step 2: Define Areas +-------------------- + +Group your nodes into logical subsystems. Common groupings: + +- **By function**: perception, localization, navigation, control +- **By hardware**: sensors, actuators, compute +- **By namespace**: ROS 2 namespace structure + +Example area mapping: + +.. code-block:: yaml + + areas: + - id: perception + name: "Perception" + category: "sensor-processing" + description: "Sensor data acquisition and processing" + + - id: localization + name: "Localization" + category: "state-estimation" + description: "Robot pose estimation" + + - id: navigation + name: "Navigation" + category: "motion-planning" + description: "Path planning and execution" + + - id: control + name: "Control" + category: "motion-control" + description: "Low-level motor control" + +.. tip:: + + Start with broad categories and refine later. You can nest areas using + ``subareas`` if needed. + +Step 3: Define Components +------------------------- + +Map physical and virtual hardware. Components represent: + +- Physical sensors (LiDAR, cameras, IMU) +- Actuators (motors, grippers) +- Compute units (main computer, edge devices) +- Virtual units (containers, processes) + +Example component mapping: + +.. code-block:: yaml + + components: + - id: lidar-sensor + name: "LiDAR Sensor" + type: "sensor" + area: perception + description: "360° laser range finder" + + - id: main-computer + name: "Main Computer" + type: "controller" + area: control + description: "Raspberry Pi 4 running ROS 2" + + - id: opencr-board + name: "OpenCR Board" + type: "controller" + area: control + description: "Motor controller board" + +Step 4: Define Apps +------------------- + +Create an app entry for each ROS 2 node. Key decisions: + +1. **Choose a stable ID**: Use lowercase with hyphens (e.g., ``lidar-driver``) +2. **Set human-readable name**: Clear, descriptive name +3. **Configure ros_binding**: How to match the ROS 2 node +4. **Set component**: Which component hosts this app +5. **Set depends_on**: Dependencies on other apps + +Example app mapping: + +.. code-block:: yaml + + apps: + # Node: /ld08_driver + - id: lidar-driver + name: "LiDAR Driver" + category: "driver" + component: lidar-sensor + ros_binding: + node_name: ld08_driver + + # Node: /amcl + - id: amcl-node + name: "AMCL Localization" + category: "localization" + component: main-computer + depends_on: + - lidar-driver + ros_binding: + node_name: amcl + + # Node: /planner_server + - id: planner-server + name: "Planner Server" + category: "navigation" + component: main-computer + depends_on: + - amcl-node + ros_binding: + node_name: planner_server + +**Handling namespaced nodes:** + +.. code-block:: yaml + + # Node: /robot1/lidar_driver + - id: robot1-lidar-driver + name: "Robot 1 LiDAR Driver" + ros_binding: + node_name: lidar_driver + namespace: /robot1 + + # Match node in any namespace + - id: generic-teleop + name: "Teleop Controller" + ros_binding: + node_name: teleop_keyboard + namespace: "*" + +Step 5: Define Functions +------------------------ + +Identify high-level capabilities your robot provides: + +- What can your robot do as a user? +- Which apps work together for each capability? + +Example function definitions: + +.. code-block:: yaml + + functions: + - id: autonomous-navigation + name: "Autonomous Navigation" + category: "mobility" + description: "Navigate autonomously to goals" + hosted_by: + - amcl-node + - planner-server + - controller-server + - bt-navigator + + - id: localization + name: "Localization" + category: "state-estimation" + description: "Determine robot position on map" + hosted_by: + - amcl-node + - map-server + + - id: perception + name: "Environment Perception" + category: "sensing" + hosted_by: + - lidar-driver + +Step 6: Create the Manifest File +-------------------------------- + +Combine all sections into a complete manifest: + +.. code-block:: yaml + + manifest_version: "1.0" + + metadata: + name: "my-robot" + version: "1.0.0" + description: "My robot system manifest" + + config: + unmanifested_nodes: warn + inherit_runtime_resources: true + + areas: + # ... your area definitions + + components: + # ... your component definitions + + apps: + # ... your app definitions + + functions: + # ... your function definitions + +Save as ``system_manifest.yaml`` in your config directory. + +Step 7: Test in Hybrid Mode +--------------------------- + +1. **Start your ROS 2 system** as normal + +2. **Start the gateway with manifest**: + + .. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p manifest.enabled:=true \ + -p manifest.file_path:=/path/to/system_manifest.yaml \ + -p manifest.mode:=hybrid + +3. **Check manifest status**: + + .. code-block:: bash + + curl http://localhost:8080/api/v1/manifest/status | jq + +4. **Verify apps are linked**: + + .. code-block:: bash + + curl http://localhost:8080/api/v1/apps | jq '.[] | {id, name, is_online}' + +5. **Check for orphan nodes** (warnings in gateway logs): + + .. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node ... 2>&1 | grep -i orphan + +Step 8: Iterate and Refine +-------------------------- + +Based on testing results: + +**App not linking to node:** + +- Check ``ros_binding.node_name`` spelling +- Verify namespace matches (use ``ros2 node list`` to confirm) +- Try wildcard namespace: ``namespace: "*"`` +- Check gateway logs for matching attempts + +**Orphan nodes appearing:** + +- Add app entries for missing nodes +- Or set ``config.unmanifested_nodes: ignore`` to hide them + +**Missing data/operations:** + +- Ensure ``config.inherit_runtime_resources: true`` +- Check that the bound node has the expected topics/services + +**Refining the manifest:** + +- Add descriptions to improve documentation +- Add tags for filtering +- Adjust categories for better organization +- Define dependencies between apps + +Validation Checklist +-------------------- + +Before finalizing your manifest: + +.. code-block:: text + + [ ] All required fields present (manifest_version, entity ids and names) + [ ] All area references are valid (component.area, subarea.parent) + [ ] All component references are valid (app.component) + [ ] All app references are valid (depends_on, function.hosted_by) + [ ] IDs are unique within each entity type + [ ] ros_binding configured for all apps that map to ROS nodes + [ ] Functions have at least one app in hosted_by + +Run validation: + +.. code-block:: bash + + # Start gateway with strict validation + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p manifest.enabled:=true \ + -p manifest.file_path:=/path/to/system_manifest.yaml \ + -p manifest.strict_validation:=true + +Common Issues +------------- + +**"App X not found for function Y"** + +The ``hosted_by`` list references an app ID that doesn't exist. + +.. code-block:: yaml + + # Wrong - typo in app ID + functions: + - id: nav-function + hosted_by: + - amcl_node # Should be "amcl-node" + +**"Component X not found for app Y"** + +The ``component`` references a component that doesn't exist. + +.. code-block:: yaml + + # Wrong - component doesn't exist + apps: + - id: my-app + component: nonexistent-component + +**"Duplicate ID: X"** + +Two entities have the same ID. + +.. code-block:: yaml + + # Wrong - duplicate IDs + components: + - id: sensor + name: "LiDAR" + - id: sensor # Duplicate! + name: "Camera" + +**Node not linking (is_online: false)** + +Common causes: + +1. Node name mismatch (check exact spelling) +2. Namespace mismatch (try wildcard) +3. Node not running when gateway starts +4. Node in different ROS domain + +Debug with: + +.. code-block:: bash + + # List actual nodes + ros2 node list + + # Check gateway logs + ros2 run ros2_medkit_gateway gateway_node ... 2>&1 | grep -i link + +Best Practices +-------------- + +1. **Start simple**: Begin with just apps and expand to functions later + +2. **Use consistent naming**: Follow a pattern for IDs (e.g., ``{component}-{function}``) + +3. **Document as you go**: Add descriptions while the context is fresh + +4. **Version your manifest**: Use semantic versioning in metadata + +5. **Keep manifest in source control**: Track changes alongside code + +6. **Test incrementally**: Add a few entities, test, repeat + +7. **Use validation**: Always run with ``strict_validation: true`` initially + +Next Steps +---------- + +- :doc:`manifest-discovery` - Complete user guide +- :doc:`/config/manifest-schema` - Full schema reference diff --git a/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp b/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp index c187a85..2612636 100644 --- a/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp +++ b/src/ros2_medkit_fault_manager/src/fault_manager_node.cpp @@ -304,8 +304,8 @@ SnapshotConfig FaultManagerNode::create_snapshot_config() { // Validate max_message_size (must be positive before casting to size_t) auto max_message_size_param = declare_parameter("snapshots.max_message_size", 65536); if (max_message_size_param <= 0) { - RCLCPP_WARN(get_logger(), "snapshots.max_message_size must be positive, got %d. Using default 65536", - max_message_size_param); + RCLCPP_WARN(get_logger(), "snapshots.max_message_size must be positive, got %ld. Using default 65536", + static_cast(max_message_size_param)); max_message_size_param = 65536; } config.max_message_size = static_cast(max_message_size_param); diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index acd86f9..7abd75f 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -103,21 +103,37 @@ include_directories(include) add_library(gateway_lib STATIC src/config.cpp src/gateway_node.cpp - src/discovery_manager.cpp src/data_access_manager.cpp src/type_introspection.cpp src/native_topic_sampler.cpp src/operation_manager.cpp src/configuration_manager.cpp src/fault_manager.cpp + # Discovery module + src/discovery/discovery_manager.cpp + src/discovery/runtime_discovery.cpp + src/discovery/hybrid_discovery.cpp + # Discovery models (with .cpp serialization) + src/discovery/models/app.cpp + src/discovery/models/function.cpp + # Manifest parser, validator and manager + src/discovery/manifest/manifest_parser.cpp + src/discovery/manifest/manifest_validator.cpp + src/discovery/manifest/manifest_manager.cpp + src/discovery/manifest/runtime_linker.cpp # HTTP module (subfolder) src/http/http_server.cpp src/http/rest_server.cpp - # HTTP handlers (subfolder) + # HTTP handlers - discovery + src/http/handlers/discovery/area_handlers.cpp + src/http/handlers/discovery/component_handlers.cpp + src/http/handlers/discovery/app_handlers.cpp + src/http/handlers/discovery/function_handlers.cpp + # HTTP handlers - utilities + src/http/handlers/capability_builder.cpp + # HTTP handlers - other src/http/handlers/handler_context.cpp src/http/handlers/health_handlers.cpp - src/http/handlers/area_handlers.cpp - src/http/handlers/component_handlers.cpp src/http/handlers/operation_handlers.cpp src/http/handlers/config_handlers.cpp src/http/handlers/fault_handlers.cpp @@ -212,7 +228,7 @@ if(BUILD_TESTING) "${CMAKE_CURRENT_BINARY_DIR}" CONFIG_FILE "${ament_cmake_clang_tidy_CONFIG_FILE}" HEADER_FILTER "${ament_cmake_clang_tidy_HEADER_FILTER}" - TIMEOUT 600 + TIMEOUT 1200 ) # Add GTest @@ -250,6 +266,30 @@ if(BUILD_TESTING) target_link_libraries(test_fault_manager gateway_lib) ament_target_dependencies(test_fault_manager rclcpp ros2_medkit_msgs) + # Add discovery models tests + ament_add_gtest(test_discovery_models test/test_discovery_models.cpp) + target_link_libraries(test_discovery_models gateway_lib) + + # Add manifest parser tests + ament_add_gtest(test_manifest_parser test/test_manifest_parser.cpp) + target_link_libraries(test_manifest_parser gateway_lib) + + # Add manifest validator tests + ament_add_gtest(test_manifest_validator test/test_manifest_validator.cpp) + target_link_libraries(test_manifest_validator gateway_lib) + + # Add manifest manager tests + ament_add_gtest(test_manifest_manager test/test_manifest_manager.cpp) + target_link_libraries(test_manifest_manager gateway_lib) + + # Add runtime linker tests + ament_add_gtest(test_runtime_linker test/test_runtime_linker.cpp) + target_link_libraries(test_runtime_linker gateway_lib) + + # Add capability builder tests + ament_add_gtest(test_capability_builder test/test_capability_builder.cpp) + target_link_libraries(test_capability_builder gateway_lib) + # Apply coverage flags to test targets if(ENABLE_COVERAGE) target_compile_options(test_gateway_node PRIVATE --coverage -O0 -g) @@ -268,6 +308,12 @@ if(BUILD_TESTING) target_link_options(test_tls_config PRIVATE --coverage) target_compile_options(test_fault_manager PRIVATE --coverage -O0 -g) target_link_options(test_fault_manager PRIVATE --coverage) + target_compile_options(test_discovery_models PRIVATE --coverage -O0 -g) + target_link_options(test_discovery_models PRIVATE --coverage) + target_compile_options(test_manifest_parser PRIVATE --coverage -O0 -g) + target_link_options(test_manifest_parser PRIVATE --coverage) + target_compile_options(test_manifest_validator PRIVATE --coverage -O0 -g) + target_link_options(test_manifest_validator PRIVATE --coverage) endif() # Integration testing @@ -307,6 +353,20 @@ if(BUILD_TESTING) TIMEOUT 120 ) + # Add manifest-only discovery integration tests + add_launch_test( + test/test_discovery_manifest.test.py + TARGET test_discovery_manifest + TIMEOUT 120 + ) + + # Add hybrid discovery integration tests + add_launch_test( + test/test_discovery_hybrid.test.py + TARGET test_discovery_hybrid + 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 new file mode 100644 index 0000000..fde9545 --- /dev/null +++ b/src/ros2_medkit_gateway/config/examples/demo_nodes_manifest.yaml @@ -0,0 +1,269 @@ +# SOVD System Manifest: Demo Nodes for Integration Testing +# ======================================================== +# This manifest describes the demo nodes used for ros2_medkit gateway +# integration testing. It models an automotive system with subsystems. +# +# Use with: ros2 launch ros2_medkit_gateway demo_nodes.launch.py + +manifest_version: "1.0" + +metadata: + name: "demo-vehicle" + version: "1.0.0" + description: "Demo vehicle system for integration testing" + +config: + # Fail on orphan nodes during testing to catch manifest mismatches + unmanifested_nodes: warn + inherit_runtime_resources: true + +# ============================================================================= +# AREAS - Vehicle subsystems (matches demo_nodes.launch.py hierarchy) +# ============================================================================= +areas: + - id: powertrain + name: "Powertrain" + category: "propulsion" + namespace: /powertrain + description: "Engine and transmission systems" + subareas: + - id: engine + name: "Engine" + namespace: /powertrain/engine + description: "Engine sensors and calibration services" + + - id: chassis + name: "Chassis" + category: "vehicle-dynamics" + namespace: /chassis + description: "Braking and suspension systems" + subareas: + - id: brakes + name: "Brakes" + namespace: /chassis/brakes + description: "Brake sensors and actuators" + + - id: body + name: "Body" + category: "comfort" + namespace: /body + description: "Body electronics and comfort systems" + subareas: + - id: door + name: "Door" + namespace: /body/door + description: "Door sensors and actuators" + subareas: + - id: front-left-door + name: "Front Left Door" + namespace: /body/door/front_left + + - id: lights + name: "Lights" + namespace: /body/lights + description: "Interior and exterior lighting" + + - id: perception + name: "Perception" + category: "adas" + namespace: /perception + description: "ADAS sensors and processing" + subareas: + - id: lidar + name: "LiDAR" + namespace: /perception/lidar + description: "LiDAR sensor system" + +# ============================================================================= +# COMPONENTS - Hardware units +# ============================================================================= +components: + # Powertrain components + - id: engine-ecu + name: "Engine ECU" + type: "controller" + area: engine + description: "Engine control unit" + + - id: temp-sensor-hw + name: "Temperature Sensor" + type: "sensor" + area: engine + + - id: rpm-sensor-hw + name: "RPM Sensor" + type: "sensor" + area: engine + + # Chassis components + - id: brake-ecu + name: "Brake ECU" + type: "controller" + area: brakes + + - id: brake-pressure-sensor-hw + name: "Brake Pressure Sensor" + type: "sensor" + area: brakes + + - id: brake-actuator-hw + name: "Brake Actuator" + type: "actuator" + area: brakes + + # Body components + - id: door-sensor-hw + name: "Door Sensor" + type: "sensor" + area: front-left-door + + - id: light-module + name: "Light Module" + type: "actuator" + area: lights + + # Perception components + - id: lidar-unit + name: "LiDAR Unit" + type: "sensor" + area: lidar + description: "360° laser scanner with fault reporting" + +# ============================================================================= +# APPS - ROS 2 nodes (matches demo_nodes.launch.py exactly) +# ============================================================================= +apps: + # === Powertrain/Engine Apps === + - id: engine-temp-sensor + name: "Engine Temperature Sensor" + category: "sensor" + is_located_on: temp-sensor-hw + description: "Publishes engine temperature readings" + ros_binding: + node_name: temp_sensor + namespace: /powertrain/engine + + - id: engine-rpm-sensor + name: "Engine RPM Sensor" + category: "sensor" + is_located_on: rpm-sensor-hw + description: "Publishes engine RPM readings" + ros_binding: + node_name: rpm_sensor + namespace: /powertrain/engine + + - id: engine-calibration-service + name: "Engine Calibration Service" + category: "service" + is_located_on: engine-ecu + description: "Provides synchronous calibration service (Trigger)" + ros_binding: + node_name: calibration + namespace: /powertrain/engine + + - id: engine-long-calibration + name: "Engine Long Calibration" + category: "action" + is_located_on: engine-ecu + description: "Provides async long-running calibration action (Fibonacci)" + depends_on: + - engine-calibration-service + ros_binding: + node_name: long_calibration + namespace: /powertrain/engine + + # === Chassis/Brakes Apps === + - id: brake-pressure-sensor + name: "Brake Pressure Sensor" + category: "sensor" + is_located_on: brake-pressure-sensor-hw + description: "Publishes brake pressure readings" + ros_binding: + node_name: pressure_sensor + namespace: /chassis/brakes + + - id: brake-actuator + name: "Brake Actuator" + category: "actuator" + is_located_on: brake-actuator-hw + description: "Controls brake pressure via command topic" + ros_binding: + node_name: actuator + namespace: /chassis/brakes + + # === Body Apps === + - id: door-status-sensor + name: "Door Status Sensor" + category: "sensor" + is_located_on: door-sensor-hw + description: "Publishes door open/closed status" + ros_binding: + node_name: status_sensor + namespace: /body/door/front_left + + - id: light-controller + name: "Light Controller" + category: "actuator" + is_located_on: light-module + description: "Controls lights via command topic" + ros_binding: + node_name: controller + namespace: /body/lights + + # === Perception Apps === + - id: lidar-sensor + name: "LiDAR Sensor" + category: "sensor" + is_located_on: lidar-unit + description: "LiDAR with fault reporting and calibration service" + tags: + - fault-reporter + - safety-critical + ros_binding: + node_name: lidar_sensor + namespace: /perception/lidar + +# ============================================================================= +# FUNCTIONS - High-level capabilities +# ============================================================================= +functions: + - id: engine-monitoring + name: "Engine Monitoring" + category: "monitoring" + description: "Monitor engine health via temperature and RPM" + hosted_by: + - engine-temp-sensor + - engine-rpm-sensor + + - id: engine-calibration + name: "Engine Calibration" + category: "maintenance" + description: "Engine calibration capabilities (sync and async)" + hosted_by: + - engine-calibration-service + - engine-long-calibration + + - id: brake-system + name: "Brake System" + category: "safety" + description: "Complete brake monitoring and control" + tags: + - safety-critical + hosted_by: + - brake-pressure-sensor + - brake-actuator + + - id: body-electronics + name: "Body Electronics" + category: "comfort" + description: "Door and lighting control" + hosted_by: + - door-status-sensor + - light-controller + + - id: perception-system + name: "Perception System" + category: "adas" + description: "Environment perception via LiDAR" + hosted_by: + - lidar-sensor diff --git a/src/ros2_medkit_gateway/config/examples/minimal_manifest.yaml b/src/ros2_medkit_gateway/config/examples/minimal_manifest.yaml new file mode 100644 index 0000000..3e2ba5a --- /dev/null +++ b/src/ros2_medkit_gateway/config/examples/minimal_manifest.yaml @@ -0,0 +1,33 @@ +# SOVD System Manifest: Minimal Example +# ====================================== +# A minimal manifest demonstrating the basic structure. +# Start with this template and expand as needed. + +manifest_version: "1.0" + +metadata: + name: "minimal-robot" + version: "1.0.0" + description: "Minimal manifest example" + +# Single area for all components +areas: + - id: robot + name: "Robot" + description: "Main robot system" + +# Single component representing the robot +components: + - id: robot-controller + name: "Robot Controller" + type: "controller" + area: robot + +# Apps mapping to ROS 2 nodes +apps: + - id: example-node + name: "Example Node" + is_located_on: robot-controller + ros_binding: + node_name: example_node + namespace: / diff --git a/src/ros2_medkit_gateway/config/examples/multi_robot_manifest.yaml b/src/ros2_medkit_gateway/config/examples/multi_robot_manifest.yaml new file mode 100644 index 0000000..5ddaaa8 --- /dev/null +++ b/src/ros2_medkit_gateway/config/examples/multi_robot_manifest.yaml @@ -0,0 +1,173 @@ +# SOVD System Manifest: Multi-Robot System +# ========================================= +# Example manifest for a multi-robot system with namespaced nodes. +# Demonstrates wildcard namespace matching and hierarchical areas. + +manifest_version: "1.0" + +metadata: + name: "multi-robot-fleet" + version: "1.0.0" + description: "Fleet of mobile robots with central coordinator" + +config: + # Include orphan nodes (useful for discovering new robots) + unmanifested_nodes: include_as_orphan + inherit_runtime_resources: true + +# ============================================================================= +# AREAS - Hierarchical organization +# ============================================================================= +areas: + - id: fleet-management + name: "Fleet Management" + category: "coordination" + description: "Central fleet coordination and task allocation" + + - id: robot-1 + name: "Robot 1" + category: "mobile-robot" + namespace: /robot1 + subareas: + - id: robot-1-perception + name: "Robot 1 Perception" + namespace: /robot1/perception + + - id: robot-1-navigation + name: "Robot 1 Navigation" + namespace: /robot1/navigation + + - id: robot-2 + name: "Robot 2" + category: "mobile-robot" + namespace: /robot2 + subareas: + - id: robot-2-perception + name: "Robot 2 Perception" + namespace: /robot2/perception + + - id: robot-2-navigation + name: "Robot 2 Navigation" + namespace: /robot2/navigation + +# ============================================================================= +# COMPONENTS +# ============================================================================= +components: + # Fleet server (central) + - id: fleet-server + name: "Fleet Server" + type: "controller" + area: fleet-management + description: "Central server for fleet coordination" + + # Robot 1 components + - id: robot-1-computer + name: "Robot 1 Computer" + type: "controller" + area: robot-1 + + - id: robot-1-lidar + name: "Robot 1 LiDAR" + type: "sensor" + area: robot-1-perception + + # Robot 2 components + - id: robot-2-computer + name: "Robot 2 Computer" + type: "controller" + area: robot-2 + + - id: robot-2-lidar + name: "Robot 2 LiDAR" + type: "sensor" + area: robot-2-perception + +# ============================================================================= +# APPS +# ============================================================================= +apps: + # Fleet coordinator + - id: fleet-coordinator + name: "Fleet Coordinator" + category: "coordination" + is_located_on: fleet-server + description: "Coordinates task allocation across robots" + ros_binding: + node_name: fleet_coordinator + namespace: /fleet + + # Robot 1 apps - using explicit namespace + - id: robot-1-lidar-driver + name: "Robot 1 LiDAR Driver" + category: "driver" + is_located_on: robot-1-lidar + ros_binding: + node_name: lidar_driver + namespace: /robot1 + + - id: robot-1-navigation + name: "Robot 1 Navigation" + category: "navigation" + is_located_on: robot-1-computer + depends_on: + - robot-1-lidar-driver + ros_binding: + node_name: navigation + namespace: /robot1 + + # Robot 2 apps - using explicit namespace + - id: robot-2-lidar-driver + name: "Robot 2 LiDAR Driver" + category: "driver" + is_located_on: robot-2-lidar + ros_binding: + node_name: lidar_driver + namespace: /robot2 + + - id: robot-2-navigation + name: "Robot 2 Navigation" + category: "navigation" + is_located_on: robot-2-computer + depends_on: + - robot-2-lidar-driver + ros_binding: + node_name: navigation + namespace: /robot2 + + # Generic apps using wildcard namespace (matches any robot) + # Useful for template definitions + - id: generic-teleop + name: "Teleop Controller" + category: "control" + description: "Matches teleop node in any robot namespace" + ros_binding: + node_name: teleop_keyboard + namespace: "*" + +# ============================================================================= +# FUNCTIONS +# ============================================================================= +functions: + - id: fleet-operations + name: "Fleet Operations" + category: "coordination" + description: "Complete fleet operation capability" + hosted_by: + - fleet-coordinator + - robot-1-navigation + - robot-2-navigation + + - id: robot-1-autonomy + name: "Robot 1 Autonomy" + category: "autonomy" + hosted_by: + - robot-1-lidar-driver + - robot-1-navigation + + - id: robot-2-autonomy + name: "Robot 2 Autonomy" + category: "autonomy" + hosted_by: + - robot-2-lidar-driver + - robot-2-navigation diff --git a/src/ros2_medkit_gateway/config/examples/turtlebot3_manifest.yaml b/src/ros2_medkit_gateway/config/examples/turtlebot3_manifest.yaml new file mode 100644 index 0000000..71d9d50 --- /dev/null +++ b/src/ros2_medkit_gateway/config/examples/turtlebot3_manifest.yaml @@ -0,0 +1,248 @@ +# SOVD System Manifest: TurtleBot3 Navigation +# ============================================ +# This manifest describes a TurtleBot3 robot running the Nav2 navigation stack. +# +# Use this manifest as a template for your own robot systems. +# See documentation: https://ros2-medkit.readthedocs.io/tutorials/manifest-discovery.html + +manifest_version: "1.0" + +metadata: + name: "turtlebot3-nav2" + version: "2.0.0" + description: "TurtleBot3 robot with Nav2 navigation stack for autonomous navigation" + +config: + # Policy for ROS nodes not declared in manifest + # Options: ignore, warn, error, include_as_orphan + unmanifested_nodes: warn + + # Copy topics/services from runtime nodes to manifest apps + inherit_runtime_resources: true + +# ============================================================================= +# AREAS - Logical subsystems +# ============================================================================= +areas: + - id: perception + name: "Perception" + category: "sensor-processing" + description: "Sensor data acquisition and processing" + tags: + - sensors + - realtime + + - id: localization + name: "Localization" + category: "state-estimation" + description: "Robot pose estimation and map management" + + - id: navigation + name: "Navigation" + category: "motion-planning" + description: "Path planning and trajectory execution" + + - id: control + name: "Control" + category: "motion-control" + description: "Low-level motor control and hardware interface" + +# ============================================================================= +# COMPONENTS - Hardware and virtual units +# ============================================================================= +components: + # Sensors + - id: lidar-sensor + name: "LiDAR Sensor" + type: "sensor" + area: perception + description: "360° laser distance sensor (LDS-01 or LDS-02)" + tags: + - laser + - 360-degree + + - id: imu-sensor + name: "IMU Sensor" + type: "sensor" + area: perception + description: "Inertial Measurement Unit on OpenCR board" + + - id: wheel-encoders + name: "Wheel Encoders" + type: "sensor" + area: control + description: "Wheel rotation encoders for odometry" + + # Compute units + - id: main-computer + name: "Main Computer" + type: "controller" + area: control + description: "Raspberry Pi 4 running ROS 2 Jazzy" + variant: "rpi4-4gb" + tags: + - compute + - ros2 + + - id: opencr-board + name: "OpenCR Board" + type: "controller" + area: control + description: "Motor controller and sensor interface board" + tags: + - embedded + - realtime + +# ============================================================================= +# APPS - Software applications (ROS 2 nodes) +# ============================================================================= +apps: + # --- Perception Apps --- + - id: lidar-driver + name: "LiDAR Driver" + category: "driver" + is_located_on: lidar-sensor + description: "Publishes laser scans from LDS sensor" + ros_binding: + node_name: ld08_driver + namespace: / + + # --- Localization Apps --- + - id: amcl-node + name: "AMCL Localization" + category: "localization" + is_located_on: main-computer + description: "Adaptive Monte Carlo Localization for pose estimation" + depends_on: + - lidar-driver + ros_binding: + node_name: amcl + namespace: / + + - id: map-server + name: "Map Server" + category: "localization" + is_located_on: main-computer + description: "Serves static map for localization" + ros_binding: + node_name: map_server + namespace: / + + # --- Navigation Apps --- + - id: planner-server + name: "Planner Server" + category: "navigation" + is_located_on: main-computer + description: "Global path planning using NavFn or Smac planners" + depends_on: + - amcl-node + - map-server + ros_binding: + node_name: planner_server + namespace: / + + - id: controller-server + name: "Controller Server" + category: "navigation" + is_located_on: main-computer + description: "Local trajectory tracking using DWB or MPPI controllers" + depends_on: + - planner-server + ros_binding: + node_name: controller_server + namespace: / + + - id: bt-navigator + name: "Behavior Tree Navigator" + category: "navigation" + is_located_on: main-computer + description: "High-level navigation behavior using behavior trees" + depends_on: + - planner-server + - controller-server + ros_binding: + node_name: bt_navigator + namespace: / + + - id: recoveries-server + name: "Recoveries Server" + category: "navigation" + is_located_on: main-computer + description: "Recovery behaviors (spin, backup, wait)" + ros_binding: + node_name: recoveries_server + namespace: / + + - id: waypoint-follower + name: "Waypoint Follower" + category: "navigation" + is_located_on: main-computer + description: "Follows a sequence of waypoints" + depends_on: + - bt-navigator + ros_binding: + node_name: waypoint_follower + namespace: / + + # --- Control Apps --- + - id: turtlebot3-node + name: "TurtleBot3 Node" + category: "control" + is_located_on: opencr-board + description: "Differential drive controller and odometry publisher" + ros_binding: + node_name: turtlebot3_node + namespace: / + + - id: robot-state-publisher + name: "Robot State Publisher" + category: "control" + is_located_on: main-computer + description: "Publishes robot URDF transforms" + ros_binding: + node_name: robot_state_publisher + namespace: / + +# ============================================================================= +# FUNCTIONS - High-level capabilities +# ============================================================================= +functions: + - id: autonomous-navigation + name: "Autonomous Navigation" + category: "mobility" + description: "Complete autonomous navigation capability including planning, control, and recovery" + tags: + - autonomous + - safety-critical + hosted_by: + - amcl-node + - map-server + - planner-server + - controller-server + - bt-navigator + - recoveries-server + + - id: localization-function + name: "Localization" + category: "state-estimation" + description: "Robot pose estimation on known map" + hosted_by: + - amcl-node + - map-server + + - id: perception-function + name: "Environment Perception" + category: "sensing" + description: "LiDAR-based environment sensing" + hosted_by: + - lidar-driver + + - id: waypoint-mission + name: "Waypoint Mission" + category: "mission" + description: "Execute multi-waypoint navigation missions" + depends_on: + - autonomous-navigation + hosted_by: + - waypoint-follower + - bt-navigator 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 new file mode 100644 index 0000000..77a53a5 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp @@ -0,0 +1,328 @@ +// 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_MANAGER_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_MANAGER_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" +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/common.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" +#include "ros2_medkit_gateway/discovery/runtime_discovery.hpp" + +#include +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +// Forward declarations +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 + */ +struct DiscoveryConfig { + DiscoveryMode mode{DiscoveryMode::RUNTIME_ONLY}; + std::string manifest_path; + bool manifest_strict_validation{true}; +}; + +/** + * @brief Orchestrates entity discovery using pluggable strategies + * + * This class delegates discovery to strategy implementations based on + * the configured mode: + * - RUNTIME_ONLY: Uses RuntimeDiscoveryStrategy (traditional ROS graph) + * - MANIFEST_ONLY: Uses manifest as sole source of truth + * - HYBRID: Combines manifest definitions with runtime linking + * + * The DiscoveryManager provides a unified interface for discovering: + * - Areas: Logical groupings (ROS 2 namespaces or manifest areas) + * - Components: Software/hardware units (ROS 2 nodes) + * - Apps: Software applications (manifest-defined) + * - Functions: Functional groupings (manifest-defined) + * + * @see discovery::RuntimeDiscoveryStrategy + * @see discovery::HybridDiscoveryStrategy + * @see discovery::ManifestManager + */ +class DiscoveryManager { + public: + /** + * @brief Construct the discovery manager + * @param node ROS 2 node for graph introspection (must outlive this manager) + */ + explicit DiscoveryManager(rclcpp::Node * node); + + /** + * @brief Initialize with configuration + * + * Loads manifest if configured, creates appropriate strategy. + * For RUNTIME_ONLY mode, this is a no-op. + * + * @param config Discovery configuration + * @return true if initialization succeeded + */ + bool initialize(const DiscoveryConfig & config); + + // ========================================================================= + // Main discovery methods (delegate to strategy) + // ========================================================================= + + /** + * @brief Discover all areas + * @return Vector of discovered Area entities + */ + std::vector discover_areas(); + + /** + * @brief Discover all components + * @return Vector of discovered Component entities + */ + std::vector discover_components(); + + /** + * @brief Discover all apps + * @return Vector of discovered App entities (empty in runtime-only mode) + */ + std::vector discover_apps(); + + /** + * @brief Discover all functions + * @return Vector of discovered Function entities (empty in runtime-only mode) + */ + std::vector discover_functions(); + + // ========================================================================= + // Entity lookup by ID + // ========================================================================= + + /** + * @brief Get area by ID + * @param id Area identifier + * @return Area if found + */ + std::optional get_area(const std::string & id); + + /** + * @brief Get component by ID + * @param id Component identifier + * @return Component if found + */ + std::optional get_component(const std::string & id); + + /** + * @brief Get app by ID + * @param id App identifier + * @return App if found + */ + std::optional get_app(const std::string & id); + + /** + * @brief Get function by ID + * @param id Function identifier + * @return Function if found + */ + std::optional get_function(const std::string & id); + + // ========================================================================= + // Relationship queries + // ========================================================================= + + /** + * @brief Get subareas of an area + * @param area_id Parent area ID + * @return Vector of child areas + */ + std::vector get_subareas(const std::string & area_id); + + /** + * @brief Get subcomponents of a component + * @param component_id Parent component ID + * @return Vector of child components + */ + std::vector get_subcomponents(const std::string & component_id); + + /** + * @brief Get components in an area + * @param area_id Area ID + * @return Vector of components in the area + */ + std::vector get_components_for_area(const std::string & area_id); + + /** + * @brief Get apps for a component + * @param component_id Component ID + * @return Vector of apps associated with the component + */ + std::vector get_apps_for_component(const std::string & component_id); + + /** + * @brief Get host component IDs for a function + * @param function_id Function ID + * @return Vector of component IDs that host the function + */ + std::vector get_hosts_for_function(const std::string & function_id); + + // ========================================================================= + // Runtime-specific methods (delegate to runtime strategy) + // ========================================================================= + + /** + * @brief Discover components from topic namespaces + * @return Vector of topic-based components + * @see discovery::RuntimeDiscoveryStrategy::discover_topic_components + */ + std::vector discover_topic_components(); + + /** + * @brief Discover all services in the system + * @return Vector of ServiceInfo with schema information + */ + std::vector discover_services(); + + /** + * @brief Discover all actions in the system + * @return Vector of ActionInfo with schema information + */ + std::vector discover_actions(); + + /** + * @brief Find a service by component namespace and operation name + * @param component_ns Component namespace + * @param operation_name Service name + * @return ServiceInfo if found, nullopt otherwise + */ + std::optional find_service(const std::string & component_ns, const std::string & operation_name) const; + + /** + * @brief Find an action by component namespace and operation name + * @param component_ns Component namespace + * @param operation_name Action name + * @return ActionInfo if found, nullopt otherwise + */ + std::optional find_action(const std::string & component_ns, const std::string & operation_name) const; + + /** + * @brief Set the topic sampler for component-topic mapping + * @param sampler Pointer to NativeTopicSampler (must outlive DiscoveryManager) + */ + void set_topic_sampler(NativeTopicSampler * sampler); + + /** + * @brief Set the type introspection for operation schema enrichment + * @param introspection Pointer to TypeIntrospection (must outlive DiscoveryManager) + */ + void set_type_introspection(TypeIntrospection * introspection); + + /** + * @brief Refresh the cached topic map + */ + void refresh_topic_map(); + + /** + * @brief Check if topic map has been built at least once + * @return true if topic map is ready, false if not yet built + */ + bool is_topic_map_ready() const; + + // ========================================================================= + // Manifest management + // ========================================================================= + + /** + * @brief Get the manifest manager + * @return Pointer to manifest manager (nullptr if not using manifest) + */ + discovery::ManifestManager * get_manifest_manager(); + + /** + * @brief Reload manifest from file + * + * Only works if a manifest was loaded during initialize(). + * + * @return true if reload succeeded + */ + bool reload_manifest(); + + // ========================================================================= + // Status + // ========================================================================= + + /** + * @brief Get current discovery mode + * @return Active discovery mode + */ + DiscoveryMode get_mode() const { + return config_.mode; + } + + /** + * @brief Get the current discovery strategy name + * @return Strategy name (e.g., "runtime", "manifest", "hybrid") + */ + std::string get_strategy_name() const; + + private: + /** + * @brief Create and activate the appropriate strategy + */ + void create_strategy(); + + rclcpp::Node * node_; + DiscoveryConfig config_; + + // Strategies + std::unique_ptr runtime_strategy_; + std::unique_ptr manifest_manager_; + std::unique_ptr hybrid_strategy_; + + // Active strategy pointer (points to one of the above) + discovery::DiscoveryStrategy * active_strategy_{nullptr}; +}; + +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_MANAGER_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_strategy.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_strategy.hpp new file mode 100644 index 0000000..0a08594 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_strategy.hpp @@ -0,0 +1,64 @@ +// 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_STRATEGY_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_STRATEGY_HPP_ + +#include +#include + +namespace ros2_medkit_gateway { + +// Forward declarations +struct Area; +struct Component; +struct App; +struct Function; + +namespace discovery { + +/** + * @brief Interface for discovery strategies (Strategy Pattern) + * + * Allows different discovery implementations: + * - RuntimeDiscoveryStrategy: Discovers from ROS 2 graph (current behavior) + * - ManifestDiscoveryStrategy: Discovers from YAML manifest (future) + * - HybridDiscoveryStrategy: Combines both (future) + * + * @see RuntimeDiscoveryStrategy + */ +class DiscoveryStrategy { + public: + virtual ~DiscoveryStrategy() = default; + + /// Discover all areas + virtual std::vector discover_areas() = 0; + + /// Discover all components + virtual std::vector discover_components() = 0; + + /// Discover all apps (empty for runtime-only strategy) + virtual std::vector discover_apps() = 0; + + /// Discover all functions (empty for runtime-only strategy) + virtual std::vector discover_functions() = 0; + + /// Get strategy name for logging + virtual std::string get_name() const = 0; +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__DISCOVERY_STRATEGY_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/hybrid_discovery.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/hybrid_discovery.hpp new file mode 100644 index 0000000..50a6c59 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/hybrid_discovery.hpp @@ -0,0 +1,137 @@ +// 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__HYBRID_DISCOVERY_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__HYBRID_DISCOVERY_HPP_ + +#include "ros2_medkit_gateway/discovery/discovery_strategy.hpp" +#include "ros2_medkit_gateway/discovery/manifest/manifest_manager.hpp" +#include "ros2_medkit_gateway/discovery/manifest/runtime_linker.hpp" +#include "ros2_medkit_gateway/discovery/runtime_discovery.hpp" + +#include + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +/** + * @brief Hybrid discovery combining manifest and runtime discovery + * + * Uses manifest as source of truth for entity IDs and hierarchy while + * linking to runtime ROS 2 nodes for live data (topics, services, actions). + * + * Behavior: + * - Areas: From manifest (runtime areas not exposed unless orphan policy allows) + * - Components: From manifest, enriched with runtime data if linked + * - Apps: From manifest, bound to runtime nodes via RuntimeLinker + * - Functions: From manifest only + * + * The hybrid strategy maintains a RuntimeLinker that binds manifest Apps + * to actual ROS 2 nodes discovered at runtime. + */ +class HybridDiscoveryStrategy : public DiscoveryStrategy { + public: + /** + * @brief Construct hybrid discovery strategy + * @param node ROS 2 node for logging + * @param manifest_manager Manifest manager (must be loaded before use) + * @param runtime_strategy Runtime discovery strategy for ROS graph introspection + */ + HybridDiscoveryStrategy(rclcpp::Node * node, ManifestManager * manifest_manager, + RuntimeDiscoveryStrategy * runtime_strategy); + + /** + * @brief Discover areas from manifest + * @return Areas defined in manifest + */ + std::vector discover_areas() override; + + /** + * @brief Discover components from manifest, linked to runtime + * @return Components with runtime data if linked + */ + std::vector discover_components() override; + + /** + * @brief Discover apps from manifest, linked to runtime nodes + * @return Apps with is_online and bound_fqn set + */ + std::vector discover_apps() override; + + /** + * @brief Discover functions from manifest + * @return Functions defined in manifest + */ + std::vector discover_functions() override; + + /** + * @brief Get strategy name + * @return "hybrid" + */ + std::string get_name() const override { + return "hybrid"; + } + + /** + * @brief Refresh runtime linking + * + * Call this after runtime discovery refresh to update app-node bindings. + * This will re-run the RuntimeLinker with fresh runtime component data. + */ + void refresh_linking(); + + /** + * @brief Get the last linking result + * @return Reference to last linking result + */ + const LinkingResult & get_linking_result() const { + return linking_result_; + } + + /** + * @brief Get orphan nodes from last linking + * @return Vector of node FQNs not bound to any manifest app + */ + const std::vector & get_orphan_nodes() const { + return linking_result_.orphan_nodes; + } + + private: + /** + * @brief Perform initial linking on construction + */ + void perform_linking(); + + /** + * @brief Log message at info level + */ + void log_info(const std::string & msg) const; + + rclcpp::Node * node_; + ManifestManager * manifest_manager_; + RuntimeDiscoveryStrategy * runtime_strategy_; + RuntimeLinker linker_; + LinkingResult linking_result_; + mutable std::mutex mutex_; +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__HYBRID_DISCOVERY_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest.hpp new file mode 100644 index 0000000..7148491 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest.hpp @@ -0,0 +1,95 @@ +// 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__MANIFEST__MANIFEST_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_HPP_ + +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +using json = nlohmann::json; + +/** + * @brief Configuration for discovery behavior when manifest is present + */ +struct ManifestConfig { + /** + * @brief Policy for ROS nodes not declared in manifest + */ + enum class UnmanifestedNodePolicy { + IGNORE, ///< Don't expose unmanifested nodes + WARN, ///< Log warning, include as orphan + ERROR, ///< Fail startup + INCLUDE_AS_ORPHAN ///< Include with source="orphan" + }; + + UnmanifestedNodePolicy unmanifested_nodes{UnmanifestedNodePolicy::WARN}; + bool inherit_runtime_resources{true}; ///< Copy topics/services from runtime + bool allow_manifest_override{true}; ///< Manifest can override runtime props + + /// Parse policy from string + static UnmanifestedNodePolicy parse_policy(const std::string & str); + /// Convert policy to string + static std::string policy_to_string(UnmanifestedNodePolicy policy); +}; + +/** + * @brief Manifest metadata + */ +struct ManifestMetadata { + std::string name; + std::string description; + std::string version; + std::string created_at; +}; + +/** + * @brief Full manifest structure + * + * Represents a parsed manifest YAML file containing entity definitions + * and discovery configuration. + */ +struct Manifest { + std::string manifest_version; ///< Must be "1.0" + ManifestMetadata metadata; + ManifestConfig config; + + std::vector areas; + std::vector components; + std::vector apps; + std::vector functions; + + /// Custom capabilities overrides per entity + std::unordered_map capabilities; + + /// Check if manifest has been loaded + bool is_loaded() const { + return !manifest_version.empty(); + } +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_manager.hpp new file mode 100644 index 0000000..2ec816a --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_manager.hpp @@ -0,0 +1,228 @@ +// 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__MANIFEST__MANIFEST_MANAGER_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_MANAGER_HPP_ + +#include "ros2_medkit_gateway/discovery/manifest/manifest.hpp" +#include "ros2_medkit_gateway/discovery/manifest/manifest_parser.hpp" +#include "ros2_medkit_gateway/discovery/manifest/manifest_validator.hpp" +#include "ros2_medkit_gateway/discovery/manifest/validation_error.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +using json = nlohmann::json; + +/** + * @brief Manages manifest loading, validation, and entity access + * + * Thread-safe class for managing the manifest lifecycle. + * Provides access to parsed entities and relationship queries. + */ +class ManifestManager { + public: + /** + * @brief Construct ManifestManager + * @param node ROS node for logging (can be nullptr for testing) + */ + explicit ManifestManager(rclcpp::Node * node = nullptr); + + // === Manifest Loading === + + /** + * @brief Load manifest from file + * @param file_path Path to manifest YAML file + * @param strict If true, also fail on warnings; errors always cause failure + * @return true if loaded and validated successfully + * + * @note ERRORs (broken references, circular deps, duplicate bindings) always + * cause failure regardless of strict mode, as they indicate a fundamentally + * broken manifest. Only WARNINGs are affected by the strict flag. + */ + bool load_manifest(const std::string & file_path, bool strict = true); + + /** + * @brief Load manifest from YAML string (useful for testing) + * @param yaml_content YAML content + * @param strict If true, also fail on warnings; errors always cause failure + * @return true if loaded and validated successfully + * + * @note See load_manifest() for behavior details. + */ + bool load_manifest_from_string(const std::string & yaml_content, bool strict = true); + + /** + * @brief Reload manifest from previously loaded file path + * @return true if reloaded successfully + */ + bool reload_manifest(); + + /** + * @brief Unload current manifest (revert to runtime-only mode) + */ + void unload_manifest(); + + // === Status === + + /** + * @brief Check if manifest is loaded and active + */ + bool is_manifest_active() const; + + /** + * @brief Get the loaded manifest file path + */ + std::string get_manifest_path() const; + + /** + * @brief Get last validation result + */ + ValidationResult get_validation_result() const; + + /** + * @brief Get manifest metadata + */ + std::optional get_metadata() const; + + /** + * @brief Get manifest config + */ + ManifestConfig get_config() const; + + // === Entity Access === + + /** + * @brief Get all areas from manifest + */ + std::vector get_areas() const; + + /** + * @brief Get all components from manifest + */ + std::vector get_components() const; + + /** + * @brief Get all apps from manifest + */ + std::vector get_apps() const; + + /** + * @brief Get all functions from manifest + */ + std::vector get_functions() const; + + /** + * @brief Get area by ID + */ + std::optional get_area(const std::string & id) const; + + /** + * @brief Get component by ID + */ + std::optional get_component(const std::string & id) const; + + /** + * @brief Get app by ID + */ + std::optional get_app(const std::string & id) const; + + /** + * @brief Get function by ID + */ + std::optional get_function(const std::string & id) const; + + // === Relationship Queries === + + /** + * @brief Get components belonging to an area + */ + std::vector get_components_for_area(const std::string & area_id) const; + + /** + * @brief Get apps located on a component + */ + std::vector get_apps_for_component(const std::string & component_id) const; + + /** + * @brief Get entities hosted by a function + */ + std::vector get_hosts_for_function(const std::string & function_id) const; + + /** + * @brief Get subareas of an area + */ + std::vector get_subareas(const std::string & area_id) const; + + /** + * @brief Get subcomponents of a component + */ + std::vector get_subcomponents(const std::string & component_id) const; + + // === Capabilities === + + /** + * @brief Get custom capabilities override for an entity + */ + std::optional get_capabilities_override(const std::string & entity_id) const; + + // === Status JSON for REST API === + + /** + * @brief Get manifest status as JSON (for /manifest/status endpoint) + */ + json get_status_json() const; + + private: + /// Build lookup maps after loading manifest + void build_lookup_maps(); + + /// Log info message (handles null node) + void log_info(const std::string & msg) const; + /// Log warning message + void log_warn(const std::string & msg) const; + /// Log error message + void log_error(const std::string & msg) const; + + rclcpp::Node * node_; + mutable std::mutex mutex_; + + std::optional manifest_; + std::string manifest_path_; + ValidationResult validation_result_; + bool strict_mode_{true}; + + ManifestParser parser_; + ManifestValidator validator_; + + // Lookup maps for fast access by ID + std::unordered_map area_index_; + std::unordered_map component_index_; + std::unordered_map app_index_; + std::unordered_map function_index_; +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_MANAGER_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_parser.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_parser.hpp new file mode 100644 index 0000000..d316005 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_parser.hpp @@ -0,0 +1,74 @@ +// 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__MANIFEST__MANIFEST_PARSER_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_PARSER_HPP_ + +#include "ros2_medkit_gateway/discovery/manifest/manifest.hpp" + +#include +#include + +// Forward declare YAML::Node to avoid including yaml-cpp in header +namespace YAML { +class Node; +} + +namespace ros2_medkit_gateway { +namespace discovery { + +/** + * @brief Parses manifest YAML files into Manifest structure + * + * The parser handles YAML loading and converts the document into + * internal model structures (Area, Component, App, Function). + */ +class ManifestParser { + public: + /** + * @brief Parse manifest from file + * @param file_path Path to YAML file + * @return Parsed manifest + * @throws std::runtime_error if file cannot be read or parsed + */ + Manifest parse_file(const std::string & file_path) const; + + /** + * @brief Parse manifest from YAML string + * @param yaml_content YAML content as string + * @return Parsed manifest + * @throws std::runtime_error if YAML is malformed + */ + Manifest parse_string(const std::string & yaml_content) const; + + private: + /// Recursively parse area and its nested subareas + void parse_area_recursive(const YAML::Node & node, const std::string & parent_id, std::vector & areas) const; + Component parse_component(const YAML::Node & node) const; + App parse_app(const YAML::Node & node) const; + Function parse_function(const YAML::Node & node) const; + ManifestConfig parse_config(const YAML::Node & node) const; + ManifestMetadata parse_metadata(const YAML::Node & node) const; + + /// Get string value with default + std::string get_string(const YAML::Node & node, const std::string & key, const std::string & default_val = "") const; + + /// Get string vector + std::vector get_string_vector(const YAML::Node & node, const std::string & key) const; +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_PARSER_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_validator.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_validator.hpp new file mode 100644 index 0000000..b4a158b --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/manifest_validator.hpp @@ -0,0 +1,64 @@ +// 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__MANIFEST__MANIFEST_VALIDATOR_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_VALIDATOR_HPP_ + +#include "ros2_medkit_gateway/discovery/manifest/manifest.hpp" +#include "ros2_medkit_gateway/discovery/manifest/validation_error.hpp" + +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +/** + * @brief Validates manifest structure and references + * + * Applies validation rules R001-R011 to detect errors and warnings + * in the manifest structure. + */ +class ManifestValidator { + public: + /** + * @brief Validate a manifest + * @param manifest The manifest to validate + * @return Validation result with errors and warnings + */ + ValidationResult validate(const Manifest & manifest) const; + + private: + void validate_version(const Manifest & manifest, ValidationResult & result) const; + void validate_unique_ids(const Manifest & manifest, ValidationResult & result) const; + void validate_area_references(const Manifest & manifest, ValidationResult & result) const; + void validate_component_references(const Manifest & manifest, ValidationResult & result) const; + void validate_app_references(const Manifest & manifest, ValidationResult & result) const; + void validate_function_references(const Manifest & manifest, ValidationResult & result) const; + void validate_ros_bindings(const Manifest & manifest, ValidationResult & result) const; + void validate_circular_dependencies(const Manifest & manifest, ValidationResult & result) const; + + /// Check if ID exists in any entity collection + bool entity_exists(const Manifest & manifest, const std::string & id) const; + + /// Detect circular dependencies using DFS + bool has_cycle(const std::string & start, + const std::unordered_map> & graph) const; +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__MANIFEST_VALIDATOR_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/runtime_linker.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/runtime_linker.hpp new file mode 100644 index 0000000..c5c88f9 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/runtime_linker.hpp @@ -0,0 +1,182 @@ +// 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__MANIFEST__RUNTIME_LINKER_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__RUNTIME_LINKER_HPP_ + +#include "ros2_medkit_gateway/discovery/manifest/manifest.hpp" +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +/** + * @brief Result of runtime linking operation + * + * Contains all the information about how manifest Apps were linked + * to runtime ROS 2 nodes, including orphan detection. + */ +struct LinkingResult { + /// Apps with successfully bound ROS nodes (and those that failed) + std::vector linked_apps; + + /// App IDs that have no matching ROS node (offline or external) + std::vector unlinked_app_ids; + + /// ROS node FQNs that have no matching manifest App (orphans) + std::vector orphan_nodes; + + /// Mapping from App ID to bound node FQN + std::unordered_map app_to_node; + + /// Mapping from node FQN to App ID (reverse lookup) + std::unordered_map node_to_app; + + /// Check if linking produced any errors based on policy + bool has_errors(ManifestConfig::UnmanifestedNodePolicy policy) const { + return policy == ManifestConfig::UnmanifestedNodePolicy::ERROR && !orphan_nodes.empty(); + } + + /// Get statistics summary + std::string summary() const { + return std::to_string(app_to_node.size()) + " linked, " + std::to_string(unlinked_app_ids.size()) + " unlinked, " + + std::to_string(orphan_nodes.size()) + " orphan nodes"; + } +}; + +/** + * @brief Links manifest Apps to runtime ROS 2 nodes + * + * RuntimeLinker performs the binding between manifest-declared Apps + * and actual ROS 2 nodes discovered at runtime. This enables: + * - Stable App IDs from manifest + * - Live data (topics, services, actions) from runtime nodes + * - Detection of offline apps and orphan nodes + * + * Match priority: + * 1. Exact match: node_name + namespace both match + * 2. Wildcard namespace: node_name matches, namespace is "*" + * 3. Topic namespace: topic_namespace prefix matches node's topics + */ +class RuntimeLinker { + public: + /** + * @brief Constructor + * @param node ROS node for logging (can be nullptr for testing) + */ + explicit RuntimeLinker(rclcpp::Node * node = nullptr); + + /** + * @brief Link manifest apps to runtime nodes + * + * @param apps Apps from manifest + * @param runtime_components Components discovered from ROS graph + * @param config Manifest config with orphan policy + * @return LinkingResult with linked apps and orphan info + */ + LinkingResult link(const std::vector & apps, const std::vector & runtime_components, + const ManifestConfig & config); + + /** + * @brief Check if a specific app is linked to a runtime node + * @param app_id App identifier + * @return true if app is online (linked to a node) + */ + bool is_app_online(const std::string & app_id) const; + + /** + * @brief Get the node FQN bound to an app + * @param app_id App identifier + * @return Node FQN if linked, nullopt otherwise + */ + std::optional get_bound_node(const std::string & app_id) const; + + /** + * @brief Get the app ID for a node FQN + * @param node_fqn Fully qualified node name + * @return App ID if bound, nullopt otherwise + */ + std::optional get_app_for_node(const std::string & node_fqn) const; + + /** + * @brief Get the last linking result + */ + const LinkingResult & get_last_result() const { + return last_result_; + } + + private: + /** + * @brief Try to match a ROS node to an app's binding + * @param binding The app's ROS binding configuration + * @param node_fqn Fully qualified node name + * @param node_name Node name (without namespace) + * @param node_namespace Node namespace + * @return true if binding matches + */ + bool matches_binding(const App::RosBinding & binding, const std::string & node_fqn, const std::string & node_name, + const std::string & node_namespace) const; + + /** + * @brief Try to match by topic namespace + * @param topic_namespace Topic namespace pattern from binding + * @param component Component with topic info + * @return true if any topic matches the prefix + */ + bool matches_topic_namespace(const std::string & topic_namespace, const Component & component) const; + + /** + * @brief Enrich app with runtime data from matched component + * @param app App to enrich (modified in place) + * @param component Component with runtime data + */ + void enrich_app(App & app, const Component & component); + + /** + * @brief Log message at info level + */ + void log_info(const std::string & msg) const; + + /** + * @brief Log message at debug level + */ + void log_debug(const std::string & msg) const; + + /** + * @brief Log message at warning level + */ + void log_warn(const std::string & msg) const; + + /** + * @brief Log message at error level + */ + void log_error(const std::string & msg) const; + + rclcpp::Node * node_; + LinkingResult last_result_; +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__RUNTIME_LINKER_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/validation_error.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/validation_error.hpp new file mode 100644 index 0000000..2c42405 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/manifest/validation_error.hpp @@ -0,0 +1,91 @@ +// 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__MANIFEST__VALIDATION_ERROR_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__VALIDATION_ERROR_HPP_ + +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +/** + * @brief Severity level for validation errors + */ +enum class ValidationSeverity { + ERROR, ///< Manifest is invalid, cannot proceed + WARNING ///< Issue detected, but can proceed +}; + +/** + * @brief A single validation error or warning + */ +struct ValidationError { + std::string rule_id; ///< e.g., "R001" + ValidationSeverity severity; ///< ERROR or WARNING + std::string message; ///< Human-readable description + std::string path; ///< YAML path where error occurred + + /** + * @brief Convert to human-readable string + */ + std::string to_string() const { + std::string sev = (severity == ValidationSeverity::ERROR) ? "ERROR" : "WARNING"; + return "[" + rule_id + "] " + sev + ": " + message + " (at " + path + ")"; + } +}; + +/** + * @brief Result of manifest validation + */ +struct ValidationResult { + bool is_valid{true}; + std::vector errors; + std::vector warnings; + + bool has_errors() const { + return !errors.empty(); + } + bool has_warnings() const { + return !warnings.empty(); + } + + /** + * @brief Add an error (manifest is invalid) + */ + void add_error(const std::string & rule, const std::string & msg, const std::string & path) { + errors.push_back({rule, ValidationSeverity::ERROR, msg, path}); + is_valid = false; + } + + /** + * @brief Add a warning (manifest is valid but has issues) + */ + void add_warning(const std::string & rule, const std::string & msg, const std::string & path) { + warnings.push_back({rule, ValidationSeverity::WARNING, msg, path}); + } + + /** + * @brief Get summary string + */ + std::string summary() const { + return std::to_string(errors.size()) + " errors, " + std::to_string(warnings.size()) + " warnings"; + } +}; + +} // namespace discovery +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MANIFEST__VALIDATION_ERROR_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp new file mode 100644 index 0000000..033243e --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/app.hpp @@ -0,0 +1,119 @@ +// 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__MODELS__APP_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__APP_HPP_ + +#include "ros2_medkit_gateway/discovery/models/common.hpp" + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +/** + * @brief SOVD App entity - represents a software application + * + * In the ROS 2 context, an App typically corresponds to a ROS node. + * Apps are located on Components and can depend on other Apps. + * + * Apps can be: + * - Manifest-defined: Declared in YAML manifest with ROS binding + * - Runtime-discovered: Automatically created from ROS nodes (future) + */ +struct App { + // === Required fields === + std::string id; ///< Unique identifier + std::string name; ///< Human-readable name + + // === Optional SOVD fields === + std::string translation_id; ///< For i18n support + std::string description; ///< Detailed description + std::vector tags; ///< Tags for filtering + + // === Relationships === + std::string component_id; ///< is-located-on relationship + std::vector depends_on; ///< depends-on relationship (App IDs) + + // === ROS binding (for manifest) === + /** + * @brief ROS 2 binding configuration for manifest-defined apps + * + * Specifies how to bind this App to a ROS 2 node at runtime. + */ + struct RosBinding { + std::string node_name; ///< ROS node name to bind to + std::string namespace_pattern; ///< Namespace (can be "*" for wildcard) + std::string topic_namespace; ///< Alternative: bind by topic prefix + + bool is_empty() const { + return node_name.empty() && topic_namespace.empty(); + } + + json to_json() const { + json j; + if (!node_name.empty()) { + j["nodeName"] = node_name; + if (!namespace_pattern.empty()) { + j["namespace"] = namespace_pattern; + } + } + if (!topic_namespace.empty()) { + j["topicNamespace"] = topic_namespace; + } + return j; + } + }; + std::optional ros_binding; + + // === Runtime state (populated after linking) === + std::optional bound_fqn; ///< Bound ROS node FQN + bool is_online{false}; ///< Whether the bound node is running + bool external{false}; ///< True if not a ROS node + + // === Resources (populated from bound node) === + ComponentTopics topics; + std::vector services; + std::vector actions; + + // === Discovery metadata === + std::string source = "manifest"; ///< "manifest" or "runtime" + + // === Serialization methods === + + /** + * @brief Serialize to full JSON representation + */ + json to_json() const; + + /** + * @brief Create SOVD EntityReference format + * @param base_url Base URL for href (e.g., "/api/v1") + */ + json to_entity_reference(const std::string & base_url) const; + + /** + * @brief Create SOVD Entity Capabilities format + * @param base_url Base URL for capability URIs + */ + json to_capabilities(const std::string & base_url) const; +}; + +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__APP_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/area.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/area.hpp new file mode 100644 index 0000000..7b6caf7 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/area.hpp @@ -0,0 +1,104 @@ +// 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__MODELS__AREA_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__AREA_HPP_ + +#include +#include +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +/** + * @brief SOVD Area entity - represents a logical grouping (ROS 2 namespace) + * + * Areas are derived from ROS 2 namespaces or defined in manifests. + * They provide a hierarchical organization of components. + */ +struct Area { + std::string id; ///< Unique identifier (e.g., "powertrain") + std::string name; ///< Human-readable name (e.g., "Powertrain System") + std::string namespace_path; ///< ROS 2 namespace path (e.g., "/powertrain") + std::string type = "Area"; ///< Entity type (always "Area") + std::string translation_id; ///< Internationalization key + std::string description; ///< Human-readable description + std::vector tags; ///< Tags for filtering + std::string parent_area_id; ///< Parent area ID for sub-areas + + /** + * @brief Convert to JSON representation + * @return JSON object with area data + */ + json to_json() const { + json j = {{"id", id}, {"namespace", namespace_path}, {"type", type}}; + + // Include optional fields only if set + if (!name.empty()) { + j["name"] = name; + } + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + if (!description.empty()) { + j["description"] = description; + } + if (!tags.empty()) { + j["tags"] = tags; + } + if (!parent_area_id.empty()) { + j["parentAreaId"] = parent_area_id; + } + + return j; + } + + /** + * @brief Create SOVD EntityReference format + * @param base_url Base URL for self links + * @return JSON object in EntityReference format + */ + json to_entity_reference(const std::string & base_url) const { + json j = {{"id", id}, {"type", type}}; + if (!name.empty()) { + j["name"] = name; + } + j["self"] = base_url + "/areas/" + id; + return j; + } + + /** + * @brief Create SOVD Entity Capabilities format + * @param base_url Base URL for capability links + * @return JSON object listing available sub-resources + */ + json to_capabilities(const std::string & base_url) const { + json capabilities = json::array(); + std::string area_url = base_url + "/areas/" + id; + + // Areas contain components + capabilities.push_back({{"name", "components"}, {"href", area_url + "/components"}}); + + // Sub-areas if this area has children + capabilities.push_back({{"name", "areas"}, {"href", area_url + "/areas"}}); + + return {{"id", id}, {"type", type}, {"capabilities", capabilities}}; + } +}; + +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__AREA_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/common.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/common.hpp new file mode 100644 index 0000000..65f8f91 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/common.hpp @@ -0,0 +1,140 @@ +// 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__MODELS__COMMON_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__COMMON_HPP_ + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +/** + * @brief QoS profile information for a topic endpoint + */ +struct QosProfile { + std::string reliability; ///< "reliable", "best_effort", "system_default", "unknown" + std::string durability; ///< "volatile", "transient_local", "system_default", "unknown" + std::string history; ///< "keep_last", "keep_all", "system_default", "unknown" + size_t depth{0}; ///< History depth (for keep_last) + std::string liveliness; ///< "automatic", "manual_by_topic", "system_default", "unknown" + + json to_json() const { + return {{"reliability", reliability}, + {"durability", durability}, + {"history", history}, + {"depth", depth}, + {"liveliness", liveliness}}; + } +}; + +/** + * @brief Information about an endpoint (publisher or subscriber) on a topic + */ +struct TopicEndpoint { + std::string node_name; ///< Name of the node (e.g., "controller_server") + std::string node_namespace; ///< Namespace of the node (e.g., "/navigation") + std::string topic_type; ///< Message type (e.g., "geometry_msgs/msg/Twist") + QosProfile qos; ///< QoS profile of this endpoint + + /// Get fully qualified node name + std::string fqn() const { + if (node_namespace == "/" || node_namespace.empty()) { + return "/" + node_name; + } + return node_namespace + "/" + node_name; + } + + json to_json() const { + return {{"node_name", node_name}, {"node_namespace", node_namespace}, {"fqn", fqn()}, {"qos", qos.to_json()}}; + } +}; + +/** + * @brief Topic with its publishers and subscribers + */ +struct TopicConnection { + std::string topic_name; ///< Full topic path (e.g., "/cmd_vel") + std::string topic_type; ///< Message type + std::vector publishers; + std::vector subscribers; + + json to_json() const { + json pub_json = json::array(); + for (const auto & p : publishers) { + pub_json.push_back(p.to_json()); + } + json sub_json = json::array(); + for (const auto & s : subscribers) { + sub_json.push_back(s.to_json()); + } + return {{"topic", topic_name}, {"type", topic_type}, {"publishers", pub_json}, {"subscribers", sub_json}}; + } +}; + +/** + * @brief Topics associated with a component (node) + */ +struct ComponentTopics { + std::vector publishes; ///< Topics this component publishes to + std::vector subscribes; ///< Topics this component subscribes to + + json to_json() const { + return {{"publishes", publishes}, {"subscribes", subscribes}}; + } +}; + +/** + * @brief Information about a ROS2 service discovered in the system + */ +struct ServiceInfo { + std::string name; ///< Service name (e.g., "calibrate") + std::string full_path; ///< Full service path (e.g., "/powertrain/engine/calibrate") + std::string type; ///< Service type (e.g., "std_srvs/srv/Trigger") + std::optional type_info; ///< Schema info with request/response schemas + + json to_json() const { + json j = {{"name", name}, {"path", full_path}, {"type", type}, {"kind", "service"}}; + if (type_info.has_value()) { + j["type_info"] = type_info.value(); + } + return j; + } +}; + +/** + * @brief Information about a ROS2 action discovered in the system + */ +struct ActionInfo { + std::string name; ///< Action name (e.g., "navigate_to_pose") + std::string full_path; ///< Full action path (e.g., "/navigation/navigate_to_pose") + std::string type; ///< Action type (e.g., "nav2_msgs/action/NavigateToPose") + std::optional type_info; ///< Schema info with goal/result/feedback schemas + + json to_json() const { + json j = {{"name", name}, {"path", full_path}, {"type", type}, {"kind", "action"}}; + if (type_info.has_value()) { + j["type_info"] = type_info.value(); + } + return j; + } +}; + +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__COMMON_HPP_ 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 new file mode 100644 index 0000000..6f58f29 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp @@ -0,0 +1,138 @@ +// 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__MODELS__COMPONENT_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__COMPONENT_HPP_ + +#include "ros2_medkit_gateway/discovery/models/common.hpp" + +#include +#include +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +/** + * @brief SOVD Component entity - represents a software/hardware component (ROS 2 node) + * + * Components are derived from ROS 2 nodes or defined in manifests. + * 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 + + /** + * @brief Convert to JSON representation + * @return JSON object with component data + */ + json to_json() const { + json j = {{"id", id}, {"namespace", namespace_path}, {"fqn", fqn}, {"type", type}, {"area", area}, + {"source", source}, {"topics", topics.to_json()}}; + + // Include optional fields only if set + if (!name.empty()) { + j["name"] = name; + } + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + if (!description.empty()) { + j["description"] = description; + } + if (!variant.empty()) { + j["variant"] = variant; + } + if (!tags.empty()) { + j["tags"] = tags; + } + if (!parent_component_id.empty()) { + j["parentComponentId"] = parent_component_id; + } + + // Add operations array combining services and actions + json operations = json::array(); + for (const auto & svc : services) { + operations.push_back(svc.to_json()); + } + for (const auto & act : actions) { + operations.push_back(act.to_json()); + } + if (!operations.empty()) { + j["operations"] = operations; + } + + return j; + } + + /** + * @brief Create SOVD EntityReference format + * @param base_url Base URL for self links + * @return JSON object in EntityReference format + */ + json to_entity_reference(const std::string & base_url) const { + json j = {{"id", id}, {"type", type}}; + if (!name.empty()) { + j["name"] = name; + } + j["self"] = base_url + "/components/" + id; + return j; + } + + /** + * @brief Create SOVD Entity Capabilities format + * @param base_url Base URL for capability links + * @return JSON object listing available sub-resources + */ + json to_capabilities(const std::string & base_url) const { + json capabilities = json::array(); + std::string component_url = base_url + "/components/" + id; + + // Data capability (topics) + if (!topics.publishes.empty() || !topics.subscribes.empty()) { + capabilities.push_back({{"name", "data"}, {"href", component_url + "/data"}}); + } + + // Operations capability (services + actions) + if (!services.empty() || !actions.empty()) { + capabilities.push_back({{"name", "operations"}, {"href", component_url + "/operations"}}); + } + + // Configurations capability (parameters) - always present for ROS 2 nodes + if (source == "node") { + capabilities.push_back({{"name", "configurations"}, {"href", component_url + "/configurations"}}); + } + + return {{"id", id}, {"type", type}, {"capabilities", capabilities}}; + } +}; + +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__COMPONENT_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp new file mode 100644 index 0000000..91bec07 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp @@ -0,0 +1,74 @@ +// 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__MODELS__FUNCTION_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__FUNCTION_HPP_ + +#include +#include +#include + +namespace ros2_medkit_gateway { + +using json = nlohmann::json; + +/** + * @brief SOVD Function entity - represents a functional group + * + * Functions are higher-level abstractions that group Apps and/or Components + * representing a complete capability (e.g., "Autonomous Navigation"). + * + * Functions are always manifest-defined and don't exist at runtime by themselves. + * They aggregate data, operations, and faults from their hosted entities. + */ +struct Function { + // === Required fields === + std::string id; ///< Unique identifier + std::string name; ///< Human-readable name + + // === Optional SOVD fields === + std::string translation_id; ///< For i18n support + std::string description; ///< Detailed description + std::vector tags; ///< Tags for filtering + + // === Relationships === + std::vector hosts; ///< App or Component IDs this function hosts + std::vector depends_on; ///< depends-on relationship (Function IDs) + + // === Discovery metadata === + std::string source = "manifest"; ///< Always "manifest" (functions don't exist at runtime) + + // === Serialization methods === + + /** + * @brief Serialize to full JSON representation + */ + json to_json() const; + + /** + * @brief Create SOVD EntityReference format + * @param base_url Base URL for href (e.g., "/api/v1") + */ + json to_entity_reference(const std::string & base_url) const; + + /** + * @brief Create SOVD Entity Capabilities format + * @param base_url Base URL for capability URIs + */ + json to_capabilities(const std::string & base_url) const; +}; + +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__MODELS__FUNCTION_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp similarity index 50% rename from src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery_manager.hpp rename to src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp index 6f242f6..950b984 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp @@ -1,4 +1,4 @@ -// Copyright 2025 mfaferek93 +// 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. @@ -12,28 +12,73 @@ // See the License for the specific language governing permissions and // limitations under the License. -#pragma once +#ifndef ROS2_MEDKIT_GATEWAY__DISCOVERY__RUNTIME_DISCOVERY_HPP_ +#define ROS2_MEDKIT_GATEWAY__DISCOVERY__RUNTIME_DISCOVERY_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" +#include "ros2_medkit_gateway/discovery/models/common.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" +#include "ros2_medkit_gateway/native_topic_sampler.hpp" +#include "ros2_medkit_gateway/type_introspection.hpp" #include -#include #include #include #include #include #include -#include "ros2_medkit_gateway/models.hpp" -#include "ros2_medkit_gateway/native_topic_sampler.hpp" -#include "ros2_medkit_gateway/type_introspection.hpp" - namespace ros2_medkit_gateway { - -class DiscoveryManager { +namespace discovery { + +/** + * @brief Runtime discovery strategy using ROS 2 graph introspection + * + * This is the current discovery behavior extracted into a strategy class. + * It discovers entities by querying the ROS 2 node graph at runtime. + * + * Features: + * - Discovers areas from node namespaces + * - Discovers components from ROS 2 nodes + * - Discovers topic-based "virtual" components for systems like Isaac Sim + * - Enriches components with services, actions, and topics + * + * @note Apps and Functions are not supported in runtime-only mode. + * Use ManifestDiscoveryStrategy (future) for those entities. + */ +class RuntimeDiscoveryStrategy : public DiscoveryStrategy { public: - explicit DiscoveryManager(rclcpp::Node * node); + /** + * @brief Construct runtime discovery strategy + * @param node ROS 2 node for graph introspection (must outlive this strategy) + */ + explicit RuntimeDiscoveryStrategy(rclcpp::Node * node); + + /// @copydoc DiscoveryStrategy::discover_areas + std::vector discover_areas() override; + + /// @copydoc DiscoveryStrategy::discover_components + std::vector discover_components() override; + + /// @copydoc DiscoveryStrategy::discover_apps + /// @note Returns empty vector - apps require manifest + std::vector discover_apps() override; - std::vector discover_areas(); - std::vector discover_components(); + /// @copydoc DiscoveryStrategy::discover_functions + /// @note Returns empty vector - functions require manifest + std::vector discover_functions() override; + + /// @copydoc DiscoveryStrategy::get_name + std::string get_name() const override { + return "runtime"; + } + + // ========================================================================= + // Runtime-specific methods (from current DiscoveryManager) + // ========================================================================= /** * @brief Discover components from topic namespaces (topic-based discovery) @@ -42,56 +87,52 @@ class DiscoveryManager { * corresponding ROS 2 nodes. This is useful for systems like Isaac Sim * that publish topics without creating proper ROS 2 nodes. * - * Example: Topics ["/carter1/odom", "/carter1/cmd_vel", "/carter2/odom"] - * Creates components: carter1, carter2 (if no matching nodes exist) - * - * Components are created with: - * - id: namespace name (e.g., "carter1") - * - source: "topic" (to distinguish from node-based components) - * - topics.publishes: all topics under this namespace - * * @return Vector of topic-based components (excludes namespaces with existing nodes) */ std::vector discover_topic_components(); - /// Discover all services in the system with their types + /** + * @brief Discover all services in the system with their types + * @return Vector of ServiceInfo with schema information + */ std::vector discover_services(); - /// Discover all actions in the system with their types + /** + * @brief Discover all actions in the system with their types + * @return Vector of ActionInfo with schema information + */ std::vector discover_actions(); - /// Find a service by component namespace and operation name + /** + * @brief Find a service by component namespace and operation name + * @param component_ns Component namespace + * @param operation_name Service name + * @return ServiceInfo if found, nullopt otherwise + */ std::optional find_service(const std::string & component_ns, const std::string & operation_name) const; - /// Find an action by component namespace and operation name + /** + * @brief Find an action by component namespace and operation name + * @param component_ns Component namespace + * @param operation_name Action name + * @return ActionInfo if found, nullopt otherwise + */ std::optional find_action(const std::string & component_ns, const std::string & operation_name) const; /** * @brief Set the topic sampler for component-topic mapping - * - * When set, discover_components() will populate the topics field - * of each component with its publishes/subscribes lists. - * - * @param sampler Pointer to NativeTopicSampler (must outlive DiscoveryManager) + * @param sampler Pointer to NativeTopicSampler (must outlive this strategy) */ void set_topic_sampler(NativeTopicSampler * sampler); /** * @brief Set the type introspection for operation schema enrichment - * - * When set, discover_services() and discover_actions() will populate - * the type_info field with schema information. - * - * @param introspection Pointer to TypeIntrospection (must outlive DiscoveryManager) + * @param introspection Pointer to TypeIntrospection (must outlive this strategy) */ void set_type_introspection(TypeIntrospection * introspection); /** * @brief Refresh the cached topic map - * - * Call this to force a rebuild of the component-topic map. - * The map is built once at startup and cached for performance. - * Periodic background refresh will also update this cache. */ void refresh_topic_map(); @@ -102,6 +143,7 @@ class DiscoveryManager { bool is_topic_map_ready() const; private: + /// Extract area ID from namespace (e.g., "/powertrain/engine" -> "powertrain") std::string extract_area_from_namespace(const std::string & ns); /// Extract the last segment from a path (e.g., "/a/b/c" -> "c") @@ -113,7 +155,7 @@ class DiscoveryManager { /// Check if a service path belongs to a component namespace bool path_belongs_to_namespace(const std::string & path, const std::string & ns) const; - /// Check if a service path is an internal ROS2 service (parameter services, action internals, etc.) + /// Check if a service path is an internal ROS2 service static bool is_internal_service(const std::string & service_path); rclcpp::Node * node_; @@ -129,4 +171,7 @@ class DiscoveryManager { bool topic_map_ready_{false}; }; +} // namespace discovery } // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__DISCOVERY__RUNTIME_DISCOVERY_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index 93eedd5..e2e1e57 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -27,7 +27,7 @@ #include "ros2_medkit_gateway/config.hpp" #include "ros2_medkit_gateway/configuration_manager.hpp" #include "ros2_medkit_gateway/data_access_manager.hpp" -#include "ros2_medkit_gateway/discovery_manager.hpp" +#include "ros2_medkit_gateway/discovery/discovery_manager.hpp" #include "ros2_medkit_gateway/fault_manager.hpp" #include "ros2_medkit_gateway/http/rest_server.hpp" #include "ros2_medkit_gateway/models.hpp" 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 new file mode 100644 index 0000000..6ede778 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/capability_builder.hpp @@ -0,0 +1,153 @@ +// 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__HTTP__HANDLERS__CAPABILITY_BUILDER_HPP_ +#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__CAPABILITY_BUILDER_HPP_ + +#include +#include + +#include + +namespace ros2_medkit_gateway { +namespace handlers { + +/** + * @brief Utility class for building SOVD-compliant capability arrays. + * + * Generates capabilities JSON for entity responses, ensuring consistent + * format across all entity types (areas, components, apps, functions). + * + * @example + * using Cap = CapabilityBuilder::Capability; + * std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS}; + * auto json = CapabilityBuilder::build_capabilities("components", "my-comp", caps); + * + * @verifies REQ_DISCOVERY_003 Entity capabilities + */ +class CapabilityBuilder { + public: + /** + * @brief Available capability types for SOVD entities. + */ + enum class Capability { + DATA, ///< Entity has data endpoints + OPERATIONS, ///< Entity has operations (services/actions) + CONFIGURATIONS, ///< Entity has configurations (parameters) + FAULTS, ///< Entity has fault management + SUBAREAS, ///< Entity has child areas (areas only) + 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) + }; + + /** + * @brief Build capabilities JSON array for an entity. + * + * @param entity_type The entity type (e.g., "areas", "components", "apps", "functions") + * @param entity_id The entity identifier + * @param capabilities Vector of capability types to include + * @return JSON array of capability objects with name and href + */ + static nlohmann::json build_capabilities(const std::string & entity_type, const std::string & entity_id, + const std::vector & capabilities); + + /** + * @brief Convert capability enum to string name. + * + * @param cap The capability enum value + * @return String name for the capability (e.g., "data", "operations") + */ + static std::string capability_to_name(Capability cap); + + /** + * @brief Convert capability enum to URL path segment. + * + * @param cap The capability enum value + * @return URL path segment for the capability (e.g., "data", "operations") + */ + static std::string capability_to_path(Capability cap); +}; + +/** + * @brief Fluent builder for HATEOAS _links objects. + * + * Provides a fluent API for constructing SOVD-compliant _links JSON objects. + * + * @example + * LinksBuilder links; + * auto json = links.self("/components/my-comp") + * .parent("/areas/powertrain") + * .collection("/components") + * .add("custom", "/custom/link") + * .build(); + * + * @verifies REQ_API_002 HATEOAS links + */ +class LinksBuilder { + public: + /** + * @brief Construct a new LinksBuilder. + */ + LinksBuilder() = default; + + /** + * @brief Set the self link. + * + * @param href The href for the self link + * @return Reference to this builder for chaining + */ + LinksBuilder & self(const std::string & href); + + /** + * @brief Set the parent link. + * + * @param href The href for the parent link + * @return Reference to this builder for chaining + */ + LinksBuilder & parent(const std::string & href); + + /** + * @brief Set the collection link. + * + * @param href The href for the collection link + * @return Reference to this builder for chaining + */ + LinksBuilder & collection(const std::string & href); + + /** + * @brief Add a custom link. + * + * @param rel The relation name + * @param href The href for the link + * @return Reference to this builder for chaining + */ + LinksBuilder & add(const std::string & rel, const std::string & href); + + /** + * @brief Build the final _links JSON object. + * + * @return JSON object containing all configured links + */ + nlohmann::json build() const; + + private: + nlohmann::json links_; +}; + +} // namespace handlers +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__CAPABILITY_BUILDER_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/app_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/app_handlers.hpp new file mode 100644 index 0000000..9739e71 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/app_handlers.hpp @@ -0,0 +1,125 @@ +// 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__HTTP__HANDLERS__DISCOVERY__APP_HANDLERS_HPP_ +#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__APP_HANDLERS_HPP_ + +#include "ros2_medkit_gateway/http/handlers/handler_context.hpp" + +namespace ros2_medkit_gateway { +namespace handlers { + +/** + * @brief Handlers for app-related REST API endpoints. + * + * Apps represent software applications (typically 1:1 with ROS nodes) + * that provide functionality within the system. + * + * Provides handlers for: + * - GET /apps - List all apps + * - GET /apps/{app-id} - Get app capabilities + * - GET /apps/{app-id}/data - Get app topic data + * - GET /apps/{app-id}/data/{data-id} - Get specific data item + * - GET /apps/{app-id}/operations - List app operations + * - GET /apps/{app-id}/configurations - Get app configurations + * - GET /components/{id}/related-apps - List apps on component + * + * @verifies REQ_DISCOVERY_002 Apps discovery + */ +class AppHandlers { + public: + /** + * @brief Construct app handlers with shared context. + * @param ctx The shared handler context + */ + explicit AppHandlers(HandlerContext & ctx) : ctx_(ctx) { + } + + // ========================================================================= + // Collection endpoints + // ========================================================================= + + /** + * @brief Handle GET /apps - list all apps. + * + * Returns all apps discovered from manifest or runtime. + * In runtime-only mode, returns empty list. + */ + void handle_list_apps(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Handle GET /apps/{app-id} - get app capabilities. + * + * Returns app details with capabilities (data, operations, configurations) + * and HATEOAS links. + */ + void handle_get_app(const httplib::Request & req, httplib::Response & res); + + // ========================================================================= + // App data endpoints + // ========================================================================= + + /** + * @brief Handle GET /apps/{app-id}/data - list app topics. + * + * Returns all topics associated with the app. + */ + void handle_get_app_data(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Handle GET /apps/{app-id}/data/{data-id} - get specific data item. + * + * Returns metadata for a specific topic. + */ + void handle_get_app_data_item(const httplib::Request & req, httplib::Response & res); + + // ========================================================================= + // App operations + // ========================================================================= + + /** + * @brief Handle GET /apps/{app-id}/operations - list app operations. + * + * Returns all services and actions associated with the app. + */ + void handle_list_app_operations(const httplib::Request & req, httplib::Response & res); + + // ========================================================================= + // App configurations + // ========================================================================= + + /** + * @brief Handle GET /apps/{app-id}/configurations - list app parameters. + * + * Returns parameters if app is linked to a runtime node. + */ + void handle_list_app_configurations(const httplib::Request & req, httplib::Response & res); + + // ========================================================================= + // Relationship endpoints + // ========================================================================= + + /** + * @brief Handle GET /components/{id}/related-apps - list apps on component. + */ + void handle_related_apps(const httplib::Request & req, httplib::Response & res); + + private: + HandlerContext & ctx_; +}; + +} // namespace handlers +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__APP_HANDLERS_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/area_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/area_handlers.hpp similarity index 59% rename from src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/area_handlers.hpp rename to src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/area_handlers.hpp index 9846ec9..d5a6014 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/area_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/area_handlers.hpp @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -#ifndef ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__AREA_HANDLERS_HPP_ -#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__AREA_HANDLERS_HPP_ +#ifndef ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__AREA_HANDLERS_HPP_ +#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__AREA_HANDLERS_HPP_ #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" @@ -25,7 +25,12 @@ namespace handlers { * * Provides handlers for: * - GET /areas - List all areas + * - GET /areas/{area_id} - Get a specific area with capabilities * - GET /areas/{area_id}/components - List components in an area + * - GET /areas/{area_id}/subareas - List nested areas within an area + * - GET /areas/{area_id}/related-components - List components related to area + * + * @verifies REQ_DISCOVERY_004 Entity relationships */ class AreaHandlers { public: @@ -41,11 +46,26 @@ class AreaHandlers { */ void handle_list_areas(const httplib::Request & req, httplib::Response & res); + /** + * @brief Handle GET /areas/{area_id} - get a specific area with capabilities. + */ + void handle_get_area(const httplib::Request & req, httplib::Response & res); + /** * @brief Handle GET /areas/{area_id}/components - list components in area. */ void handle_area_components(const httplib::Request & req, httplib::Response & res); + /** + * @brief Handle GET /areas/{area_id}/subareas - list nested areas. + */ + void handle_get_subareas(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Handle GET /areas/{area_id}/related-components - list related components. + */ + void handle_get_related_components(const httplib::Request & req, httplib::Response & res); + private: HandlerContext & ctx_; }; @@ -53,4 +73,4 @@ class AreaHandlers { } // namespace handlers } // namespace ros2_medkit_gateway -#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__AREA_HANDLERS_HPP_ +#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__AREA_HANDLERS_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/component_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp similarity index 65% rename from src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/component_handlers.hpp rename to src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp index 57e152c..d733381 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/component_handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp @@ -12,8 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -#ifndef ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__COMPONENT_HANDLERS_HPP_ -#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__COMPONENT_HANDLERS_HPP_ +#ifndef ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__COMPONENT_HANDLERS_HPP_ +#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__COMPONENT_HANDLERS_HPP_ #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" @@ -25,9 +25,14 @@ namespace handlers { * * Provides handlers for: * - GET /components - List all components + * - GET /components/{component_id} - Get a specific component with capabilities * - GET /components/{component_id}/data - Get all topic data for a component * - GET /components/{component_id}/data/{topic_name} - Get specific topic data * - PUT /components/{component_id}/data/{topic_name} - Publish to a topic + * - GET /components/{component_id}/subcomponents - List nested components + * - GET /components/{component_id}/related-apps - List apps on component + * + * @verifies REQ_DISCOVERY_004 Entity relationships */ class ComponentHandlers { public: @@ -43,6 +48,11 @@ class ComponentHandlers { */ void handle_list_components(const httplib::Request & req, httplib::Response & res); + /** + * @brief Handle GET /components/{component_id} - get a specific component with capabilities. + */ + void handle_get_component(const httplib::Request & req, httplib::Response & res); + /** * @brief Handle GET /components/{component_id}/data - get all topic data. */ @@ -58,6 +68,16 @@ class ComponentHandlers { */ void handle_component_topic_publish(const httplib::Request & req, httplib::Response & res); + /** + * @brief Handle GET /components/{component_id}/subcomponents - list nested components. + */ + void handle_get_subcomponents(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Handle GET /components/{component_id}/related-apps - list apps on component. + */ + void handle_get_related_apps(const httplib::Request & req, httplib::Response & res); + private: HandlerContext & ctx_; }; @@ -65,4 +85,4 @@ class ComponentHandlers { } // namespace handlers } // namespace ros2_medkit_gateway -#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__COMPONENT_HANDLERS_HPP_ +#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__COMPONENT_HANDLERS_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/function_handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/function_handlers.hpp new file mode 100644 index 0000000..b856f8a --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/discovery/function_handlers.hpp @@ -0,0 +1,103 @@ +// 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__HTTP__HANDLERS__DISCOVERY__FUNCTION_HANDLERS_HPP_ +#define ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__FUNCTION_HANDLERS_HPP_ + +#include "ros2_medkit_gateway/http/handlers/handler_context.hpp" + +namespace ros2_medkit_gateway { +namespace handlers { + +/** + * @brief Handlers for function-related REST API endpoints. + * + * Functions are high-level capability groupings (e.g., "navigation", + * "localization") that may be hosted by multiple apps. + * + * Provides handlers for: + * - GET /functions - List all functions + * - GET /functions/{function-id} - Get function capabilities + * - GET /functions/{function-id}/hosts - Get apps that host this function + * - GET /functions/{function-id}/data - Get aggregated function data + * - GET /functions/{function-id}/operations - List function operations + * + * @verifies REQ_DISCOVERY_003 Functions discovery + */ +class FunctionHandlers { + public: + /** + * @brief Construct function handlers with shared context. + * @param ctx The shared handler context + */ + explicit FunctionHandlers(HandlerContext & ctx) : ctx_(ctx) { + } + + // ========================================================================= + // Collection endpoints + // ========================================================================= + + /** + * @brief Handle GET /functions - list all functions. + * + * Returns all functions discovered from manifest. + * In runtime-only mode, returns empty list. + */ + void handle_list_functions(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Handle GET /functions/{function-id} - get function capabilities. + * + * Returns function details with capabilities (hosts, data, operations) + * and HATEOAS links. + */ + void handle_get_function(const httplib::Request & req, httplib::Response & res); + + // ========================================================================= + // Function hosts + // ========================================================================= + + /** + * @brief Handle GET /functions/{function-id}/hosts - get hosts for function. + * + * Returns apps that provide this function. + */ + void handle_function_hosts(const httplib::Request & req, httplib::Response & res); + + // ========================================================================= + // Function data & operations (aggregated from host apps) + // ========================================================================= + + /** + * @brief Handle GET /functions/{function-id}/data - get aggregated data. + * + * Returns topics aggregated from all host apps. + */ + void handle_get_function_data(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Handle GET /functions/{function-id}/operations - list operations. + * + * Returns services and actions aggregated from all host apps. + */ + void handle_list_function_operations(const httplib::Request & req, httplib::Response & res); + + private: + HandlerContext & ctx_; +}; + +} // namespace handlers +} // namespace ros2_medkit_gateway + +#endif // ROS2_MEDKIT_GATEWAY__HTTP__HANDLERS__DISCOVERY__FUNCTION_HANDLERS_HPP_ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handlers.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handlers.hpp index f8b3988..57f2df4 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handlers.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handlers.hpp @@ -22,9 +22,14 @@ * Include this file to get access to all REST API handlers. */ -#include "ros2_medkit_gateway/http/handlers/area_handlers.hpp" +// Discovery handlers (areas, components, apps, functions) +#include "ros2_medkit_gateway/http/handlers/discovery/app_handlers.hpp" +#include "ros2_medkit_gateway/http/handlers/discovery/area_handlers.hpp" +#include "ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp" +#include "ros2_medkit_gateway/http/handlers/discovery/function_handlers.hpp" + +// Other handlers #include "ros2_medkit_gateway/http/handlers/auth_handlers.hpp" -#include "ros2_medkit_gateway/http/handlers/component_handlers.hpp" #include "ros2_medkit_gateway/http/handlers/config_handlers.hpp" #include "ros2_medkit_gateway/http/handlers/fault_handlers.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp index ad1b1bc..2d2bcb3 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp @@ -86,6 +86,8 @@ class RESTServer { std::unique_ptr health_handlers_; std::unique_ptr area_handlers_; std::unique_ptr component_handlers_; + std::unique_ptr app_handlers_; + std::unique_ptr function_handlers_; std::unique_ptr operation_handlers_; std::unique_ptr config_handlers_; std::unique_ptr fault_handlers_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp index f7b8f4c..01c86ed 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models.hpp @@ -14,167 +14,26 @@ #pragma once +// Include all discovery models from the new modular structure +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/common.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" + #include -#include -#include -#include #include namespace ros2_medkit_gateway { -using json = nlohmann::json; - -/** - * @brief QoS profile information for a topic endpoint - */ -struct QosProfile { - std::string reliability; ///< "reliable", "best_effort", "system_default", "unknown" - std::string durability; ///< "volatile", "transient_local", "system_default", "unknown" - std::string history; ///< "keep_last", "keep_all", "system_default", "unknown" - size_t depth{0}; ///< History depth (for keep_last) - std::string liveliness; ///< "automatic", "manual_by_topic", "system_default", "unknown" - - json to_json() const { - return {{"reliability", reliability}, - {"durability", durability}, - {"history", history}, - {"depth", depth}, - {"liveliness", liveliness}}; - } -}; - -/** - * @brief Information about an endpoint (publisher or subscriber) on a topic - */ -struct TopicEndpoint { - std::string node_name; ///< Name of the node (e.g., "controller_server") - std::string node_namespace; ///< Namespace of the node (e.g., "/navigation") - std::string topic_type; ///< Message type (e.g., "geometry_msgs/msg/Twist") - QosProfile qos; ///< QoS profile of this endpoint - - /// Get fully qualified node name - std::string fqn() const { - if (node_namespace == "/" || node_namespace.empty()) { - return "/" + node_name; - } - return node_namespace + "/" + node_name; - } - - json to_json() const { - return {{"node_name", node_name}, {"node_namespace", node_namespace}, {"fqn", fqn()}, {"qos", qos.to_json()}}; - } -}; - -/** - * @brief Topic with its publishers and subscribers - */ -struct TopicConnection { - std::string topic_name; ///< Full topic path (e.g., "/cmd_vel") - std::string topic_type; ///< Message type - std::vector publishers; - std::vector subscribers; - - json to_json() const { - json pub_json = json::array(); - for (const auto & p : publishers) { - pub_json.push_back(p.to_json()); - } - json sub_json = json::array(); - for (const auto & s : subscribers) { - sub_json.push_back(s.to_json()); - } - return {{"topic", topic_name}, {"type", topic_type}, {"publishers", pub_json}, {"subscribers", sub_json}}; - } -}; - /** - * @brief Topics associated with a component (node) + * @brief Cache for discovered entities */ -struct ComponentTopics { - std::vector publishes; ///< Topics this component publishes to - std::vector subscribes; ///< Topics this component subscribes to - - json to_json() const { - return {{"publishes", publishes}, {"subscribes", subscribes}}; - } -}; - -struct Area { - std::string id; - std::string namespace_path; - std::string type = "Area"; - - json to_json() const { - return {{"id", id}, {"namespace", namespace_path}, {"type", type}}; - } -}; - -/// Information about a ROS2 service discovered in the system -struct ServiceInfo { - std::string name; // Service name (e.g., "calibrate") - std::string full_path; // Full service path (e.g., "/powertrain/engine/calibrate") - std::string type; // Service type (e.g., "std_srvs/srv/Trigger") - std::optional type_info; // Schema info with request/response schemas - - json to_json() const { - json j = {{"name", name}, {"path", full_path}, {"type", type}, {"kind", "service"}}; - if (type_info.has_value()) { - j["type_info"] = type_info.value(); - } - return j; - } -}; - -/// Information about a ROS2 action discovered in the system -struct ActionInfo { - std::string name; // Action name (e.g., "navigate_to_pose") - std::string full_path; // Full action path (e.g., "/navigation/navigate_to_pose") - std::string type; // Action type (e.g., "nav2_msgs/action/NavigateToPose") - std::optional type_info; // Schema info with goal/result/feedback schemas - - json to_json() const { - json j = {{"name", name}, {"path", full_path}, {"type", type}, {"kind", "action"}}; - if (type_info.has_value()) { - j["type_info"] = type_info.value(); - } - return j; - } -}; - -struct Component { - std::string id; - std::string namespace_path; - std::string fqn; - std::string type = "Component"; - std::string area; - std::string source = "node"; ///< Discovery source: "node" or "topic" - std::vector services; - std::vector actions; - ComponentTopics topics; ///< Topics this component publishes/subscribes - - json to_json() const { - json j = {{"id", id}, {"namespace", namespace_path}, {"fqn", fqn}, {"type", type}, {"area", area}, - {"source", source}, {"topics", topics.to_json()}}; - - // Add operations array combining services and actions - json operations = json::array(); - for (const auto & svc : services) { - operations.push_back(svc.to_json()); - } - for (const auto & act : actions) { - operations.push_back(act.to_json()); - } - if (!operations.empty()) { - j["operations"] = operations; - } - - return j; - } -}; - struct EntityCache { std::vector areas; std::vector components; + std::vector apps; + std::vector functions; std::chrono::system_clock::time_point last_update; }; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp index 2ae77fe..2c840ce 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/operation_manager.hpp @@ -29,7 +29,7 @@ #include #include -#include "ros2_medkit_gateway/discovery_manager.hpp" +#include "ros2_medkit_gateway/discovery/discovery_manager.hpp" #include "ros2_medkit_gateway/models.hpp" #include "ros2_medkit_serialization/json_serializer.hpp" #include "ros2_medkit_serialization/service_action_types.hpp" diff --git a/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp b/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp new file mode 100644 index 0000000..f705702 --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp @@ -0,0 +1,277 @@ +// 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_manager.hpp" + +#include "ros2_medkit_gateway/discovery/hybrid_discovery.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"; + } +} + +DiscoveryManager::DiscoveryManager(rclcpp::Node * node) + : node_(node), runtime_strategy_(std::make_unique(node)) { + // Default to runtime strategy + active_strategy_ = runtime_strategy_.get(); +} + +bool DiscoveryManager::initialize(const DiscoveryConfig & config) { + config_ = config; + + RCLCPP_INFO(node_->get_logger(), "Initializing discovery with mode: %s", + discovery_mode_to_string(config.mode).c_str()); + + // Create manifest manager if needed + if (config.mode == DiscoveryMode::MANIFEST_ONLY || config.mode == DiscoveryMode::HYBRID) { + manifest_manager_ = std::make_unique(node_); + + if (!config.manifest_path.empty()) { + if (!manifest_manager_->load_manifest(config.manifest_path, config.manifest_strict_validation)) { + if (config.mode == DiscoveryMode::MANIFEST_ONLY) { + RCLCPP_ERROR(node_->get_logger(), "Manifest load failed and mode is manifest_only. Cannot proceed."); + return false; + } + RCLCPP_WARN(node_->get_logger(), "Manifest load failed. Falling back to runtime-only discovery."); + config_.mode = DiscoveryMode::RUNTIME_ONLY; + } + } else if (config.mode == DiscoveryMode::MANIFEST_ONLY) { + RCLCPP_ERROR(node_->get_logger(), "Manifest path required for manifest_only mode."); + return false; + } + } + + create_strategy(); + return true; +} + +void DiscoveryManager::create_strategy() { + switch (config_.mode) { + case DiscoveryMode::MANIFEST_ONLY: + // In manifest_only mode, we use a special mode where we return manifest entities + // without runtime linking. We still use the runtime_strategy for services/actions + // but main entities come from manifest. + active_strategy_ = runtime_strategy_.get(); + RCLCPP_INFO(node_->get_logger(), "Discovery mode: manifest_only"); + break; + + case DiscoveryMode::HYBRID: + hybrid_strategy_ = + std::make_unique(node_, manifest_manager_.get(), runtime_strategy_.get()); + active_strategy_ = hybrid_strategy_.get(); + RCLCPP_INFO(node_->get_logger(), "Discovery mode: hybrid"); + break; + + default: + active_strategy_ = runtime_strategy_.get(); + RCLCPP_INFO(node_->get_logger(), "Discovery mode: runtime_only"); + break; + } +} + +std::vector DiscoveryManager::discover_areas() { + if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_areas(); + } + return active_strategy_->discover_areas(); +} + +std::vector DiscoveryManager::discover_components() { + if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_components(); + } + return active_strategy_->discover_components(); +} + +std::vector DiscoveryManager::discover_apps() { + if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_apps(); + } + return active_strategy_->discover_apps(); +} + +std::vector DiscoveryManager::discover_functions() { + if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_functions(); + } + return active_strategy_->discover_functions(); +} + +std::optional DiscoveryManager::get_area(const std::string & id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_area(id); + } + // Fallback to runtime lookup + auto areas = discover_areas(); + for (const auto & a : areas) { + if (a.id == id) { + return a; + } + } + return std::nullopt; +} + +std::optional DiscoveryManager::get_component(const std::string & id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_component(id); + } + auto components = discover_components(); + for (const auto & c : components) { + if (c.id == id) { + return c; + } + } + return std::nullopt; +} + +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 +} + +std::optional DiscoveryManager::get_function(const std::string & id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_function(id); + } + return std::nullopt; // No functions in runtime-only mode +} + +std::vector DiscoveryManager::get_subareas(const std::string & area_id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_subareas(area_id); + } + return {}; // No subareas in runtime mode +} + +std::vector DiscoveryManager::get_subcomponents(const std::string & component_id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_subcomponents(component_id); + } + return {}; // No subcomponents in runtime mode +} + +std::vector DiscoveryManager::get_components_for_area(const std::string & area_id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_components_for_area(area_id); + } + // Fallback: filter runtime components by area + std::vector result; + auto all = discover_components(); + for (const auto & c : all) { + if (c.area == area_id) { + result.push_back(c); + } + } + return result; +} + +std::vector DiscoveryManager::get_apps_for_component(const std::string & component_id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_apps_for_component(component_id); + } + return {}; // No apps in runtime mode +} + +std::vector DiscoveryManager::get_hosts_for_function(const std::string & function_id) { + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_hosts_for_function(function_id); + } + return {}; +} + +std::vector DiscoveryManager::discover_topic_components() { + return runtime_strategy_->discover_topic_components(); +} + +std::vector DiscoveryManager::discover_services() { + return runtime_strategy_->discover_services(); +} + +std::vector DiscoveryManager::discover_actions() { + return runtime_strategy_->discover_actions(); +} + +std::optional DiscoveryManager::find_service(const std::string & component_ns, + const std::string & operation_name) const { + return runtime_strategy_->find_service(component_ns, operation_name); +} + +std::optional DiscoveryManager::find_action(const std::string & component_ns, + const std::string & operation_name) const { + return runtime_strategy_->find_action(component_ns, operation_name); +} + +void DiscoveryManager::set_topic_sampler(NativeTopicSampler * sampler) { + runtime_strategy_->set_topic_sampler(sampler); +} + +void DiscoveryManager::set_type_introspection(TypeIntrospection * introspection) { + runtime_strategy_->set_type_introspection(introspection); +} + +void DiscoveryManager::refresh_topic_map() { + runtime_strategy_->refresh_topic_map(); + if (hybrid_strategy_) { + hybrid_strategy_->refresh_linking(); + } +} + +bool DiscoveryManager::is_topic_map_ready() const { + return runtime_strategy_->is_topic_map_ready(); +} + +discovery::ManifestManager * DiscoveryManager::get_manifest_manager() { + return manifest_manager_.get(); +} + +bool DiscoveryManager::reload_manifest() { + if (!manifest_manager_) { + RCLCPP_WARN(node_->get_logger(), "No manifest manager to reload"); + return false; + } + bool result = manifest_manager_->reload_manifest(); + if (result && hybrid_strategy_) { + hybrid_strategy_->refresh_linking(); + } + return result; +} + +std::string DiscoveryManager::get_strategy_name() const { + if (active_strategy_) { + return active_strategy_->get_name(); + } + return "unknown"; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp b/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp new file mode 100644 index 0000000..0b66f50 --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/hybrid_discovery.cpp @@ -0,0 +1,147 @@ +// 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/hybrid_discovery.hpp" + +namespace ros2_medkit_gateway { +namespace discovery { + +HybridDiscoveryStrategy::HybridDiscoveryStrategy(rclcpp::Node * node, ManifestManager * manifest_manager, + RuntimeDiscoveryStrategy * runtime_strategy) + : node_(node), manifest_manager_(manifest_manager), runtime_strategy_(runtime_strategy), linker_(node) { + // Perform initial linking if manifest is loaded + if (manifest_manager_ && manifest_manager_->is_manifest_active()) { + perform_linking(); + } +} + +std::vector HybridDiscoveryStrategy::discover_areas() { + std::lock_guard lock(mutex_); + + if (!manifest_manager_ || !manifest_manager_->is_manifest_active()) { + // Fallback to runtime if no manifest + return runtime_strategy_->discover_areas(); + } + + return manifest_manager_->get_areas(); +} + +std::vector HybridDiscoveryStrategy::discover_components() { + std::lock_guard lock(mutex_); + + if (!manifest_manager_ || !manifest_manager_->is_manifest_active()) { + return runtime_strategy_->discover_components(); + } + + // Get manifest components + std::vector result = manifest_manager_->get_components(); + + // Get runtime components for enrichment + auto runtime_components = runtime_strategy_->discover_components(); + + // Build a map of runtime components by FQN for quick lookup + std::unordered_map runtime_map; + for (const auto & comp : runtime_components) { + runtime_map[comp.fqn] = ∁ + } + + // Enrich manifest components with runtime data if they match + for (auto & comp : result) { + auto it = runtime_map.find(comp.fqn); + if (it != runtime_map.end()) { + // Copy runtime data + comp.topics = it->second->topics; + comp.services = it->second->services; + comp.actions = it->second->actions; + } + } + + // Handle orphan nodes based on policy + auto config = manifest_manager_->get_config(); + if (config.unmanifested_nodes == ManifestConfig::UnmanifestedNodePolicy::INCLUDE_AS_ORPHAN) { + // Add orphan nodes as components + std::set manifest_fqns; + for (const auto & comp : result) { + manifest_fqns.insert(comp.fqn); + } + + for (const auto & runtime_comp : runtime_components) { + if (manifest_fqns.find(runtime_comp.fqn) == manifest_fqns.end()) { + Component orphan = runtime_comp; + orphan.source = "orphan"; + result.push_back(orphan); + } + } + } + + return result; +} + +std::vector HybridDiscoveryStrategy::discover_apps() { + std::lock_guard lock(mutex_); + + if (!manifest_manager_ || !manifest_manager_->is_manifest_active()) { + // No apps in runtime-only mode + return {}; + } + + // Return linked apps from last linking result + return linking_result_.linked_apps; +} + +std::vector HybridDiscoveryStrategy::discover_functions() { + std::lock_guard lock(mutex_); + + if (!manifest_manager_ || !manifest_manager_->is_manifest_active()) { + // No functions in runtime-only mode + return {}; + } + + return manifest_manager_->get_functions(); +} + +void HybridDiscoveryStrategy::refresh_linking() { + std::lock_guard lock(mutex_); + perform_linking(); +} + +void HybridDiscoveryStrategy::perform_linking() { + if (!manifest_manager_ || !manifest_manager_->is_manifest_active()) { + log_info("Cannot perform linking: no active manifest"); + return; + } + + // Get manifest apps + auto apps = manifest_manager_->get_apps(); + + // Get runtime components + auto runtime_components = runtime_strategy_->discover_components(); + + // Get config for orphan policy + auto config = manifest_manager_->get_config(); + + // Perform linking + linking_result_ = linker_.link(apps, runtime_components, config); + + log_info("Hybrid linking complete: " + linking_result_.summary()); +} + +void HybridDiscoveryStrategy::log_info(const std::string & msg) const { + if (node_) { + RCLCPP_INFO(node_->get_logger(), "%s", msg.c_str()); + } +} + +} // namespace discovery +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp new file mode 100644 index 0000000..4eae15a --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_manager.cpp @@ -0,0 +1,445 @@ +// 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/manifest/manifest_manager.hpp" + +namespace ros2_medkit_gateway { +namespace discovery { + +ManifestManager::ManifestManager(rclcpp::Node * node) : node_(node) { +} + +bool ManifestManager::load_manifest(const std::string & file_path, bool strict) { + std::lock_guard lock(mutex_); + + strict_mode_ = strict; + manifest_path_ = file_path; + + try { + log_info("Loading manifest from: " + file_path); + Manifest loaded = parser_.parse_file(file_path); + + // Validate + validation_result_ = validator_.validate(loaded); + + // Always fail on ERRORs (broken references, circular deps, duplicate bindings) + // These indicate a fundamentally broken manifest that would cause runtime issues + if (validation_result_.has_errors()) { + for (const auto & err : validation_result_.errors) { + log_error("Manifest error: " + err.to_string()); + } + log_error("Manifest validation failed: " + std::to_string(validation_result_.errors.size()) + " errors found"); + return false; + } + + // In strict mode, also fail on warnings; otherwise just log them + for (const auto & warn : validation_result_.warnings) { + log_warn("Manifest warning: " + warn.to_string()); + } + if (strict && validation_result_.has_warnings()) { + log_error("Manifest validation failed (strict mode): warnings treated as errors"); + return false; + } + + manifest_ = std::move(loaded); + build_lookup_maps(); + + log_info("Manifest loaded successfully: " + std::to_string(manifest_->areas.size()) + " areas, " + + std::to_string(manifest_->components.size()) + " components, " + std::to_string(manifest_->apps.size()) + + " apps, " + std::to_string(manifest_->functions.size()) + " functions"); + return true; + + } catch (const std::exception & e) { + log_error("Failed to load manifest: " + std::string(e.what())); + validation_result_.add_error("LOAD", e.what(), file_path); + return false; + } +} + +bool ManifestManager::load_manifest_from_string(const std::string & yaml_content, bool strict) { + std::lock_guard lock(mutex_); + + strict_mode_ = strict; + manifest_path_ = ""; + + try { + Manifest loaded = parser_.parse_string(yaml_content); + validation_result_ = validator_.validate(loaded); + + // Always fail on ERRORs (broken references, circular deps, duplicate bindings) + if (validation_result_.has_errors()) { + for (const auto & err : validation_result_.errors) { + log_error("Manifest error: " + err.to_string()); + } + log_error("Manifest validation failed: " + std::to_string(validation_result_.errors.size()) + " errors found"); + return false; + } + + // In strict mode, also fail on warnings; otherwise just log them + for (const auto & warn : validation_result_.warnings) { + log_warn("Manifest warning: " + warn.to_string()); + } + if (strict && validation_result_.has_warnings()) { + log_error("Manifest validation failed (strict mode): warnings treated as errors"); + return false; + } + + manifest_ = std::move(loaded); + build_lookup_maps(); + return true; + + } catch (const std::exception & e) { + log_error("Failed to parse manifest: " + std::string(e.what())); + validation_result_.add_error("LOAD", e.what(), ""); + return false; + } +} + +bool ManifestManager::reload_manifest() { + // Note: This method acquires lock internally via load_manifest + if (manifest_path_.empty() || manifest_path_ == "") { + log_warn("No manifest file path to reload"); + return false; + } + + // Store current state + std::optional old_manifest; + bool old_strict_mode; + { + std::lock_guard lock(mutex_); + old_manifest = std::move(manifest_); + old_strict_mode = strict_mode_; + manifest_.reset(); + } + + std::string path_to_reload = manifest_path_; + + if (!load_manifest(path_to_reload, old_strict_mode)) { + // Restore old manifest on failure + std::lock_guard lock(mutex_); + manifest_ = std::move(old_manifest); + log_warn("Manifest reload failed, keeping previous version"); + return false; + } + + log_info("Manifest reloaded successfully"); + return true; +} + +void ManifestManager::unload_manifest() { + std::lock_guard lock(mutex_); + + manifest_.reset(); + manifest_path_.clear(); + validation_result_ = ValidationResult{}; + area_index_.clear(); + component_index_.clear(); + app_index_.clear(); + function_index_.clear(); + + log_info("Manifest unloaded"); +} + +bool ManifestManager::is_manifest_active() const { + std::lock_guard lock(mutex_); + return manifest_.has_value() && manifest_->is_loaded(); +} + +std::string ManifestManager::get_manifest_path() const { + std::lock_guard lock(mutex_); + return manifest_path_; +} + +ValidationResult ManifestManager::get_validation_result() const { + std::lock_guard lock(mutex_); + return validation_result_; +} + +std::optional ManifestManager::get_metadata() const { + std::lock_guard lock(mutex_); + if (manifest_) { + return manifest_->metadata; + } + return std::nullopt; +} + +ManifestConfig ManifestManager::get_config() const { + std::lock_guard lock(mutex_); + if (manifest_) { + return manifest_->config; + } + return ManifestConfig{}; +} + +std::vector ManifestManager::get_areas() const { + std::lock_guard lock(mutex_); + if (manifest_) { + return manifest_->areas; + } + return {}; +} + +std::vector ManifestManager::get_components() const { + std::lock_guard lock(mutex_); + if (manifest_) { + return manifest_->components; + } + return {}; +} + +std::vector ManifestManager::get_apps() const { + std::lock_guard lock(mutex_); + if (manifest_) { + return manifest_->apps; + } + return {}; +} + +std::vector ManifestManager::get_functions() const { + std::lock_guard lock(mutex_); + if (manifest_) { + return manifest_->functions; + } + return {}; +} + +std::optional ManifestManager::get_area(const std::string & id) const { + std::lock_guard lock(mutex_); + if (!manifest_) { + return std::nullopt; + } + + auto it = area_index_.find(id); + if (it != area_index_.end()) { + return manifest_->areas[it->second]; + } + return std::nullopt; +} + +std::optional ManifestManager::get_component(const std::string & id) const { + std::lock_guard lock(mutex_); + if (!manifest_) { + return std::nullopt; + } + + auto it = component_index_.find(id); + if (it != component_index_.end()) { + return manifest_->components[it->second]; + } + return std::nullopt; +} + +std::optional ManifestManager::get_app(const std::string & id) const { + std::lock_guard lock(mutex_); + if (!manifest_) { + return std::nullopt; + } + + auto it = app_index_.find(id); + if (it != app_index_.end()) { + return manifest_->apps[it->second]; + } + return std::nullopt; +} + +std::optional ManifestManager::get_function(const std::string & id) const { + std::lock_guard lock(mutex_); + if (!manifest_) { + return std::nullopt; + } + + auto it = function_index_.find(id); + if (it != function_index_.end()) { + return manifest_->functions[it->second]; + } + return std::nullopt; +} + +std::vector ManifestManager::get_components_for_area(const std::string & area_id) const { + std::lock_guard lock(mutex_); + std::vector result; + if (!manifest_) { + return result; + } + + // Collect all area IDs that are descendants of area_id (including area_id itself) + std::vector area_ids; + area_ids.push_back(area_id); + + // Find all descendant areas (areas whose parent is in our list) + bool found_new = true; + while (found_new) { + found_new = false; + for (const auto & area : manifest_->areas) { + // Check if this area's parent is in our list + if (!area.parent_area_id.empty()) { + bool parent_in_list = std::find(area_ids.begin(), area_ids.end(), area.parent_area_id) != area_ids.end(); + bool area_in_list = std::find(area_ids.begin(), area_ids.end(), area.id) != area_ids.end(); + if (parent_in_list && !area_in_list) { + area_ids.push_back(area.id); + found_new = true; + } + } + } + } + + // Now collect components from all matching areas + for (const auto & comp : manifest_->components) { + if (std::find(area_ids.begin(), area_ids.end(), comp.area) != area_ids.end()) { + result.push_back(comp); + } + } + return result; +} + +std::vector ManifestManager::get_apps_for_component(const std::string & component_id) const { + std::lock_guard lock(mutex_); + std::vector result; + if (!manifest_) { + return result; + } + + for (const auto & app : manifest_->apps) { + if (app.component_id == component_id) { + result.push_back(app); + } + } + return result; +} + +std::vector ManifestManager::get_hosts_for_function(const std::string & function_id) const { + std::lock_guard lock(mutex_); + if (!manifest_) { + return {}; + } + + auto it = function_index_.find(function_id); + if (it != function_index_.end()) { + return manifest_->functions[it->second].hosts; + } + return {}; +} + +std::vector ManifestManager::get_subareas(const std::string & area_id) const { + std::lock_guard lock(mutex_); + std::vector result; + if (!manifest_) { + return result; + } + + for (const auto & area : manifest_->areas) { + if (area.parent_area_id == area_id) { + result.push_back(area); + } + } + return result; +} + +std::vector ManifestManager::get_subcomponents(const std::string & component_id) const { + std::lock_guard lock(mutex_); + std::vector result; + if (!manifest_) { + return result; + } + + for (const auto & comp : manifest_->components) { + if (comp.parent_component_id == component_id) { + result.push_back(comp); + } + } + return result; +} + +std::optional ManifestManager::get_capabilities_override(const std::string & entity_id) const { + std::lock_guard lock(mutex_); + if (!manifest_) { + return std::nullopt; + } + + auto it = manifest_->capabilities.find(entity_id); + if (it != manifest_->capabilities.end()) { + return it->second; + } + return std::nullopt; +} + +json ManifestManager::get_status_json() const { + std::lock_guard lock(mutex_); + + bool active = manifest_.has_value() && manifest_->is_loaded(); + + json status = {{"active", active}, {"path", manifest_path_}}; + + if (manifest_) { + status["metadata"] = {{"name", manifest_->metadata.name}, + {"version", manifest_->metadata.version}, + {"description", manifest_->metadata.description}}; + status["manifest_version"] = manifest_->manifest_version; + status["entity_counts"] = {{"areas", manifest_->areas.size()}, + {"components", manifest_->components.size()}, + {"apps", manifest_->apps.size()}, + {"functions", manifest_->functions.size()}}; + } + + status["validation"] = {{"is_valid", validation_result_.is_valid}, + {"error_count", validation_result_.errors.size()}, + {"warning_count", validation_result_.warnings.size()}}; + + return status; +} + +void ManifestManager::build_lookup_maps() { + area_index_.clear(); + component_index_.clear(); + app_index_.clear(); + function_index_.clear(); + + if (!manifest_) { + return; + } + + for (size_t i = 0; i < manifest_->areas.size(); ++i) { + area_index_[manifest_->areas[i].id] = i; + } + for (size_t i = 0; i < manifest_->components.size(); ++i) { + component_index_[manifest_->components[i].id] = i; + } + for (size_t i = 0; i < manifest_->apps.size(); ++i) { + app_index_[manifest_->apps[i].id] = i; + } + for (size_t i = 0; i < manifest_->functions.size(); ++i) { + function_index_[manifest_->functions[i].id] = i; + } +} + +void ManifestManager::log_info(const std::string & msg) const { + if (node_) { + RCLCPP_INFO(node_->get_logger(), "%s", msg.c_str()); + } +} + +void ManifestManager::log_warn(const std::string & msg) const { + if (node_) { + RCLCPP_WARN(node_->get_logger(), "%s", msg.c_str()); + } +} + +void ManifestManager::log_error(const std::string & msg) const { + if (node_) { + RCLCPP_ERROR(node_->get_logger(), "%s", msg.c_str()); + } +} + +} // namespace discovery +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp new file mode 100644 index 0000000..da1548d --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp @@ -0,0 +1,267 @@ +// 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/manifest/manifest_parser.hpp" + +#include + +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +Manifest ManifestParser::parse_file(const std::string & file_path) const { + std::ifstream file(file_path); + if (!file.is_open()) { + throw std::runtime_error("Cannot open manifest file: " + file_path); + } + + std::stringstream buffer; + buffer << file.rdbuf(); + return parse_string(buffer.str()); +} + +Manifest ManifestParser::parse_string(const std::string & yaml_content) const { + YAML::Node root; + try { + root = YAML::Load(yaml_content); + } catch (const YAML::Exception & e) { + throw std::runtime_error("YAML parse error: " + std::string(e.what())); + } + + Manifest manifest; + + // Parse version (required) + manifest.manifest_version = get_string(root, "manifest_version"); + if (manifest.manifest_version.empty()) { + throw std::runtime_error("Missing required field: manifest_version"); + } + + // Parse metadata + if (root["metadata"]) { + manifest.metadata = parse_metadata(root["metadata"]); + } + + // Parse discovery config + if (root["discovery"]) { + manifest.config = parse_config(root["discovery"]); + } + + // Parse areas (with recursive subareas) + if (root["areas"] && root["areas"].IsSequence()) { + for (const auto & node : root["areas"]) { + parse_area_recursive(node, "", manifest.areas); + } + } + + // Parse components + if (root["components"] && root["components"].IsSequence()) { + for (const auto & node : root["components"]) { + manifest.components.push_back(parse_component(node)); + } + } + + // Parse apps + if (root["apps"] && root["apps"].IsSequence()) { + for (const auto & node : root["apps"]) { + manifest.apps.push_back(parse_app(node)); + } + } + + // Parse functions + if (root["functions"] && root["functions"].IsSequence()) { + for (const auto & node : root["functions"]) { + manifest.functions.push_back(parse_function(node)); + } + } + + // Parse capabilities (optional map) + if (root["capabilities"] && root["capabilities"].IsMap()) { + for (const auto & it : root["capabilities"]) { + std::string entity_id = it.first.as(); + // Store as empty JSON object for now + // Full YAML->JSON conversion can be added if needed + manifest.capabilities[entity_id] = json::object(); + } + } + + return manifest; +} + +ManifestMetadata ManifestParser::parse_metadata(const YAML::Node & node) const { + ManifestMetadata meta; + meta.name = get_string(node, "name"); + meta.description = get_string(node, "description"); + meta.version = get_string(node, "version"); + meta.created_at = get_string(node, "created_at"); + return meta; +} + +ManifestConfig ManifestParser::parse_config(const YAML::Node & node) const { + ManifestConfig config; + + std::string policy = get_string(node, "unmanifested_nodes", "warn"); + config.unmanifested_nodes = ManifestConfig::parse_policy(policy); + + if (node["inherit_runtime_resources"]) { + config.inherit_runtime_resources = node["inherit_runtime_resources"].as(); + } + if (node["allow_manifest_override"]) { + config.allow_manifest_override = node["allow_manifest_override"].as(); + } + + return config; +} + +void ManifestParser::parse_area_recursive(const YAML::Node & node, const std::string & parent_id, + std::vector & areas) const { + Area area; + area.id = get_string(node, "id"); + area.name = get_string(node, "name", area.id); // Default to id if no name + area.namespace_path = get_string(node, "namespace", "/" + area.id); + area.translation_id = get_string(node, "translation_id"); + area.description = get_string(node, "description"); + area.tags = get_string_vector(node, "tags"); + // Set parent from recursive call, or from explicit parent_area field + area.parent_area_id = parent_id.empty() ? get_string(node, "parent_area") : parent_id; + + areas.push_back(area); + + // Recursively parse nested subareas + if (node["subareas"] && node["subareas"].IsSequence()) { + for (const auto & subarea_node : node["subareas"]) { + parse_area_recursive(subarea_node, area.id, areas); + } + } +} + +Component ManifestParser::parse_component(const YAML::Node & node) const { + Component comp; + comp.id = get_string(node, "id"); + comp.name = get_string(node, "name", comp.id); + comp.namespace_path = get_string(node, "namespace"); + comp.area = get_string(node, "area"); + comp.translation_id = get_string(node, "translation_id"); + comp.description = get_string(node, "description"); + comp.variant = get_string(node, "variant"); + comp.tags = get_string_vector(node, "tags"); + comp.parent_component_id = get_string(node, "parent_component_id"); + comp.source = "manifest"; + + // Parse type if provided (e.g., "controller", "sensor", "actuator") + std::string type_val = get_string(node, "type"); + if (!type_val.empty()) { + comp.type = type_val; + } + + // Compute FQN if namespace and id are provided + if (!comp.namespace_path.empty()) { + comp.fqn = comp.namespace_path + "/" + comp.id; + } else { + comp.fqn = "/" + comp.id; + } + + return comp; +} + +App ManifestParser::parse_app(const YAML::Node & node) const { + App app; + app.id = get_string(node, "id"); + app.name = get_string(node, "name", app.id); + app.translation_id = get_string(node, "translation_id"); + app.description = get_string(node, "description"); + app.component_id = get_string(node, "is_located_on"); + app.depends_on = get_string_vector(node, "depends_on"); + app.tags = get_string_vector(node, "tags"); + app.external = node["external"] ? node["external"].as() : false; + app.source = "manifest"; + + // Parse ros_binding + if (node["ros_binding"]) { + App::RosBinding binding; + binding.node_name = get_string(node["ros_binding"], "node_name"); + binding.namespace_pattern = get_string(node["ros_binding"], "namespace", "*"); + binding.topic_namespace = get_string(node["ros_binding"], "topic_namespace"); + app.ros_binding = binding; + } + + return app; +} + +Function ManifestParser::parse_function(const YAML::Node & node) const { + Function func; + func.id = get_string(node, "id"); + func.name = get_string(node, "name", func.id); + func.translation_id = get_string(node, "translation_id"); + func.description = get_string(node, "description"); + // Support both "hosted_by" (manifest) and "hosts" (internal) + func.hosts = get_string_vector(node, "hosted_by"); + if (func.hosts.empty()) { + func.hosts = get_string_vector(node, "hosts"); + } + func.depends_on = get_string_vector(node, "depends_on"); + func.tags = get_string_vector(node, "tags"); + func.source = "manifest"; + + return func; +} + +std::string ManifestParser::get_string(const YAML::Node & node, const std::string & key, + const std::string & default_val) const { + if (node[key]) { + return node[key].as(); + } + return default_val; +} + +std::vector ManifestParser::get_string_vector(const YAML::Node & node, const std::string & key) const { + std::vector result; + if (node[key] && node[key].IsSequence()) { + for (const auto & item : node[key]) { + result.push_back(item.as()); + } + } + return result; +} + +// ManifestConfig helper implementations +ManifestConfig::UnmanifestedNodePolicy ManifestConfig::parse_policy(const std::string & str) { + if (str == "ignore") { + return UnmanifestedNodePolicy::IGNORE; + } + if (str == "error") { + return UnmanifestedNodePolicy::ERROR; + } + if (str == "include_as_orphan") { + return UnmanifestedNodePolicy::INCLUDE_AS_ORPHAN; + } + return UnmanifestedNodePolicy::WARN; // Default +} + +std::string ManifestConfig::policy_to_string(UnmanifestedNodePolicy policy) { + switch (policy) { + case UnmanifestedNodePolicy::IGNORE: + return "ignore"; + case UnmanifestedNodePolicy::ERROR: + return "error"; + case UnmanifestedNodePolicy::INCLUDE_AS_ORPHAN: + return "include_as_orphan"; + default: + return "warn"; + } +} + +} // namespace discovery +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_validator.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_validator.cpp new file mode 100644 index 0000000..8c91d2b --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_validator.cpp @@ -0,0 +1,297 @@ +// 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/manifest/manifest_validator.hpp" + +#include +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +ValidationResult ManifestValidator::validate(const Manifest & manifest) const { + ValidationResult result; + + validate_version(manifest, result); + validate_unique_ids(manifest, result); + validate_area_references(manifest, result); + validate_component_references(manifest, result); + validate_app_references(manifest, result); + validate_function_references(manifest, result); + validate_ros_bindings(manifest, result); + validate_circular_dependencies(manifest, result); + + return result; +} + +void ManifestValidator::validate_version(const Manifest & manifest, ValidationResult & result) const { + if (manifest.manifest_version != "1.0") { + result.add_error("R001", "Invalid manifest_version: '" + manifest.manifest_version + "', expected '1.0'", + "manifest_version"); + } +} + +void ManifestValidator::validate_unique_ids(const Manifest & manifest, ValidationResult & result) const { + std::set seen_ids; + + // Check area IDs + for (const auto & area : manifest.areas) { + if (seen_ids.count(area.id)) { + result.add_error("R002", "Duplicate area ID: " + area.id, "areas"); + } + seen_ids.insert(area.id); + } + + // Check component IDs + for (const auto & comp : manifest.components) { + if (seen_ids.count(comp.id)) { + result.add_error("R003", "Duplicate component ID: " + comp.id, "components"); + } + seen_ids.insert(comp.id); + } + + // Check app IDs + for (const auto & app : manifest.apps) { + if (seen_ids.count(app.id)) { + result.add_error("R004", "Duplicate app ID: " + app.id, "apps"); + } + seen_ids.insert(app.id); + } + + // Check function IDs + for (const auto & func : manifest.functions) { + if (seen_ids.count(func.id)) { + result.add_error("R005", "Duplicate function ID: " + func.id, "functions"); + } + seen_ids.insert(func.id); + } +} + +void ManifestValidator::validate_area_references(const Manifest & manifest, ValidationResult & result) const { + std::set area_ids; + for (const auto & area : manifest.areas) { + area_ids.insert(area.id); + } + + // Check parent_area references + for (const auto & area : manifest.areas) { + if (!area.parent_area_id.empty() && !area_ids.count(area.parent_area_id)) { + result.add_error("R006", "Area '" + area.id + "' references non-existent parent_area: " + area.parent_area_id, + "areas/" + area.id + "/parent_area"); + } + } +} + +void ManifestValidator::validate_component_references(const Manifest & manifest, ValidationResult & result) const { + std::set area_ids; + for (const auto & area : manifest.areas) { + area_ids.insert(area.id); + } + + std::set comp_ids; + for (const auto & comp : manifest.components) { + comp_ids.insert(comp.id); + } + + for (const auto & comp : manifest.components) { + // Check area reference + if (!comp.area.empty() && !area_ids.count(comp.area)) { + result.add_error("R006", "Component '" + comp.id + "' references non-existent area: " + comp.area, + "components/" + comp.id + "/area"); + } + + // Check parent_component reference + if (!comp.parent_component_id.empty() && !comp_ids.count(comp.parent_component_id)) { + result.add_error( + "R006", "Component '" + comp.id + "' references non-existent parent_component: " + comp.parent_component_id, + "components/" + comp.id + "/parent_component"); + } + } +} + +void ManifestValidator::validate_app_references(const Manifest & manifest, ValidationResult & result) const { + std::set comp_ids; + for (const auto & comp : manifest.components) { + comp_ids.insert(comp.id); + } + + std::set app_ids; + for (const auto & app : manifest.apps) { + app_ids.insert(app.id); + } + + for (const auto & app : manifest.apps) { + // Check component reference + if (!app.component_id.empty() && !comp_ids.count(app.component_id)) { + result.add_error("R007", "App '" + app.id + "' references non-existent component: " + app.component_id, + "apps/" + app.id + "/component"); + } + + // Check depends_on references (warning only) + for (const auto & dep : app.depends_on) { + if (!app_ids.count(dep)) { + result.add_warning("R008", "App '" + app.id + "' depends on non-existent app: " + dep, + "apps/" + app.id + "/depends_on"); + } + } + } +} + +void ManifestValidator::validate_function_references(const Manifest & manifest, ValidationResult & result) const { + std::set func_ids; + for (const auto & func : manifest.functions) { + func_ids.insert(func.id); + } + + for (const auto & func : manifest.functions) { + // Check hosts references (warning only) + for (const auto & host : func.hosts) { + if (!entity_exists(manifest, host)) { + result.add_warning("R009", "Function '" + func.id + "' hosts non-existent entity: " + host, + "functions/" + func.id + "/hosts"); + } + } + + // Check depends_on references + for (const auto & dep : func.depends_on) { + if (!func_ids.count(dep)) { + result.add_warning("R008", "Function '" + func.id + "' depends on non-existent function: " + dep, + "functions/" + func.id + "/depends_on"); + } + } + } +} + +void ManifestValidator::validate_ros_bindings(const Manifest & manifest, ValidationResult & result) const { + std::set seen_bindings; + + for (const auto & app : manifest.apps) { + if (app.ros_binding.has_value() && !app.ros_binding->is_empty()) { + std::string binding_key = app.ros_binding->node_name + "@" + app.ros_binding->namespace_pattern; + + // For topic namespace bindings + if (!app.ros_binding->topic_namespace.empty()) { + binding_key = "topic:" + app.ros_binding->topic_namespace; + } + + // Only flag exact duplicates (not wildcards) + if (seen_bindings.count(binding_key) && binding_key.find('*') == std::string::npos) { + result.add_error("R010", "Duplicate ROS binding: " + binding_key + " (app: " + app.id + ")", + "apps/" + app.id + "/ros_binding"); + } + seen_bindings.insert(binding_key); + } + } +} + +void ManifestValidator::validate_circular_dependencies(const Manifest & manifest, ValidationResult & result) const { + // Build dependency graph for apps + std::unordered_map> app_graph; + for (const auto & app : manifest.apps) { + app_graph[app.id] = app.depends_on; + } + + // Check for cycles in app dependencies + for (const auto & app : manifest.apps) { + if (has_cycle(app.id, app_graph)) { + result.add_error("R011", "Circular dependency detected involving app: " + app.id, + "apps/" + app.id + "/depends_on"); + break; // One cycle error is enough + } + } + + // Build and check function dependency graph + std::unordered_map> func_graph; + for (const auto & func : manifest.functions) { + func_graph[func.id] = func.depends_on; + } + + for (const auto & func : manifest.functions) { + if (has_cycle(func.id, func_graph)) { + result.add_error("R011", "Circular dependency detected involving function: " + func.id, + "functions/" + func.id + "/depends_on"); + break; + } + } +} + +bool ManifestValidator::entity_exists(const Manifest & manifest, const std::string & id) const { + for (const auto & a : manifest.areas) { + if (a.id == id) { + return true; + } + } + for (const auto & c : manifest.components) { + if (c.id == id) { + return true; + } + } + for (const auto & a : manifest.apps) { + if (a.id == id) { + return true; + } + } + for (const auto & f : manifest.functions) { + if (f.id == id) { + return true; + } + } + return false; +} + +bool ManifestValidator::has_cycle(const std::string & start, + const std::unordered_map> & graph) const { + std::set visited; + std::set in_stack; + std::stack stack; + + stack.push(start); + in_stack.insert(start); + + while (!stack.empty()) { + std::string current = stack.top(); + + auto it = graph.find(current); + if (it == graph.end() || visited.count(current)) { + stack.pop(); + in_stack.erase(current); + visited.insert(current); + continue; + } + + bool found_unvisited = false; + for (const auto & dep : it->second) { + if (in_stack.count(dep)) { + return true; // Cycle detected + } + if (!visited.count(dep) && graph.count(dep)) { + stack.push(dep); + in_stack.insert(dep); + found_unvisited = true; + break; + } + } + + if (!found_unvisited) { + stack.pop(); + in_stack.erase(current); + visited.insert(current); + } + } + + return false; +} + +} // namespace discovery +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp new file mode 100644 index 0000000..4f30360 --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp @@ -0,0 +1,248 @@ +// 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/manifest/runtime_linker.hpp" + +#include + +namespace ros2_medkit_gateway { +namespace discovery { + +RuntimeLinker::RuntimeLinker(rclcpp::Node * node) : node_(node) { +} + +LinkingResult RuntimeLinker::link(const std::vector & apps, const std::vector & runtime_components, + const ManifestConfig & config) { + LinkingResult result; + + // Build a map of node FQN -> Component for quick lookup + std::unordered_map fqn_to_component; + for (const auto & comp : runtime_components) { + fqn_to_component[comp.fqn] = ∁ + } + + // Track which runtime nodes have been matched + std::set matched_nodes; + + // Process each manifest app + for (const auto & manifest_app : apps) { + App linked_app = manifest_app; // Copy + linked_app.is_online = false; + + // External apps don't need ROS binding + if (manifest_app.external) { + result.linked_apps.push_back(linked_app); + continue; + } + + // If no ROS binding, app can't be linked + if (!manifest_app.ros_binding.has_value() || manifest_app.ros_binding->is_empty()) { + result.unlinked_app_ids.push_back(manifest_app.id); + result.linked_apps.push_back(linked_app); + continue; + } + + const auto & binding = manifest_app.ros_binding.value(); + bool found = false; + + // Try to find matching runtime node + for (const auto & comp : runtime_components) { + // Extract node name and namespace from component + std::string node_name = comp.id; + std::string node_ns = comp.namespace_path; + + if (matches_binding(binding, comp.fqn, node_name, node_ns)) { + // Match found! + linked_app.bound_fqn = comp.fqn; + linked_app.is_online = true; + enrich_app(linked_app, comp); + + result.app_to_node[manifest_app.id] = comp.fqn; + result.node_to_app[comp.fqn] = manifest_app.id; + matched_nodes.insert(comp.fqn); + found = true; + + log_debug("Linked app '" + manifest_app.id + "' to node '" + comp.fqn + "'"); + break; + } + + // Try topic namespace matching + if (!binding.topic_namespace.empty() && matches_topic_namespace(binding.topic_namespace, comp)) { + linked_app.bound_fqn = comp.fqn; + linked_app.is_online = true; + enrich_app(linked_app, comp); + + result.app_to_node[manifest_app.id] = comp.fqn; + result.node_to_app[comp.fqn] = manifest_app.id; + matched_nodes.insert(comp.fqn); + found = true; + + log_debug("Linked app '" + manifest_app.id + "' to node '" + comp.fqn + "' (topic namespace)"); + break; + } + } + + if (!found) { + result.unlinked_app_ids.push_back(manifest_app.id); + log_debug("App '" + manifest_app.id + "' not linked (no matching node)"); + } + + result.linked_apps.push_back(linked_app); + } + + // Find orphan nodes (runtime nodes not matching any manifest app) + for (const auto & comp : runtime_components) { + if (matched_nodes.find(comp.fqn) == matched_nodes.end()) { + result.orphan_nodes.push_back(comp.fqn); + } + } + + // Log summary + log_info("Runtime linking: " + result.summary()); + + // Handle orphan nodes according to policy + if (!result.orphan_nodes.empty()) { + switch (config.unmanifested_nodes) { + case ManifestConfig::UnmanifestedNodePolicy::IGNORE: + log_debug("Ignoring " + std::to_string(result.orphan_nodes.size()) + " orphan nodes"); + break; + + case ManifestConfig::UnmanifestedNodePolicy::WARN: + for (const auto & orphan : result.orphan_nodes) { + log_warn("Orphan node (not in manifest): " + orphan); + } + break; + + case ManifestConfig::UnmanifestedNodePolicy::ERROR: + log_error("Orphan nodes detected with 'error' policy. Discovery will fail."); + break; + + case ManifestConfig::UnmanifestedNodePolicy::INCLUDE_AS_ORPHAN: + log_info("Including " + std::to_string(result.orphan_nodes.size()) + " orphan nodes with source='orphan'"); + break; + } + } + + last_result_ = result; + return result; +} + +bool RuntimeLinker::matches_binding(const App::RosBinding & binding, const std::string & node_fqn, + const std::string & node_name, const std::string & node_namespace) const { + // Check node name match + if (binding.node_name.empty()) { + return false; + } + + // Node name can be simple or with subpath (e.g., "local_costmap/local_costmap") + // Check if binding.node_name matches node_name or is contained in fqn + bool name_matches = (node_name == binding.node_name) || (node_fqn.find("/" + binding.node_name) != std::string::npos); + + if (!name_matches) { + return false; + } + + // Check namespace match + if (binding.namespace_pattern == "*") { + // Wildcard matches any namespace + return true; + } + + // Exact namespace match + std::string expected_ns = binding.namespace_pattern; + if (expected_ns.empty()) { + expected_ns = "/"; + } + + // Normalize namespaces for comparison + std::string actual_ns = node_namespace; + if (actual_ns.empty()) { + actual_ns = "/"; + } + + return actual_ns == expected_ns || actual_ns.find(expected_ns) == 0; // Prefix match +} + +bool RuntimeLinker::matches_topic_namespace(const std::string & topic_namespace, const Component & component) const { + // Check if any topic starts with the given namespace + for (const auto & topic : component.topics.publishes) { + if (topic.find(topic_namespace) == 0) { + return true; + } + } + for (const auto & topic : component.topics.subscribes) { + if (topic.find(topic_namespace) == 0) { + return true; + } + } + return false; +} + +void RuntimeLinker::enrich_app(App & app, const Component & component) { + // Copy topics + app.topics = component.topics; + + // Copy services + app.services = component.services; + + // Copy actions + app.actions = component.actions; +} + +bool RuntimeLinker::is_app_online(const std::string & app_id) const { + return last_result_.app_to_node.find(app_id) != last_result_.app_to_node.end(); +} + +std::optional RuntimeLinker::get_bound_node(const std::string & app_id) const { + auto it = last_result_.app_to_node.find(app_id); + if (it != last_result_.app_to_node.end()) { + return it->second; + } + return std::nullopt; +} + +std::optional RuntimeLinker::get_app_for_node(const std::string & node_fqn) const { + auto it = last_result_.node_to_app.find(node_fqn); + if (it != last_result_.node_to_app.end()) { + return it->second; + } + return std::nullopt; +} + +void RuntimeLinker::log_info(const std::string & msg) const { + if (node_) { + RCLCPP_INFO(node_->get_logger(), "%s", msg.c_str()); + } +} + +void RuntimeLinker::log_debug(const std::string & msg) const { + if (node_) { + RCLCPP_DEBUG(node_->get_logger(), "%s", msg.c_str()); + } +} + +void RuntimeLinker::log_warn(const std::string & msg) const { + if (node_) { + RCLCPP_WARN(node_->get_logger(), "%s", msg.c_str()); + } +} + +void RuntimeLinker::log_error(const std::string & msg) const { + if (node_) { + RCLCPP_ERROR(node_->get_logger(), "%s", msg.c_str()); + } +} + +} // namespace discovery +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/models/app.cpp b/src/ros2_medkit_gateway/src/discovery/models/app.cpp new file mode 100644 index 0000000..4556dd8 --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/models/app.cpp @@ -0,0 +1,115 @@ +// 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/models/app.hpp" + +namespace ros2_medkit_gateway { + +json App::to_json() const { + json j = {{"id", id}, {"name", name}, {"type", "App"}, {"source", source}}; + + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + if (!description.empty()) { + j["description"] = description; + } + if (!tags.empty()) { + j["tags"] = tags; + } + if (!component_id.empty()) { + j["componentId"] = component_id; + } + if (!depends_on.empty()) { + j["dependsOn"] = depends_on; + } + if (ros_binding.has_value() && !ros_binding->is_empty()) { + j["rosBinding"] = ros_binding->to_json(); + } + if (bound_fqn.has_value()) { + j["boundFqn"] = bound_fqn.value(); + } + j["isOnline"] = is_online; + if (external) { + j["external"] = external; + } + + // Add topics if present + if (!topics.publishes.empty() || !topics.subscribes.empty()) { + j["topics"] = topics.to_json(); + } + + // Add operations (combine services and actions) + json operations = json::array(); + for (const auto & svc : services) { + operations.push_back(svc.to_json()); + } + for (const auto & act : actions) { + operations.push_back(act.to_json()); + } + if (!operations.empty()) { + j["operations"] = operations; + } + + return j; +} + +json App::to_entity_reference(const std::string & base_url) const { + json j = {{"id", id}, {"name", name}, {"href", base_url + "/apps/" + id}}; + + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + if (!tags.empty()) { + j["tags"] = tags; + } + + return j; +} + +json App::to_capabilities(const std::string & base_url) const { + std::string app_base = base_url + "/apps/" + id; + + json j = {{"id", id}, {"name", name}}; + + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + + // Add capability URIs + if (!topics.publishes.empty() || !topics.subscribes.empty()) { + j["data"] = app_base + "/data"; + } + if (!services.empty() || !actions.empty()) { + j["operations"] = app_base + "/operations"; + } + // Always include configurations (parameters) for non-external apps + if (!external) { + j["configurations"] = app_base + "/configurations"; + } + // Always include faults + j["faults"] = app_base + "/faults"; + + // Relationships + if (!component_id.empty()) { + j["isLocatedOn"] = base_url + "/components/" + component_id; + } + if (!depends_on.empty()) { + j["dependsOn"] = app_base + "/depends-on"; + } + + return j; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/models/function.cpp b/src/ros2_medkit_gateway/src/discovery/models/function.cpp new file mode 100644 index 0000000..e769216 --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/models/function.cpp @@ -0,0 +1,79 @@ +// 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/models/function.hpp" + +namespace ros2_medkit_gateway { + +json Function::to_json() const { + json j = {{"id", id}, {"name", name}, {"type", "Function"}, {"source", source}}; + + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + if (!description.empty()) { + j["description"] = description; + } + if (!tags.empty()) { + j["tags"] = tags; + } + if (!hosts.empty()) { + j["hosts"] = hosts; + } + if (!depends_on.empty()) { + j["dependsOn"] = depends_on; + } + + return j; +} + +json Function::to_entity_reference(const std::string & base_url) const { + json j = {{"id", id}, {"name", name}, {"href", base_url + "/functions/" + id}}; + + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + if (!tags.empty()) { + j["tags"] = tags; + } + + return j; +} + +json Function::to_capabilities(const std::string & base_url) const { + std::string func_base = base_url + "/functions/" + id; + + json j = {{"id", id}, {"name", name}}; + + if (!translation_id.empty()) { + j["translationId"] = translation_id; + } + + // Function-specific capabilities + if (!hosts.empty()) { + j["hosts"] = func_base + "/hosts"; + } + if (!depends_on.empty()) { + j["dependsOn"] = func_base + "/depends-on"; + } + + // Functions can also have data, operations, faults aggregated from hosted entities + j["data"] = func_base + "/data"; + j["operations"] = func_base + "/operations"; + j["faults"] = func_base + "/faults"; + + return j; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery_manager.cpp b/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp similarity index 88% rename from src/ros2_medkit_gateway/src/discovery_manager.cpp rename to src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp index 62ab452..6b88708 100644 --- a/src/ros2_medkit_gateway/src/discovery_manager.cpp +++ b/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp @@ -1,4 +1,4 @@ -// Copyright 2025 mfaferek93 +// Copyright 2025 selfpatch // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,16 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "ros2_medkit_gateway/discovery_manager.hpp" +#include "ros2_medkit_gateway/discovery/runtime_discovery.hpp" #include #include #include namespace ros2_medkit_gateway { +namespace discovery { // Helper function to check if a service path is internal ROS2 infrastructure -bool DiscoveryManager::is_internal_service(const std::string & service_path) { +bool RuntimeDiscoveryStrategy::is_internal_service(const std::string & service_path) { return service_path.find("/get_parameters") != std::string::npos || service_path.find("/set_parameters") != std::string::npos || service_path.find("/list_parameters") != std::string::npos || @@ -32,10 +33,10 @@ bool DiscoveryManager::is_internal_service(const std::string & service_path) { service_path.find("/_action/") != std::string::npos; // Action internal services } -DiscoveryManager::DiscoveryManager(rclcpp::Node * node) : node_(node) { +RuntimeDiscoveryStrategy::RuntimeDiscoveryStrategy(rclcpp::Node * node) : node_(node) { } -std::vector DiscoveryManager::discover_areas() { +std::vector RuntimeDiscoveryStrategy::discover_areas() { // Extract unique areas from namespaces std::set area_set; @@ -71,7 +72,7 @@ std::vector DiscoveryManager::discover_areas() { return areas; } -std::vector DiscoveryManager::discover_components() { +std::vector RuntimeDiscoveryStrategy::discover_components() { std::vector components; // Pre-build service info map for schema lookups @@ -173,7 +174,19 @@ std::vector DiscoveryManager::discover_components() { return components; } -std::vector DiscoveryManager::discover_services() { +std::vector RuntimeDiscoveryStrategy::discover_apps() { + // Apps are not supported in runtime-only mode + // They require manifest definitions + return {}; +} + +std::vector RuntimeDiscoveryStrategy::discover_functions() { + // Functions are not supported in runtime-only mode + // They require manifest definitions + return {}; +} + +std::vector RuntimeDiscoveryStrategy::discover_services() { std::vector services; // Use native rclcpp API to get service names and types @@ -214,7 +227,7 @@ std::vector DiscoveryManager::discover_services() { return services; } -std::vector DiscoveryManager::discover_actions() { +std::vector RuntimeDiscoveryStrategy::discover_actions() { std::vector actions; // Use native rclcpp API to get action names and types @@ -283,8 +296,8 @@ std::vector DiscoveryManager::discover_actions() { return actions; } -std::optional DiscoveryManager::find_service(const std::string & component_ns, - const std::string & operation_name) const { +std::optional RuntimeDiscoveryStrategy::find_service(const std::string & component_ns, + const std::string & operation_name) const { // Construct expected service path std::string expected_path = component_ns; if (!expected_path.empty() && expected_path.back() != '/') { @@ -304,8 +317,8 @@ std::optional DiscoveryManager::find_service(const std::string & co return std::nullopt; } -std::optional DiscoveryManager::find_action(const std::string & component_ns, - const std::string & operation_name) const { +std::optional RuntimeDiscoveryStrategy::find_action(const std::string & component_ns, + const std::string & operation_name) const { // Construct expected action path std::string expected_path = component_ns; if (!expected_path.empty() && expected_path.back() != '/') { @@ -325,15 +338,15 @@ std::optional DiscoveryManager::find_action(const std::string & comp return std::nullopt; } -void DiscoveryManager::set_topic_sampler(NativeTopicSampler * sampler) { +void RuntimeDiscoveryStrategy::set_topic_sampler(NativeTopicSampler * sampler) { topic_sampler_ = sampler; } -void DiscoveryManager::set_type_introspection(TypeIntrospection * introspection) { +void RuntimeDiscoveryStrategy::set_type_introspection(TypeIntrospection * introspection) { type_introspection_ = introspection; } -void DiscoveryManager::refresh_topic_map() { +void RuntimeDiscoveryStrategy::refresh_topic_map() { if (!topic_sampler_) { return; } @@ -342,11 +355,11 @@ void DiscoveryManager::refresh_topic_map() { RCLCPP_DEBUG(node_->get_logger(), "Topic map refreshed: %zu components", cached_topic_map_.size()); } -bool DiscoveryManager::is_topic_map_ready() const { +bool RuntimeDiscoveryStrategy::is_topic_map_ready() const { return topic_map_ready_; } -std::string DiscoveryManager::extract_area_from_namespace(const std::string & ns) { +std::string RuntimeDiscoveryStrategy::extract_area_from_namespace(const std::string & ns) { if (ns == "/" || ns.empty()) { return "root"; } @@ -366,7 +379,7 @@ std::string DiscoveryManager::extract_area_from_namespace(const std::string & ns return cleaned; } -std::string DiscoveryManager::extract_name_from_path(const std::string & path) { +std::string RuntimeDiscoveryStrategy::extract_name_from_path(const std::string & path) { if (path.empty()) { return ""; } @@ -380,7 +393,7 @@ std::string DiscoveryManager::extract_name_from_path(const std::string & path) { return path; } -std::set DiscoveryManager::get_node_namespaces() { +std::set RuntimeDiscoveryStrategy::get_node_namespaces() { std::set namespaces; auto node_graph = node_->get_node_graph_interface(); @@ -397,7 +410,7 @@ std::set DiscoveryManager::get_node_namespaces() { return namespaces; } -std::vector DiscoveryManager::discover_topic_components() { +std::vector RuntimeDiscoveryStrategy::discover_topic_components() { std::vector components; if (!topic_sampler_) { @@ -445,7 +458,7 @@ std::vector DiscoveryManager::discover_topic_components() { return components; } -bool DiscoveryManager::path_belongs_to_namespace(const std::string & path, const std::string & ns) const { +bool RuntimeDiscoveryStrategy::path_belongs_to_namespace(const std::string & path, const std::string & ns) const { if (ns.empty() || ns == "/") { // Root namespace - check if path has only one segment after leading slash if (path.empty() || path[0] != '/') { @@ -474,4 +487,5 @@ bool DiscoveryManager::path_belongs_to_namespace(const std::string & path, const return remainder.find('/') == std::string::npos; } +} // 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 f0b0a9e..212aef0 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -56,6 +56,11 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { declare_parameter("auth.issuer", "ros2_medkit_gateway"); declare_parameter("auth.clients", std::vector{}); + // Discovery mode parameters + declare_parameter("discovery_mode", "runtime_only"); // runtime_only, manifest_only, hybrid + declare_parameter("manifest_path", ""); + declare_parameter("manifest_strict_validation", true); + // Get parameter values server_host_ = get_parameter("server.host").as_string(); server_port_ = static_cast(get_parameter("server.port").as_int()); @@ -202,6 +207,18 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") { // Initialize managers discovery_mgr_ = std::make_unique(this); + + // Configure and initialize discovery manager + DiscoveryConfig discovery_config; + discovery_config.mode = parse_discovery_mode(get_parameter("discovery_mode").as_string()); + discovery_config.manifest_path = get_parameter("manifest_path").as_string(); + discovery_config.manifest_strict_validation = get_parameter("manifest_strict_validation").as_bool(); + + if (!discovery_mgr_->initialize(discovery_config)) { + RCLCPP_ERROR(get_logger(), "Failed to initialize discovery manager"); + throw std::runtime_error("Discovery initialization failed"); + } + data_access_mgr_ = std::make_unique(this); operation_mgr_ = std::make_unique(this, discovery_mgr_.get()); config_mgr_ = std::make_unique(this); diff --git a/src/ros2_medkit_gateway/src/http/handlers/area_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/area_handlers.cpp deleted file mode 100644 index 2b27644..0000000 --- a/src/ros2_medkit_gateway/src/http/handlers/area_handlers.cpp +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright 2025 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/http/handlers/area_handlers.hpp" - -#include "ros2_medkit_gateway/gateway_node.hpp" - -using json = nlohmann::json; -using httplib::StatusCode; - -namespace ros2_medkit_gateway { -namespace handlers { - -void AreaHandlers::handle_list_areas(const httplib::Request & req, httplib::Response & res) { - (void)req; // Unused parameter - - try { - const auto cache = ctx_.node()->get_entity_cache(); - - json areas_json = json::array(); - for (const auto & area : cache.areas) { - areas_json.push_back(area.to_json()); - } - - HandlerContext::send_json(res, areas_json); - } catch (const std::exception & e) { - HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Internal server error"); - RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_areas: %s", e.what()); - } -} - -void AreaHandlers::handle_area_components(const httplib::Request & req, httplib::Response & res) { - try { - // Extract area_id from URL path - if (req.matches.size() < 2) { - HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); - return; - } - - std::string area_id = req.matches[1]; - - // Validate area_id - auto validation_result = ctx_.validate_entity_id(area_id); - if (!validation_result) { - HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid area ID", - {{"details", validation_result.error()}, {"area_id", area_id}}); - return; - } - - const auto cache = ctx_.node()->get_entity_cache(); - - // Check if area exists - bool area_exists = false; - for (const auto & area : cache.areas) { - if (area.id == area_id) { - area_exists = true; - break; - } - } - - if (!area_exists) { - HandlerContext::send_error(res, StatusCode::NotFound_404, "Area not found", {{"area_id", area_id}}); - return; - } - - // Filter components by area - json components_json = json::array(); - for (const auto & component : cache.components) { - if (component.area == area_id) { - components_json.push_back(component.to_json()); - } - } - - HandlerContext::send_json(res, components_json); - } catch (const std::exception & e) { - HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Internal server error"); - RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_area_components: %s", e.what()); - } -} - -} // namespace handlers -} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp b/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp new file mode 100644 index 0000000..f009a5a --- /dev/null +++ b/src/ros2_medkit_gateway/src/http/handlers/capability_builder.cpp @@ -0,0 +1,109 @@ +// 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/http/handlers/capability_builder.hpp" + +namespace ros2_medkit_gateway { +namespace handlers { + +std::string CapabilityBuilder::capability_to_name(Capability cap) { + switch (cap) { + case Capability::DATA: + return "data"; + case Capability::OPERATIONS: + return "operations"; + case Capability::CONFIGURATIONS: + return "configurations"; + case Capability::FAULTS: + return "faults"; + case Capability::SUBAREAS: + return "subareas"; + case Capability::SUBCOMPONENTS: + return "subcomponents"; + case Capability::RELATED_COMPONENTS: + return "related-components"; + case Capability::RELATED_APPS: + return "related-apps"; + case Capability::HOSTS: + return "hosts"; + default: + return "unknown"; + } +} + +std::string CapabilityBuilder::capability_to_path(Capability cap) { + // Path segments match names for most capabilities + return capability_to_name(cap); +} + +nlohmann::json CapabilityBuilder::build_capabilities(const std::string & entity_type, const std::string & entity_id, + const std::vector & capabilities) { + nlohmann::json result = nlohmann::json::array(); + + for (const auto & cap : capabilities) { + nlohmann::json cap_obj; + cap_obj["name"] = capability_to_name(cap); + + // Build href: /api/v1/{entity_type}/{entity_id}/{capability_path} + std::string href = "/api/v1/"; + href.append(entity_type).append("/").append(entity_id).append("/").append(capability_to_path(cap)); + cap_obj["href"] = href; + + result.push_back(cap_obj); + } + + return result; +} + +LinksBuilder & LinksBuilder::self(const std::string & href) { + if (!links_.is_object()) { + links_ = nlohmann::json::object(); + } + links_["self"] = href; + return *this; +} + +LinksBuilder & LinksBuilder::parent(const std::string & href) { + if (!links_.is_object()) { + links_ = nlohmann::json::object(); + } + links_["parent"] = href; + return *this; +} + +LinksBuilder & LinksBuilder::collection(const std::string & href) { + if (!links_.is_object()) { + links_ = nlohmann::json::object(); + } + links_["collection"] = href; + return *this; +} + +LinksBuilder & LinksBuilder::add(const std::string & rel, const std::string & href) { + if (!links_.is_object()) { + links_ = nlohmann::json::object(); + } + links_[rel] = href; + return *this; +} + +nlohmann::json LinksBuilder::build() const { + if (links_.is_null()) { + return nlohmann::json::object(); + } + return links_; +} + +} // namespace handlers +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery/app_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery/app_handlers.cpp new file mode 100644 index 0000000..9b3ddd0 --- /dev/null +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery/app_handlers.cpp @@ -0,0 +1,473 @@ +// 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/http/handlers/discovery/app_handlers.hpp" + +#include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/http/handlers/capability_builder.hpp" + +using json = nlohmann::json; +using httplib::StatusCode; + +namespace ros2_medkit_gateway { +namespace handlers { + +void AppHandlers::handle_list_apps(const httplib::Request & req, httplib::Response & res) { + (void)req; // Unused parameter + + try { + auto discovery = ctx_.node()->get_discovery_manager(); + auto apps = discovery->discover_apps(); + + json items = json::array(); + for (const auto & app : apps) { + json app_item; + app_item["id"] = app.id; + app_item["name"] = app.name; + if (!app.description.empty()) { + app_item["description"] = app.description; + } + if (!app.tags.empty()) { + app_item["tags"] = app.tags; + } + if (app.is_online) { + app_item["is_online"] = true; + } + if (!app.component_id.empty()) { + app_item["component_id"] = app.component_id; + } + items.push_back(app_item); + } + + json response; + response["items"] = items; + response["total_count"] = apps.size(); + + 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_list_apps: %s", e.what()); + } +} + +void AppHandlers::handle_get_app(const httplib::Request & req, httplib::Response & res) { + try { + // Extract app_id from URL path + if (req.matches.size() < 2) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); + return; + } + + std::string app_id = req.matches[1]; + + // Validate app_id + auto validation_result = ctx_.validate_entity_id(app_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid app ID", + {{"details", validation_result.error()}, {"app_id", app_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto app_opt = discovery->get_app(app_id); + + if (!app_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "App not found", {{"app_id", app_id}}); + return; + } + + const auto & app = *app_opt; + + // Build response with capabilities (SOVD entity/{id} pattern) + json response; + response["id"] = app.id; + response["name"] = app.name; + + if (!app.description.empty()) { + response["description"] = app.description; + } + if (!app.translation_id.empty()) { + response["translation_id"] = app.translation_id; + } + if (!app.tags.empty()) { + response["tags"] = app.tags; + } + if (app.is_online) { + response["is_online"] = true; + } + if (app.bound_fqn) { + response["bound_fqn"] = *app.bound_fqn; + } + response["source"] = app.source; + + // Build capabilities using CapabilityBuilder + using Cap = CapabilityBuilder::Capability; + std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS}; + response["capabilities"] = CapabilityBuilder::build_capabilities("apps", app.id, caps); + + // Build HATEOAS links using LinksBuilder + LinksBuilder links; + links.self("/api/v1/apps/" + app.id).collection("/api/v1/apps"); + if (!app.component_id.empty()) { + links.add("is-located-on", "/api/v1/components/" + app.component_id); + } + response["_links"] = links.build(); + + // Add depends-on as array if present (special case - multiple links) + if (!app.depends_on.empty()) { + json depends_links = json::array(); + for (const auto & dep_id : app.depends_on) { + depends_links.push_back("/api/v1/apps/" + dep_id); + } + response["_links"]["depends-on"] = depends_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_app: %s", e.what()); + } +} + +void AppHandlers::handle_get_app_data(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 app_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(app_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid app ID", + {{"details", validation_result.error()}, {"app_id", app_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto app_opt = discovery->get_app(app_id); + + if (!app_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "App not found", {{"app_id", app_id}}); + return; + } + + const auto & app = *app_opt; + + // Build data items from app's topics + json items = json::array(); + + // Publishers + for (const auto & topic_name : app.topics.publishes) { + json item; + item["id"] = topic_name; + item["name"] = topic_name; + item["direction"] = "publish"; + item["href"] = "/api/v1/apps/" + app.id + "/data/" + topic_name; + items.push_back(item); + } + + // Subscribers + for (const auto & topic_name : app.topics.subscribes) { + json item; + item["id"] = topic_name; + item["name"] = topic_name; + item["direction"] = "subscribe"; + item["href"] = "/api/v1/apps/" + app.id + "/data/" + topic_name; + items.push_back(item); + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + 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_app_data: %s", e.what()); + } +} + +void AppHandlers::handle_get_app_data_item(const httplib::Request & req, httplib::Response & res) { + try { + if (req.matches.size() < 3) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); + return; + } + + std::string app_id = req.matches[1]; + std::string data_id = req.matches[2]; + + auto validation_result = ctx_.validate_entity_id(app_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid app ID", + {{"details", validation_result.error()}, {"app_id", app_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto app_opt = discovery->get_app(app_id); + + if (!app_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "App not found", {{"app_id", app_id}}); + return; + } + + const auto & app = *app_opt; + + // Get data access manager for topic sampling + auto data_access_mgr = ctx_.node()->get_data_access_manager(); + auto native_sampler = data_access_mgr->get_native_sampler(); + + // Search in publishers + for (const auto & topic_name : app.topics.publishes) { + if (topic_name == data_id) { + json response; + response["id"] = topic_name; + response["name"] = topic_name; + response["direction"] = "publish"; + + // Sample topic value via native sampler + auto sample = native_sampler->sample_topic(topic_name, data_access_mgr->get_topic_sample_timeout()); + response["timestamp"] = sample.timestamp_ns; + response["publisher_count"] = sample.publisher_count; + response["subscriber_count"] = sample.subscriber_count; + + if (sample.has_data && sample.data) { + response["status"] = "data"; + response["data"] = *sample.data; + } else { + response["status"] = "metadata_only"; + } + + if (!sample.message_type.empty()) { + response["type"] = sample.message_type; + } + + HandlerContext::send_json(res, response); + return; + } + } + + // Search in subscribers + for (const auto & topic_name : app.topics.subscribes) { + if (topic_name == data_id) { + json response; + response["id"] = topic_name; + response["name"] = topic_name; + response["direction"] = "subscribe"; + + // Sample topic value via native sampler + auto sample = native_sampler->sample_topic(topic_name, data_access_mgr->get_topic_sample_timeout()); + response["timestamp"] = sample.timestamp_ns; + response["publisher_count"] = sample.publisher_count; + response["subscriber_count"] = sample.subscriber_count; + + if (sample.has_data && sample.data) { + response["status"] = "data"; + response["data"] = *sample.data; + } else { + response["status"] = "metadata_only"; + } + + if (!sample.message_type.empty()) { + response["type"] = sample.message_type; + } + + HandlerContext::send_json(res, response); + return; + } + } + + HandlerContext::send_error(res, StatusCode::NotFound_404, "Data item not found", + {{"app_id", app_id}, {"data_id", data_id}}); + } 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_app_data_item: %s", e.what()); + } +} + +void AppHandlers::handle_list_app_operations(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 app_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(app_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid app ID", + {{"details", validation_result.error()}, {"app_id", app_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto app_opt = discovery->get_app(app_id); + + if (!app_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "App not found", {{"app_id", app_id}}); + return; + } + + const auto & app = *app_opt; + + json items = json::array(); + + // Add services + for (const auto & svc : app.services) { + json item; + item["id"] = svc.name; + item["name"] = svc.name; + item["type"] = "service"; + item["service_type"] = svc.type; + items.push_back(item); + } + + // Add actions + for (const auto & act : app.actions) { + json item; + item["id"] = act.name; + item["name"] = act.name; + item["type"] = "action"; + item["action_type"] = act.type; + items.push_back(item); + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + 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_list_app_operations: %s", e.what()); + } +} + +void AppHandlers::handle_list_app_configurations(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 app_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(app_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid app ID", + {{"details", validation_result.error()}, {"app_id", app_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto app_opt = discovery->get_app(app_id); + + if (!app_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "App not found", {{"app_id", app_id}}); + return; + } + + const auto & app = *app_opt; + + json response; + json items = json::array(); + + // If app is linked to a runtime node, fetch parameters from it + if (app.bound_fqn) { + auto config_mgr = ctx_.node()->get_configuration_manager(); + auto result = config_mgr->list_parameters(*app.bound_fqn); + + if (result.success && result.data.is_array()) { + for (const auto & param : result.data) { + json item; + item["id"] = param["name"]; + item["name"] = param["name"]; + item["value"] = param["value"]; + item["type"] = param["type"]; + items.push_back(item); + } + } + response["bound_node"] = *app.bound_fqn; + } + + response["items"] = items; + response["total_count"] = items.size(); + + 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_list_app_configurations: %s", e.what()); + } +} + +void AppHandlers::handle_related_apps(const httplib::Request & req, httplib::Response & res) { + try { + // Extract component_id from URL path + if (req.matches.size() < 2) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); + return; + } + + std::string component_id = req.matches[1]; + + // Validate component_id + 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 apps = discovery->get_apps_for_component(component_id); + + json items = json::array(); + for (const auto & app : apps) { + json app_item; + app_item["id"] = app.id; + app_item["name"] = app.name; + if (!app.description.empty()) { + app_item["description"] = app.description; + } + if (app.is_online) { + app_item["is_online"] = true; + } + app_item["_links"] = {{"self", {{"href", "/api/v1/apps/" + app.id}}}}; + items.push_back(app_item); + } + + json response; + response["items"] = items; + response["total_count"] = apps.size(); + + 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_related_apps: %s", e.what()); + } +} + +} // namespace handlers +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery/area_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery/area_handlers.cpp new file mode 100644 index 0000000..878bbcf --- /dev/null +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery/area_handlers.cpp @@ -0,0 +1,266 @@ +// Copyright 2025 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/http/handlers/discovery/area_handlers.hpp" + +#include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/http/handlers/capability_builder.hpp" + +using json = nlohmann::json; +using httplib::StatusCode; + +namespace ros2_medkit_gateway { +namespace handlers { + +void AreaHandlers::handle_list_areas(const httplib::Request & req, httplib::Response & res) { + (void)req; // Unused parameter + + try { + const auto cache = ctx_.node()->get_entity_cache(); + + json items = json::array(); + for (const auto & area : cache.areas) { + items.push_back(area.to_json()); + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + HandlerContext::send_json(res, response); + } catch (const std::exception & e) { + HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Internal server error"); + RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_areas: %s", e.what()); + } +} + +void AreaHandlers::handle_get_area(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 area_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(area_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid area ID", + {{"details", validation_result.error()}, {"area_id", area_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto area_opt = discovery->get_area(area_id); + + if (!area_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Area not found", {{"area_id", area_id}}); + return; + } + + const auto & area = *area_opt; + + json response; + response["id"] = area.id; + response["name"] = area.name; + response["type"] = area.type; + + if (!area.description.empty()) { + response["description"] = area.description; + } + + // Build capabilities for areas + using Cap = CapabilityBuilder::Capability; + std::vector caps = {Cap::SUBAREAS, Cap::RELATED_COMPONENTS}; + response["capabilities"] = CapabilityBuilder::build_capabilities("areas", area.id, caps); + + // Build HATEOAS links + LinksBuilder links; + links.self("/api/v1/areas/" + area.id).collection("/api/v1/areas"); + if (!area.parent_area_id.empty()) { + links.parent("/api/v1/areas/" + area.parent_area_id); + } + response["_links"] = links.build(); + + 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_area: %s", e.what()); + } +} + +void AreaHandlers::handle_area_components(const httplib::Request & req, httplib::Response & res) { + try { + // Extract area_id from URL path + if (req.matches.size() < 2) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); + return; + } + + std::string area_id = req.matches[1]; + + // Validate area_id + auto validation_result = ctx_.validate_entity_id(area_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid area ID", + {{"details", validation_result.error()}, {"area_id", area_id}}); + return; + } + + const auto cache = ctx_.node()->get_entity_cache(); + + // Check if area exists + bool area_exists = false; + for (const auto & area : cache.areas) { + if (area.id == area_id) { + area_exists = true; + break; + } + } + + if (!area_exists) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Area not found", {{"area_id", area_id}}); + return; + } + + // Filter components by area + json items = json::array(); + for (const auto & component : cache.components) { + if (component.area == area_id) { + items.push_back(component.to_json()); + } + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + HandlerContext::send_json(res, response); + } catch (const std::exception & e) { + HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Internal server error"); + RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_area_components: %s", e.what()); + } +} + +void AreaHandlers::handle_get_subareas(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 area_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(area_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid area ID", + {{"details", validation_result.error()}, {"area_id", area_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto area_opt = discovery->get_area(area_id); + + if (!area_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Area not found", {{"area_id", area_id}}); + return; + } + + // Get subareas + auto subareas = discovery->get_subareas(area_id); + + json items = json::array(); + for (const auto & subarea : subareas) { + json item; + item["id"] = subarea.id; + item["name"] = subarea.name; + item["href"] = "/api/v1/areas/" + subarea.id; + items.push_back(item); + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + // HATEOAS links + json links; + links["self"] = "/api/v1/areas/" + area_id + "/subareas"; + links["parent"] = "/api/v1/areas/" + area_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_subareas: %s", e.what()); + } +} + +void AreaHandlers::handle_get_related_components(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 area_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(area_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid area ID", + {{"details", validation_result.error()}, {"area_id", area_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto area_opt = discovery->get_area(area_id); + + if (!area_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Area not found", {{"area_id", area_id}}); + return; + } + + // Get components for this area + auto components = discovery->get_components_for_area(area_id); + + json items = json::array(); + for (const auto & comp : components) { + json item; + item["id"] = comp.id; + item["name"] = comp.name; + item["href"] = "/api/v1/components/" + comp.id; + items.push_back(item); + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + // HATEOAS links + json links; + links["self"] = "/api/v1/areas/" + area_id + "/related-components"; + links["area"] = "/api/v1/areas/" + area_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_related_components: %s", e.what()); + } +} + +} // namespace handlers +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/component_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp similarity index 67% rename from src/ros2_medkit_gateway/src/http/handlers/component_handlers.cpp rename to src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp index 8d1752b..44bcd02 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/component_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery/component_handlers.cpp @@ -12,13 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -#include "ros2_medkit_gateway/http/handlers/component_handlers.hpp" +#include "ros2_medkit_gateway/http/handlers/discovery/component_handlers.hpp" #include #include #include "ros2_medkit_gateway/exceptions.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/http/handlers/capability_builder.hpp" using json = nlohmann::json; using httplib::StatusCode; @@ -32,18 +33,83 @@ void ComponentHandlers::handle_list_components(const httplib::Request & req, htt try { const auto cache = ctx_.node()->get_entity_cache(); - json components_json = json::array(); + json items = json::array(); for (const auto & component : cache.components) { - components_json.push_back(component.to_json()); + items.push_back(component.to_json()); } - HandlerContext::send_json(res, components_json); + json response; + response["items"] = items; + response["total_count"] = items.size(); + + HandlerContext::send_json(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, StatusCode::InternalServerError_500, "Internal server error"); RCLCPP_ERROR(HandlerContext::logger(), "Error in handle_list_components: %s", e.what()); } } +void ComponentHandlers::handle_get_component(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; + + json response; + response["id"] = comp.id; + response["name"] = comp.name; + response["type"] = comp.type; + + if (!comp.description.empty()) { + response["description"] = comp.description; + } + + // Build capabilities for components + using Cap = CapabilityBuilder::Capability; + std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, + Cap::FAULTS, Cap::SUBCOMPONENTS, Cap::RELATED_APPS}; + response["capabilities"] = CapabilityBuilder::build_capabilities("components", comp.id, caps); + + // Build HATEOAS links + LinksBuilder links; + links.self("/api/v1/components/" + comp.id).collection("/api/v1/components"); + if (!comp.area.empty()) { + links.add("area", "/api/v1/areas/" + comp.area); + } + if (!comp.parent_component_id.empty()) { + links.parent("/api/v1/components/" + comp.parent_component_id); + } + response["_links"] = links.build(); + + 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_component: %s", e.what()); + } +} + void ComponentHandlers::handle_component_data(const httplib::Request & req, httplib::Response & res) { std::string component_id; try { @@ -336,5 +402,118 @@ void ComponentHandlers::handle_component_topic_publish(const httplib::Request & } } +void ComponentHandlers::handle_get_subcomponents(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; + } + + // Get subcomponents + auto subcomponents = discovery->get_subcomponents(component_id); + + json items = json::array(); + for (const auto & sub : subcomponents) { + json item; + item["id"] = sub.id; + item["name"] = sub.name; + item["href"] = "/api/v1/components/" + sub.id; + 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 + "/subcomponents"; + links["parent"] = "/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_subcomponents: %s", e.what()); + } +} + +void ComponentHandlers::handle_get_related_apps(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; + } + + // Get apps for this component (via is-located-on relationship) + auto apps = discovery->get_apps_for_component(component_id); + + json items = json::array(); + for (const auto & app : apps) { + json item; + item["id"] = app.id; + item["name"] = app.name; + item["href"] = "/api/v1/apps/" + app.id; + if (app.is_online) { + item["is_online"] = true; + } + 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 + "/related-apps"; + 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_related_apps: %s", e.what()); + } +} + } // namespace handlers } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery/function_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery/function_handlers.cpp new file mode 100644 index 0000000..b788e06 --- /dev/null +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery/function_handlers.cpp @@ -0,0 +1,323 @@ +// 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/http/handlers/discovery/function_handlers.hpp" + +#include "ros2_medkit_gateway/gateway_node.hpp" +#include "ros2_medkit_gateway/http/handlers/capability_builder.hpp" + +using json = nlohmann::json; +using httplib::StatusCode; + +namespace ros2_medkit_gateway { +namespace handlers { + +void FunctionHandlers::handle_list_functions(const httplib::Request & req, httplib::Response & res) { + (void)req; // Unused parameter + + try { + auto discovery = ctx_.node()->get_discovery_manager(); + auto functions = discovery->discover_functions(); + + json items = json::array(); + for (const auto & func : functions) { + json func_item; + func_item["id"] = func.id; + func_item["name"] = func.name; + if (!func.description.empty()) { + func_item["description"] = func.description; + } + if (!func.tags.empty()) { + func_item["tags"] = func.tags; + } + items.push_back(func_item); + } + + json response; + response["items"] = items; + response["total_count"] = functions.size(); + + 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_list_functions: %s", e.what()); + } +} + +void FunctionHandlers::handle_get_function(const httplib::Request & req, httplib::Response & res) { + try { + // Extract function_id from URL path + if (req.matches.size() < 2) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); + return; + } + + std::string function_id = req.matches[1]; + + // Validate function_id + auto validation_result = ctx_.validate_entity_id(function_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid function ID", + {{"details", validation_result.error()}, {"function_id", function_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto func_opt = discovery->get_function(function_id); + + if (!func_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Function not found", {{"function_id", function_id}}); + return; + } + + const auto & func = *func_opt; + + // Build response with capabilities (SOVD entity/{id} pattern) + json response; + response["id"] = func.id; + response["name"] = func.name; + + if (!func.description.empty()) { + response["description"] = func.description; + } + if (!func.translation_id.empty()) { + response["translation_id"] = func.translation_id; + } + if (!func.tags.empty()) { + response["tags"] = func.tags; + } + response["source"] = func.source; + + // Build capabilities using CapabilityBuilder + using Cap = CapabilityBuilder::Capability; + std::vector caps = {Cap::HOSTS, Cap::DATA, Cap::OPERATIONS}; + response["capabilities"] = CapabilityBuilder::build_capabilities("functions", func.id, caps); + + // Build HATEOAS links using LinksBuilder + LinksBuilder links; + links.self("/api/v1/functions/" + func.id).collection("/api/v1/functions"); + response["_links"] = links.build(); + + // Add depends-on as array if present (special case - multiple links) + if (!func.depends_on.empty()) { + json depends_links = json::array(); + for (const auto & dep_id : func.depends_on) { + depends_links.push_back("/api/v1/functions/" + dep_id); + } + response["_links"]["depends-on"] = depends_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_function: %s", e.what()); + } +} + +void FunctionHandlers::handle_function_hosts(const httplib::Request & req, httplib::Response & res) { + try { + // Extract function_id from URL path + if (req.matches.size() < 2) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid request"); + return; + } + + std::string function_id = req.matches[1]; + + // Validate function_id + auto validation_result = ctx_.validate_entity_id(function_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid function ID", + {{"details", validation_result.error()}, {"function_id", function_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto func_opt = discovery->get_function(function_id); + + if (!func_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Function not found", {{"function_id", function_id}}); + return; + } + + // Get host app IDs + auto host_ids = discovery->get_hosts_for_function(function_id); + + json items = json::array(); + for (const auto & app_id : host_ids) { + auto app_opt = discovery->get_app(app_id); + if (app_opt) { + json item; + item["id"] = app_opt->id; + item["name"] = app_opt->name; + item["href"] = "/api/v1/apps/" + app_opt->id; + if (app_opt->is_online) { + item["is_online"] = true; + } + items.push_back(item); + } + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + 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_function_hosts: %s", e.what()); + } +} + +void FunctionHandlers::handle_get_function_data(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 function_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(function_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid function ID", + {{"details", validation_result.error()}, {"function_id", function_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto func_opt = discovery->get_function(function_id); + + if (!func_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Function not found", {{"function_id", function_id}}); + return; + } + + // Aggregate data from all host apps + auto host_ids = discovery->get_hosts_for_function(function_id); + + json items = json::array(); + for (const auto & app_id : host_ids) { + auto app_opt = discovery->get_app(app_id); + if (app_opt) { + // Publishers + for (const auto & topic_name : app_opt->topics.publishes) { + json item; + item["id"] = topic_name; + item["name"] = topic_name; + item["direction"] = "publish"; + item["source_app"] = app_id; + std::string href = "/api/v1/apps/"; + href.append(app_id).append("/data/").append(topic_name); + item["href"] = href; + items.push_back(item); + } + // Subscribers + for (const auto & topic_name : app_opt->topics.subscribes) { + json item; + item["id"] = topic_name; + item["name"] = topic_name; + item["direction"] = "subscribe"; + item["source_app"] = app_id; + std::string href = "/api/v1/apps/"; + href.append(app_id).append("/data/").append(topic_name); + item["href"] = href; + items.push_back(item); + } + } + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + 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_function_data: %s", e.what()); + } +} + +void FunctionHandlers::handle_list_function_operations(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 function_id = req.matches[1]; + + auto validation_result = ctx_.validate_entity_id(function_id); + if (!validation_result) { + HandlerContext::send_error(res, StatusCode::BadRequest_400, "Invalid function ID", + {{"details", validation_result.error()}, {"function_id", function_id}}); + return; + } + + auto discovery = ctx_.node()->get_discovery_manager(); + auto func_opt = discovery->get_function(function_id); + + if (!func_opt) { + HandlerContext::send_error(res, StatusCode::NotFound_404, "Function not found", {{"function_id", function_id}}); + return; + } + + // Aggregate operations from all host apps + auto host_ids = discovery->get_hosts_for_function(function_id); + + json items = json::array(); + for (const auto & app_id : host_ids) { + auto app_opt = discovery->get_app(app_id); + if (app_opt) { + // Services + for (const auto & svc : app_opt->services) { + json item; + item["id"] = svc.name; + item["name"] = svc.name; + item["type"] = "service"; + item["service_type"] = svc.type; + item["source_app"] = app_id; + items.push_back(item); + } + // Actions + for (const auto & act : app_opt->actions) { + json item; + item["id"] = act.name; + item["name"] = act.name; + item["type"] = "action"; + item["action_type"] = act.type; + item["source_app"] = app_id; + items.push_back(item); + } + } + } + + json response; + response["items"] = items; + response["total_count"] = items.size(); + + 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_list_function_operations: %s", e.what()); + } +} + +} // namespace handlers +} // namespace ros2_medkit_gateway 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 931917e..db7aa75 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp @@ -33,13 +33,13 @@ tl::expected HandlerContext::validate_entity_id(const std::st return tl::unexpected("Entity ID too long (max 256 characters)"); } - // Validate characters according to ROS 2 naming conventions - // Allow: alphanumeric (a-z, A-Z, 0-9), underscore (_) - // Reject: hyphen (not allowed in ROS 2 names), forward slash (conflicts with URL routing), - // special characters, escape sequences + // Validate characters according to naming conventions + // Allow: alphanumeric (a-z, A-Z, 0-9), underscore (_), hyphen (-) + // Reject: forward slash (conflicts with URL routing), special characters, escape sequences + // Note: Hyphens are allowed in manifest entity IDs (e.g., "engine-ecu", "front-left-door") for (char c : entity_id) { bool is_alphanumeric = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); - bool is_allowed_special = (c == '_'); + bool is_allowed_special = (c == '_' || c == '-'); if (!is_alphanumeric && !is_allowed_special) { // For non-printable characters, show the character code @@ -53,7 +53,7 @@ tl::expected HandlerContext::validate_entity_id(const std::st char_repr = std::string(1, c); } return tl::unexpected("Entity ID contains invalid character: '" + char_repr + - "'. Only alphanumeric and underscore are allowed"); + "'. Only alphanumeric, underscore and hyphen are allowed"); } } diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 62cb00d..9076aeb 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -54,6 +54,8 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c health_handlers_ = std::make_unique(*handler_ctx_); area_handlers_ = std::make_unique(*handler_ctx_); component_handlers_ = std::make_unique(*handler_ctx_); + app_handlers_ = std::make_unique(*handler_ctx_); + function_handlers_ = std::make_unique(*handler_ctx_); operation_handlers_ = std::make_unique(*handler_ctx_); config_handlers_ = std::make_unique(*handler_ctx_); fault_handlers_ = std::make_unique(*handler_ctx_); @@ -148,6 +150,67 @@ void RESTServer::setup_routes() { area_handlers_->handle_list_areas(req, res); }); + // Apps - must register before /apps/{id} to avoid regex conflict + srv->Get(api_path("/apps"), [this](const httplib::Request & req, httplib::Response & res) { + app_handlers_->handle_list_apps(req, res); + }); + + // App data item (specific topic) - register before /apps/{id}/data + srv->Get((api_path("/apps") + R"(/([^/]+)/data/(.+)$)"), + [this](const httplib::Request & req, httplib::Response & res) { + app_handlers_->handle_get_app_data_item(req, res); + }); + + // App data (all topics) + srv->Get((api_path("/apps") + R"(/([^/]+)/data$)"), [this](const httplib::Request & req, httplib::Response & res) { + app_handlers_->handle_get_app_data(req, res); + }); + + // 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); + }); + + // App configurations + srv->Get((api_path("/apps") + R"(/([^/]+)/configurations$)"), + [this](const httplib::Request & req, httplib::Response & res) { + app_handlers_->handle_list_app_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); + }); + + // Functions - list all functions + srv->Get(api_path("/functions"), [this](const httplib::Request & req, httplib::Response & res) { + function_handlers_->handle_list_functions(req, res); + }); + + // Function hosts + srv->Get((api_path("/functions") + R"(/([^/]+)/hosts$)"), + [this](const httplib::Request & req, httplib::Response & res) { + function_handlers_->handle_function_hosts(req, res); + }); + + // Function data (aggregated from host apps) + srv->Get((api_path("/functions") + R"(/([^/]+)/data$)"), + [this](const httplib::Request & req, httplib::Response & res) { + function_handlers_->handle_get_function_data(req, res); + }); + + // Function operations (aggregated from host apps) + srv->Get((api_path("/functions") + R"(/([^/]+)/operations$)"), + [this](const httplib::Request & req, httplib::Response & res) { + function_handlers_->handle_list_function_operations(req, res); + }); + + // Single function (capabilities) - must be after more specific routes + srv->Get((api_path("/functions") + R"(/([^/]+)$)"), [this](const httplib::Request & req, httplib::Response & res) { + function_handlers_->handle_get_function(req, res); + }); + // Components srv->Get(api_path("/components"), [this](const httplib::Request & req, httplib::Response & res) { component_handlers_->handle_list_components(req, res); @@ -159,6 +222,23 @@ void RESTServer::setup_routes() { area_handlers_->handle_area_components(req, res); }); + // Area subareas (relationship endpoint) + srv->Get((api_path("/areas") + R"(/([^/]+)/subareas$)"), + [this](const httplib::Request & req, httplib::Response & res) { + area_handlers_->handle_get_subareas(req, res); + }); + + // Area related-components (relationship endpoint) + srv->Get((api_path("/areas") + R"(/([^/]+)/related-components$)"), + [this](const httplib::Request & req, httplib::Response & res) { + area_handlers_->handle_get_related_components(req, res); + }); + + // Single area (capabilities) - must be after more specific routes + srv->Get((api_path("/areas") + R"(/([^/]+)$)"), [this](const httplib::Request & req, httplib::Response & res) { + area_handlers_->handle_get_area(req, res); + }); + // Component topic data (specific topic) - register before general route // Use (.+) for topic_name to accept slashes from percent-encoded URLs (%2F -> /) srv->Get((api_path("/components") + R"(/([^/]+)/data/(.+)$)"), @@ -172,6 +252,23 @@ void RESTServer::setup_routes() { component_handlers_->handle_component_data(req, res); }); + // Component subcomponents (relationship endpoint) + srv->Get((api_path("/components") + R"(/([^/]+)/subcomponents$)"), + [this](const httplib::Request & req, httplib::Response & res) { + component_handlers_->handle_get_subcomponents(req, res); + }); + + // Component related-apps (relationship endpoint) + srv->Get((api_path("/components") + R"(/([^/]+)/related-apps$)"), + [this](const httplib::Request & req, httplib::Response & res) { + component_handlers_->handle_get_related_apps(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); + }); + // Component topic publish (PUT) // Use (.+) for topic_name to accept slashes from percent-encoded URLs (%2F -> /) srv->Put((api_path("/components") + R"(/([^/]+)/data/(.+)$)"), diff --git a/src/ros2_medkit_gateway/test/test_capability_builder.cpp b/src/ros2_medkit_gateway/test/test_capability_builder.cpp new file mode 100644 index 0000000..650ff1e --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_capability_builder.cpp @@ -0,0 +1,162 @@ +// 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 + +#include "ros2_medkit_gateway/http/handlers/capability_builder.hpp" + +using namespace ros2_medkit_gateway::handlers; +using Cap = CapabilityBuilder::Capability; + +// ============================================================================= +// CapabilityBuilder Tests +// ============================================================================= + +TEST(CapabilityBuilderTest, BuildsCorrectCapabilities) { + std::vector caps = {Cap::DATA, Cap::OPERATIONS}; + + auto result = CapabilityBuilder::build_capabilities("components", "test-comp", caps); + + ASSERT_EQ(result.size(), 2); + EXPECT_EQ(result[0]["name"], "data"); + EXPECT_EQ(result[0]["href"], "/api/v1/components/test-comp/data"); + EXPECT_EQ(result[1]["name"], "operations"); + EXPECT_EQ(result[1]["href"], "/api/v1/components/test-comp/operations"); +} + +TEST(CapabilityBuilderTest, BuildsEmptyArray) { + std::vector caps = {}; + + auto result = CapabilityBuilder::build_capabilities("areas", "test-area", caps); + + EXPECT_TRUE(result.is_array()); + EXPECT_EQ(result.size(), 0); +} + +TEST(CapabilityBuilderTest, BuildsAllCapabilities) { + std::vector caps = {Cap::DATA, Cap::OPERATIONS, Cap::CONFIGURATIONS, Cap::FAULTS, Cap::SUBAREAS, + Cap::SUBCOMPONENTS, Cap::RELATED_COMPONENTS, Cap::RELATED_APPS, Cap::HOSTS}; + + auto result = CapabilityBuilder::build_capabilities("entities", "test-id", caps); + + EXPECT_EQ(result.size(), 9); +} + +TEST(CapabilityBuilderTest, CapabilityToNameReturnsCorrectStrings) { + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::DATA), "data"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::OPERATIONS), "operations"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::CONFIGURATIONS), "configurations"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::FAULTS), "faults"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::SUBAREAS), "subareas"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::SUBCOMPONENTS), "subcomponents"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::RELATED_COMPONENTS), "related-components"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::RELATED_APPS), "related-apps"); + EXPECT_EQ(CapabilityBuilder::capability_to_name(Cap::HOSTS), "hosts"); +} + +TEST(CapabilityBuilderTest, CapabilityToPathMatchesName) { + // For all capabilities, the path segment matches the name + EXPECT_EQ(CapabilityBuilder::capability_to_path(Cap::DATA), CapabilityBuilder::capability_to_name(Cap::DATA)); + EXPECT_EQ(CapabilityBuilder::capability_to_path(Cap::RELATED_COMPONENTS), + CapabilityBuilder::capability_to_name(Cap::RELATED_COMPONENTS)); +} + +TEST(CapabilityBuilderTest, BuildsForDifferentEntityTypes) { + std::vector caps = {Cap::DATA}; + + auto areas_result = CapabilityBuilder::build_capabilities("areas", "a1", caps); + auto components_result = CapabilityBuilder::build_capabilities("components", "c1", caps); + auto apps_result = CapabilityBuilder::build_capabilities("apps", "app1", caps); + auto functions_result = CapabilityBuilder::build_capabilities("functions", "f1", caps); + + EXPECT_EQ(areas_result[0]["href"], "/api/v1/areas/a1/data"); + EXPECT_EQ(components_result[0]["href"], "/api/v1/components/c1/data"); + EXPECT_EQ(apps_result[0]["href"], "/api/v1/apps/app1/data"); + EXPECT_EQ(functions_result[0]["href"], "/api/v1/functions/f1/data"); +} + +// ============================================================================= +// LinksBuilder Tests +// ============================================================================= + +TEST(LinksBuilderTest, BuildsLinks) { + LinksBuilder builder; + auto links = builder.self("/areas/test").parent("/areas").add("custom", "/custom/link").build(); + + EXPECT_EQ(links["self"], "/areas/test"); + EXPECT_EQ(links["parent"], "/areas"); + EXPECT_EQ(links["custom"], "/custom/link"); +} + +TEST(LinksBuilderTest, BuildsEmptyLinks) { + LinksBuilder builder; + auto links = builder.build(); + + EXPECT_TRUE(links.is_object()); + EXPECT_EQ(links.size(), 0); +} + +TEST(LinksBuilderTest, SelfLinkOnly) { + LinksBuilder builder; + auto links = builder.self("/components/my-comp").build(); + + EXPECT_EQ(links.size(), 1); + EXPECT_EQ(links["self"], "/components/my-comp"); +} + +TEST(LinksBuilderTest, CollectionLink) { + LinksBuilder builder; + auto links = builder.self("/apps/app1").collection("/apps").build(); + + EXPECT_EQ(links["self"], "/apps/app1"); + EXPECT_EQ(links["collection"], "/apps"); +} + +TEST(LinksBuilderTest, FluentChaining) { + LinksBuilder builder; + auto links = builder.self("/functions/f1") + .collection("/functions") + .parent("/functions") + .add("hosts", "/functions/f1/hosts") + .add("depends-on", "/functions/f2") + .build(); + + EXPECT_EQ(links.size(), 5); + EXPECT_EQ(links["self"], "/functions/f1"); + EXPECT_EQ(links["collection"], "/functions"); + EXPECT_EQ(links["parent"], "/functions"); + EXPECT_EQ(links["hosts"], "/functions/f1/hosts"); + EXPECT_EQ(links["depends-on"], "/functions/f2"); +} + +TEST(LinksBuilderTest, OverwriteLink) { + LinksBuilder builder; + auto links = builder.self("/old").self("/new").build(); + + EXPECT_EQ(links["self"], "/new"); +} + +TEST(LinksBuilderTest, MultipleCustomLinks) { + LinksBuilder builder; + auto links = builder.self("/areas/area1") + .add("subareas", "/areas/area1/subareas") + .add("related-components", "/areas/area1/related-components") + .add("area", "/areas/parent") + .build(); + + EXPECT_EQ(links.size(), 4); + EXPECT_EQ(links["subareas"], "/areas/area1/subareas"); + EXPECT_EQ(links["related-components"], "/areas/area1/related-components"); + EXPECT_EQ(links["area"], "/areas/parent"); +} diff --git a/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py b/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py new file mode 100644 index 0000000..9553add --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_discovery_hybrid.test.py @@ -0,0 +1,650 @@ +#!/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 hybrid discovery mode. + +This test file validates discovery endpoints when the gateway is configured +with discovery_mode: hybrid, combining manifest definitions with runtime +ROS 2 graph discovery. + +Tests verify: +- Areas from manifest are present +- Components from manifest are present +- Apps from manifest are enriched with runtime data (topics, services) +- Runtime-discovered nodes are linked to manifest apps +- Functions aggregate data from their hosting apps +- Orphan nodes (not in manifest) are handled according to config +""" + +import os +import time +import unittest + +from ament_index_python.packages import get_package_share_directory +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 - gracefully continue without coverage settings + pass + return {} + + +def generate_test_description(): + """Generate launch description with gateway in hybrid discovery mode.""" + pkg_share = get_package_share_directory('ros2_medkit_gateway') + manifest_path = os.path.join( + pkg_share, 'config', 'examples', 'demo_nodes_manifest.yaml' + ) + + coverage_env = get_coverage_env() + + # Gateway node with hybrid discovery mode + gateway_node = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='ros2_medkit_gateway', + output='screen', + parameters=[{ + 'discovery_mode': 'hybrid', + 'manifest_path': manifest_path, + 'manifest_strict_validation': False, # Allow warnings about subarea references + # Allow orphan nodes to be discovered (warn, not fail) + 'unmanifested_nodes': 'warn', + }], + additional_env=coverage_env, + ) + + # Launch demo nodes matching the manifest + demo_nodes = [ + # Powertrain/Engine + 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_calibration_service', + name='calibration', + namespace='/powertrain/engine', + output='screen', + additional_env=coverage_env, + ), + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_long_calibration_action', + name='long_calibration', + namespace='/powertrain/engine', + output='screen', + additional_env=coverage_env, + ), + # Chassis/Brakes + 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, + ), + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_brake_actuator', + name='actuator', + namespace='/chassis/brakes', + output='screen', + additional_env=coverage_env, + ), + # Body + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_door_status_sensor', + name='status_sensor', + namespace='/body/door/front_left', + output='screen', + additional_env=coverage_env, + ), + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_light_controller', + name='controller', + namespace='/body/lights', + output='screen', + additional_env=coverage_env, + ), + # Perception + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_lidar_sensor', + name='lidar_sensor', + namespace='/perception/lidar', + output='screen', + additional_env=coverage_env, + ), + ] + + delayed_nodes = TimerAction( + period=2.0, + actions=demo_nodes, + ) + + return ( + LaunchDescription([ + gateway_node, + delayed_nodes, + launch_testing.actions.ReadyToTest(), + ]), + {'gateway_node': gateway_node}, + ) + + +API_BASE_PATH = '/api/v1' + + +class TestDiscoveryHybridMode(unittest.TestCase): + """Integration tests for hybrid discovery mode.""" + + BASE_URL = f'http://localhost:8080{API_BASE_PATH}' + MAX_DISCOVERY_WAIT = 60.0 + MIN_EXPECTED_APPS_ONLINE = 5 + + @classmethod + def setUpClass(cls): + """Wait for gateway and discovery to complete.""" + # Wait for gateway health + for i in range(30): + try: + response = requests.get(f'{cls.BASE_URL}/health', timeout=2) + if response.status_code == 200: + break + except requests.exceptions.RequestException: + # Gateway not ready yet, retry after sleep + pass + time.sleep(1) + else: + raise unittest.SkipTest('Gateway not responding') + + # Wait for apps to come online (runtime linking) + 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: + data = response.json() + online_count = sum( + 1 for a in data['items'] if a.get('is_online', False) + ) + if online_count >= cls.MIN_EXPECTED_APPS_ONLINE: + print(f'✓ Hybrid discovery: {online_count} apps online') + return + print(f' Waiting for apps: {online_count}/{cls.MIN_EXPECTED_APPS_ONLINE}...') + except requests.exceptions.RequestException: + # Apps endpoint not ready yet, retry after sleep + pass + time.sleep(2) + + print('⚠ Warning: Not all expected apps came online') + + # ========================================================================= + # Areas - Manifest + Runtime + # ========================================================================= + + def test_areas_from_manifest(self): + """ + Test areas are loaded from manifest in hybrid mode. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/areas', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + area_ids = [a['id'] for a in data['items']] + + # Manifest-defined areas should be present + self.assertIn('powertrain', area_ids) + self.assertIn('chassis', area_ids) + self.assertIn('body', area_ids) + self.assertIn('perception', area_ids) + self.assertIn('engine', area_ids) + + def test_area_with_description(self): + """ + Test area descriptions from manifest are preserved. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/areas/powertrain', timeout=5) + self.assertEqual(response.status_code, 200) + + area = response.json() + self.assertEqual(area['id'], 'powertrain') + # Description should come from manifest + if 'description' in area: + self.assertIn('Engine', area['description']) + + def test_area_subareas_hierarchy(self): + """ + Test subarea relationships from manifest. + + @verifies REQ_INTEROP_004 + """ + response = requests.get(f'{self.BASE_URL}/areas/body/subareas', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + subarea_ids = [s['id'] for s in data['items']] + + # Body has subareas: door, lights + self.assertIn('door', subarea_ids) + self.assertIn('lights', subarea_ids) + + def test_nested_subareas(self): + """ + Test deeply nested subareas (door -> front-left-door). + + @verifies REQ_INTEROP_004 + """ + response = requests.get(f'{self.BASE_URL}/areas/door/subareas', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + subarea_ids = [s['id'] for s in data['items']] + self.assertIn('front-left-door', subarea_ids) + + # ========================================================================= + # Components - Manifest Definitions + # ========================================================================= + + def test_components_from_manifest(self): + """ + Test components are loaded from manifest. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/components', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + component_ids = [c['id'] for c in data['items']] + + # Hardware components from manifest + self.assertIn('engine-ecu', component_ids) + self.assertIn('temp-sensor-hw', component_ids) + self.assertIn('brake-ecu', component_ids) + self.assertIn('lidar-unit', component_ids) + + def test_component_type_preserved(self): + """ + Test component type from manifest is preserved. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/components/engine-ecu', timeout=5) + self.assertEqual(response.status_code, 200) + + component = response.json() + self.assertEqual(component['type'], 'controller') + + def test_component_area_relationship(self): + """ + Test component is associated with correct area. + + @verifies REQ_INTEROP_006 + """ + response = requests.get(f'{self.BASE_URL}/areas/engine/components', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + component_ids = [c['id'] for c in data['items']] + + # Engine area should have these components + self.assertIn('engine-ecu', component_ids) + self.assertIn('temp-sensor-hw', component_ids) + self.assertIn('rpm-sensor-hw', component_ids) + + def test_hybrid_component_subcomponents(self): + """ + Test GET /components/{id}/subcomponents returns subcomponents in hybrid mode. + + @verifies REQ_INTEROP_005 + """ + # Test subcomponents endpoint for a component + # Returns empty list if no subcomponents defined, but endpoint works + response = requests.get(f'{self.BASE_URL}/components/engine-ecu/subcomponents', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + # Subcomponents may be empty but format should be correct + self.assertIsInstance(data['items'], list) + + def test_hybrid_component_subcomponents_not_found(self): + """ + Test GET /components/{id}/subcomponents returns 404 for unknown component in hybrid mode. + + @verifies REQ_INTEROP_005 + """ + response = requests.get(f'{self.BASE_URL}/components/nonexistent/subcomponents', timeout=5) + self.assertEqual(response.status_code, 404) + + # ========================================================================= + # Apps - Manifest + Runtime Linking + # ========================================================================= + + def test_apps_from_manifest(self): + """ + Test apps are loaded from manifest. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/apps', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + app_ids = [a['id'] for a in data['items']] + + # All manifest apps should be present + expected_apps = [ + 'engine-temp-sensor', + 'engine-rpm-sensor', + 'engine-calibration-service', + 'engine-long-calibration', + 'brake-pressure-sensor', + 'brake-actuator', + 'door-status-sensor', + 'light-controller', + 'lidar-sensor', + ] + + for app_id in expected_apps: + self.assertIn(app_id, app_ids, f'Missing app: {app_id}') + + def test_app_online_with_runtime_node(self): + """ + Test apps linked to running nodes have is_online=true. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/apps', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + apps_by_id = {a['id']: a for a in data['items']} + + # At least some apps should be online + online_apps = [ + app_id for app_id, app in apps_by_id.items() + if app.get('is_online', False) + ] + + self.assertGreater( + len(online_apps), 0, + 'No apps are online - runtime linking may have failed' + ) + + def test_app_has_runtime_topics(self): + """Test online app has topics from runtime discovery.""" + # Wait a bit for runtime linking + time.sleep(3) + + response = requests.get( + f'{self.BASE_URL}/apps/engine-temp-sensor/data', timeout=5 + ) + + # This test verifies runtime linking when it succeeds. + # Skip assertion if no topics found - runtime linking may take longer. + if response.status_code == 200: + data = response.json() + # Should have temperature topic if runtime linking worked + if 'items' in data and data['items']: + topic_names = [t.get('name', '') for t in data['items']] + # Demo node publishes 'temperature' + self.assertTrue( + any('temperature' in name for name in topic_names), + f'Expected temperature topic, got: {topic_names}' + ) + + def test_app_has_runtime_service(self): + """Test app with service has it discovered at runtime.""" + response = requests.get( + f'{self.BASE_URL}/apps/engine-calibration-service/operations', timeout=5 + ) + + # This test verifies runtime linking when it succeeds. + # Skip assertion if no operations found - runtime linking may take longer. + if response.status_code == 200: + data = response.json() + if 'items' in data and data['items']: + op_names = [o.get('name', '') for o in data['items']] + # Demo node provides 'calibrate' service + self.assertTrue( + any('calibrate' in name for name in op_names), + f'Expected calibrate service, got: {op_names}' + ) + + def test_app_component_relationship(self): + """Test app is_located_on links to correct component.""" + response = requests.get(f'{self.BASE_URL}/apps/engine-temp-sensor', timeout=5) + self.assertEqual(response.status_code, 200) + + app = response.json() + # Should be located on temp-sensor-hw via HATEOAS link + self.assertIn('_links', app, 'App response should contain _links') + self.assertIn( + 'is-located-on', app['_links'], + 'App should have is-located-on link when component is specified' + ) + self.assertEqual( + app['_links']['is-located-on'], + '/api/v1/components/temp-sensor-hw' + ) + + def test_app_depends_on_relationship(self): + """ + Test app depends_on creates dependency link. + + @verifies REQ_INTEROP_009 + """ + response = requests.get(f'{self.BASE_URL}/apps/engine-long-calibration', timeout=5) + self.assertEqual(response.status_code, 200) + + app = response.json() + # Should depend on engine-calibration-service + if 'depends_on' in app: + self.assertIn('engine-calibration-service', app['depends_on']) + + # ========================================================================= + # Functions - Aggregation from Hosts + # ========================================================================= + + def test_functions_from_manifest(self): + """ + Test functions are loaded from manifest. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/functions', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + function_ids = [f['id'] for f in data['items']] + + expected_functions = [ + 'engine-monitoring', + 'engine-calibration', + 'brake-system', + 'body-electronics', + 'perception-system', + ] + + for func_id in expected_functions: + self.assertIn(func_id, function_ids, f'Missing function: {func_id}') + + def test_function_hosts_relationship(self): + """ + Test function hosts are correctly linked. + + @verifies REQ_INTEROP_007 + """ + response = requests.get( + f'{self.BASE_URL}/functions/engine-monitoring/hosts', timeout=5 + ) + self.assertEqual(response.status_code, 200) + + data = response.json() + host_ids = [h['id'] for h in data['items']] + + # engine-monitoring hosted by temp-sensor and rpm-sensor + self.assertIn('engine-temp-sensor', host_ids) + self.assertIn('engine-rpm-sensor', host_ids) + + def test_function_aggregates_host_data(self): + """Test function /data aggregates topics from all hosts.""" + response = requests.get( + f'{self.BASE_URL}/functions/engine-monitoring/data', timeout=5 + ) + + if response.status_code == 200: + data = response.json() + if 'items' in data: + # Should have topics from both temp_sensor and rpm_sensor + topic_names = [t.get('name', '') for t in data['items']] + # At minimum should have some data + self.assertIsInstance(topic_names, list) + + def test_function_aggregates_host_operations(self): + """Test function /operations aggregates services from all hosts.""" + response = requests.get( + f'{self.BASE_URL}/functions/engine-calibration/operations', timeout=5 + ) + + if response.status_code == 200: + data = response.json() + if 'items' in data: + # Should have calibrate service and long_calibration action + op_names = [o.get('name', '') for o in data['items']] + self.assertIsInstance(op_names, list) + + def test_function_with_tags(self): + """ + Test function tags from manifest are preserved. + + @verifies REQ_INTEROP_011 + """ + response = requests.get(f'{self.BASE_URL}/functions/brake-system', timeout=5) + self.assertEqual(response.status_code, 200) + + func = response.json() + if 'tags' in func: + self.assertIn('safety-critical', func['tags']) + + # ========================================================================= + # Hybrid-Specific Behavior + # ========================================================================= + + def test_runtime_enriches_manifest_data(self): + """Test runtime discovery adds data to manifest entities.""" + # Get an app that's online + response = requests.get(f'{self.BASE_URL}/apps/lidar-sensor', timeout=5) + self.assertEqual(response.status_code, 200) + + app = response.json() + + # Manifest data should be present + self.assertEqual(app['name'], 'LiDAR Sensor') + + # Tags from manifest + if 'tags' in app: + self.assertIn('fault-reporter', app['tags']) + + def test_capabilities_include_runtime_resources(self): + """Test capabilities reflect runtime-discovered resources.""" + response = requests.get(f'{self.BASE_URL}/apps/engine-temp-sensor', timeout=5) + self.assertEqual(response.status_code, 200) + + app = response.json() + self.assertIn('capabilities', app) + + # Should have data capability (topics discovered at runtime) + cap_hrefs = [c.get('href', '') for c in app['capabilities']] + self.assertTrue( + any('/data' in href for href in cap_hrefs), + 'Expected data capability' + ) + + # ========================================================================= + # Error Handling + # ========================================================================= + + def test_nonexistent_area(self): + """Test 404 for non-existent area.""" + response = requests.get(f'{self.BASE_URL}/areas/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_nonexistent_component(self): + """Test 404 for non-existent component.""" + response = requests.get(f'{self.BASE_URL}/components/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_nonexistent_app(self): + """Test 404 for non-existent app.""" + response = requests.get(f'{self.BASE_URL}/apps/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_nonexistent_function(self): + """Test 404 for non-existent function.""" + response = requests.get(f'{self.BASE_URL}/functions/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + +@launch_testing.post_shutdown_test() +class TestDiscoveryHybridModeShutdown(unittest.TestCase): + """Post-shutdown tests.""" + + def test_exit_code(self, proc_info): + """Check gateway exited cleanly.""" + launch_testing.asserts.assertExitCodes(proc_info) diff --git a/src/ros2_medkit_gateway/test/test_discovery_manager.cpp b/src/ros2_medkit_gateway/test/test_discovery_manager.cpp index 38e3275..fec7da0 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_manager.cpp @@ -21,7 +21,7 @@ #include #include -#include "ros2_medkit_gateway/discovery_manager.hpp" +#include "ros2_medkit_gateway/discovery/discovery_manager.hpp" #include "ros2_medkit_gateway/native_topic_sampler.hpp" using ros2_medkit_gateway::DiscoveryManager; diff --git a/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py b/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py new file mode 100644 index 0000000..580b1a6 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_discovery_manifest.test.py @@ -0,0 +1,584 @@ +#!/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 manifest-only discovery mode. + +This test file validates discovery endpoints when the gateway is configured +with discovery_mode: manifest_only using demo_nodes_manifest.yaml. + +Tests verify: +- Areas are loaded from manifest (not runtime discovery) +- Components are loaded from manifest +- Apps are loaded from manifest with correct ros_binding +- Functions are loaded from manifest with hosted_by relationships +- Subareas and related-components relationships work +- Entity details and capabilities are correct +""" + +import os +import time +import unittest + +from ament_index_python.packages import get_package_share_directory +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 - gracefully continue without coverage settings + pass + return {} + + +def generate_test_description(): + """Generate launch description with gateway in manifest_only mode.""" + pkg_share = get_package_share_directory('ros2_medkit_gateway') + manifest_path = os.path.join( + pkg_share, 'config', 'examples', 'demo_nodes_manifest.yaml' + ) + + coverage_env = get_coverage_env() + + # Gateway node with manifest_only discovery mode + gateway_node = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='ros2_medkit_gateway', + output='screen', + parameters=[{ + 'discovery_mode': 'manifest_only', + 'manifest_path': manifest_path, + 'manifest_strict_validation': False, # Allow warnings about subarea references + }], + additional_env=coverage_env, + ) + + # Launch demo nodes to verify apps become online + 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, + ), + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_calibration_service', + name='calibration', + namespace='/powertrain/engine', + output='screen', + additional_env=coverage_env, + ), + launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='demo_lidar_sensor', + name='lidar_sensor', + namespace='/perception/lidar', + output='screen', + additional_env=coverage_env, + ), + ] + + delayed_nodes = TimerAction( + period=2.0, + actions=demo_nodes, + ) + + return ( + LaunchDescription([ + gateway_node, + delayed_nodes, + launch_testing.actions.ReadyToTest(), + ]), + {'gateway_node': gateway_node}, + ) + + +API_BASE_PATH = '/api/v1' + + +class TestDiscoveryManifestMode(unittest.TestCase): + """Integration tests for manifest-only discovery mode.""" + + BASE_URL = f'http://localhost:8080{API_BASE_PATH}' + MAX_WAIT = 30.0 + + @classmethod + def setUpClass(cls): + """Wait for gateway to be ready.""" + for i in range(30): + try: + response = requests.get(f'{cls.BASE_URL}/health', timeout=2) + if response.status_code == 200: + # Give time for manifest to be loaded + time.sleep(2) + return + except requests.exceptions.RequestException: + # Gateway not ready yet, retry after sleep + pass + time.sleep(1) + raise unittest.SkipTest('Gateway not responding') + + # ========================================================================= + # Areas Endpoints + # ========================================================================= + + def test_list_areas(self): + """ + Test GET /areas returns all manifest-defined areas. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/areas', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + self.assertIn('total_count', data) + + # Manifest defines: powertrain, chassis, body, perception (top-level) + # Plus subareas: engine, brakes, door, front-left-door, lights, lidar + area_ids = [a['id'] for a in data['items']] + + # Check top-level areas + self.assertIn('powertrain', area_ids) + self.assertIn('chassis', area_ids) + self.assertIn('body', area_ids) + self.assertIn('perception', area_ids) + + # Check subareas + self.assertIn('engine', area_ids) + self.assertIn('brakes', area_ids) + self.assertIn('lidar', area_ids) + + def test_get_area_details(self): + """ + Test GET /areas/{id} returns area with capabilities. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/areas/powertrain', timeout=5) + self.assertEqual(response.status_code, 200) + + area = response.json() + self.assertEqual(area['id'], 'powertrain') + self.assertEqual(area['name'], 'Powertrain') + self.assertIn('capabilities', area) + self.assertIn('_links', area) + + def test_get_area_not_found(self): + """Test GET /areas/{id} returns 404 for unknown area.""" + response = requests.get(f'{self.BASE_URL}/areas/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_area_subareas(self): + """ + Test GET /areas/{id}/subareas returns nested areas. + + @verifies REQ_INTEROP_004 + """ + response = requests.get(f'{self.BASE_URL}/areas/powertrain/subareas', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + subarea_ids = [s['id'] for s in data['items']] + self.assertIn('engine', subarea_ids) + + def test_area_components(self): + """ + Test GET /areas/{id}/components returns components in area. + + @verifies REQ_INTEROP_006 + """ + response = requests.get(f'{self.BASE_URL}/areas/engine/components', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + + def test_area_related_components(self): + """ + Test GET /areas/{id}/related-components includes subarea components. + + @verifies REQ_INTEROP_006 + """ + response = requests.get( + f'{self.BASE_URL}/areas/powertrain/related-components', timeout=5 + ) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + # Should include engine components (from subarea) + component_ids = [c['id'] for c in data['items']] + self.assertIn('engine-ecu', component_ids) + + # ========================================================================= + # Components Endpoints + # ========================================================================= + + def test_list_components(self): + """ + Test GET /components returns all manifest-defined components. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/components', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + self.assertIn('total_count', data) + + component_ids = [c['id'] for c in data['items']] + + # Check manifest-defined components + self.assertIn('engine-ecu', component_ids) + self.assertIn('temp-sensor-hw', component_ids) + self.assertIn('brake-ecu', component_ids) + self.assertIn('lidar-unit', component_ids) + + def test_get_component_details(self): + """ + Test GET /components/{id} returns component with capabilities. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/components/engine-ecu', timeout=5) + self.assertEqual(response.status_code, 200) + + component = response.json() + self.assertEqual(component['id'], 'engine-ecu') + self.assertEqual(component['name'], 'Engine ECU') + self.assertIn('capabilities', component) + self.assertIn('_links', component) + + def test_get_component_not_found(self): + """Test GET /components/{id} returns 404 for unknown component.""" + response = requests.get(f'{self.BASE_URL}/components/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_component_subcomponents(self): + """ + Test GET /components/{id}/subcomponents returns subcomponents. + + @verifies REQ_INTEROP_005 + """ + # Test subcomponents endpoint for a component + # Returns empty list if no subcomponents defined, but endpoint works + response = requests.get(f'{self.BASE_URL}/components/engine-ecu/subcomponents', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + # Subcomponents may be empty but format should be correct + self.assertIsInstance(data['items'], list) + + def test_component_subcomponents_not_found(self): + """ + Test GET /components/{id}/subcomponents returns 404 for unknown component. + + @verifies REQ_INTEROP_005 + """ + response = requests.get(f'{self.BASE_URL}/components/nonexistent/subcomponents', timeout=5) + self.assertEqual(response.status_code, 404) + + # ========================================================================= + # Apps Endpoints + # ========================================================================= + + def test_list_apps(self): + """ + Test GET /apps returns all manifest-defined apps. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/apps', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + self.assertIn('total_count', data) + + app_ids = [a['id'] for a in data['items']] + + # Check manifest-defined apps + self.assertIn('engine-temp-sensor', app_ids) + self.assertIn('engine-rpm-sensor', app_ids) + self.assertIn('brake-pressure-sensor', app_ids) + self.assertIn('lidar-sensor', app_ids) + + def test_get_app_details(self): + """ + Test GET /apps/{id} returns app with capabilities. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/apps/engine-temp-sensor', timeout=5) + self.assertEqual(response.status_code, 200) + + app = response.json() + self.assertEqual(app['id'], 'engine-temp-sensor') + self.assertEqual(app['name'], 'Engine Temperature Sensor') + self.assertIn('capabilities', app) + self.assertIn('_links', app) + + def test_get_app_not_found(self): + """Test GET /apps/{id} returns 404 for unknown app.""" + response = requests.get(f'{self.BASE_URL}/apps/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_app_online_status(self): + """Test that apps with running nodes have is_online=true.""" + # Wait for nodes to be discovered + time.sleep(5) + + response = requests.get(f'{self.BASE_URL}/apps', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + apps_by_id = {a['id']: a for a in data['items']} + + # engine-temp-sensor should be online (demo node running) + if 'engine-temp-sensor' in apps_by_id: + # May or may not be online depending on timing + pass # Just verify it exists + + def test_app_data_endpoint(self): + """Test GET /apps/{id}/data returns topic list.""" + response = requests.get(f'{self.BASE_URL}/apps/engine-temp-sensor/data', timeout=5) + # May return 200 with topics or empty list + self.assertIn(response.status_code, [200, 404]) + + def test_app_operations_endpoint(self): + """Test GET /apps/{id}/operations returns services/actions.""" + response = requests.get( + f'{self.BASE_URL}/apps/engine-calibration-service/operations', timeout=5 + ) + self.assertIn(response.status_code, [200, 404]) + + def test_app_configurations_endpoint(self): + """Test GET /apps/{id}/configurations returns parameters.""" + response = requests.get( + f'{self.BASE_URL}/apps/lidar-sensor/configurations', timeout=5 + ) + self.assertIn(response.status_code, [200, 404]) + + def test_app_data_item_endpoint(self): + """ + Test GET /apps/{id}/data/{data_id} returns sampled topic data. + + @verifies REQ_INTEROP_003 + """ + # First get the list of data items for the app + response = requests.get(f'{self.BASE_URL}/apps/engine-temp-sensor/data', timeout=5) + if response.status_code != 200: + self.skipTest('App data endpoint not available') + + data = response.json() + if not data.get('items'): + self.skipTest('No data items for app') + + # Get the first data item + data_id = data['items'][0]['id'] + response = requests.get( + f'{self.BASE_URL}/apps/engine-temp-sensor/data/{data_id}', timeout=5 + ) + self.assertIn(response.status_code, [200, 404]) + + if response.status_code == 200: + item = response.json() + self.assertIn('id', item) + self.assertIn('direction', item) + + def test_component_related_apps(self): + """ + Test GET /components/{id}/related-apps returns apps hosted on component. + + @verifies REQ_INTEROP_003 + """ + # temp-sensor-hw hosts engine-temp-sensor according to manifest + url = f'{self.BASE_URL}/components/temp-sensor-hw/related-apps' + response = requests.get(url, timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + self.assertIn('total_count', data) + + # Apps hosted on temp-sensor-hw should be returned + app_ids = [a['id'] for a in data['items']] + self.assertIn('engine-temp-sensor', app_ids) + + def test_component_related_apps_empty(self): + """Test GET /components/{id}/related-apps returns empty for component with no apps.""" + # engine-ecu doesn't have apps directly hosted on it + response = requests.get(f'{self.BASE_URL}/components/engine-ecu/related-apps', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_component_related_apps_not_found(self): + """Test GET /components/{id}/related-apps returns 404 for unknown component.""" + response = requests.get(f'{self.BASE_URL}/components/nonexistent/related-apps', timeout=5) + self.assertEqual(response.status_code, 404) + + # ========================================================================= + # Functions Endpoints + # ========================================================================= + + def test_list_functions(self): + """ + Test GET /functions returns all manifest-defined functions. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/functions', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + self.assertIn('total_count', data) + + function_ids = [f['id'] for f in data['items']] + + # Check manifest-defined functions + self.assertIn('engine-monitoring', function_ids) + self.assertIn('engine-calibration', function_ids) + self.assertIn('brake-system', function_ids) + self.assertIn('body-electronics', function_ids) + self.assertIn('perception-system', function_ids) + + def test_get_function_details(self): + """ + Test GET /functions/{id} returns function with capabilities. + + @verifies REQ_INTEROP_003 + """ + response = requests.get(f'{self.BASE_URL}/functions/engine-monitoring', timeout=5) + self.assertEqual(response.status_code, 200) + + func = response.json() + self.assertEqual(func['id'], 'engine-monitoring') + self.assertEqual(func['name'], 'Engine Monitoring') + self.assertIn('capabilities', func) + self.assertIn('_links', func) + + def test_get_function_not_found(self): + """Test GET /functions/{id} returns 404 for unknown function.""" + response = requests.get(f'{self.BASE_URL}/functions/nonexistent', timeout=5) + self.assertEqual(response.status_code, 404) + + def test_function_hosts(self): + """ + Test GET /functions/{id}/hosts returns hosting apps. + + @verifies REQ_INTEROP_007 + """ + response = requests.get(f'{self.BASE_URL}/functions/engine-monitoring/hosts', timeout=5) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertIn('items', data) + host_ids = [h['id'] for h in data['items']] + + # engine-monitoring is hosted by engine-temp-sensor and engine-rpm-sensor + self.assertIn('engine-temp-sensor', host_ids) + self.assertIn('engine-rpm-sensor', host_ids) + + def test_function_data(self): + """Test GET /functions/{id}/data aggregates data from hosts.""" + response = requests.get(f'{self.BASE_URL}/functions/engine-monitoring/data', timeout=5) + self.assertIn(response.status_code, [200, 404]) + + def test_function_operations(self): + """Test GET /functions/{id}/operations aggregates operations from hosts.""" + response = requests.get( + f'{self.BASE_URL}/functions/engine-calibration/operations', timeout=5 + ) + self.assertIn(response.status_code, [200, 404]) + + # ========================================================================= + # Discovery Statistics + # ========================================================================= + + def test_discovery_stats(self): + """Test GET /discovery/stats returns manifest mode info.""" + response = requests.get(f'{self.BASE_URL}/discovery/stats', timeout=5) + if response.status_code == 200: + stats = response.json() + # Should indicate manifest_only mode + if 'mode' in stats: + self.assertEqual(stats['mode'], 'manifest_only') + + # ========================================================================= + # Error Cases + # ========================================================================= + + def test_invalid_area_id(self): + """Test GET /areas/{id} with invalid ID returns 400.""" + response = requests.get(f'{self.BASE_URL}/areas/invalid..id', timeout=5) + self.assertIn(response.status_code, [400, 404]) + + def test_invalid_component_id(self): + """Test GET /components/{id} with invalid ID returns 400.""" + response = requests.get(f'{self.BASE_URL}/components/../etc/passwd', timeout=5) + self.assertIn(response.status_code, [400, 404]) + + +@launch_testing.post_shutdown_test() +class TestDiscoveryManifestModeShutdown(unittest.TestCase): + """Post-shutdown tests.""" + + def test_exit_code(self, proc_info): + """Check gateway exited cleanly.""" + launch_testing.asserts.assertExitCodes(proc_info) diff --git a/src/ros2_medkit_gateway/test/test_discovery_models.cpp b/src/ros2_medkit_gateway/test/test_discovery_models.cpp new file mode 100644 index 0000000..6bb3820 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_discovery_models.cpp @@ -0,0 +1,449 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @file test_discovery_models.cpp + * @brief Unit tests for SOVD discovery model serialization + * + * @verifies REQ_DISCOVERY_002 App/Function model serialization + */ + +#include + +#include +#include + +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/common.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" + +using ros2_medkit_gateway::App; +using ros2_medkit_gateway::Area; +using ros2_medkit_gateway::Component; +using ros2_medkit_gateway::Function; +using ros2_medkit_gateway::json; + +// ============================================================================= +// Area Model Tests +// ============================================================================= + +class AreaModelTest : public ::testing::Test { + protected: + void SetUp() override { + area_.id = "powertrain"; + area_.name = "Powertrain System"; + area_.namespace_path = "/powertrain"; + area_.type = "Area"; + area_.translation_id = "area.powertrain"; + area_.description = "Powertrain control systems"; + area_.tags = {"critical", "automotive"}; + area_.parent_area_id = "vehicle"; + } + + Area area_; +}; + +TEST_F(AreaModelTest, ToJson_ContainsRequiredFields) { + json j = area_.to_json(); + + EXPECT_EQ(j["id"], "powertrain"); + EXPECT_EQ(j["namespace"], "/powertrain"); + EXPECT_EQ(j["type"], "Area"); +} + +TEST_F(AreaModelTest, ToJson_ContainsOptionalFields) { + json j = area_.to_json(); + + EXPECT_EQ(j["name"], "Powertrain System"); + EXPECT_EQ(j["translationId"], "area.powertrain"); + EXPECT_EQ(j["description"], "Powertrain control systems"); + EXPECT_EQ(j["tags"].size(), 2); + EXPECT_EQ(j["parentAreaId"], "vehicle"); +} + +TEST_F(AreaModelTest, ToJson_OmitsEmptyOptionalFields) { + Area minimal; + minimal.id = "test"; + minimal.namespace_path = "/test"; + + json j = minimal.to_json(); + + EXPECT_FALSE(j.contains("name")); + EXPECT_FALSE(j.contains("translationId")); + EXPECT_FALSE(j.contains("description")); + EXPECT_FALSE(j.contains("tags")); + EXPECT_FALSE(j.contains("parentAreaId")); +} + +TEST_F(AreaModelTest, ToEntityReference_ContainsRequiredFields) { + json j = area_.to_entity_reference("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "powertrain"); + EXPECT_EQ(j["type"], "Area"); + EXPECT_EQ(j["self"], "http://localhost:8080/api/v1/areas/powertrain"); +} + +TEST_F(AreaModelTest, ToCapabilities_ContainsSubResources) { + json j = area_.to_capabilities("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "powertrain"); + EXPECT_EQ(j["type"], "Area"); + EXPECT_TRUE(j.contains("capabilities")); + EXPECT_EQ(j["capabilities"].size(), 2); +} + +// ============================================================================= +// Component Model Tests +// ============================================================================= + +class ComponentModelTest : public ::testing::Test { + protected: + void SetUp() override { + comp_.id = "motor_controller"; + comp_.name = "Motor Controller"; + comp_.namespace_path = "/powertrain"; + comp_.fqn = "/powertrain/motor_controller"; + comp_.type = "Component"; + comp_.area = "powertrain"; + comp_.source = "node"; + comp_.translation_id = "comp.motor"; + comp_.description = "Controls the electric motor"; + comp_.variant = "v2"; + comp_.tags = {"actuator"}; + } + + Component comp_; +}; + +TEST_F(ComponentModelTest, ToJson_ContainsRequiredFields) { + json j = comp_.to_json(); + + EXPECT_EQ(j["id"], "motor_controller"); + EXPECT_EQ(j["namespace"], "/powertrain"); + EXPECT_EQ(j["fqn"], "/powertrain/motor_controller"); + EXPECT_EQ(j["type"], "Component"); + EXPECT_EQ(j["area"], "powertrain"); + EXPECT_EQ(j["source"], "node"); +} + +TEST_F(ComponentModelTest, ToJson_ContainsOptionalFields) { + json j = comp_.to_json(); + + EXPECT_EQ(j["name"], "Motor Controller"); + EXPECT_EQ(j["translationId"], "comp.motor"); + EXPECT_EQ(j["description"], "Controls the electric motor"); + EXPECT_EQ(j["variant"], "v2"); + EXPECT_EQ(j["tags"].size(), 1); +} + +TEST_F(ComponentModelTest, ToEntityReference_ContainsRequiredFields) { + json j = comp_.to_entity_reference("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "motor_controller"); + EXPECT_EQ(j["type"], "Component"); + EXPECT_EQ(j["self"], "http://localhost:8080/api/v1/components/motor_controller"); +} + +TEST_F(ComponentModelTest, ToCapabilities_ContainsConfigurationsForNodes) { + json j = comp_.to_capabilities("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "motor_controller"); + EXPECT_EQ(j["type"], "Component"); + EXPECT_TRUE(j.contains("capabilities")); + + // Node-based components should have configurations capability + bool has_configurations = false; + for (const auto & cap : j["capabilities"]) { + if (cap["name"] == "configurations") { + has_configurations = true; + EXPECT_EQ(cap["href"], "http://localhost:8080/api/v1/components/motor_controller/configurations"); + } + } + EXPECT_TRUE(has_configurations); +} + +// ============================================================================= +// App Model Tests +// ============================================================================= + +class AppModelTest : public ::testing::Test { + protected: + void SetUp() override { + app_.id = "nav2"; + app_.name = "Navigation 2"; + app_.source = "manifest"; + app_.translation_id = "app.nav2"; + app_.description = "Navigation stack for ROS 2"; + app_.tags = {"navigation", "autonomous"}; + + // ROS binding + App::RosBinding binding; + binding.node_name = "nav2_controller"; + binding.namespace_pattern = "/nav2"; + app_.ros_binding = binding; + + // Runtime state + app_.bound_fqn = "/nav2/controller_server"; + app_.is_online = true; + app_.external = false; + + // Component relationship + app_.component_id = "navigation_server"; + app_.depends_on = {"localization", "mapping"}; + } + + App app_; +}; + +TEST_F(AppModelTest, ToJson_ContainsRequiredFields) { + json j = app_.to_json(); + + EXPECT_EQ(j["id"], "nav2"); + EXPECT_EQ(j["name"], "Navigation 2"); + EXPECT_EQ(j["type"], "App"); + EXPECT_EQ(j["source"], "manifest"); +} + +TEST_F(AppModelTest, ToJson_ContainsOptionalFields) { + json j = app_.to_json(); + + EXPECT_EQ(j["translationId"], "app.nav2"); + EXPECT_EQ(j["description"], "Navigation stack for ROS 2"); + EXPECT_EQ(j["tags"].size(), 2); +} + +TEST_F(AppModelTest, ToJson_ContainsRosBinding) { + json j = app_.to_json(); + + ASSERT_TRUE(j.contains("rosBinding")); + EXPECT_EQ(j["rosBinding"]["nodeName"], "nav2_controller"); + EXPECT_EQ(j["rosBinding"]["namespace"], "/nav2"); +} + +TEST_F(AppModelTest, ToJson_ContainsRuntimeState) { + json j = app_.to_json(); + + EXPECT_EQ(j["boundFqn"], "/nav2/controller_server"); + EXPECT_EQ(j["isOnline"], true); + // external is only included when true, so should not be present when false + EXPECT_FALSE(j.contains("external")); +} + +TEST_F(AppModelTest, ToJson_ExternalWhenTrue) { + app_.external = true; + json j = app_.to_json(); + + EXPECT_EQ(j["external"], true); +} + +TEST_F(AppModelTest, ToJson_OmitsEmptyOptionalFields) { + App minimal; + minimal.id = "test"; + minimal.name = "Test App"; + minimal.source = "manifest"; + + json j = minimal.to_json(); + + EXPECT_FALSE(j.contains("translationId")); + EXPECT_FALSE(j.contains("description")); + EXPECT_FALSE(j.contains("tags")); + EXPECT_FALSE(j.contains("rosBinding")); + EXPECT_FALSE(j.contains("boundFqn")); +} + +TEST_F(AppModelTest, ToEntityReference_ContainsRequiredFields) { + json j = app_.to_entity_reference("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "nav2"); + EXPECT_EQ(j["name"], "Navigation 2"); + EXPECT_EQ(j["href"], "http://localhost:8080/api/v1/apps/nav2"); +} + +TEST_F(AppModelTest, ToCapabilities_ContainsExpectedResources) { + // Add some topics and services to get data and operations capabilities + app_.topics.publishes.push_back("/nav2/path"); + ros2_medkit_gateway::ServiceInfo svc; + svc.name = "get_plan"; + svc.full_path = "/nav2/get_plan"; + svc.type = "nav2_msgs/srv/GetPlan"; + app_.services.push_back(svc); + + json j = app_.to_capabilities("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "nav2"); + EXPECT_EQ(j["name"], "Navigation 2"); + EXPECT_TRUE(j.contains("data")); + EXPECT_TRUE(j.contains("operations")); + EXPECT_TRUE(j.contains("faults")); +} + +TEST_F(AppModelTest, ToCapabilities_OmitsDataWithoutTopics) { + // No topics, no services + App minimal; + minimal.id = "test"; + minimal.name = "Test App"; + minimal.source = "manifest"; + + json j = minimal.to_capabilities("http://localhost:8080/api/v1"); + + EXPECT_FALSE(j.contains("data")); + EXPECT_FALSE(j.contains("operations")); + EXPECT_TRUE(j.contains("faults")); + EXPECT_TRUE(j.contains("configurations")); +} + +// ============================================================================= +// Function Model Tests +// ============================================================================= + +class FunctionModelTest : public ::testing::Test { + protected: + void SetUp() override { + func_.id = "path_planning"; + func_.name = "Path Planning"; + func_.source = "manifest"; + func_.translation_id = "func.path_planning"; + func_.description = "Computes optimal paths to goal"; + func_.tags = {"planning", "core"}; + func_.hosts = {"nav2_planner_server"}; + func_.depends_on = {"localization"}; + } + + Function func_; +}; + +TEST_F(FunctionModelTest, ToJson_ContainsRequiredFields) { + json j = func_.to_json(); + + EXPECT_EQ(j["id"], "path_planning"); + EXPECT_EQ(j["name"], "Path Planning"); + EXPECT_EQ(j["type"], "Function"); + EXPECT_EQ(j["source"], "manifest"); +} + +TEST_F(FunctionModelTest, ToJson_ContainsOptionalFields) { + json j = func_.to_json(); + + EXPECT_EQ(j["translationId"], "func.path_planning"); + EXPECT_EQ(j["description"], "Computes optimal paths to goal"); + EXPECT_EQ(j["tags"].size(), 2); + EXPECT_EQ(j["hosts"].size(), 1); + EXPECT_EQ(j["dependsOn"].size(), 1); +} + +TEST_F(FunctionModelTest, ToJson_OmitsEmptyOptionalFields) { + Function minimal; + minimal.id = "test"; + minimal.name = "Test Function"; + minimal.source = "manifest"; + + json j = minimal.to_json(); + + EXPECT_FALSE(j.contains("translationId")); + EXPECT_FALSE(j.contains("description")); + EXPECT_FALSE(j.contains("tags")); + EXPECT_FALSE(j.contains("hosts")); + EXPECT_FALSE(j.contains("dependsOn")); +} + +TEST_F(FunctionModelTest, ToEntityReference_ContainsRequiredFields) { + json j = func_.to_entity_reference("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "path_planning"); + EXPECT_EQ(j["name"], "Path Planning"); + EXPECT_EQ(j["href"], "http://localhost:8080/api/v1/functions/path_planning"); +} + +TEST_F(FunctionModelTest, ToCapabilities_ContainsExpectedResources) { + json j = func_.to_capabilities("http://localhost:8080/api/v1"); + + EXPECT_EQ(j["id"], "path_planning"); + EXPECT_EQ(j["name"], "Path Planning"); + EXPECT_TRUE(j.contains("data")); + EXPECT_TRUE(j.contains("operations")); + EXPECT_TRUE(j.contains("faults")); +} + +// ============================================================================= +// Common Types Tests +// ============================================================================= + +TEST(CommonTypesTest, ServiceInfo_ToJson) { + ros2_medkit_gateway::ServiceInfo service; + service.name = "set_speed"; + service.full_path = "/motor/set_speed"; + service.type = "std_srvs/srv/SetBool"; + + json j = service.to_json(); + + EXPECT_EQ(j["name"], "set_speed"); + EXPECT_EQ(j["path"], "/motor/set_speed"); + EXPECT_EQ(j["type"], "std_srvs/srv/SetBool"); + EXPECT_EQ(j["kind"], "service"); +} + +TEST(CommonTypesTest, ActionInfo_ToJson) { + ros2_medkit_gateway::ActionInfo action; + action.name = "navigate_to_pose"; + action.full_path = "/navigate_to_pose"; + action.type = "nav2_msgs/action/NavigateToPose"; + + json j = action.to_json(); + + EXPECT_EQ(j["name"], "navigate_to_pose"); + EXPECT_EQ(j["path"], "/navigate_to_pose"); + EXPECT_EQ(j["type"], "nav2_msgs/action/NavigateToPose"); + EXPECT_EQ(j["kind"], "action"); +} + +TEST(CommonTypesTest, ComponentTopics_ToJson) { + ros2_medkit_gateway::ComponentTopics topics; + topics.publishes.push_back("/odom"); + topics.subscribes.push_back("/cmd_vel"); + + json j = topics.to_json(); + + EXPECT_EQ(j["publishes"].size(), 1); + EXPECT_EQ(j["subscribes"].size(), 1); + EXPECT_EQ(j["publishes"][0], "/odom"); + EXPECT_EQ(j["subscribes"][0], "/cmd_vel"); +} + +TEST(CommonTypesTest, QosProfile_ToJson) { + ros2_medkit_gateway::QosProfile qos; + qos.reliability = "reliable"; + qos.durability = "volatile"; + qos.history = "keep_last"; + qos.depth = 10; + qos.liveliness = "automatic"; + + json j = qos.to_json(); + + EXPECT_EQ(j["reliability"], "reliable"); + EXPECT_EQ(j["durability"], "volatile"); + EXPECT_EQ(j["history"], "keep_last"); + EXPECT_EQ(j["depth"], 10); + EXPECT_EQ(j["liveliness"], "automatic"); +} + +// ============================================================================= +// Main +// ============================================================================= + +int main(int argc, char ** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/ros2_medkit_gateway/test/test_gateway_node.cpp b/src/ros2_medkit_gateway/test/test_gateway_node.cpp index 6281b34..14952ac 100644 --- a/src/ros2_medkit_gateway/test/test_gateway_node.cpp +++ b/src/ros2_medkit_gateway/test/test_gateway_node.cpp @@ -142,7 +142,9 @@ TEST_F(TestGatewayNode, test_list_areas_endpoint) { EXPECT_EQ(res->get_header_value("Content-Type"), "application/json"); auto json_response = nlohmann::json::parse(res->body); - EXPECT_TRUE(json_response.is_array()); + EXPECT_TRUE(json_response.is_object()); + EXPECT_TRUE(json_response.contains("items")); + EXPECT_TRUE(json_response["items"].is_array()); } TEST_F(TestGatewayNode, test_list_components_endpoint) { @@ -156,7 +158,9 @@ TEST_F(TestGatewayNode, test_list_components_endpoint) { EXPECT_EQ(res->get_header_value("Content-Type"), "application/json"); auto json_response = nlohmann::json::parse(res->body); - EXPECT_TRUE(json_response.is_array()); + EXPECT_TRUE(json_response.is_object()); + EXPECT_TRUE(json_response.contains("items")); + EXPECT_TRUE(json_response["items"].is_array()); } int main(int argc, char ** argv) { diff --git a/src/ros2_medkit_gateway/test/test_integration.test.py b/src/ros2_medkit_gateway/test/test_integration.test.py index 284c2ed..c8061fa 100644 --- a/src/ros2_medkit_gateway/test/test_integration.test.py +++ b/src/ros2_medkit_gateway/test/test_integration.test.py @@ -366,7 +366,9 @@ def test_02_list_areas(self): @verifies REQ_INTEROP_003 """ - areas = self._get_json('/areas') + data = self._get_json('/areas') + self.assertIn('items', data) + areas = data['items'] self.assertIsInstance(areas, list) self.assertGreaterEqual(len(areas), 1) area_ids = [area['id'] for area in areas] @@ -379,7 +381,9 @@ def test_03_list_components(self): @verifies REQ_INTEROP_003 """ - components = self._get_json('/components') + data = self._get_json('/components') + 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) @@ -407,7 +411,8 @@ def test_04_automotive_areas_discovery(self): @verifies REQ_INTEROP_003 """ - areas = self._get_json('/areas') + data = self._get_json('/areas') + areas = data['items'] area_ids = [area['id'] for area in areas] expected_areas = ['powertrain', 'chassis', 'body'] @@ -423,7 +428,9 @@ def test_05_area_components_success(self): @verifies REQ_INTEROP_006 """ # Test powertrain area - components = self._get_json('/areas/powertrain/components') + data = self._get_json('/areas/powertrain/components') + self.assertIn('items', data) + components = data['items'] self.assertIsInstance(components, list) self.assertGreater(len(components), 0) @@ -709,16 +716,17 @@ def test_15_valid_ids_with_underscores(self): print('✓ Valid IDs with underscores test passed') - def test_16_invalid_ids_with_hyphens(self): + def test_16_invalid_ids_with_special_chars(self): """ - Test that IDs with hyphens are rejected (not allowed in ROS 2 names). + Test that IDs with special chars (except underscore/hyphen) are rejected. @verifies REQ_INTEROP_018 """ invalid_ids = [ - 'component-name', - 'component-name-123', - 'my-component', + 'component@name', + 'component name', + 'component!name', + 'component$name', ] for invalid_id in invalid_ids: @@ -728,14 +736,14 @@ def test_16_invalid_ids_with_hyphens(self): self.assertEqual( response.status_code, 400, - f'Expected 400 for hyphenated ID: {invalid_id}', + f'Expected 400 for invalid ID: {invalid_id}', ) data = response.json() self.assertIn('error', data) self.assertEqual(data['error'], 'Invalid component ID') - print('✓ Invalid IDs with hyphens test passed') + print('✓ Invalid IDs with special chars test passed') def test_17_component_topic_temperature(self): """ @@ -1182,7 +1190,7 @@ def test_34_operation_call_invalid_component_id(self): invalid_ids = [ 'component;drop', 'component