Plugin System

The gateway supports a plugin system for extending functionality with shared libraries (.so files). Plugins can provide software update backends, platform-specific introspection, and custom REST endpoints.

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 - 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 Discovery Options Reference 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 REST API Reference.

  • ScriptProvider - replaces or augments the default filesystem-based script backend. Plugins can provide script listings, create custom scripts, and execute them using alternative runtimes. See the /scripts endpoints in REST API Reference.

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).

Configuration

Add plugins to gateway_params.yaml:

ros2_medkit_gateway:
  ros__parameters:
    plugins: ["my_ota_plugin"]
    plugins.my_ota_plugin.path: "/opt/ros2_medkit/lib/libmy_ota_plugin.so"
    plugins.my_ota_plugin.server_url: "https://updates.example.com"
    plugins.my_ota_plugin.api_key: "secret123"
    plugins.my_ota_plugin.timeout_ms: 5000

    # Enable updates if your plugin implements UpdateProvider
    updates:
      enabled: true

Each plugin name in the plugins array requires a corresponding plugins.<name>.path parameter with the absolute path to the .so file. Any additional plugins.<name>.<key> parameters are collected into a JSON object and passed to the plugin’s configure() method.

For the example above, configure() receives:

{"server_url": "https://updates.example.com", "api_key": "secret123", "timeout_ms": 5000}

Plugins are loaded in the order listed. An empty list (default) means no plugins are loaded, with zero overhead.

Plugin names must contain only alphanumeric characters, underscores, and hyphens (max 256 chars).

All standard ROS 2 parameter types are supported: strings, integers, doubles, booleans, and their array variants. They are automatically converted to their JSON equivalents.

Writing a Plugin

  1. Create a C++ shared library implementing GatewayPlugin and optionally one or more providers:

#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp"
#include "ros2_medkit_gateway/plugins/plugin_types.hpp"
#include "ros2_medkit_gateway/providers/update_provider.hpp"

using namespace ros2_medkit_gateway;

class MyPlugin : public GatewayPlugin, public UpdateProvider {
 public:
  std::string name() const override { return "my_plugin"; }

  void configure(const nlohmann::json& config) override {
    // Read plugin-specific configuration (env vars, files, etc.)
  }

  void shutdown() override {
    // Clean up resources
  }

  // UpdateProvider methods - all 7 must be implemented:

  tl::expected<std::vector<std::string>, UpdateBackendErrorInfo>
    list_updates(const UpdateFilter& filter) override { /* ... */ }

  tl::expected<nlohmann::json, UpdateBackendErrorInfo>
    get_update(const std::string& id) override { /* ... */ }

  tl::expected<void, UpdateBackendErrorInfo>
    register_update(const nlohmann::json& metadata) override { /* ... */ }

  tl::expected<void, UpdateBackendErrorInfo>
    delete_update(const std::string& id) override { /* ... */ }

  tl::expected<void, UpdateBackendErrorInfo>
    prepare(const std::string& id, UpdateProgressReporter& reporter) override { /* ... */ }

  tl::expected<void, UpdateBackendErrorInfo>
    execute(const std::string& id, UpdateProgressReporter& reporter) override { /* ... */ }

  tl::expected<bool, UpdateBackendErrorInfo>
    supports_automated(const std::string& id) override { /* ... */ }
};
  1. Export the required extern "C" symbols:

// Required: API version check
extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() {
  return ros2_medkit_gateway::PLUGIN_API_VERSION;
}

// Required: factory function
extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin* create_plugin() {
  return new MyPlugin();
}

// Required if your plugin implements UpdateProvider:
extern "C" GATEWAY_PLUGIN_EXPORT UpdateProvider* get_update_provider(GatewayPlugin* p) {
  return static_cast<MyPlugin*>(p);
}

The get_update_provider (and get_introspection_provider, get_log_provider, get_script_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.

Without the corresponding get_*_provider export, the gateway cannot detect that your plugin implements the provider interface, even if the class inherits from it.

  1. Build as a MODULE library:

add_library(my_plugin MODULE src/my_plugin.cpp)
target_link_libraries(my_plugin gateway_lib)
  1. Install the .so and add its path to gateway_params.yaml.

Complete Minimal Plugin

A self-contained plugin implementing UpdateProvider (copy-paste starting point):

// my_ota_plugin.cpp
#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp"
#include "ros2_medkit_gateway/plugins/plugin_types.hpp"
#include "ros2_medkit_gateway/providers/update_provider.hpp"

#include <httplib.h>
#include <nlohmann/json.hpp>

using namespace ros2_medkit_gateway;

class MyOtaPlugin : public GatewayPlugin, public UpdateProvider {
 public:
  std::string name() const override { return "my_ota"; }

  void configure(const nlohmann::json& /*config*/) override {}

  void shutdown() override {}

  // UpdateProvider CRUD
  tl::expected<std::vector<std::string>, UpdateBackendErrorInfo>
  list_updates(const UpdateFilter& /*filter*/) override {
    return std::vector<std::string>{};
  }

  tl::expected<nlohmann::json, UpdateBackendErrorInfo>
  get_update(const std::string& id) override {
    return tl::make_unexpected(
      UpdateBackendErrorInfo{UpdateBackendError::NotFound, "not found: " + id});
  }

  tl::expected<void, UpdateBackendErrorInfo>
  register_update(const nlohmann::json& /*metadata*/) override { return {}; }

  tl::expected<void, UpdateBackendErrorInfo>
  delete_update(const std::string& /*id*/) override { return {}; }

  // UpdateProvider async operations
  tl::expected<void, UpdateBackendErrorInfo>
  prepare(const std::string& /*id*/, UpdateProgressReporter& reporter) override {
    reporter.set_progress(100);
    return {};
  }

  tl::expected<void, UpdateBackendErrorInfo>
  execute(const std::string& /*id*/, UpdateProgressReporter& reporter) override {
    reporter.set_progress(100);
    return {};
  }

  tl::expected<bool, UpdateBackendErrorInfo>
  supports_automated(const std::string& /*id*/) override { return false; }
};

// Required exports
extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() {
  return PLUGIN_API_VERSION;
}

extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin* create_plugin() {
  return new MyOtaPlugin();
}

// Required for UpdateProvider detection
extern "C" GATEWAY_PLUGIN_EXPORT UpdateProvider* get_update_provider(GatewayPlugin* p) {
  return static_cast<MyOtaPlugin*>(p);
}

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() / get_log_provider() / get_script_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

  8. Runtime: subsystem managers call provider methods as needed

  9. shutdown() is called before the plugin is destroyed

PluginContext

After configure(), the gateway calls set_context() with a PluginContext reference providing access to gateway data and utilities:

  • node() - ROS 2 node pointer for subscriptions, service clients, timers, etc.

  • get_entity(id) - look up any entity (area, component, app, function) from the discovery cache

  • list_entity_faults(entity_id) - query faults for an entity

  • validate_entity_for_route(req, res, entity_id) - validate entity exists and matches the route type, auto-sending SOVD errors on failure

  • send_error() / send_json() - SOVD-compliant HTTP response helpers (static methods)

  • register_capability() / register_entity_capability() - register custom capabilities on entities

  • check_lock(entity_id, client_id, collection) - verify lock access before mutating operations; returns LockAccessResult with allowed flag and denial details

  • acquire_lock() / release_lock() - acquire and release entity locks with optional scope and TTL

  • get_entity_snapshot() - returns an IntrospectionInput populated from the current entity cache

  • list_all_faults() - returns JSON object with a "faults" array containing all active faults across all entities

  • register_sampler(collection, fn) - registers a cyclic subscription sampler for a custom collection name

void set_context(PluginContext& ctx) override {
  ctx_ = &ctx;

  // Register a custom capability for all apps
  ctx.register_capability(SovdEntityType::APP, "x-medkit-traces");

  // Register a capability for a specific entity
  ctx.register_entity_capability("sensor1", "x-medkit-calibration");

  // Get a snapshot of all currently discovered entities
  IntrospectionInput snapshot = ctx.get_entity_snapshot();

  // Query all active faults (returns {"faults": [...]})
  nlohmann::json all_faults = ctx.list_all_faults();

  // Register a sampler so clients can subscribe to "x-medkit-metrics" cyclically
  ctx.register_sampler("x-medkit-metrics",
    [this](const std::string& entity_id, const std::string& /*resource_path*/)
        -> tl::expected<nlohmann::json, std::string> {
      auto data = collect_metrics(entity_id);
      if (!data) return tl::make_unexpected("no data for: " + entity_id);
      return *data;
    });
}

PluginContext* ctx_ = nullptr;

get_entity_snapshot() returns an IntrospectionInput with vectors for all discovered areas, components, apps, and functions at the moment of the call. The snapshot is read-only and reflects the state of the gateway’s thread-safe entity cache.

list_all_faults() is useful for plugins that need cross-entity fault visibility (e.g. mapping fault codes to topics). Returns {} if the fault manager is unavailable.

register_sampler(collection, fn) wires a sampler into the ResourceSamplerRegistry so that cyclic subscriptions created for collection (e.g. "x-medkit-metrics") call fn(entity_id, resource_path) on each tick. The function must return tl::expected<nlohmann::json, std::string>. See Cyclic Subscription Extensions for the lower-level registry API.

Note

The PluginContext interface is versioned alongside PLUGIN_API_VERSION. Breaking changes to existing methods or removal of methods increment the version. New non-breaking methods (like check_lock, get_entity_snapshot, list_all_faults, and register_sampler) provide default no-op implementations so plugins that do not use these methods need no code changes. However, a rebuild is still required because plugin_api_version() must return the current version (exact-match check).

PluginContext API (v4)

Version 4 of the plugin API introduced several new methods on PluginContext. These methods have default no-op implementations, so existing plugins continue to compile without changes (though a rebuild is required to match the new PLUGIN_API_VERSION).

check_lock(entity_id, client_id, collection)

Verify whether a lock blocks access to a resource collection on an entity. Plugins that perform mutating operations (writing configurations, executing scripts, etc.) should call this before proceeding:

auto result = ctx_->check_lock(entity_id, client_id, "configurations");
if (!result.allowed) {
  PluginContext::send_error(res, 409, result.denied_code, result.denied_reason);
  return;
}

The returned LockAccessResult contains an allowed flag and, when denied, denied_by_lock_id, denied_code, and denied_reason fields. Companion methods acquire_lock() and release_lock() let plugins manage locks directly.

get_entity_snapshot()

Returns an IntrospectionInput populated from the current entity cache. The snapshot contains read-only vectors for all discovered areas, components, apps, and functions at the moment of the call:

IntrospectionInput snapshot = ctx_->get_entity_snapshot();
for (const auto& app : snapshot.apps) {
  RCLCPP_INFO(ctx_->node()->get_logger(), "App: %s", app.id.c_str());
}

This is useful for plugins that need a consistent view of all entities without subscribing to discovery events.

list_all_faults()

Returns a JSON object with a "faults" array containing all active faults across all entities. Returns an empty object if the fault manager is unavailable:

nlohmann::json faults = ctx_->list_all_faults();
for (const auto& fault : faults.value("faults", nlohmann::json::array())) {
  // Process each fault
}

register_sampler(collection, fn)

Registers a cyclic subscription sampler for a custom collection name. Once registered, clients can create cyclic subscriptions on that collection for any entity:

ctx_->register_sampler("x-medkit-metrics",
  [this](const std::string& entity_id, const std::string& /*resource_path*/)
      -> tl::expected<nlohmann::json, std::string> {
    auto data = collect_metrics(entity_id);
    if (!data) return tl::make_unexpected("no data for: " + entity_id);
    return *data;
  });

This is a convenience wrapper around the lower-level ResourceSamplerRegistry API described in Cyclic Subscription Extensions.

Custom REST Endpoints

Any plugin can register vendor-specific endpoints via register_routes(). Use PluginContext utilities for entity validation and SOVD-compliant responses:

void register_routes(httplib::Server& server, const std::string& api_prefix) override {
  // Global vendor endpoint
  server.Get(api_prefix + "/x-myvendor/status",
    [this](const httplib::Request&, httplib::Response& res) {
      PluginContext::send_json(res, get_status_json());
    });

  // Entity-scoped endpoint (matches a registered capability)
  server.Get((api_prefix + R"(/apps/([^/]+)/x-medkit-traces)").c_str(),
    [this](const httplib::Request& req, httplib::Response& res) {
      auto entity = ctx_->validate_entity_for_route(req, res, req.matches[1]);
      if (!entity) return;  // Error already sent

      auto faults = ctx_->list_entity_faults(entity->id);
      PluginContext::send_json(res, {{"entity", entity->id}, {"faults", faults}});
    });
}

Use the x- prefix for vendor-specific endpoints per SOVD convention.

For entity-scoped endpoints, register a matching capability via register_capability() or register_entity_capability() in set_context() so the endpoint appears in the entity’s capabilities array in discovery responses.

Cyclic Subscription Extensions

Plugins can extend cyclic subscriptions by registering custom resource samplers and transport providers during set_context().

Resource Samplers provide the data for a collection when sampled by a subscription. Built-in samplers (data, faults, configurations, updates) are registered by the gateway during startup. Custom samplers are registered via ResourceSamplerRegistry on the GatewayNode:

auto* sampler_registry = node->get_sampler_registry();
sampler_registry->register_sampler("x-medkit-metrics",
  [this](const std::string& entity_id, const std::string& resource_path)
    -> tl::expected<nlohmann::json, std::string> {
    return get_metrics(entity_id, resource_path);
  });

Once registered, clients can create cyclic subscriptions on the x-medkit-metrics collection for any entity.

Transport Providers deliver subscription data via alternative protocols (beyond the built-in SSE transport). Register via TransportRegistry on the GatewayNode:

auto* transport_registry = node->get_transport_registry();
transport_registry->register_transport(
  std::make_unique<MqttTransportProvider>(mqtt_client_));

The transport must implement SubscriptionTransportProvider (start, stop, notify_update, protocol()). Clients specify the protocol in the subscription creation request.

IntrospectionProvider Example

An IntrospectionProvider enriches entities in the merge pipeline. The plugin receives all entities from earlier layers (manifest + runtime) and returns new or enriched entities with ENRICHMENT merge policy.

The ros2_medkit_topic_beacon (push-based) and ros2_medkit_param_beacon (pull-based) plugins are the reference implementations. ros2_medkit_topic_beacon subscribes to /ros2_medkit/discovery, stores incoming MedkitDiscoveryHint messages, and injects their fields during introspect(). ros2_medkit_param_beacon polls ROS 2 parameters instead:

#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp"
#include "ros2_medkit_gateway/providers/introspection_provider.hpp"

using namespace ros2_medkit_gateway;

class MyIntrospectionPlugin : public GatewayPlugin, public IntrospectionProvider {
 public:
  std::string name() const override { return "my_introspection"; }

  void configure(const nlohmann::json& config) override {
    // Read plugin configuration
  }

  void shutdown() override {}

  // IntrospectionProvider: called on every merge pipeline refresh
  IntrospectionResult introspect(const IntrospectionInput& input) override {
    IntrospectionResult result;
    for (const auto& app : input.apps) {
      // Shadow the existing app with enriched metadata
      App shadow;
      shadow.id = app.id;
      result.new_entities.apps.push_back(shadow);
      result.metadata[app.id] = {
        {"x-platform-version", get_platform_version()}
      };
    }
    return result;
  }
};

// Required exports
extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() {
  return PLUGIN_API_VERSION;
}

extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin* create_plugin() {
  return new MyIntrospectionPlugin();
}

extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider* get_introspection_provider(GatewayPlugin* p) {
  return static_cast<MyIntrospectionPlugin*>(p);
}

The IntrospectionInput contains entity vectors from previous layers:

  • input.areas, input.components, input.apps, input.functions - read-only vectors of discovered entities to use as context

The returned IntrospectionResult wraps two fields:

  • new_entities - a NewEntities struct with vectors for areas, components, apps, and functions that the plugin introduces

  • metadata - a map from entity ID to a nlohmann::json object with plugin-specific enrichment data (e.g. vendor extension fields)

New entities in new_entities only appear in responses when allow_new_entities is true in the plugin configuration (or an equivalent policy is set).

ScriptProvider Example

A ScriptProvider replaces the built-in filesystem-based script backend with a custom implementation. This is useful for plugins that store scripts in a database, fetch them from a remote service, or execute them in a sandboxed runtime.

The interface mirrors the /scripts REST endpoints - list, get, upload, delete, execute, and control executions:

#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp"
#include "ros2_medkit_gateway/providers/script_provider.hpp"

using namespace ros2_medkit_gateway;

class MyScriptPlugin : public GatewayPlugin, public ScriptProvider {
 public:
  std::string name() const override { return "my_scripts"; }

  void configure(const nlohmann::json& /*config*/) override {}

  void shutdown() override {}

  // ScriptProvider: list scripts available for an entity
  tl::expected<std::vector<ScriptInfo>, ScriptBackendErrorInfo>
  list_scripts(const std::string& /*entity_id*/) override {
    return std::vector<ScriptInfo>{};
  }

  // ScriptProvider: get metadata for a specific script
  tl::expected<ScriptInfo, ScriptBackendErrorInfo>
  get_script(const std::string& /*entity_id*/, const std::string& script_id) override {
    return tl::make_unexpected(
      ScriptBackendErrorInfo{ScriptBackendError::NotFound, "not found: " + script_id});
  }

  // ScriptProvider: upload a new script
  tl::expected<ScriptUploadResult, ScriptBackendErrorInfo>
  upload_script(const std::string& /*entity_id*/, const std::string& /*filename*/,
                const std::string& /*content*/,
                const std::optional<nlohmann::json>& /*metadata*/) override {
    return tl::make_unexpected(
      ScriptBackendErrorInfo{ScriptBackendError::UnsupportedType, "uploads not supported"});
  }

  // ScriptProvider: delete a script
  tl::expected<void, ScriptBackendErrorInfo>
  delete_script(const std::string& /*entity_id*/, const std::string& /*script_id*/) override {
    return {};
  }

  // ScriptProvider: start executing a script
  tl::expected<ExecutionInfo, ScriptBackendErrorInfo>
  start_execution(const std::string& /*entity_id*/, const std::string& /*script_id*/,
                  const ExecutionRequest& /*request*/) override {
    return tl::make_unexpected(
      ScriptBackendErrorInfo{ScriptBackendError::NotFound, "no scripts available"});
  }

  // ScriptProvider: query execution status
  tl::expected<ExecutionInfo, ScriptBackendErrorInfo>
  get_execution(const std::string& /*entity_id*/, const std::string& /*script_id*/,
                const std::string& /*execution_id*/) override {
    return tl::make_unexpected(
      ScriptBackendErrorInfo{ScriptBackendError::NotFound, "no executions"});
  }

  // ScriptProvider: control a running execution (stop or force-terminate)
  tl::expected<ExecutionInfo, ScriptBackendErrorInfo>
  control_execution(const std::string& /*entity_id*/, const std::string& /*script_id*/,
                    const std::string& /*execution_id*/,
                    const std::string& /*action*/) override {
    return tl::make_unexpected(
      ScriptBackendErrorInfo{ScriptBackendError::NotRunning, "no running execution"});
  }

  // ScriptProvider: delete a completed execution record
  tl::expected<void, ScriptBackendErrorInfo>
  delete_execution(const std::string& /*entity_id*/, const std::string& /*script_id*/,
                   const std::string& /*execution_id*/) override {
    return {};
  }
};

// Required exports
extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() {
  return PLUGIN_API_VERSION;
}

extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin* create_plugin() {
  return new MyScriptPlugin();
}

// Required for ScriptProvider detection
extern "C" GATEWAY_PLUGIN_EXPORT ScriptProvider* get_script_provider(GatewayPlugin* p) {
  return static_cast<MyScriptPlugin*>(p);
}

When a plugin ScriptProvider is detected, it replaces the built-in DefaultScriptProvider. Only the first ScriptProvider plugin is used (same semantics as UpdateProvider). All 8 methods must be implemented - the ScriptManager wraps calls with null-safety and exception isolation.

Configuration - enable scripts and load the plugin:

ros2_medkit_gateway:
  ros__parameters:
    plugins: ["my_scripts"]
    plugins.my_scripts.path: "/opt/ros2_medkit/lib/libmy_scripts.so"

    scripts:
      scripts_dir: "/var/lib/ros2_medkit/scripts"

The scripts.scripts_dir parameter must be set for the scripts subsystem to initialize, even when using a plugin backend. The plugin replaces how scripts are stored and executed, but the subsystem must be enabled first.

Multiple Plugins

Multiple plugins can be loaded simultaneously:

  • UpdateProvider: Only one plugin’s UpdateProvider is used (first in config order)

  • IntrospectionProvider: All plugins’ IntrospectionResult values are merged via the PluginLayer in the discovery pipeline - new_entities vectors are concatenated and metadata maps are merged. Both ros2_medkit_topic_beacon and ros2_medkit_param_beacon can be active at the same time, each contributing their own discovered entities and metadata.

  • LogProvider: Only the first plugin’s LogProvider is used for queries (same as UpdateProvider). All LogProvider plugins receive on_log_entry() calls as observers.

  • ScriptProvider: Only the first plugin’s ScriptProvider is used (same as UpdateProvider).

  • Custom routes: All plugins can register endpoints (use unique path prefixes)

Graph Provider Plugin (ros2_medkit_graph_provider)

The gateway ships with an optional first-party plugin that exposes a ROS 2 topic graph for each SOVD Function entity. It lives in a separate colcon package, ros2_medkit_graph_provider, under src/ros2_medkit_plugins/.

What it does

  • Registers the x-medkit-graph vendor capability on all Function entities.

  • Exposes GET /api/v1/functions/{id}/x-medkit-graph returning a graph document with nodes (apps), edges (topic connections), per-edge frequency/latency/drop-rate metrics (sourced from the /diagnostics topic), and an overall pipeline_status (healthy, degraded, or broken).

  • Supports cyclic subscriptions on the x-medkit-graph collection so clients can stream live graph updates.

Package layout

src/ros2_medkit_plugins/
└── ros2_medkit_graph_provider/
    ├── CMakeLists.txt
    ├── package.xml
    ├── include/ros2_medkit_graph_provider/graph_provider_plugin.hpp
    └── src/
        ├── graph_provider_plugin.cpp
        └── graph_provider_plugin_exports.cpp

Loading the plugin

The plugin is loaded via the gateway_params.yaml plugin list. The .so path is resolved at launch time by gateway.launch.py using get_package_prefix(); if the package is not installed the gateway starts normally without graph functionality:

# Excerpt from gateway.launch.py
try:
    graph_provider_prefix = get_package_prefix('ros2_medkit_graph_provider')
    graph_provider_path = os.path.join(
        graph_provider_prefix, 'lib', 'ros2_medkit_graph_provider',
        'libros2_medkit_graph_provider_plugin.so')
except PackageNotFoundError:
    pass  # Plugin not installed - gateway runs without graph provider

The path is then injected into the node parameters as plugins.graph_provider.path.

YAML configuration

ros2_medkit_gateway:
  ros__parameters:
    plugins: ["graph_provider"]

    # Absolute path to the .so - set automatically by gateway.launch.py
    plugins.graph_provider.path: "/opt/ros/jazzy/lib/ros2_medkit_graph_provider/libros2_medkit_graph_provider_plugin.so"

    # Default expected publish frequency for topics without per-topic overrides.
    # An edge whose measured frequency is below
    # expected_frequency_hz_default * degraded_frequency_ratio is marked degraded.
    plugins.graph_provider.expected_frequency_hz_default: 30.0

    # Fraction of expected frequency below which an edge is "degraded" (0.0-1.0).
    plugins.graph_provider.degraded_frequency_ratio: 0.5

    # Drop-rate percentage above which an edge is marked degraded.
    plugins.graph_provider.drop_rate_percent_threshold: 5.0

Per-function overrides are also supported:

# Override thresholds for a specific function
plugins.graph_provider.function_overrides.my_pipeline.expected_frequency_hz: 10.0
plugins.graph_provider.function_overrides.my_pipeline.degraded_frequency_ratio: 0.3
plugins.graph_provider.function_overrides.my_pipeline.drop_rate_percent_threshold: 2.0

Disabling the plugin

To disable graph functionality without uninstalling the package, remove "graph_provider" from the plugins list in your params file:

plugins: []

Alternatively, simply do not install the ros2_medkit_graph_provider package - gateway.launch.py will skip the plugin automatically.

Error Handling

If a plugin throws during any lifecycle method (configure, set_context, register_routes, shutdown), the exception is caught and logged. The plugin is disabled but the gateway continues operating. A failing plugin never crashes the gateway.

API Versioning

Plugins export plugin_api_version() which must return the gateway’s PLUGIN_API_VERSION. If the version does not match, the plugin is rejected with a clear error message suggesting a rebuild against matching gateway headers.

The current API version is 4. It is incremented when the PluginContext vtable changes or breaking changes are made to GatewayPlugin or provider interfaces.

Build Requirements

  • Same compiler and ABI as the gateway executable

  • RTTI must be enabled for in-process plugins - the add_plugin() path uses dynamic_cast to query provider interfaces, so -fno-rtti will break it. Shared-library plugins loaded via PluginLoader::load() use extern "C" query functions and do not require RTTI.

  • The GATEWAY_PLUGIN_EXPORT macro ensures correct symbol visibility