ros2_medkit_gateway
This section contains design documentation for the ros2_medkit_gateway project.
Architecture
Build Layers
The package compiles into two layered static libraries with a strict dependency direction:
gateway_core- the ROS-free layer, defined incmake/gateway_core.cmake. Sources:src/core/, the HTTP server and REST server,src/http/handlers/,src/openapi/, the plugin manager and loader, aggregation, and the script manager. Links only header-only and C-level externals (cpp-httplib, nlohmann/json, yaml-cpp, tl::expected, jwt-cpp, OpenSSL, SQLite, dl). Carries no rclcpp / rcl_interfaces / message-package dependency.gateway_ros2- the ROS adapters. Compiles the remaining sources undersrc/and linksgateway_corepublicly viamedkit_target_dependencies. HostsGatewayNode(therclcpp::Nodeentry point), the ROS transports and providers, runtime discovery, the native topic sampler, and the plugin context.
cmake/gateway_core.cmake is plain CMake. The ament build includes it by
path, and so does scripts/check_cores_without_ros, which links
gateway_core. The file takes a target the including build already
defines, else find_package, else the copy vendored in src/vendored/.
The check implements GatewayServices with minimal stand-ins and starts
RESTServer. A program that hosts the core names its server through
RestConfig::server_name, vendor_name and server_version; the
defaults are the ROS 2 node’s values. scripts/check_cores_without_ros/ builds and runs it on
Ubuntu 20.04 with gcc 9, the oldest compiler the cores are checked with.
A program that hosts the core also gets the cyclic-subscription samplers and the bulk-data store from it:
register_builtin_samplers(services)(core/builtin_samplers.hpp) registersdata,faults,configurationsandlogsonservices.sampler_registry(), plusupdateswhenservices.update_manager()is set. Each sampler reads through the managers and helpers of its GET route.dataon a plugin-owned entity goes through the plugin’sDataProvider(core/data/data_item_reader.hpp, shared withGET /{entity}/data/{item}); on any other entity it goes throughDataAccessManagerand itsTopicDataProvider. The host passesGatewayServices, which must outlive the samplers, and registers a subscription transport itself.BulkDataConfig(core/config.hpp) andmake_bulk_data_store()(core/managers/bulk_data_store.hpp) build the store. The host returns it fromGatewayServices::bulk_data_store()before it startsRESTServer. Therosbagscategory is listed only whenFaultServiceTransport::serves_rosbags()is true (the default).make_freeze_frame_capture()(core/faults/freeze_frame_capture.hpp) builds aFreezeFrameCapture. Onfault_confirmedit reads the data of each reporting source throughplugin_data_readers()(core/data/data_item_reader.hpp, the path ofGET /{entity}/data: the plugin’sDataProvider, else itsx-plc-dataroute) on a thread of its own, keeps the frames up toFreezeFrameCaptureConfig::max_faults(oldest fault evicted first), and serves them asFreezeFrameSource. The host passes the config, aFaultEventSourceand thePluginManager. A source serves several subscribers, eachFaultEventSubscriptionending only its own delivery, so the capture and the fault SSE stream can share the source the host returns fromGatewayServices::fault_events(). The host also returns the capture fromGatewayServices::freeze_frames(). The ROS 2 node feeds the same class throughEntityFreezeFrameCapture, which owns its event source and the capture (built in that order, destroyed in reverse), subscribes on the shared subscription executor and holds the startup catch-up until the events subscription has matched a publisher.GET /reportsbulk_datatrue only when a store exists or rosbags are served, andcyclic_subscriptionstrue only when a transport and at least one sampler are registered.LockReaper(core/managers/lock_reaper.hpp) removes expired locks everyLockConfig::cleanup_intervalseconds on a thread it owns, and with them the cyclic subscriptions of the locked entity when the lock scope is empty or namescyclic-subscriptions. A host that does not start one keeps expired locks. The ROS 2 node starts the same class.make_script_host(core/managers/script_manager.hpp) builds theScriptManagerfrom aScriptsConfigand an optional plugin backend. A non-emptyscripts_dirgives theDefaultScriptProvider. The host returns the manager fromGatewayServices::script_manager().AggregationHost(core/aggregation/aggregation_host.hpp) builds theAggregationManagerand the mDNS discovery from anAggregationConfig. The host passes the manager toRESTServer::set_aggregation_manager()and callsmerge_peers()on every entity refresh. The ROS 2 node reads theaggregation.*parameters into the config and uses the same class.make_trigger_host(core/trigger_host.hpp) builds the trigger store and theTriggerManagerfrom aTriggerHostConfig(thetriggers.*names and defaults, plusdata_poll_interval_ms), wires the entity lookups to the entity cache and restores persistent triggers. Fault triggers are fed by aTriggerFaultFeedthat holds oneFaultEventSubscriptionon aFaultEventSource, so it can share the source with the fault SSE stream and the freeze-frame capture. A data trigger with a known topic reads from theTopicSubscriptionTransport. Any other data trigger reads from aDataItemSource;PollingDataItemSourcereads the item throughread_plugin_data_itemonce perdata_poll_interval_msand delivers thedatafield of the item body, as a topic trigger sees the message, so a host whose data a plugin serves needs no topics. The ROS 2 node passes the topic transport and feeds fault triggers throughTriggerFaultSubscriber, a ROS 2 event source under the same feed.make_core_plugin_context(core/plugins/core_plugin_context.hpp) builds the plugin context fromGatewayServicesand aCorePluginContextWiring(notifier, condition registry, aggregation, entity-change callback, notices). It is aRosPluginContextwhosenode()is null, soas_ros_plugin_context()is valid for every plugin and a plugin that needs ROS checks the pointer. The host gives it toPluginManager::set_context(). The ROS 2 node derives its context from the same class and overrides onlynode().make_fault_trigger_host(core/fault_trigger_host.hpp) builds the threshold-ruleFaultTriggerEngineand the thread that evaluates it from aFaultTriggerHostConfig(thefault_triggers.*names and defaults). The rules read plugin data through thePluginManagerand report and clear faults through theFaultManager. It returns null when disabled or when no plugin is loaded. The host returns the engine fromGatewayServices::fault_trigger_engine(). The ROS 2 node uses the same builder.RESTServertakes a nullLifecycleStateReaderfor a host without lifecycle nodes.GET /{entity}/statusthen derives readiness from presence andPUT /{entity}/status/{action}answers 501 without aLifecycleProvider.
The gateway_node executable and existing test targets link
gateway_ros2, so they transitively get both layers from a single
dependency. The neutral contract is enforced by two CTest checks:
gateway_core_purity(linter label) - grepscore/for any ROS-package include and fails on any match.test_gateway_core_smoke(unit label) - compiles a translation unit including a sampling ofcore/headers and links exclusively againstgateway_core+ GTest with noament_target_dependencies. Build failure indicates a transitive ROS coupling that the grep guard might miss when an include is reached through a third-party header.
Class Diagram
The following diagram shows the relationships between the main components of the gateway.
ROS 2 Medkit Gateway Class Architecture
Testing with Mock Transports
The Transport ports under core/transports/ are abstract interfaces. A
manager unit test injects a mock implementation and links only against
gateway_core + GTest, with no rclcpp on the link line. This is the
pattern used by the test_*_manager_routing.cpp tests:
class MockTopicTransport : public TopicTransport {
public:
nlohmann::json publish(const std::string &, const std::string &,
const nlohmann::json &,
std::chrono::duration<double>) override {
return nlohmann::json::object();
}
TopicSample sample(const std::string & topic,
std::chrono::duration<double>) override {
TopicSample s;
s.success = true;
s.status = "data";
s.topic = topic;
s.data = next_sample_;
return s;
}
std::pair<uint64_t, uint64_t>
count_publishers_subscribers(const std::string &) const override {
return {1, 0};
}
nlohmann::json next_sample_ = nlohmann::json::object();
};
TEST(DataAccessManagerRouting, ReadRoutesThroughTransport) {
MockTopicTransport transport;
transport.next_sample_["value"] = 42;
DataAccessManager mgr(&transport, /* other neutral deps */);
auto result = mgr.get_topic_sample_with_fallback("/sensor", "");
ASSERT_EQ(result["data"]["value"], 42);
}
The mock is purely C++; no ROS context, executor, or domain ID is needed.
Message, service and action schemas are not part of TopicTransport. They
come from TypeSchemaProvider (core/types/type_schema_provider.hpp),
which the HTTP layer reaches through GatewayServices::type_schemas().
Main Components
Each entry below is tagged with the static library it compiles into:
[gateway_core] (middleware-neutral, no ROS dependency) or
[gateway_ros2] (links rclcpp / message packages).
GatewayNode
[gateway_ros2]- The main ROS 2 node that orchestrates the system - Extendsrclcpp::Node- Manages periodic discovery and cache refresh - Runs the REST server in a separate thread - Provides thread-safe access to the entity cache - Manages periodic cleanup of old action goals (60s interval)DiscoveryManager
[gateway_ros2]- Discovers ROS 2 entities and maps them to the SOVD hierarchy - Discovers Areas from node namespaces or manifest definitions - Discovers Components (synthetic groups from runtime, or explicit from manifest) - Discovers Apps from ROS 2 nodes (individual running processes) - Discovers Services and Actions using native rclcpp APIs - Attaches operations (services/actions) to their parent Apps and Components - Routes built-in graph queries through the sameIntrospectionProviderchain used by plugins - Uses O(n+m) algorithm with hash maps for efficient service/action attachmentDiscovery providers and layers:
Ros2RuntimeIntrospection
[gateway_ros2]- IntrospectionProvider wrapping ROS 2 graph queries - Maps nodes to Apps withsource: "heuristic"- Creates Functions from namespace grouping - Never creates Areas or Components (those come from manifest/HostInfoProvider) - Same interface as plugin-provided IntrospectionProviders, so the merge pipeline treats built-in graph queries identically to plugin contributionsManifestLayer
[gateway_core]- Static layer fed byManifestManager- Provides stable, semantic entity IDs from declarative YAML - Supports offline detection of failed components -manifest_onlymode bypasses the merge pipeline and routes directly throughManifestManagerHybrid mode (DiscoveryManager + MergePipeline) - Combines manifest + runtime + plugins - DiscoveryManager constructs a
MergePipelinewith the configured layers - Supports dynamic plugin layers added at runtime viaadd_plugin_layer()- Thread-safe: a mutex protects the cached merged result, all reads return by value
Merge Pipeline:
The
MergePipelineis the core engine for hybrid discovery. It:Maintains an ordered list of
DiscoveryLayerinstances (first = highest priority)Executes all layers, collects entities by ID, and merges them per-field-group
Each layer declares a
MergePolicyperFieldGroup: AUTHORITATIVE (wins), ENRICHMENT (fills empty), FALLBACK (last resort)Runs
RuntimeLinkerpost-merge to bind manifest apps to live ROS 2 nodesSuppresses runtime-origin entities that duplicate manifest entities: components/areas by namespace match, apps by ID match (gap-fill apps in uncovered namespaces survive)
Produces a
MergeReportwith conflict diagnostics, enrichment counts, and ID collision detection
Built-in Layers:
ManifestLayer- Wraps ManifestManager; IDENTITY/HIERARCHY/METADATA are AUTHORITATIVE, LIVE_DATA is ENRICHMENT (runtime wins for topics/services), STATUS is FALLBACKRuntimeLayer- Wraps Ros2RuntimeIntrospection; LIVE_DATA/STATUS are AUTHORITATIVE, METADATA is ENRICHMENT, IDENTITY/HIERARCHY are FALLBACK. SupportsGapFillConfigto control which heuristic entities are allowed when manifest is presentPluginLayer- Wraps IntrospectionProvider; all fields ENRICHMENT (plugins enrich, they don’t override). Before each layer’sdiscover()call, the pipeline populatesIntrospectionInputwith entities from all previous layers, so plugins see the current manifest + runtime entity set
OperationManager
[gateway_core]- Routes SOVD operation execution throughServiceTransportandActionTransportports - Manager body is middleware-neutral;Ros2ServiceTransportandRos2ActionTransportadapters perform the actualrclcpp::GenericClient/ action-client calls - Tracks active action goals with status, feedback, and timestamps - Subscribes to/_action/statustopics via the action transport for real-time goal status updates - Supports goal cancellation via the action transport’s cancel path - Supports SOVD capability-based control (stop maps to ROS 2 cancel) - Automatically cleans up completed goals older than 5 minutes - JSON ↔ ROS 2 message conversion is performed inside the ROS-side transport adapter viaros2_medkit_serializationRESTServer
[gateway_core]- Provides the HTTP/REST API (route table couples to gateway lifecycle; the individual handlers it dispatches to live ingateway_core) - Discovery endpoints:/health,/areas,/components- Data endpoints:/components/{id}/data,/components/{id}/data/{topic}- Operations endpoints:/apps/{id}/operations,/apps/{id}/operations/{op}/executions- Configurations endpoints:/apps/{id}/configurations,/apps/{id}/configurations/{param}- Scripts endpoints:/{entity_type}/{id}/scripts,/{entity_type}/{id}/scripts/{script_id}/executions- Retrieves cached entities from the GatewayNode - Uses DataAccessManager for runtime topic data access - Uses OperationManager for service/action execution - Uses ConfigurationManager for parameter CRUD operations - Uses ScriptManager for script upload and execution - Runs on configurable host and port with CORS supportConfigurationManager
[gateway_core]- Routes SOVD configuration CRUD throughParameterTransport- Manager body is middleware-neutral;Ros2ParameterTransportadapter performs therclcpp::AsyncParametersClientcalls (explicitspin_until_future_completeso a service TIMEOUT is distinguished from a successful-but-empty response) - Lists / gets / sets individual parameter values with type conversion - Provides parameter descriptors (description, constraints, read-only flag) - The transport caches parameter clients per node for efficiency - JSON ↔ ROS 2 parameter conversion happens inside the transport adapterDataAccessManager
[gateway_core]- Routes SOVD data read/write throughTopicTransport- Manager body is middleware-neutral;Ros2TopicTransportadapter performsrclcpp::GenericPublisher-backed publishing and CDR serialization - Delegates topic sampling to the attachedTopicDataProvider(pool-backed, race-free) - Checks publisher counts before sampling to skip idle topics instantly - Returns metadata (type, schema) for topics without publishers - Returns topic data as JSON with metadata (topic name, timestamp, type info) - Parallel topic sampling with configurable concurrency limit (data_provider.max_parallel_sampleson the TopicDataProvider, default: 8)TopicDataProvider / Ros2TopicDataProvider
[gateway_core / gateway_ros2]- The interface lives in the neutral layer (core/providers/data_provider.hpp); the pool-backed ROS 2 default implementation lives in the adapter layer -TopicDataProvideris a pure C++ interface consumed by HTTP handlers and managers, with no rclcpp headers required on the consumer side -Ros2TopicDataProviderkeeps one shared subscription per topic and serves many sample calls from the cached latest message, avoiding the rcl hash-map race that short-lived per-sample subscriptions used to trigger - All subscription / callback-group creation and destruction runs onRos2SubscriptionExecutor’s single worker thread - LRU cap, idle safety-net sweep, graph-change eviction, cold-wait cap for cpp-httplib liveness, and publisher-matching QoS (reliable / transient_local) - Exposes pool + executor stats onGET /healthunder thex-medkit-subscription-executorandx-medkit-data-providervendor-extension keys - See ROS 2 Subscription Architecture for the full designJsonSerializer (ros2_medkit_serialization) - Converts between JSON and ROS 2 messages (separate package, not part of the gateway layer split) - Uses
dynmsglibrary for dynamic type introspection - Serializes JSON to CDR format for publishing viaserialize()- Deserializes CDR to JSON for subscriptions viadeserialize()- Converts between deserialized ROS 2 messages and JSON viato_json()/from_json()- Provides staticyaml_to_json()utility for YAML to JSON conversion - Thread-safe and stateless design- LockManager
[gateway_core]- SOVD resource locking (ISO 17978-3, Section 7.17) Transport-agnostic lock store with
shared_mutexfor thread safetyAcquire, release, extend locks on components and apps
Scoped locks restrict protection to specific resource collections
Lazy parent propagation (lock on component protects child apps)
Lock-required enforcement via per-entity
required_scopesconfigAutomatic expiry with cyclic subscription cleanup
Plugin access via
PluginContext::check_lock/acquire_lock/release_lock
- LockManager
Data Models
[gateway_core]- Entity representations -Area- Physical or logical domain (namespace grouping) -Component- Logical grouping of Apps; can besynthetic(auto-created from namespace),topic(from topic-only namespace), ormanifest(explicitly defined) -App- Software application (ROS 2 node); individual running process linked to parent Component -ServiceInfo- Service metadata (path, name, type) -ActionInfo- Action metadata (path, name, type) -EntityCache- Thread-safe cache of discovered entities (areas, components, apps)ScriptManager
[gateway_core]- Manages diagnostic script upload, storage, and execution (SOVD 7.15) - Delegates to a pluggableScriptProviderbackend (set viaset_backend()) - Lists, uploads, and deletes scripts per entity - Starts script executions as POSIX subprocesses with timeout support - Tracks execution status (prepared,running,completed,failed,terminated) - Supports termination viastop(SIGTERM) andforced_termination(SIGKILL) - Built-inDefaultScriptProviderhandles filesystem storage and manifest-defined scripts - Supports concurrent execution limits and per-script timeout configuration
Triggers
The trigger subsystem implements SOVD condition-based resource change notifications. It consists of five main components:
TriggerManager
[gateway_core]- Central coordinator for trigger lifecycle (CRUD), condition evaluation, and event dispatch.Subscribes to
ResourceChangeNotifierfor resource change eventsEvaluates conditions using registered
ConditionEvaluatorinstances via theConditionRegistryUses O(1) dispatch indexing by
{collection, entity_id}for efficient notification matchingSupports entity hierarchy matching (area-level triggers catch descendant changes)
Manages pending events for SSE stream pickup with per-trigger mutexes
Persists triggers via the
TriggerStoreinterfaceData-trigger ROS subscriptions are routed through
TopicSubscriptionTransport(Ros2TopicSubscriptionTransportadapter wrapsTriggerTopicSubscriber)
ResourceChangeNotifier
[gateway_core]- Async notification hub for resource changes.Producers (FaultManager, DataAccessManager, UpdateManager, OperationManager, LogManager) call
notify()Observers (TriggerManager) register callbacks with filters
notify()is non-blocking - pushes to an internal queue processed by a dedicated worker threadFilters support collection, entity_id, and resource_path matching
unsubscribe(id)from another thread returns once that callback is not running, and the callback is never called again, so an observer can be destroyed right after it unsubscribes. From inside a notifier callback it returns at once, the current invocation continues, and the callback is not called again after it returns.Teardown contract: producers hold a non-owning
ResourceChangeNotifier *, so every producer must be quiesced before the notifier is destroyed.notify()’s internal drain guard only protects calls that are already in progress - it cannot help a call that starts after the object is freed. Most producers notify from executor callbacks, which have already stopped by the time~GatewayNoderuns.UpdateManageris the exception: its prepare/execute tasks run on their ownstd::asyncthreads and notify after the backend returns, so~GatewayNodecallsUpdateManager::shutdown()(step 4b) to join them before step 5 shuts the notifier down. Relying on member declaration order alone is not sufficient -update_mgr_is declared beforeresource_change_notifier_, so reverse destruction frees the notifier first.
ConditionRegistry
[gateway_core]- Thread-safe registry for condition evaluators.Built-in SOVD types:
OnChange,OnChangeTo,EnterRange,LeaveRangePlugins register custom evaluators with
x-prefixed namesUses
shared_mutexfor concurrent read access during evaluation
TriggerStore / SqliteTriggerStore
[gateway_core]- Persistence backend for triggers.Abstract
TriggerStoreinterface allows plugin-provided backendsDefault
SqliteTriggerStoreuses SQLite for persistent triggersStores trigger metadata, condition parameters, and evaluator state (previous values)
Supports partial updates for status changes and lifetime extensions
TriggerTopicSubscriber
[gateway_ros2]- Generic per-handle subscription executor for data triggers.Creates one
rclcpp::GenericSubscriptionperhandle_keyprovided by the callerEach trigger owns its own subscription handle - no reference-counting across triggers
Wrapped by
Ros2TopicSubscriptionTransport, which exposes the neutralTopicSubscriptionTransportinterface toTriggerManagerSubscription lifetime is tied to the trigger entry: removing the trigger drops the handle and tears down the underlying subscription
Per-handle callbacks publish samples back to
ResourceChangeNotifierfor condition evaluation