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
/rosoutlog backend. Can operate in observer mode (receives log entries) or full-ingestion mode (owns the entire log pipeline). See the/logsendpoints 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
/scriptsendpoints in REST API Reference.DataProvider - per-entity data resource backend (list, read, write data). Plugins that create entities via IntrospectionProvider can serve data for those entities. Entity requests are routed to the owning plugin automatically.
OperationProvider - per-entity operation backend (list operations, execute). Uses the same per-entity routing model as DataProvider.
FaultProvider - per-entity fault backend (list faults, get fault details, clear faults). Uses the same per-entity routing model as DataProvider.
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
Create a C++ shared library implementing
GatewayPluginand 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/updates/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 { /* ... */ }
};
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, get_data_provider, get_operation_provider, get_fault_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.
Build as a MODULE library:
add_library(my_plugin MODULE src/my_plugin.cpp)
target_link_libraries(my_plugin gateway_lib)
Install the
.soand add its path togateway_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/updates/update_provider.hpp"
#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
dlopenloads the.sowithRTLD_NOW | RTLD_LOCALplugin_api_version()is checked against the gateway’sPLUGIN_API_VERSIONcreate_plugin()factory function creates the plugin instanceProvider interfaces are queried via
get_update_provider()/get_introspection_provider()/get_log_provider()/get_script_provider()/get_data_provider()/get_operation_provider()/get_fault_provider()configure()is called with per-plugin JSON configset_context()providesPluginContextwith ROS 2 node, entity cache, faults, and HTTP utilitiesget_routes()returns custom REST endpoint definitions asvector<PluginRoute>Runtime: subsystem managers call provider methods as needed
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 service clients, timers, and parameter access.Warning
Do not call
node()->create_subscription<T>(),create_generic_subscription(), orcreate_callback_group()directly on this node. Issue #375 showed that concurrent rcl mutations on the gateway node race its internal hash map and SIGSEGV under load on Rolling. The regression gate inscripts/check_no_naked_subscriptions.shis run in CI and will fail PRs that add such calls outside the allowlist. Useros2_medkit_gateway::ros2_common::Ros2SubscriptionSlot::create_typedorcreate_genericvia the executor that already serializes all subscription lifecycle calls. See ROS 2 Subscription Architecture for the allowed pattern and the rationale.get_entity(id)- look up any entity (area, component, app, function) from the discovery cachelist_entity_faults(entity_id)- query faults for an entityvalidate_entity_for_route(req, res, entity_id)- validate entity exists and matches the route type, auto-sending SOVD errors on failureregister_capability()/register_entity_capability()- register custom capabilities on entities
Note
SOVD-compliant HTTP response helpers (send_json(), send_error()) are instance
methods on PluginResponse, not static methods on PluginContext. Use
res.send_json(data) and res.send_error(status, code, msg) inside route handlers.
check_lock(entity_id, client_id, collection)- verify lock access before mutating operations; returnsLockAccessResultwithallowedflag and denial detailsacquire_lock()/release_lock()- acquire and release entity locks with optional scope and TTLget_entity_snapshot()- returns anIntrospectionInputpopulated from the current entity cachelist_all_faults()- returns JSON object with a"faults"array containing all active faults across all entitiesregister_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 (v5)
Version 5 of the plugin API replaced register_routes() with get_routes()
and moved send_json/send_error from PluginContext static methods to
PluginResponse instance methods. Plugins that implement custom REST routes
require source changes to adapt to the new API. Plugins that do not implement
routes only need a rebuild 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) {
res.send_error(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 expose vendor-specific endpoints by overriding get_routes(), which
returns a vector<PluginRoute>. Each route specifies an HTTP method, a URL pattern
relative to the API prefix (no leading slash), and a handler. Use PluginRequest and
PluginResponse for path parameters and SOVD-compliant responses:
std::vector<PluginRoute> get_routes() override {
return {
// Global vendor endpoint
{"GET", "x-myvendor/status",
[this](const PluginRequest& /*req*/, PluginResponse& res) {
res.send_json(get_status_json());
}},
// Entity-scoped endpoint (matches a registered capability)
{"GET", R"(apps/([^/]+)/x-medkit-traces)",
[this](const PluginRequest& req, PluginResponse& res) {
auto entity_id = req.path_param(1);
auto entity = ctx_->validate_entity_for_route(req, res, entity_id);
if (!entity) return; // Error already sent
auto faults = ctx_->list_entity_faults(entity->id);
res.send_json({{"entity", entity->id}, {"faults", faults}});
}},
};
}
Use the x- prefix for vendor-specific endpoints per SOVD convention. Patterns are
relative to the API prefix and must not include a leading slash.
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/discovery/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- aNewEntitiesstruct with vectors forareas,components,apps, andfunctionsthat the plugin introducesmetadata- a map from entity ID to anlohmann::jsonobject 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/scripts/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.
Per-Entity Provider Example (DataProvider)
A plugin that creates entities via IntrospectionProvider and serves data for them
via DataProvider. OperationProvider and FaultProvider follow the same pattern.
#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp"
#include "ros2_medkit_gateway/plugins/plugin_types.hpp"
#include "ros2_medkit_gateway/discovery/introspection_provider.hpp"
#include "ros2_medkit_gateway/providers/data_provider.hpp"
using namespace ros2_medkit_gateway;
class MyDataPlugin : public GatewayPlugin, public IntrospectionProvider, public DataProvider {
public:
std::string name() const override { return "my_data_plugin"; }
void configure(const nlohmann::json &) override {}
void shutdown() override {}
// IntrospectionProvider: create entities owned by this plugin
IntrospectionResult introspect(const IntrospectionInput &) override {
IntrospectionResult result;
App ecu;
ecu.id = "my_ecu";
ecu.name = "My ECU";
result.new_entities.apps.push_back(std::move(ecu));
return result;
}
// DataProvider: serve data for owned entities
tl::expected<nlohmann::json, DataProviderErrorInfo> list_data(const std::string & entity_id) override {
return nlohmann::json{{"items", {{{"id", "temperature"}, {"name", "Temperature"}}}}};
}
tl::expected<nlohmann::json, DataProviderErrorInfo> read_data(
const std::string &, const std::string & resource_name) override {
if (resource_name == "temperature") {
return nlohmann::json{{"id", resource_name}, {"data", {{"temperature", 42.5}}}};
}
return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::ResourceNotFound, "Unknown resource", 404});
}
tl::expected<nlohmann::json, DataProviderErrorInfo> write_data(
const std::string &, const std::string &, const nlohmann::json &) override {
return tl::make_unexpected(DataProviderErrorInfo{DataProviderError::ReadOnly, "Read-only data", 403});
}
};
// Plugin exports - both providers must be exported via extern "C"
extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin* create_plugin() { return new MyDataPlugin(); }
extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { return PLUGIN_API_VERSION; }
extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider* get_introspection_provider(GatewayPlugin* p) {
return static_cast<MyDataPlugin*>(p);
}
extern "C" GATEWAY_PLUGIN_EXPORT DataProvider* get_data_provider(GatewayPlugin* p) {
return static_cast<MyDataPlugin*>(p);
}
The gateway automatically adds data capability to entities owned by a plugin that
registers a DataProvider. Similarly for operations (OperationProvider) and faults
(FaultProvider). There is no need to call register_entity_capability() for these
standard resource collections.
Warning
All provider methods may be called concurrently from multiple HTTP handler
threads. Implementations that access shared state must provide their own
synchronization (e.g., std::mutex). Plugin responses are passed through
verbatim to the API consumer - match the SOVD response format for consistency
with native entities.
Multiple Plugins
Multiple plugins can be loaded simultaneously:
UpdateProvider: Only one plugin’s UpdateProvider is used (first in config order)
IntrospectionProvider: All plugins’
IntrospectionResultvalues are merged via the PluginLayer in the discovery pipeline -new_entitiesvectors are concatenated andmetadatamaps are merged. Bothros2_medkit_topic_beaconandros2_medkit_param_beaconcan 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).
DataProvider / OperationProvider / FaultProvider: These use per-entity routing based on entity ownership. Entities created by a plugin’s IntrospectionProvider are automatically routed to that same plugin’s DataProvider, OperationProvider, and FaultProvider. Multiple plugins can each serve different entities concurrently - there is no “first wins” conflict because each plugin only handles requests for its own entities.
Custom routes: All plugins can register endpoints (use unique path prefixes)
Entity Ownership
DataProvider, OperationProvider, and FaultProvider use an entity ownership model to route requests to the correct plugin.
IntrospectionProvider::introspect()determines ownership: entities returned in a plugin’sIntrospectionResult::new_entitiesare owned by that plugin.Entity ownership is refreshed periodically during cache updates (each discovery cycle re-evaluates
introspect()results from all plugins).The gateway maintains an internal map from entity ID to the plugin that created it.
When a data, operation, or fault request arrives for an entity, the handler looks up the owning plugin and delegates to its corresponding provider. Entities not owned by any plugin fall through to the default gateway behavior.
This model allows multiple plugins to coexist without conflict - each plugin manages its own entities independently.
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-graphvendor capability on allFunctionentities.Exposes
GET /api/v1/functions/{id}/x-medkit-graphreturning a graph document with nodes (apps), edges (topic connections), per-edge frequency/latency/drop-rate metrics (sourced from the/diagnosticstopic), and an overallpipeline_status(healthy,degraded, orbroken).Supports cyclic subscriptions on the
x-medkit-graphcollection 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, get_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 5. 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 usesdynamic_castto query provider interfaces, so-fno-rttiwill break it. Shared-library plugins loaded viaPluginLoader::load()useextern "C"query functions and do not require RTTI.The
GATEWAY_PLUGIN_EXPORTmacro ensures correct symbol visibility