diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 982f0fac..18dfda67 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -24,18 +24,54 @@ Server Capabilities .. code-block:: json { - "api_version": "1.0.0", - "gateway_version": "0.1.0", + "name": "ROS 2 Medkit Gateway", + "version": "0.3.0", + "api_base": "/api/v1", "endpoints": [ - {"path": "/areas", "supported_methods": ["GET"]}, - {"path": "/components", "supported_methods": ["GET"]}, - {"path": "/apps", "supported_methods": ["GET"]} - ] + "GET /api/v1/health", + "GET /api/v1/areas", + "GET /api/v1/components", + "GET /api/v1/apps", + "GET /api/v1/functions", + "GET /api/v1/faults", + "..." + ], + "capabilities": { + "discovery": true, + "data_access": true, + "operations": true, + "async_actions": true, + "configurations": true, + "faults": true, + "logs": true, + "bulk_data": true, + "cyclic_subscriptions": true, + "updates": false, + "authentication": false, + "tls": false + } } ``GET /api/v1/version-info`` Get gateway version and status information. + **Example Response:** + + .. code-block:: json + + { + "items": [ + { + "version": "1.0.0", + "base_uri": "/api/v1", + "vendor_info": { + "version": "0.3.0", + "name": "ros2_medkit" + } + } + ] + } + ``GET /api/v1/health`` Health check endpoint. Returns HTTP 200 if gateway is operational. @@ -57,7 +93,7 @@ Areas { "id": "powertrain", "name": "Powertrain", - "self": "/api/v1/areas/powertrain" + "href": "/api/v1/areas/powertrain" } ] } @@ -71,6 +107,9 @@ Areas ``GET /api/v1/areas/{area_id}/components`` List components in a specific area. + Areas also support the same resource collections as components: ``/data``, ``/operations``, + ``/configurations``, and ``/faults``. See the corresponding sections below. + Components ~~~~~~~~~~ @@ -86,9 +125,7 @@ Components { "id": "temp_sensor", "name": "temp_sensor", - "self": "/api/v1/components/temp_sensor", - "area": "powertrain", - "resource_collections": ["data", "operations", "configurations", "faults"] + "href": "/api/v1/components/temp_sensor" } ] } @@ -128,6 +165,9 @@ Functions ``GET /api/v1/functions/{function_id}/hosts`` List apps that host this function. + Functions also support ``/data``, ``/operations``, ``/configurations``, and ``/faults``. + See the corresponding sections below. + Data Endpoints -------------- @@ -477,6 +517,16 @@ Query and manage faults. - **204:** Fault cleared - **404:** Fault not found +``DELETE /api/v1/components/{id}/faults`` + Clear all faults for an entity. + + Accepts the optional ``?status=`` query parameter (same values as ``GET /faults``). + Without it, clears pending and confirmed faults. + + - **204:** Faults cleared (or none to clear) + - **400:** Invalid status parameter + - **503:** Fault manager unavailable + ``DELETE /api/v1/faults`` Clear all faults across the system *(ros2_medkit extension, not SOVD)*. @@ -1197,16 +1247,17 @@ The gateway implements a subset of the SOVD (Service-Oriented Vehicle Diagnostic - Operations (``/operations``, ``/executions``) - Configurations (``/configurations``) - Faults (``/faults``) with ``environment_data`` and SOVD status object +- Logs (``/logs``) with severity filtering and per-entity configuration - Bulk Data (``/bulk-data``) for binary data downloads (rosbags, logs) - Software Updates (``/updates``) with async prepare/execute lifecycle - Cyclic Subscriptions (``/cyclic-subscriptions``) with SSE-based periodic data delivery **ros2_medkit Extensions:** -- ``/health`` - Health check endpoint +- ``/health`` - Health check with discovery pipeline stats - ``/version-info`` - Gateway version information -- ``/manifest/status`` - Manifest discovery status -- SSE fault streaming - Real-time fault notifications +- ``DELETE /faults`` - Clear all faults globally +- ``GET /faults/stream`` - SSE real-time fault notifications - ``x-medkit`` extension fields in responses See Also diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index d8cc5bcd..2f16877d 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -1,6 +1,10 @@ Discovery Options Reference =========================== +.. contents:: + :local: + :depth: 2 + This document describes configuration options for the gateway's discovery system. The discovery system maps ROS 2 graph entities (nodes, topics, services, actions) to SOVD entities (areas, components, apps). @@ -87,19 +91,89 @@ The ``min_topics_for_component`` parameter (default: 1) sets the minimum number of topics required before creating a component. This can filter out namespaces with only a few stray topics. -Merge Pipeline Options (Hybrid Mode) -------------------------------------- +Merge Pipeline (Hybrid Mode) +----------------------------- + +In hybrid mode, the gateway uses a layered merge pipeline to combine entities +from multiple sources. Three layers contribute entities independently: + +- **ManifestLayer** - entities declared in the YAML manifest (highest priority for identity/hierarchy) +- **RuntimeLayer** - entities discovered from the ROS 2 graph (highest priority for live data/status) +- **PluginLayer** - entities contributed by ``IntrospectionProvider`` plugins + +Each layer's contribution is merged per **field-group** with configurable policies. + +Field Groups +^^^^^^^^^^^^ + +.. list-table:: + :header-rows: 1 + :widths: 20 80 -When using ``hybrid`` mode, the merge pipeline controls how entities from -different discovery layers are combined. The ``merge_pipeline`` section -configures gap-fill behavior for runtime-discovered entities. + * - Field Group + - Contents + * - ``identity`` + - id, name, translation_id, description, tags + * - ``hierarchy`` + - area, component_id, parent references, depends_on, hosts + * - ``live_data`` + - topics, services, actions + * - ``status`` + - is_online, bound_fqn + * - ``metadata`` + - source, x-medkit extensions, custom metadata fields -Gap-Fill Configuration -^^^^^^^^^^^^^^^^^^^^^^ +Merge Policies +^^^^^^^^^^^^^^ -In hybrid mode, the manifest is the source of truth. Runtime (heuristic) discovery -fills gaps - entities that exist at runtime but aren't in the manifest. Gap-fill -controls restrict what the runtime layer is allowed to create: +Each layer declares a policy per field-group: + +.. list-table:: + :header-rows: 1 + :widths: 20 80 + + * - Policy + - Behavior + * - ``authoritative`` + - This layer's value wins over lower-priority layers. + * - ``enrichment`` + - Fill empty fields only; don't override existing values. + * - ``fallback`` + - Use only if no other layer provides the value. + +**Defaults:** + +- Manifest: ``authoritative`` for identity/hierarchy/metadata, ``enrichment`` for live_data, ``fallback`` for status +- Runtime: ``authoritative`` for live_data/status, ``enrichment`` for metadata, ``fallback`` for identity/hierarchy + +Override per-layer policies in ``gateway_params.yaml``. Empty string means +"use layer default". Policy values are **case-sensitive** and must be lowercase +(``authoritative``, ``enrichment``, ``fallback``): + +.. code-block:: yaml + + discovery: + merge_pipeline: + layers: + manifest: + identity: "" # authoritative (default) + hierarchy: "" # authoritative (default) + live_data: "" # enrichment (default) + status: "" # fallback (default) + metadata: "" # authoritative (default) + runtime: + identity: "" # fallback (default) + hierarchy: "" # fallback (default) + live_data: "" # authoritative (default) + status: "" # authoritative (default) + metadata: "" # enrichment (default) + +Gap-Fill +^^^^^^^^ + +In hybrid mode, the runtime layer can create heuristic entities for namespaces +not covered by the manifest. Gap-fill controls what the runtime layer is allowed +to create: .. code-block:: yaml @@ -110,10 +184,10 @@ controls restrict what the runtime layer is allowed to create: allow_heuristic_components: true allow_heuristic_apps: true allow_heuristic_functions: false - namespace_whitelist: [] - namespace_blacklist: [] + # namespace_blacklist: ["/rosout"] + # namespace_whitelist: [] -.. list-table:: Gap-Fill Options +.. list-table:: :header-rows: 1 :widths: 35 15 50 @@ -122,62 +196,52 @@ controls restrict what the runtime layer is allowed to create: - Description * - ``allow_heuristic_areas`` - ``true`` - - Allow runtime layer to create Area entities not in the manifest + - Create areas from namespaces not in manifest. * - ``allow_heuristic_components`` - ``true`` - - Allow runtime layer to create Component entities not in the manifest + - Create synthetic components for unmanifested namespaces. * - ``allow_heuristic_apps`` - ``true`` - - Allow runtime layer to create App entities not in the manifest + - Create apps for nodes without manifest ``ros_binding``. * - ``allow_heuristic_functions`` - ``false`` - - Allow runtime layer to create Function entities (rarely useful at runtime) - * - ``namespace_whitelist`` - - ``[]`` - - If non-empty, only allow gap-fill from these ROS 2 namespaces (Areas and Components only) + - Create heuristic functions (not recommended). * - ``namespace_blacklist`` - ``[]`` - - Exclude gap-fill from these ROS 2 namespaces (Areas and Components only) - -When both whitelist and blacklist are empty, all namespaces are eligible for gap-fill. -If whitelist is non-empty, only listed namespaces pass. Blacklist is always applied. - -Namespace matching uses path-segment boundaries: ``/nav`` matches ``/nav`` and ``/nav/sub`` -but NOT ``/navigation``. Both lists only filter Areas and Components (which carry -``namespace_path``). Apps and Functions are not namespace-filtered. - - -Merge Policies -^^^^^^^^^^^^^^ - -Each discovery layer declares a ``MergePolicy`` per field group. When two layers -provide the same entity (matched by ID), policies determine which values win: - -.. list-table:: Merge Policies - :header-rows: 1 - :widths: 25 75 - - * - Policy - - Description - * - ``AUTHORITATIVE`` - - This layer's value wins over lower-priority layers. - If two AUTHORITATIVE layers conflict, a warning is logged and the - higher-priority (earlier) layer wins. - * - ``ENRICHMENT`` - - Fill empty fields from this layer. Non-empty target values are preserved. - Two ENRICHMENT layers merge additively (collections are unioned). - * - ``FALLBACK`` - - Use only if no other layer provides the value. Two FALLBACK layers - merge additively (first non-empty fills gaps). - -**Built-in layer policies:** - -- **ManifestLayer** (priority 1): IDENTITY, HIERARCHY, METADATA are AUTHORITATIVE. - LIVE_DATA is ENRICHMENT (runtime topics/services take precedence). - STATUS is FALLBACK (manifest cannot know online state). -- **RuntimeLayer** (priority 2): LIVE_DATA and STATUS are AUTHORITATIVE. - METADATA is ENRICHMENT. IDENTITY and HIERARCHY are FALLBACK. -- **PluginLayer** (priority 3+): All field groups ENRICHMENT + - Namespaces excluded from gap-fill (e.g., ``["/rosout"]``). + * - ``namespace_whitelist`` + - ``[]`` + - If non-empty, only these namespaces are eligible for gap-fill. + +Health Endpoint +^^^^^^^^^^^^^^^ + +``GET /api/v1/health`` includes a ``discovery`` section in hybrid mode with +pipeline stats, linking results, and merge warnings: + +.. code-block:: json + + { + "status": "healthy", + "discovery": { + "mode": "hybrid", + "strategy": "hybrid", + "pipeline": { + "layers": ["manifest", "runtime", "plugin"], + "total_entities": 6, + "enriched_count": 5, + "conflict_count": 0, + "conflicts": [], + "id_collisions": 0, + "filtered_by_gap_fill": 0 + }, + "linking": { + "linked_count": 5, + "orphan_count": 1, + "binding_conflicts": 0 + } + } + } Configuration Example --------------------- @@ -201,15 +265,13 @@ Complete YAML configuration for runtime discovery: topic_only_policy: "create_component" min_topics_for_component: 2 # Require at least 2 topics - # Note: merge_pipeline settings only apply when mode is "hybrid" + # Merge pipeline (hybrid mode only) merge_pipeline: gap_fill: allow_heuristic_areas: true allow_heuristic_components: true allow_heuristic_apps: true - allow_heuristic_functions: false - namespace_whitelist: [] - namespace_blacklist: ["/rosout", "/parameter_events"] + namespace_blacklist: ["/rosout"] Command Line Override --------------------- diff --git a/docs/config/server.rst b/docs/config/server.rst index 6eb8e7d1..550dbc1c 100644 --- a/docs/config/server.rst +++ b/docs/config/server.rst @@ -155,6 +155,37 @@ Data Access Settings - ``1.0`` - Timeout for sampling topics with active publishers. Range: 0.1-30.0. +Logging Configuration +--------------------- + +Configure the in-memory log buffer that collects ``/rosout`` messages. + +.. list-table:: + :header-rows: 1 + :widths: 25 10 15 50 + + * - Parameter + - Type + - Default + - Description + * - ``logs.buffer_size`` + - int + - ``200`` + - Maximum log entries retained per node. Valid range: 1-100000 + (values outside this range are clamped with a warning). + +Example: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + logs: + buffer_size: 500 + +A ``LogProvider`` plugin can replace the default ``/rosout`` backend. +See :doc:`/tutorials/plugin-system` for details. + Performance Tuning ------------------ @@ -219,7 +250,7 @@ Example: .. note:: - The gateway uses native rclcpp APIs for all ROS 2 interactions—no ROS 2 CLI + The gateway uses native rclcpp APIs for all ROS 2 interactions - no ROS 2 CLI dependencies. Topic discovery, sampling, publishing, service calls, and action operations are implemented in pure C++ using ros2_medkit_serialization. @@ -255,6 +286,60 @@ Example: max_clients: 10 max_subscriptions: 100 +Rate Limiting +------------- + +Token-bucket-based rate limiting for API requests. Disabled by default. + +.. list-table:: + :header-rows: 1 + :widths: 35 10 15 40 + + * - Parameter + - Type + - Default + - Description + * - ``rate_limiting.enabled`` + - bool + - ``false`` + - Enable rate limiting. + * - ``rate_limiting.global_requests_per_minute`` + - int + - ``600`` + - Maximum RPM across all clients combined. + * - ``rate_limiting.client_requests_per_minute`` + - int + - ``60`` + - Maximum RPM per client IP. + * - ``rate_limiting.endpoint_limits`` + - string[] + - ``[]`` + - Per-endpoint overrides as ``"pattern:rpm"`` strings. + Pattern uses ``*`` as single-segment wildcard + (e.g., ``"/api/v1/*/operations/*:10"``). + * - ``rate_limiting.client_cleanup_interval_seconds`` + - int + - ``300`` + - How often to scan and remove idle client tracking entries (seconds). + * - ``rate_limiting.client_max_idle_seconds`` + - int + - ``600`` + - Remove client entries idle longer than this (seconds). + +Example: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + rate_limiting: + enabled: true + global_requests_per_minute: 600 + client_requests_per_minute: 60 + endpoint_limits: ["/api/v1/*/operations/*:10"] + +See :doc:`/api/rest` for rate limiting response headers and 429 behavior. + Plugin Framework ---------------- @@ -365,6 +450,18 @@ Complete Example max_clients: 10 max_subscriptions: 100 + logs: + buffer_size: 200 + + plugins: ["my_ota_plugin"] + plugins.my_ota_plugin.path: "/opt/ros2_medkit/lib/libmy_ota_plugin.so" + + updates: + enabled: true + + rate_limiting: + enabled: false + See Also -------- diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index 1a45369e..382b2cbc 100644 --- a/docs/tutorials/manifest-discovery.rst +++ b/docs/tutorials/manifest-discovery.rst @@ -265,6 +265,13 @@ In hybrid mode, the ``GET /health`` response includes full discovery diagnostics Runtime Linking ~~~~~~~~~~~~~~~ +.. note:: + + In hybrid mode, the gateway uses a layered merge pipeline: manifest entities, + runtime-discovered entities, and plugin-contributed entities are merged per + field-group before linking. See :doc:`/config/discovery-options` for merge + pipeline configuration. + ROS Binding Configuration ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -313,7 +320,7 @@ Check which apps are online: .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq '.[] | {id, name, is_online}' + curl http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' Example response: @@ -389,30 +396,31 @@ The manifest defines a hierarchical structure: - controller-node - localization-node -Handling Unmanifested Nodes ---------------------------- +Controlling Gap-Fill in Hybrid Mode +------------------------------------ -In hybrid mode, the gateway may discover ROS 2 nodes that aren't declared -in the manifest. The ``config.unmanifested_nodes`` setting controls this: +In hybrid mode, the runtime layer can create heuristic entities for namespaces +not covered by the manifest. The ``merge_pipeline.gap_fill`` parameters control +this behavior: .. 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"`` - -.. note:: - In hybrid mode with gap-fill configuration (see :doc:`/config/discovery-options`), - namespace filtering controls which runtime entities enter the pipeline. - ``unmanifested_nodes`` controls how runtime nodes that passed gap-fill - but did not match any manifest app are handled by the RuntimeLinker. + discovery: + merge_pipeline: + gap_fill: + allow_heuristic_areas: true # Create areas from namespaces + allow_heuristic_components: true # Create synthetic components + allow_heuristic_apps: true # Create apps from unbound nodes + allow_heuristic_functions: false # Don't create heuristic functions + # namespace_blacklist: ["/rosout"] # Exclude specific namespaces + # namespace_whitelist: [] # If set, only allow these namespaces + +When all ``allow_heuristic_*`` options are ``false``, only manifest-declared +entities appear. Runtime nodes are still discovered for linking, but no +heuristic entities (areas, components, apps, functions) are created from +unmatched namespaces or nodes. + +See :doc:`/config/discovery-options` for the full merge pipeline reference. Hot Reloading ------------- diff --git a/docs/tutorials/migration-to-manifest.rst b/docs/tutorials/migration-to-manifest.rst index 263993c3..b4eb3137 100644 --- a/docs/tutorials/migration-to-manifest.rst +++ b/docs/tutorials/migration-to-manifest.rst @@ -319,7 +319,7 @@ Step 7: Test in Hybrid Mode .. code-block:: bash - curl http://localhost:8080/api/v1/apps | jq '.[] | {id, name, is_online}' + curl http://localhost:8080/api/v1/apps | jq '.items[] | {id, name, is_online}' 5. **Check for orphan nodes** (warnings in gateway logs): diff --git a/docs/tutorials/plugin-system.rst b/docs/tutorials/plugin-system.rst index 5f1b74c2..9bccee88 100644 --- a/docs/tutorials/plugin-system.rst +++ b/docs/tutorials/plugin-system.rst @@ -10,9 +10,13 @@ Overview Plugins implement the ``GatewayPlugin`` C++ base class plus one or more typed provider interfaces: - **UpdateProvider** - software update backend (CRUD, prepare/execute, automated, status) -- **IntrospectionProvider** - enriches discovered entities with platform-specific metadata - via the merge pipeline. In hybrid mode, each IntrospectionProvider is wrapped as a - ``PluginLayer`` and added to the pipeline with ENRICHMENT merge policy. +- **IntrospectionProvider** - provides platform-specific metadata and can introduce new + entities into the entity cache. Called during each discovery cycle by the merge pipeline's + PluginLayer. Plugin-provided metadata is accessible via the plugin API, not automatically + merged into entity responses. See :doc:`/config/discovery-options` for merge pipeline configuration. +- **LogProvider** - replaces or augments the default ``/rosout`` log backend. + Can operate in observer mode (receives log entries) or full-ingestion mode + (owns the entire log pipeline). See the ``/logs`` endpoints in :doc:`/api/rest`. A single plugin can implement multiple provider interfaces. For example, a "systemd" plugin could provide both introspection (discover systemd units) and updates (manage service restarts). @@ -122,12 +126,7 @@ Writing a Plugin return static_cast(p); } - // Required if your plugin implements IntrospectionProvider: - extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider* get_introspection_provider(GatewayPlugin* p) { - return static_cast(p); - } - -The ``get_update_provider`` and ``get_introspection_provider`` functions use ``extern "C"`` +The ``get_update_provider`` (and ``get_introspection_provider``, ``get_log_provider``) functions use ``extern "C"`` to avoid RTTI issues across shared library boundaries. The ``static_cast`` is safe because these functions execute inside the plugin's own ``.so`` where the type hierarchy is known. @@ -223,7 +222,7 @@ Plugin Lifecycle 1. ``dlopen`` loads the ``.so`` with ``RTLD_NOW | RTLD_LOCAL`` 2. ``plugin_api_version()`` is checked against the gateway's ``PLUGIN_API_VERSION`` 3. ``create_plugin()`` factory function creates the plugin instance -4. Provider interfaces are queried via ``get_update_provider()`` / ``get_introspection_provider()`` +4. Provider interfaces are queried via ``get_update_provider()`` / ``get_introspection_provider()`` / ``get_log_provider()`` 5. ``configure()`` is called with per-plugin JSON config 6. ``set_context()`` provides ``PluginContext`` with ROS 2 node, entity cache, faults, and HTTP utilities 7. ``register_routes()`` allows registering custom REST endpoints @@ -301,12 +300,9 @@ Multiple Plugins Multiple plugins can be loaded simultaneously: - **UpdateProvider**: Only one plugin's UpdateProvider is used (first in config order) -- **IntrospectionProvider**: All plugins are added as PluginLayers to the merge pipeline. - Each plugin's entities are merged with ENRICHMENT policy - they fill empty fields but - never override manifest or runtime values. Plugins are added after all built-in layers, - and the pipeline is refreshed once after all plugins are registered (batch registration). - The ``introspect()`` method receives an ``IntrospectionInput`` populated with all entities - from previous layers (manifest + runtime), enabling context-aware metadata and discovery. +- **IntrospectionProvider**: All plugins' results are merged via the PluginLayer in the discovery pipeline +- **LogProvider**: Only the first plugin's LogProvider is used for queries (same as UpdateProvider). + All LogProvider plugins receive ``on_log_entry()`` calls as observers. - **Custom routes**: All plugins can register endpoints (use unique path prefixes) Error Handling diff --git a/postman/collections/ros2-medkit-gateway.postman_collection.json b/postman/collections/ros2-medkit-gateway.postman_collection.json index 983958dc..b20de216 100644 --- a/postman/collections/ros2-medkit-gateway.postman_collection.json +++ b/postman/collections/ros2-medkit-gateway.postman_collection.json @@ -324,7 +324,7 @@ "version-info" ] }, - "description": "Get SOVD server version information. Returns sovd_info array with supported SOVD versions, base URIs, and vendor information. Response format: { sovd_info: [{ version, base_uri, vendor_info: { version, name } }] }" + "description": "Get SOVD server version information. Returns items array with supported SOVD versions, base URIs, and vendor information. Response format: { items: [{ version, base_uri, vendor_info: { version, name } }] }" }, "response": [] }, diff --git a/src/ros2_medkit_gateway/CHANGELOG.rst b/src/ros2_medkit_gateway/CHANGELOG.rst index 660eff0c..b9b7e688 100644 --- a/src/ros2_medkit_gateway/CHANGELOG.rst +++ b/src/ros2_medkit_gateway/CHANGELOG.rst @@ -4,6 +4,21 @@ Changelog for package ros2_medkit_gateway 0.3.0 (2026-02-27) ------------------ + +**Breaking Changes:** + +* ``GET /version-info`` response key renamed from ``sovd_info`` to ``items`` for SOVD alignment (`#258 `_) +* ``GET /`` root endpoint restructured: ``endpoints`` is now a flat string array, added ``capabilities`` object, ``api_base`` field, and ``name``/``version`` top-level fields (`#258 `_) +* Default rosbag storage format changed from ``sqlite3`` to ``mcap`` (`#258 `_) + +**Features:** + +* Layered merge pipeline for hybrid discovery with per-layer, per-field-group merge policies (`#258 `_) +* Gap-fill configuration: control heuristic entity creation with ``allow_heuristic_*`` options and namespace filtering (`#258 `_) +* Plugin layer: ``IntrospectionProvider`` now wired into discovery pipeline via ``PluginLayer`` (`#258 `_) +* ``LogProvider`` plugin interface for custom log backends (`#258 `_) +* ``/health`` endpoint includes merge pipeline diagnostics (layers, conflicts, gap-fill stats) (`#258 `_) +* Entity detail responses now include ``logs``, ``bulk-data``, ``cyclic-subscriptions`` URIs (`#258 `_) * Gateway plugin framework with dynamic C++ plugin loading (`#237 `_) * Software updates plugin with 8 SOVD-compliant endpoints (`#237 `_, `#231 `_) * SSE-based periodic data subscriptions for real-time streaming without polling (`#223 `_) diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index 234e42b7..31ffd5e7 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -620,15 +620,16 @@ void GatewayNode::refresh_cache() { auto functions = discovery_mgr_->discover_functions(); std::vector all_components; - if (discovery_mgr_->get_mode() == DiscoveryMode::HYBRID) { - // Pipeline already merges node and topic components - all_components = discovery_mgr_->discover_components(); - } else { + if (discovery_mgr_->get_mode() == DiscoveryMode::RUNTIME_ONLY) { + // RUNTIME_ONLY: merge node + topic components (no pipeline) auto node_components = discovery_mgr_->discover_components(); auto topic_components = discovery_mgr_->discover_topic_components(); all_components.reserve(node_components.size() + topic_components.size()); all_components.insert(all_components.end(), node_components.begin(), node_components.end()); all_components.insert(all_components.end(), topic_components.begin(), topic_components.end()); + } else { + // HYBRID: pipeline merges all sources; MANIFEST_ONLY: manifest components only + all_components = discovery_mgr_->discover_components(); } // Capture sizes for logging diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 2f0645ea..c06f7d1b 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -457,6 +457,9 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl response["faults"] = base + "/faults"; response["subcomponents"] = base + "/subcomponents"; response["hosts"] = base + "/hosts"; + response["logs"] = base + "/logs"; + response["bulk-data"] = base + "/bulk-data"; + response["cyclic-subscriptions"] = base + "/cyclic-subscriptions"; if (!comp.depends_on.empty()) { response["depends-on"] = base + "/depends-on"; @@ -805,6 +808,9 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re response["operations"] = base_uri + "/operations"; response["configurations"] = base_uri + "/configurations"; response["faults"] = base_uri + "/faults"; + response["logs"] = base_uri + "/logs"; + response["bulk-data"] = base_uri + "/bulk-data"; + response["cyclic-subscriptions"] = base_uri + "/cyclic-subscriptions"; if (!app.component_id.empty()) { response["is-located-on"] = "/api/v1/components/" + app.component_id; diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index 04353583..8a031026 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -99,7 +99,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/areas/{area_id}/faults/{fault_code}", "DELETE /api/v1/areas/{area_id}/faults/{fault_code}", "DELETE /api/v1/areas/{area_id}/faults", - "GET /api/v1/areas/{area_id}/faults/{fault_code}/snapshots", // Components "GET /api/v1/components", "GET /api/v1/components/{component_id}", @@ -125,7 +124,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/components/{component_id}/faults/{fault_code}", "DELETE /api/v1/components/{component_id}/faults/{fault_code}", "DELETE /api/v1/components/{component_id}/faults", - "GET /api/v1/components/{component_id}/faults/{fault_code}/snapshots", // Apps "GET /api/v1/apps", "GET /api/v1/apps/{app_id}", @@ -151,7 +149,6 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/apps/{app_id}/faults/{fault_code}", "DELETE /api/v1/apps/{app_id}/faults/{fault_code}", "DELETE /api/v1/apps/{app_id}/faults", - "GET /api/v1/apps/{app_id}/faults/{fault_code}/snapshots", // Functions "GET /api/v1/functions", "GET /api/v1/functions/{function_id}", @@ -175,12 +172,41 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response "GET /api/v1/functions/{function_id}/faults/{fault_code}", "DELETE /api/v1/functions/{function_id}/faults/{fault_code}", "DELETE /api/v1/functions/{function_id}/faults", - "GET /api/v1/functions/{function_id}/faults/{fault_code}/snapshots", + // Logs + "GET /api/v1/components/{component_id}/logs", + "GET /api/v1/components/{component_id}/logs/configuration", + "PUT /api/v1/components/{component_id}/logs/configuration", + "GET /api/v1/apps/{app_id}/logs", + "GET /api/v1/apps/{app_id}/logs/configuration", + "PUT /api/v1/apps/{app_id}/logs/configuration", + // Bulk Data + "GET /api/v1/components/{component_id}/bulk-data", + "GET /api/v1/components/{component_id}/bulk-data/{category}", + "GET /api/v1/components/{component_id}/bulk-data/{category}/{item_id}", + "POST /api/v1/components/{component_id}/bulk-data/{category}", + "DELETE /api/v1/components/{component_id}/bulk-data/{category}/{item_id}", + "GET /api/v1/apps/{app_id}/bulk-data", + "GET /api/v1/apps/{app_id}/bulk-data/{category}", + "GET /api/v1/apps/{app_id}/bulk-data/{category}/{item_id}", + "POST /api/v1/apps/{app_id}/bulk-data/{category}", + "DELETE /api/v1/apps/{app_id}/bulk-data/{category}/{item_id}", + // Cyclic Subscriptions + "POST /api/v1/components/{component_id}/cyclic-subscriptions", + "GET /api/v1/components/{component_id}/cyclic-subscriptions", + "GET /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}", + "PUT /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}", + "DELETE /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}", + "GET /api/v1/components/{component_id}/cyclic-subscriptions/{subscription_id}/events", + "POST /api/v1/apps/{app_id}/cyclic-subscriptions", + "GET /api/v1/apps/{app_id}/cyclic-subscriptions", + "GET /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}", + "PUT /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}", + "DELETE /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}", + "GET /api/v1/apps/{app_id}/cyclic-subscriptions/{subscription_id}/events", // Global Faults "GET /api/v1/faults", "GET /api/v1/faults/stream", - "GET /api/v1/faults/{fault_code}/snapshots", - "GET /api/v1/faults/{fault_code}/snapshots/bag", + "DELETE /api/v1/faults", }); const auto & auth_config = ctx_.auth_config(); @@ -193,6 +219,18 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response endpoints.push_back("POST /api/v1/auth/revoke"); } + // Add update endpoints if updates are available + if (ctx_.node() && ctx_.node()->get_update_manager()) { + endpoints.push_back("GET /api/v1/updates"); + endpoints.push_back("POST /api/v1/updates"); + endpoints.push_back("GET /api/v1/updates/{id}"); + endpoints.push_back("DELETE /api/v1/updates/{id}"); + endpoints.push_back("GET /api/v1/updates/{id}/status"); + endpoints.push_back("PUT /api/v1/updates/{id}/prepare"); + endpoints.push_back("PUT /api/v1/updates/{id}/execute"); + endpoints.push_back("PUT /api/v1/updates/{id}/automated"); + } + json capabilities = { {"discovery", true}, {"data_access", true}, @@ -200,12 +238,16 @@ void HealthHandlers::handle_root(const httplib::Request & req, httplib::Response {"async_actions", true}, {"configurations", true}, {"faults", true}, + {"logs", true}, + {"bulk_data", true}, + {"cyclic_subscriptions", true}, + {"updates", ctx_.node() && ctx_.node()->get_update_manager() != nullptr}, {"authentication", auth_config.enabled}, {"tls", tls_config.enabled}, }; json response = { - {"name", "ROS 2 Medkit Gateway"}, {"version", "0.1.0"}, {"api_base", API_BASE_PATH}, + {"name", "ROS 2 Medkit Gateway"}, {"version", "0.3.0"}, {"api_base", API_BASE_PATH}, {"endpoints", endpoints}, {"capabilities", capabilities}, }; @@ -243,10 +285,10 @@ void HealthHandlers::handle_version_info(const httplib::Request & req, httplib:: json sovd_info_entry = { {"version", "1.0.0"}, // SOVD standard version {"base_uri", API_BASE_PATH}, // Version-specific base URI - {"vendor_info", {{"version", "0.1.0"}, {"name", "ros2_medkit"}}} // Vendor-specific info + {"vendor_info", {{"version", "0.3.0"}, {"name", "ros2_medkit"}}} // Vendor-specific info }; - json response = {{"sovd_info", json::array({sovd_info_entry})}}; + json response = {{"items", json::array({sovd_info_entry})}}; HandlerContext::send_json(res, response); } catch (const std::exception & e) { diff --git a/src/ros2_medkit_gateway/test/test_gateway_node.cpp b/src/ros2_medkit_gateway/test/test_gateway_node.cpp index 8120aad6..9b89fb54 100644 --- a/src/ros2_medkit_gateway/test/test_gateway_node.cpp +++ b/src/ros2_medkit_gateway/test/test_gateway_node.cpp @@ -126,13 +126,13 @@ TEST_F(TestGatewayNode, test_version_info_endpoint) { EXPECT_EQ(res->get_header_value("Content-Type"), "application/json"); auto json_response = nlohmann::json::parse(res->body); - // Check for sovd_info array - EXPECT_TRUE(json_response.contains("sovd_info")); - EXPECT_TRUE(json_response["sovd_info"].is_array()); - EXPECT_GE(json_response["sovd_info"].size(), 1); + // Check for items array (SOVD-standard wrapper key) + EXPECT_TRUE(json_response.contains("items")); + EXPECT_TRUE(json_response["items"].is_array()); + EXPECT_GE(json_response["items"].size(), 1); - // Check first sovd_info entry - const auto & info = json_response["sovd_info"][0]; + // Check first items entry + const auto & info = json_response["items"][0]; EXPECT_TRUE(info.contains("version")); EXPECT_TRUE(info.contains("base_uri")); EXPECT_TRUE(info.contains("vendor_info")); diff --git a/src/ros2_medkit_gateway/test/test_health_handlers.cpp b/src/ros2_medkit_gateway/test/test_health_handlers.cpp index 2dc95392..45ab65e3 100644 --- a/src/ros2_medkit_gateway/test/test_health_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_health_handlers.cpp @@ -80,37 +80,37 @@ TEST_F(HealthHandlersTest, HandleHealthResponseIsValidJson) { // --- handle_version_info --- // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoContainsSovdInfoArray) { +TEST_F(HealthHandlersTest, HandleVersionInfoContainsItemsArray) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - ASSERT_TRUE(body.contains("sovd_info")); - ASSERT_TRUE(body["sovd_info"].is_array()); - EXPECT_FALSE(body["sovd_info"].empty()); + ASSERT_TRUE(body.contains("items")); + ASSERT_TRUE(body["items"].is_array()); + EXPECT_FALSE(body["items"].empty()); } // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVersionField) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasVersionField) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - auto & entry = body["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("version")); EXPECT_TRUE(entry["version"].is_string()); EXPECT_FALSE(entry["version"].get().empty()); } // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasBaseUri) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasBaseUri) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - auto & entry = body["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("base_uri")); } // @verifies REQ_INTEROP_001 -TEST_F(HealthHandlersTest, HandleVersionInfoSovdEntryHasVendorInfo) { +TEST_F(HealthHandlersTest, HandleVersionInfoItemsEntryHasVendorInfo) { handlers_.handle_version_info(req_, res_); auto body = json::parse(res_.body); - auto & entry = body["sovd_info"][0]; + auto & entry = body["items"][0]; EXPECT_TRUE(entry.contains("vendor_info")); EXPECT_TRUE(entry["vendor_info"].contains("name")); EXPECT_EQ(entry["vendor_info"]["name"], "ros2_medkit"); diff --git a/src/ros2_medkit_integration_tests/test/features/test_health.test.py b/src/ros2_medkit_integration_tests/test/features/test_health.test.py index b1aaeb59..de67fc05 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_health.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_health.test.py @@ -98,13 +98,13 @@ def test_version_endpoint(self): """ data = self.get_json('/version-info') - # Check sovd_info array - self.assertIn('sovd_info', data) - self.assertIsInstance(data['sovd_info'], list) - self.assertGreaterEqual(len(data['sovd_info']), 1) + # Check items array (SOVD-standard wrapper key) + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + self.assertGreaterEqual(len(data['items']), 1) - # Check first sovd_info entry - info = data['sovd_info'][0] + # Check first items entry + info = data['items'][0] self.assertIn('version', info) self.assertIn('base_uri', info) self.assertIn('vendor_info', info) diff --git a/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py b/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py index 17372882..e0a0d260 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_plugin_vendor_extensions.test.py @@ -59,7 +59,10 @@ def generate_test_description(): class TestPluginVendorExtensions(GatewayTestCase): - """Vendor extension endpoint tests via test_gateway_plugin.""" + """Vendor extension endpoint tests via test_gateway_plugin. + + @verifies REQ_INTEROP_003 + """ MIN_EXPECTED_APPS = 2 REQUIRED_APPS = {'temp_sensor', 'rpm_sensor'} diff --git a/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py index 41d6b228..43423242 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_snapshots_api.test.py @@ -46,20 +46,23 @@ class TestSnapshotsApi(GatewayTestCase): MIN_EXPECTED_APPS = 1 REQUIRED_APPS = {'lidar_sensor'} - def test_root_endpoint_includes_snapshots(self): - """Root endpoint lists snapshots endpoints. + def test_root_endpoint_excludes_legacy_snapshots(self): + """Root endpoint does not list removed legacy snapshot endpoints. + + Legacy snapshot endpoints were removed in favor of inline snapshots + in fault responses and bulk-data endpoints. @verifies REQ_INTEROP_088 """ data = self.get_json('/') - # Verify snapshots endpoints are listed + # Verify legacy snapshot endpoints are NOT listed (removed) self.assertIn('endpoints', data) - self.assertIn( + self.assertNotIn( 'GET /api/v1/faults/{fault_code}/snapshots', data['endpoints'] ) - self.assertIn( + self.assertNotIn( 'GET /api/v1/components/{component_id}/faults/{fault_code}/snapshots', data['endpoints'] ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_tls.test.py b/src/ros2_medkit_integration_tests/test/features/test_tls.test.py index 2461f981..0f787106 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_tls.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_tls.test.py @@ -234,11 +234,11 @@ def test_https_version_info_endpoint(self): self.assertEqual(response.status_code, 200) data = response.json() - # sovd_info array with version, base_uri, vendor_info - self.assertIn('sovd_info', data) - self.assertIsInstance(data['sovd_info'], list) - self.assertGreater(len(data['sovd_info']), 0) - info = data['sovd_info'][0] + # items array (SOVD-standard wrapper key) + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + self.assertGreater(len(data['items']), 0) + info = data['items'][0] self.assertIn('version', info) self.assertIn('base_uri', info) diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py index b10a5cb8..167bd9ff 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py @@ -361,6 +361,30 @@ def test_24_function_operations(self): data = self.get_json('/functions/engine-calibration/operations') self.assertIn('items', data) + # ========================================================================= + # Regression: no synthetic/topic components leak into manifest_only mode + # ========================================================================= + + def test_25_no_topic_components_in_manifest_only(self): + """Components list contains only manifest-defined IDs. + + Regression test: in manifest_only mode, runtime-discovered + topic-based components must not appear in the entity cache. + All component IDs must come from the manifest. + """ + manifest_component_ids = { + 'engine-ecu', 'temp-sensor-hw', 'rpm-sensor-hw', + 'brake-ecu', 'brake-pressure-sensor-hw', 'brake-actuator-hw', + 'door-sensor-hw', 'light-module', 'lidar-unit', + } + data = self.get_json('/components') + actual_ids = {c['id'] for c in data['items']} + extra = actual_ids - manifest_component_ids + self.assertEqual( + extra, set(), + f'Non-manifest components found in manifest_only mode: {extra}' + ) + # ========================================================================= # Error Cases # =========================================================================