Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/config/discovery-options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ Each layer declares a policy per field-group:
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``):
Expand Down
23 changes: 23 additions & 0 deletions docs/config/manifest-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ Schema
parent_component_id: string # Optional - parent component
depends_on: [string] # Optional - component IDs this depends on
subcomponents: [] # Optional - nested definitions
external: boolean # Optional - non-ROS external asset (default: false)

identity: # Optional - asset-identity nameplate
manufacturer: string # Vendor / manufacturer name
Expand Down Expand Up @@ -321,6 +322,17 @@ Fields
- [Component]
- No
- Nested component definitions
* - ``external``
- boolean
- No
- True if the component is a non-ROS external asset (PLC, fieldbus device,
any asset a protocol plugin bridges into SOVD). Tri-state: **omitting**
``external:`` leaves it unset, so in hybrid mode it does not clear an
``external`` classification contributed by another discovery layer (e.g. a
protocol plugin). An **explicit** value is authoritative and resolves by
normal layer priority. An external component with no bound child apps owns
its fault_manager faults under its own entity id, so a Function or Area
hosting it rolls up its faults without a synthetic child app (#516).
* - ``identity``
- object
- No
Expand Down Expand Up @@ -377,6 +389,11 @@ Example
type: "sensor"
area: perception

# External device component (not a ROS node); owns its faults by entity id
- id: line-plc
name: "Line PLC"
external: true

Assets
------

Expand Down Expand Up @@ -412,6 +429,7 @@ Schema
parent_component_id: string # Optional - parent component ID
depends_on: [string] # Optional - component IDs this asset depends on
tags: [string] # Optional - tags for filtering
external: boolean # Optional - non-ROS external asset (default: false)
any_other_key: string # Kept verbatim as an identity extra

Fields
Expand All @@ -423,6 +441,11 @@ The identity keys accept the same aliases as the CSV import:
keys land on the typed identity fields, not in the extras. Any scalar key not
listed above is preserved as an identity extra.

``external`` classifies the asset as a non-ROS device, the same tri-state field
as on a ``components:`` entry. An external asset with no bound child apps owns
its fault scope by its own entity id. It is a recognized key, so it classifies
the Component instead of being kept as an identity extra.

Placement is optional: without ``area`` (and ``namespace``) the asset is
reachable at ``/components/{id}`` and in the flat component list, but does not
appear under any Area. An ``area`` must reference an area defined in the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# SOVD System Manifest: External-component fault-rollup fixture (#516)
# ====================================================================
# An external Component (a PLC bridged into SOVD
# by a protocol plugin) owns its fault_manager faults with NO child App located
# on it. Mirrors the #517 external-App fixture but at the Component level. Also
# carries AAS-Nameplate identity to confirm identity is orthogonal to the
# external classification, and an internal (ROS) Component as the guardrail.
#
# `plc-diag` is a separate external App used only to pin App external wire
# parity (test_external_flag_and_identity_on_the_wire) - it is deliberately NOT
# `is_located_on: s7-plc`. Locating it on the Component would give
# `s7-plc` a contributing child app, and per the fault-scope rule
# (ResolveEntitySourceFqnsTest.ExternalComponentWithContributingAppKeepsAppScope)
# the component's fault scope would then resolve to the app's id/FQN instead of
# falling back to the component's own bare id
# (ResolveEntitySourceFqnsTest.ExternalComponentWithNoAppsOwnsFaultsUnderItsOwnId),
# which would silently break the component/function/area rollup this fixture
# exists to pin.
#
# Used by: test/features/test_external_component_fault_rollup.test.py
manifest_version: "1.0"

metadata:
name: "external-component-fault-rollup"
version: "1.0.0"
description: "External (non-ROS) Component fault-rollup fixture (#516)"

config:
unmanifested_nodes: warn
inherit_runtime_resources: true

areas:
- id: plc-cell
name: "PLC Cell"
namespace: /plc_cell
description: "Industrial cell hosting a PLC bridged into SOVD"

components:
- id: s7-plc
name: "Siemens S7 PLC"
type: "controller"
area: plc-cell
external: true
description: "Non-ROS PLC introspected by a protocol plugin; owns its faults"
identity:
manufacturer: "Siemens"
model: "S7-1500"
order_code: "6ES7515-2AM02-0AB0"
serial_number: "SN-PLC-0001"
role: "plc"
- id: nav-controller
name: "Nav Controller"
type: "controller"
area: plc-cell
description: "Internal ROS component (guardrail: must NOT own bare-id faults)"
identity:
manufacturer: "Acme"
model: "NavCtl-1"

apps:
- id: plc-diag
name: "PLC Diagnostics"
external: true
description: "External diagnostic app (guards App external wire parity); not located on s7-plc on purpose (see header)"

functions:
- id: material_flow
name: "Material Flow"
category: "process"
hosted_by:
- s7-plc
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ struct Component {
std::optional<json> host_metadata; ///< Host system metadata (for runtime default component)
AssetIdentity identity; ///< Asset-identity nameplate (merged across sources, per-field provenance)

/// Tri-state: nullopt = no layer classified this component, true = non-ROS
/// external asset (PLC/fieldbus/device), false = explicitly a ROS component.
/// Mirrors App::external so a device Component owns its fault_manager faults
/// without a synthetic child App (#516).
std::optional<bool> external;

/**
* @brief Convert to JSON representation
* @return JSON object with component data
Expand Down Expand Up @@ -99,6 +105,11 @@ struct Component {
if (!identity.empty()) {
x_medkit["identity"] = identity.to_json();
}
// Emit only when effectively external; omitted and explicit-false both leave
// the field absent, keeping the wire shape stable (absence == not external).
if (external.value_or(false)) {
x_medkit["external"] = true;
}
j["x-medkit"] = x_medkit;

// Add operations array combining services and actions
Expand Down Expand Up @@ -183,7 +194,8 @@ inline bool operator==(const Component & a, const Component & b) {
a.description == b.description && a.variant == b.variant && a.tags == b.tags &&
a.parent_component_id == b.parent_component_id && a.depends_on == b.depends_on &&
a.contributors == b.contributors && a.services == b.services && a.actions == b.actions &&
a.topics == b.topics && a.host_metadata == b.host_metadata && a.identity == b.identity;
a.topics == b.topics && a.host_metadata == b.host_metadata && a.identity == b.identity &&
a.external == b.external;
}
inline bool operator!=(const Component & a, const Component & b) {
return !(a == b);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ inline constexpr std::string_view dto_name<XMedkitArea> = "XMedkitArea";
// description <- comp.description (via ext.add())
// contributors <- comp.contributors
// capabilities <- capabilities JSON array (via ext.add())
// external <- comp.external, non-ROS external asset classification; emitted true-only on every
// route that carries the component x-medkit, so "absence == not external" holds (#516)
//
// Also used in sub-collection responses (depends-on, subcomponents, hosts, contains, etc.):
// missing <- true when component reference cannot be resolved
Expand All @@ -122,7 +124,8 @@ struct XMedkitComponent {
// "_provenance"). Free-form JSON so the DTO layer reuses the exact
// serialization emitted by Component::to_json and consumed by peer parsing.
std::optional<nlohmann::json> identity;
std::optional<bool> missing; // broken reference sentinel
std::optional<bool> missing; // broken reference sentinel
std::optional<bool> external; // non-ROS external asset classification (#516)
Comment thread
bburda marked this conversation as resolved.
};

template <>
Expand All @@ -132,7 +135,8 @@ inline constexpr auto dto_fields<XMedkitComponent> = std::make_tuple(
field("dependsOn", &XMedkitComponent::depends_on), field("area", &XMedkitComponent::area),
field("variant", &XMedkitComponent::variant), field("description", &XMedkitComponent::description),
field("contributors", &XMedkitComponent::contributors), field("capabilities", &XMedkitComponent::capabilities),
field("identity", &XMedkitComponent::identity), field("missing", &XMedkitComponent::missing));
field("identity", &XMedkitComponent::identity), field("missing", &XMedkitComponent::missing),
field("external", &XMedkitComponent::external));

template <>
inline constexpr std::string_view dto_name<XMedkitComponent> = "XMedkitComponent";
Expand All @@ -146,6 +150,8 @@ inline constexpr std::string_view dto_name<XMedkitComponent> = "XMedkitComponent
// ros2.node <- app.bound_fqn
// component_id <- app.component_id
// contributors <- app.contributors (detail only)
// external <- app.external, non-ROS external asset classification; emitted true-only on every route
// that carries the app x-medkit, so "absence == not external" holds (#516/#517)
//
// Also used in sub-collection responses (depends-on, hosts, function-hosts):
// missing <- true when app reference cannot be resolved
Expand All @@ -156,14 +162,16 @@ struct XMedkitApp {
std::optional<bool> is_online;
std::optional<std::string> component_id;
std::optional<std::vector<std::string>> contributors;
std::optional<bool> missing; // broken reference sentinel
std::optional<bool> missing; // broken reference sentinel
std::optional<bool> external; // non-ROS external asset classification (#516/#517)
};

template <>
inline constexpr auto dto_fields<XMedkitApp> =
std::make_tuple(field("ros2", &XMedkitApp::ros2), field("source", &XMedkitApp::source),
field("is_online", &XMedkitApp::is_online), field("component_id", &XMedkitApp::component_id),
field("contributors", &XMedkitApp::contributors), field("missing", &XMedkitApp::missing));
field("contributors", &XMedkitApp::contributors), field("missing", &XMedkitApp::missing),
field("external", &XMedkitApp::external));

template <>
inline constexpr std::string_view dto_name<XMedkitApp> = "XMedkitApp";
Expand Down
14 changes: 13 additions & 1 deletion src/ros2_medkit_gateway/src/core/faults/fault_scope.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,21 @@ void collect_app_fqn(const ThreadSafeEntityCache & cache, const std::string & ap

void collect_component_app_fqns(const ThreadSafeEntityCache & cache, const std::string & comp_id,
std::set<std::string> & out) {
std::set<std::string> local;
for (const auto & app_id : cache.get_apps_for_component(comp_id)) {
collect_app_fqn(cache, app_id, out);
collect_app_fqn(cache, app_id, local);
}
if (local.empty()) {
// No child app contributed a source (no apps, or all unbound/empty-FQN).
// An external component owns its fault_manager faults under its own id -
// mirrors the external-App rule (#516). A non-external component stays
// empty: an unbound ROS component must not claim faults it never reported.
auto comp = cache.get_component(comp_id);
if (comp && comp->external.value_or(false)) {
local.insert(comp_id);
}
}
out.insert(local.begin(), local.end());
}

void collect_area_app_fqns(const ThreadSafeEntityCache & cache, const std::string & area_id,
Expand Down
21 changes: 18 additions & 3 deletions src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,13 @@ Component ManifestParser::parse_component(const YAML::Node & node) const {
comp.source = "manifest";
comp.identity = parse_identity(node);

// Preserve the omitted-vs-explicit distinction: an absent `external:` key
// leaves nullopt so the hybrid merge cannot let a stub's default erase a
// plugin's introspected classification (#516, mirrors the App rule #517).
if (node["external"]) {
comp.external = node["external"].as<bool>();
Comment thread
bburda marked this conversation as resolved.
}

// Parse type if provided (e.g., "controller", "sensor", "actuator")
std::string type_val = get_string(node, "type");
if (!type_val.empty()) {
Expand All @@ -277,9 +284,9 @@ Component ManifestParser::parse_asset(const YAML::Node & node) const {
// exactly the names the CSV import does (serial / serial_number, ...); any
// other scalar key is retained verbatim as an extra so operator-specific
// columns are not lost.
static const std::unordered_set<std::string> structural = {"tags", "parent_component_id", "name",
"description", "namespace", "variant",
"type", "translation_id", "depends_on"};
static const std::unordered_set<std::string> structural = {
"tags", "parent_component_id", "name", "description", "namespace", "variant",
"type", "translation_id", "depends_on", "external"};

AssetEntry entry;
if (node.IsMap()) {
Expand Down Expand Up @@ -338,6 +345,14 @@ Component ManifestParser::parse_asset(const YAML::Node & node) const {
comp.tags.push_back(tag);
}

// An `assets:` entry may classify the device as a non-ROS external asset,
// mirroring `parse_component`. Tri-state: an absent key stays nullopt.
// `external` is in the `structural` set above so it is not swallowed as an
// identity extra (#516).
if (node["external"]) {
comp.external = node["external"].as<bool>();
}

return comp;
}

Expand Down
1 change: 1 addition & 0 deletions src/ros2_medkit_gateway/src/discovery/merge_pipeline.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ void apply_field_group_merge(Entity & target, const Entity & source, FieldGroup
case FieldGroup::METADATA:
merge_scalar(target.source, source.source, res.scalar);
merge_scalar(target.variant, source.variant, res.scalar);
merge_external(target.external, source.external, res.scalar);
break;
case FieldGroup::STATUS:
default:
Expand Down
Loading
Loading