Discovery Options Reference

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

Discovery Modes

The gateway supports three discovery modes, controlled by the discovery.mode parameter:

Discovery Modes

Mode

Description

runtime_only

Use ROS 2 graph introspection only. Nodes are discovered at runtime and mapped to SOVD entities using heuristic rules. (default)

manifest_only

Only expose entities declared in the manifest file. Runtime discovery is disabled.

hybrid

Manifest defines the structure, runtime links to discovered nodes.

Runtime Discovery Options

When using runtime_only or hybrid mode, the following options control how ROS 2 nodes are mapped to SOVD entities.

Entity Model

Important

The entity model changed in this release. Synthetic Areas and per-namespace Components are no longer created. If you are upgrading from a previous version, see Breaking Changes for details, removed parameters, cross-layer impact, and migration guidance.

In runtime mode, the gateway maps the ROS 2 graph to SOVD entities as follows:

  • Areas - not created by runtime discovery. Areas come from manifest only.

  • Components - a single host-level Component is created from HostInfoProvider (see Default Component below). No synthetic/heuristic Components are created from namespaces.

  • Apps - each ROS 2 node becomes an App with source: "heuristic".

  • Functions - namespace grouping creates Function entities (see below).

Default Component

discovery:
  runtime:
    default_component:
      enabled: true

When default_component.enabled is true (the default), the gateway creates a single host-level Component from the local system info (hostname, OS, architecture) via HostInfoProvider. All discovered Apps are linked to this Component.

Internal Node Filtering

discovery:
  runtime:
    filter_internal_nodes: true

When filter_internal_nodes is true (the default), ROS 2 nodes whose names start with an underscore (_) are excluded from the entity tree. This filters out ROS 2 internal infrastructure nodes such as _ros2cli_*, _param_client_node, and similar system nodes that should not appear as SOVD entities. For an App that runtime discovery found, only the node name decides. A bare name shared across namespaces gives the App a namespace-qualified id, such as line_a__ros2_medkit_gateway_sub for a hidden node or _ops_camera for the visible /_ops/camera, and that id does not change the outcome. An App declared by a manifest or a plugin is judged by its id. Apps that a peer reports are not filtered again: each gateway filters its own nodes, so a peer that runs with filter_internal_nodes: false shows its hidden nodes through the aggregator too.

The same switch also excludes the helper nodes the gateway runs inside its own process. Three of them exist in every deployment, named after the gateway node and placed in its namespace:

Node

What it is

_<gateway>_sub

The subscription executor that serves /data reads and cyclic subscriptions.

_<gateway>_fault_clients

The service clients that talk to the fault manager.

_<gateway>_lifecycle_state_reader

The client that reads managed nodes’ lifecycle state.

_<gateway>_lifecycle_watcher

graph_watchdog’s own lifecycle state client. It exists only while that plugin is loaded.

Their names begin with an underscore, so the convention above already keeps them out of runtime discovery. The gateway also recognises them by fully qualified name, so an App bound to one of them is left out whatever its id. They carry no parameters, services or actions of their own, so there is nothing on them to diagnose.

A manifest- or plugin-declared App bound to one of this gateway’s own helper nodes is not served, and the gateway logs a warning naming it: bind the App to the node you meant, or drop the declaration.

While this setting is true they are also left out of the linking report on GET /health - discovery.linking.orphan_count, and the ros_node_fqns of the unmanifested_nodes warning raised under unmanifested_nodes: error: a node that can never be an App is not a node to declare in a manifest. That report exists only in hybrid mode, where manifest apps are linked to runtime nodes; runtime_only has no linking block at all.

The helper rule leaves the gateway’s own node an App. Its ROS parameters are what the gateway serves as that App’s configurations, so /apps/<gateway>/configurations is where a client reads and writes them - there is no other entity carrying, for instance, aggregation.peer_auth_header. Two gateways watching one graph also have to agree about what is on it, which they cannot do if each hides a different node. A gateway whose node name starts with _ is the exception: runtime discovery hides that node like any other such node, so no runtime App carries those parameters. A declared App bound to it is judged by its id: one whose id does not start with _ is served and carries the parameters.

Names and namespaces: each helper node is created in the gateway’s namespace, and a node-local remap fixes its name. A process-wide namespace remap (-r __ns:=/line_a) and a remap naming the gateway alone (-r ros2_medkit_gateway:__ns:=/line_a) both move all four nodes together. A process-wide name remap (-r __node:=gateway_a, which launch_ros Node(name=...) passes) renames the gateway node only, and the helper nodes take the new name as the base of their own (_gateway_a_sub and so on).

Several gateways on one graph: every helper node starts with an underscore, so each gateway leaves out all of them, its own and the other gateways’, also when the gateways share a node name and differ only in namespace. With runtime discovery and the same configuration, the gateways list the same Apps. A declared App bound to another gateway’s helper node is judged by its id like any other declared App, so it is served, and no warning is logged. In hybrid mode the linking report leaves out only the reporting gateway’s own helper nodes. Another gateway’s helper nodes count as undeclared, like any other hidden node such as _param_client_node.

Give each gateway its own node name when several run on one ROS graph. With a shared name, the App id of a gateway outside the root namespace depends on the graph: it is the bare name while no other node has that name, and namespace-qualified (line_a_ros2_medkit_gateway) while one does. Either remap gives the gateway its own name: -r __node:=gateway_a, or -r ros2_medkit_gateway:__node:=gateway_a.

Set to false if you need to expose all ROS 2 nodes regardless of naming convention. That re-exposes the underscore nodes, the three helper nodes among them, and in hybrid mode the linking report follows: a helper node the gateway serves as an App is also an undeclared one, so it is counted in discovery.linking.orphan_count and, under unmanifested_nodes: error, named in the ros_node_fqns of the unmanifested_nodes warning. With true it appears in neither.

Function Entities from Namespaces

discovery:
  runtime:
    create_functions_from_namespaces: true

When create_functions_from_namespaces is true (the default), each ROS 2 namespace becomes a Function entity that groups the Apps in that namespace. The Function takes the first namespace segment as its id, so /line_a/cell_2 belongs to line_a, and the Apps in the root namespace form the Function root. This is the SOVD-correct mapping where Functions represent logical capabilities (what the software does) rather than deployment topology.

One rule for Function hosts. After every filter and merge has run, each Function keeps only the hosts that name an App or a Component this gateway serves. A Function that only runtime discovery created is not served once it has no host left, on GET /functions/{id} and on GET /functions/{id}/hosts alike. A namespace with no served App therefore has no Function, the root namespace included. A Function that a manifest, a plugin or a peer also contributes stays, also when it loses every host. The rule does not depend on why a host is not served: filter_internal_nodes, a node linked to a manifest App (served under the manifest id), unmanifested_nodes: ignore, allow_heuristic_apps: false, a namespace blacklist or whitelist, or a declared host that names nothing. A peer host that names a peer App renamed for an id collision is listed under the new id, <peer>__<id>.

The rule covers the Functions this gateway answers itself. A Function that only a peer contributes is forwarded: GET /functions/{id} answers with the peer’s view, so the peer’s own filter_internal_nodes decides which hosts that answer lists. GET /functions/{id}/hosts is answered here and lists only Apps this gateway serves. A Function’s resource collections (data, operations and the others) fan out to every peer that contributes to it, and each peer answers with its own view.

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.

Layer Enable/Disable

Individual layers can be disabled without changing the discovery mode. This is useful when running beacon plugins as the sole enrichment source (disable manifest and/or runtime layers, keep only plugins):

discovery:
  manifest:
    enabled: true    # Default: true
  runtime:
    enabled: true    # Default: true

When a layer is disabled, its entities are not discovered and its merge contributions are skipped. Plugins always run regardless of these flags.

Manifest Loading

Parameter

Type

Default

Description

discovery.manifest_strict_validation

bool

true

Treat manifest validation WARNINGS as fatal. Validation errors (broken references, circular dependencies, duplicate bindings) always abort the load whatever this is set to, and a manifest is only ever applied whole, so turning this off never loads a partly-valid manifest - it only lets warnings through. Turn it off to bring up a manifest that is still being written.

discovery.manifest.fragments_dir

string

""

Directory of manifest fragment YAML files. Every load and reload of the manifest scans it and merges the apps, components and functions it finds on top of the base manifest, so a fleet can ship one base file plus per-robot fragments. Empty disables fragment loading.

Field Groups

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

ros_binding, external, x-medkit extensions, custom metadata fields. Not source: an entity’s provenance decides whether the orphan filter may delete it, so it is never negotiated between layers and setting layers.manifest.metadata does not move it.

Merge Policies

Each layer declares a policy per field-group:

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

Note

The app external classification is tri-state (unset / true / false), so it follows the policy table with one refinement: a layer that does not classify the app (a manifest entry that omits external:) never clears or shadows a classification another layer contributed. A manifest stub that omits external: therefore keeps a protocol plugin’s introspected external: true (so the app still owns its faults via bare-id fault scope in hybrid mode). An explicit value is authoritative and resolves by normal layer priority: an authoritative manifest external: false overrides a plugin’s external: true. Only omission is a no-op; an explicit value is never silently discarded.

The same tri-state field and merge priority apply to Components: an external Component with no bound child Apps owns its fault-scope by entity id, so an Area or Function hosting it rolls up its faults without a synthetic child App. Components have no ros_binding, so there is no equivalent to the App’s R013 contradiction check.

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

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:

discovery:
  merge_pipeline:
    gap_fill:
      allow_heuristic_apps: true
      allow_heuristic_functions: false
      # namespace_blacklist: ["/rosout"]
      # namespace_whitelist: []

Note

Areas and Components are never created by runtime discovery. Areas come from manifest only. Components come from HostInfoProvider or manifest.

Parameter

Default

Description

allow_heuristic_apps

true

Create apps for nodes without manifest ros_binding.

allow_heuristic_functions

false

Create heuristic functions (not recommended).

namespace_blacklist

[]

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 and linking results. The warnings array and warning_schema_version are unconditional - they are served in every mode, and an empty array means the gateway has no anomaly to report (see Warning Codes). discovery.linking is the conditional part: it appears only when a manifest is loaded, and unmanifested_policy carries the configured unmanifested_nodes value so a client can evaluate orphan_count > 0 && unmanifested_policy == "error" without parsing prose.

{
  "status": "healthy",
  "warnings": [],
  "warning_schema_version": 2,
  "discovery": {
    "mode": "hybrid",
    "strategy": "hybrid",
    "pipeline": {
      "layers": ["manifest", "runtime"],
      "total_entities": 9,
      "enriched_count": 1,
      "conflict_count": 0,
      "conflicts": [],
      "id_collisions": 0,
      "filtered_by_gap_fill": 0
    },
    "linking": {
      "linked_count": 2,
      "orphan_count": 5,
      "binding_conflicts": 0,
      "unmanifested_policy": "warn"
    }
  }
}

The response also carries the x-medkit-data-provider, x-medkit-entity-cache and x-medkit-subscription-executor statistics objects, omitted here. layers lists one entry per active layer, and a discovery plugin contributes under its own registered name (for example "opcua") rather than a literal "plugin".

Configuration Example

Complete YAML configuration for runtime discovery:

ros2_medkit_gateway:
  ros__parameters:
    discovery:
      mode: "runtime_only"

      runtime:
        # Function entities from namespace grouping (default: true)
        create_functions_from_namespaces: true

        # Single host-level Component (default: true)
        default_component:
          enabled: true

      # Merge pipeline (hybrid mode only)
      merge_pipeline:
        gap_fill:
          allow_heuristic_apps: true
          namespace_blacklist: ["/rosout"]

Command Line Override

Override discovery options via command line:

ros2 launch ros2_medkit_gateway gateway.launch.py \
  discovery.runtime.create_functions_from_namespaces:=false

Discovery Mechanism Selection

Scenario

Recommended mechanism

Quick start, no configuration needed

runtime_only (default)

Stable system with known topology

manifest_only or hybrid

Runtime metadata from nodes (fast updates, custom topics)

TopicBeaconPlugin (push-based)

Runtime metadata from nodes (no custom code, uses parameters)

ParameterBeaconPlugin (pull-based)

Structured topology with runtime enrichment

hybrid + beacon plugin(s)

Topic beacon vs parameter beacon:

  • Use TopicBeaconPlugin when nodes can publish MedkitDiscoveryHint messages to a shared topic. Best for low-latency updates and nodes that already have custom publishing logic.

  • Use ParameterBeaconPlugin when nodes declare metadata as standard ROS 2 parameters. No custom publishing code needed - the plugin polls parameters automatically.

  • Both can run simultaneously. Each maintains a separate store, and both inject into the merge pipeline as enrichment layers.

Beacon Discovery Plugin (TopicBeaconPlugin)

The ros2_medkit_topic_beacon plugin enriches discovered entities with push-based metadata from ROS 2 nodes. Nodes publish ros2_medkit_msgs/msg/MedkitDiscoveryHint messages to /ros2_medkit/discovery and the plugin injects the data into the merge pipeline as an ENRICHMENT layer.

Configuration

ros2_medkit_gateway:
  ros__parameters:
    plugins: ["topic_beacon"]
    plugins.topic_beacon.path: "/path/to/libtopic_beacon_plugin.so"

    # ROS 2 topic to subscribe for beacon hints
    # Default: "/ros2_medkit/discovery"
    plugins.topic_beacon.topic: "/ros2_medkit/discovery"

    # Soft TTL: hints older than this are marked STALE (seconds)
    # Default: 10.0
    plugins.topic_beacon.beacon_ttl_sec: 10.0

    # Hard expiry: hints older than this are removed (seconds)
    # Default: 300.0
    plugins.topic_beacon.beacon_expiry_sec: 300.0

    # Allow plugin to introduce entirely new entities not seen by other layers
    # When false (recommended), only known entities are enriched
    # Default: false
    plugins.topic_beacon.allow_new_entities: false

    # Maximum number of hints to keep in memory
    # Default: 10000
    plugins.topic_beacon.max_hints: 10000

    # Rate limit for incoming beacon messages
    # Default: 100
    plugins.topic_beacon.max_messages_per_second: 100

beacon_ttl_sec takes 0.1 to 2147483647 s, beacon_expiry_sec 1.0 to 2147483647 s, and max_messages_per_second 1 to 10000. A value above its maximum, including .inf in a parameter file, becomes the maximum. NaN, -.inf and a value below the minimum become the minimum. The gateway logs a warning naming the key, the value and what replaced it. Messages over the rate limit are dropped without a log.

max_hints takes an integer from 1 to 2147483647. A 64-bit integer outside that range becomes the nearer bound, with the same warning. The parameter parser reads an integer that does not fit in 64 bits as a double. A double, such as 1.0e12 or .nan, is refused with a warning and the default 10000 is used.

Beacon Lifecycle

Each hint follows a soft TTL lifecycle based on the stamp field when provided, otherwise based on reception time:

State

Description

ACTIVE

Hint is within beacon_ttl_sec. Enrichment is applied normally.

STALE

Hint is past TTL but within beacon_expiry_sec. Enrichment is still applied; the x-medkit-topic-beacon endpoint returns "status": "stale" in its response.

EXPIRED

Hint is past beacon_expiry_sec. It is removed from the store and no longer contributes to enrichment.

The x-medkit-topic-beacon vendor endpoint exposes current beacon state:

curl http://localhost:8080/api/v1/apps/engine_temp_sensor/x-medkit-topic-beacon

Example Response:

{
  "entity_id": "engine_temp_sensor",
  "status": "active",
  "age_sec": 1.234,
  "stable_id": "",
  "display_name": "Engine Temperature Sensor",
  "transport_type": "shared_memory",
  "negotiated_format": "",
  "process_id": 12345,
  "process_name": "sensor_node",
  "hostname": "robot-1",
  "component_id": "powertrain",
  "function_ids": ["monitoring"],
  "depends_on": [],
  "metadata": {"custom_key": "custom_value"}
}

Beacon Metadata Keys

When a beacon hint is applied to an entity, the following metadata keys are injected into the entity’s response (e.g., GET /api/v1/apps/{id}). These keys appear in the entity’s metadata field, NOT in the vendor extension endpoint response.

The keys are built by build_metadata() in beacon_entity_mapper.cpp:

Key

Type

Description

x-medkit-beacon-status

string

Beacon state: "active" or "stale"

x-medkit-beacon-age-sec

number

Seconds since the hint was last seen

x-medkit-beacon-transport-type

string

DDS transport type (e.g., "shared_memory", "nitros_zero_copy")

x-medkit-beacon-negotiated-format

string

Negotiated data format (e.g., "nitros_image_bgr8")

x-medkit-beacon-process-id

integer

OS process ID (PID)

x-medkit-beacon-process-name

string

Process name

x-medkit-beacon-hostname

string

Host identifier

x-medkit-beacon-stable-id

string

Stable identity alias

x-medkit-beacon-depends-on

array

Entity IDs this entity depends on

x-medkit-beacon-functions

array

Function IDs this entity belongs to

x-medkit-beacon-<key>

string

Freeform metadata from MedkitDiscoveryHint.metadata[]

Parameter Beacon Plugin (ParameterBeaconPlugin)

The ros2_medkit_param_beacon plugin enriches discovered entities with pull-based metadata by polling ROS 2 node parameters. Unlike the topic beacon which relies on nodes publishing to a shared topic, the parameter beacon actively discovers and reads parameters matching a configurable prefix from each known node.

This approach works well for nodes that already declare their metadata as ROS 2 parameters (e.g., via declare_parameter()) without needing any custom publishing code.

Configuration

ros2_medkit_gateway:
  ros__parameters:
    plugins: ["parameter_beacon"]
    plugins.parameter_beacon.path: "/path/to/libparam_beacon_plugin.so"

    # Parameter name prefix to scan
    # Default: "ros2_medkit.discovery"
    plugins.parameter_beacon.parameter_prefix: "ros2_medkit.discovery"

    # How often to poll all nodes (seconds)
    # Default: 5.0
    plugins.parameter_beacon.poll_interval_sec: 5.0

    # Maximum time per poll cycle (seconds)
    # Default: 10.0
    plugins.parameter_beacon.poll_budget_sec: 10.0

    # Timeout for each node's parameter service call (seconds)
    # Default: 2.0
    plugins.parameter_beacon.param_timeout_sec: 2.0

    # Soft TTL: hints older than this are marked STALE (seconds)
    # Default: 15.0
    plugins.parameter_beacon.beacon_ttl_sec: 15.0

    # Hard expiry: hints older than this are removed (seconds)
    # Default: 300.0
    plugins.parameter_beacon.beacon_expiry_sec: 300.0

    # Allow plugin to introduce entirely new entities not seen by other layers
    # Default: false
    plugins.parameter_beacon.allow_new_entities: false

    # Maximum number of hints to keep in memory
    # Default: 10000
    plugins.parameter_beacon.max_hints: 10000

Every duration takes its minimum - 0.1 s, and 1.0 s for beacon_expiry_sec - up to 2147483647 s. Fast DDS keeps the seconds of a wait in a 32-bit signed integer, and a longer param_timeout_sec would make the poll thread spin. A value above the maximum, including .inf in a parameter file, becomes the maximum. NaN, -.inf and a value below the minimum become the minimum. The gateway logs a warning naming the key, the value and what replaced it.

max_hints takes an integer from 1 to 2147483647. A 64-bit integer outside that range becomes the nearer bound, with the same warning. The parameter parser reads an integer that does not fit in 64 bits as a double. A double, such as 1.0e12 or .nan, is refused with a warning and the default 10000 is used.

In runtime_only and manifest_only mode the plugin reads its poll targets from the ROS graph. In hybrid mode the merge pipeline passes it the discovered Apps, but the gateway’s refresh then calls it again with no Apps, which clears them. So a poll cycle mostly reads the graph there too; only a cycle that starts between the two calls polls the nodes of the online Apps that discovery bound to a node. A graph read skips hidden nodes (a name starting with _, the gateway’s helper nodes among them) and the gateway’s own node: they carry no beacon.

A parameter request that gets no answer within param_timeout_sec - waiting for the service, listing parameters or getting their values - is given up and removed from its client, and the node is skipped for the next 1, 2, 4 and then 8 poll cycles while it keeps timing out. A node that answers is polled on every cycle, also when its answer carries no values: rclpy answers so when one of the parameters asked for is declared with a type and no value, and the plugin then stores no hint for the node. None of this is logged.

Each node keeps one parameter client across poll cycles. A node that is no longer a poll target, also when no node is, loses its client and its skip count.

Parameter Naming

Nodes declare parameters under the configured prefix (default: ros2_medkit.discovery). Each parameter maps to a BeaconHint field:

Parameter

BeaconHint field

ros2_medkit.discovery.entity_id (string, required)

entity_id

ros2_medkit.discovery.stable_id (string)

stable_id

ros2_medkit.discovery.display_name (string)

display_name

ros2_medkit.discovery.component_id (string)

component_id

ros2_medkit.discovery.transport_type (string)

transport_type

ros2_medkit.discovery.negotiated_format (string)

negotiated_format

ros2_medkit.discovery.process_name (string)

process_name

ros2_medkit.discovery.hostname (string)

hostname

ros2_medkit.discovery.process_id (integer)

process_id

ros2_medkit.discovery.function_ids (string array)

function_ids

ros2_medkit.discovery.depends_on (string array)

depends_on

ros2_medkit.discovery.metadata.<key> (string)

metadata[<key>]

Example node (C++):

node->declare_parameter("ros2_medkit.discovery.entity_id", "my_sensor");
node->declare_parameter("ros2_medkit.discovery.display_name", "Temperature Sensor");
node->declare_parameter("ros2_medkit.discovery.function_ids",
    std::vector<std::string>{"thermal_monitoring"});
node->declare_parameter("ros2_medkit.discovery.process_id",
    static_cast<int64_t>(getpid()));

Running Both Plugins

The topic and parameter beacon plugins can be active simultaneously. Each maintains its own BeaconHintStore and contributes independently to the merge pipeline.

When both are active, align TTL and expiry values to avoid inconsistent staleness behavior:

ros2_medkit_gateway:
  ros__parameters:
    plugins: ["topic_beacon", "parameter_beacon"]
    plugins.topic_beacon.path: "/path/to/libtopic_beacon_plugin.so"
    plugins.topic_beacon.beacon_ttl_sec: 10.0
    plugins.topic_beacon.beacon_expiry_sec: 300.0

    plugins.parameter_beacon.path: "/path/to/libparam_beacon_plugin.so"
    plugins.parameter_beacon.poll_interval_sec: 5.0
    plugins.parameter_beacon.beacon_ttl_sec: 15.0
    plugins.parameter_beacon.beacon_expiry_sec: 300.0

See Also