Skip to content
Open
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
38 changes: 38 additions & 0 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,44 @@ Components
``GET /api/v1/components/{component_id}``
Get component capabilities including available resource collections.

.. note::

**ros2_medkit extension:** When a component carries an asset-identity
nameplate - from the manifest ``identity:`` block (see
:doc:`/config/manifest-schema`) or a protocol device-info read (e.g. the
OPC UA BuildInfo/DI nameplate) - both the list and detail responses
include it under ``x-medkit.identity``. Only populated fields are
emitted (camelCase), ``extra`` holds vendor-specific keys, and
``_provenance`` records which source set each field (keys use the
snake_case field names; ``extra`` entries are prefixed with
``extra.``).

.. code-block:: json

{
"id": "plc_runtime",
"name": "PLC Runtime",
"x-medkit": {
"source": "opcua",
"identity": {
"manufacturer": "SelfPatch Devices",
"model": "SPX-1000",
"serialNumber": "SN-0001-TEST",
"hardwareRevision": "HW-A2",
"softwareVersion": "SW-3.4.5",
"networkEndpoint": "opc.tcp://plc.local:4840",
"extra": {
"buildNumber": "build-4567"
},
"_provenance": {
"manufacturer": "opcua",
"serial_number": "opcua",
"extra.buildNumber": "opcua"
}
}
}
}

``GET /api/v1/components/{component_id}/hosts``
List apps hosted on this component (SOVD 7.6.2.4).

Expand Down
26 changes: 26 additions & 0 deletions docs/config/manifest-schema.rst
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,18 @@ Schema
depends_on: [string] # Optional - component IDs this depends on
subcomponents: [] # Optional - nested definitions

identity: # Optional - asset-identity nameplate
manufacturer: string # Vendor / manufacturer name
model: string # Product designation / order code
serial_number: string # Unit serial number
hardware_revision: string # Hardware revision
firmware_version: string # Firmware version
software_version: string # Software/application version
network_endpoint: string # e.g. "opc.tcp://plc.local:4840"
role: string # Functional role (e.g. "plc", "drive")
extra: # Optional - vendor-specific extras
<key>: string # Free-form string map (rack/slot, MAC, asset tag, ...)

lock: # Optional - per-entity lock configuration
required_scopes: [string] # Collections requiring a lock before mutation
breakable: boolean # Whether locks can be broken (default: true)
Expand Down Expand Up @@ -309,6 +321,20 @@ Fields
- [Component]
- No
- Nested component definitions
* - ``identity``
- object
- No
- Asset-identity nameplate of the asset behind the component. All keys
are optional strings (``manufacturer``, ``model``, ``serial_number``,
``hardware_revision``, ``firmware_version``, ``software_version``,
``network_endpoint``, ``role``) plus an extensible ``extra`` string map
for vendor-specific keys not modeled up front. Each populated field is
recorded with provenance ``manifest``; protocol plugins (e.g. OPC UA
device-info) fill in or override fields per the identity merge
precedence. A live protocol read outranks the manifest only over an
authenticated session (e.g. an OPC UA secured channel with certificate
validation); an unauthenticated read only fills fields the manifest
left empty. Exposed over REST as ``x-medkit.identity``.

Common Component Types
~~~~~~~~~~~~~~~~~~~~~~
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,27 @@ namespace discovery {
* manifest may be the authoritative *structure* source while a live protocol read is
* the authoritative *identity* source.
*
* Default precedence (highest first): a live protocol device-info read (a `plugin`
* source, or a protocol-specific source tag) beats the hand-authored `manifest` and
* `inventory` (CSV / manifest `assets:` list) declarations, which beat whatever
* runtime discovery guessed. The protocol-specific tags lead the list so that a
* provider which sets a concrete `Component.source` (e.g. "opcua") is honoured; the
* generic "plugin" tag covers the common case where the plugin layer stamps every
* plugin entity with source="plugin".
* Default precedence (highest first) encodes a trust-gated authority rule:
*
* - Protocol-class tags ("opcua", "s7", ...) lead the list and outrank the
* hand-authored `manifest`. A provider may only stamp its protocol tag when the
* session behind the read is authenticated (e.g. the OPC UA plugin requires a
* secured channel with certificate validation); an authenticated live device read
* is then more authoritative than a manifest that can go stale.
* - `manifest` outranks `inventory` (CSV / manifest `assets:` list), which outranks
* `config`: operator-authored identity beats bulk-imported inventory declarations,
* which beat config defaults.
* - The generic "plugin" tag (stamped by the plugin layer on entities without a
* source, and used by providers for identity read over an UNauthenticated session)
* ranks below `manifest`, `inventory` and `config`: it fills fields the operator
* left empty but never overrides operator-authored identity, so a spoofable read
* cannot displace the manifest.
* - Runtime discovery guesses rank last.
*/
struct IdentityMergeConfig {
std::vector<std::string> source_precedence{"opcua", "s7", "ethernet_ip", "modbus", "ads",
"profinet", "plugin", "manifest", "inventory", "config",
"runtime", "node", "topic", "heuristic"};
std::vector<std::string> source_precedence{"opcua", "s7", "ethernet_ip", "modbus", "ads",
"profinet", "manifest", "inventory", "config", "plugin",
"runtime", "node", "topic", "heuristic"};
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ class ManifestParser {
Component parse_component(const YAML::Node & node) const;
/// Parse a manual-inventory asset entry into a Component (identity populated).
Component parse_asset(const YAML::Node & node) const;
/// Parse an optional `identity:` block into an AssetIdentity, stamping
/// per-field provenance to "manifest".
AssetIdentity parse_identity(const YAML::Node & node) const;
App parse_app(const YAML::Node & node) const;
Function parse_function(const YAML::Node & node) const;
ManifestConfig parse_config(const YAML::Node & node) const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ struct XMedkitComponent {
std::optional<std::string> description;
std::optional<std::vector<std::string>> contributors;
std::optional<nlohmann::json> capabilities; // free-form JSON array
std::optional<bool> missing; // broken reference sentinel
// Asset-identity nameplate (AssetIdentity::to_json shape: camelCase fields +
// "_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
};

template <>
Expand All @@ -128,7 +132,7 @@ 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("missing", &XMedkitComponent::missing));
field("identity", &XMedkitComponent::identity), field("missing", &XMedkitComponent::missing));

template <>
inline constexpr std::string_view dto_name<XMedkitComponent> = "XMedkitComponent";
Expand Down
30 changes: 30 additions & 0 deletions src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

#include "ros2_medkit_gateway/core/discovery/manifest/manifest_parser.hpp"

#include "ros2_medkit_gateway/core/discovery/identity_merge.hpp"

#include <yaml-cpp/yaml.h>

#include <algorithm>
Expand Down Expand Up @@ -251,6 +253,7 @@ Component ManifestParser::parse_component(const YAML::Node & node) const {
comp.parent_component_id = get_string(node, "parent_component_id");
comp.depends_on = get_string_vector(node, "depends_on");
comp.source = "manifest";
comp.identity = parse_identity(node);

// Parse type if provided (e.g., "controller", "sensor", "actuator")
std::string type_val = get_string(node, "type");
Expand Down Expand Up @@ -338,6 +341,33 @@ Component ManifestParser::parse_asset(const YAML::Node & node) const {
return comp;
}

AssetIdentity ManifestParser::parse_identity(const YAML::Node & node) const {
AssetIdentity identity;
if (!node["identity"]) {
return identity;
}
Comment thread
mfaferek93 marked this conversation as resolved.
const YAML::Node & id_node = node["identity"];
identity.manufacturer = get_string(id_node, "manufacturer");
identity.model = get_string(id_node, "model");
identity.serial_number = get_string(id_node, "serial_number");
identity.hardware_revision = get_string(id_node, "hardware_revision");
identity.firmware_version = get_string(id_node, "firmware_version");
identity.software_version = get_string(id_node, "software_version");
identity.network_endpoint = get_string(id_node, "network_endpoint");
identity.role = get_string(id_node, "role");
if (id_node["extra"] && id_node["extra"].IsMap()) {
for (const auto & kv : id_node["extra"]) {
identity.extra[kv.first.as<std::string>()] = kv.second.as<std::string>();
}
}
// Pre-stamp provenance so the identity origin is recorded even in
// MANIFEST_ONLY mode, where the merge pipeline (which otherwise seeds
// provenance from Component.source) is bypassed. Only populated fields are
// stamped; a merge later overrides per field with a higher-authority source.
stamp_identity_provenance(identity, "manifest");
return identity;
}

App ManifestParser::parse_app(const YAML::Node & node) const {
App app;
app.id = get_string(node, "id");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,9 @@ DiscoveryHandlers::get_components(const http::TypedRequest & req) {
ros2.ns = component.namespace_path;
x_medkit_comp.ros2 = ros2;
}
if (!component.identity.empty()) {
x_medkit_comp.identity = component.identity.to_json();
}
Comment thread
mfaferek93 marked this conversation as resolved.
item.x_medkit = x_medkit_comp;

response.items.push_back(std::move(item));
Expand Down Expand Up @@ -685,6 +688,9 @@ http::Result<dto::ComponentDetail> DiscoveryHandlers::get_component(const http::
if (!comp.contributors.empty()) {
x_medkit_comp.contributors = sorted_contributors(comp.contributors);
}
if (!comp.identity.empty()) {
x_medkit_comp.identity = comp.identity.to_json();
}

using Cap = CapabilityBuilder::Capability;
std::vector<Cap> caps = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter).
// NOLINTBEGIN
///
// expected - An implementation of std::expected with extensions
// Written in 2017 by Sy Brand (tartanllama@gmail.com, @TartanLlama)
Expand Down Expand Up @@ -2473,3 +2475,4 @@ void swap(expected<T, E> &lhs,
} // namespace tl

#endif
// NOLINTEND
21 changes: 21 additions & 0 deletions src/ros2_medkit_gateway/test/test_asset_identity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,27 @@ TEST(MergeIdentity, UnknownSourceFillsGapsButDoesNotOverrideKnown) {
EXPECT_EQ(merged.provenance.at("serial_number"), "some_unlisted_source");
}

TEST(MergeIdentity, GenericPluginSourceFillsGapsButDoesNotOverrideManifest) {
// Trust-gated authority rule: a provider stamps its protocol tag ("opcua")
// only for an authenticated session; identity read over an unauthenticated
// session merges under the generic "plugin" tag, which ranks BELOW manifest -
// it fills fields the operator left empty but never overrides them.
IdentityMergeConfig cfg;
AssetIdentity merged;
merged.manufacturer = "Siemens";
stamp_identity_provenance(merged, "manifest");

AssetIdentity untrusted_read;
untrusted_read.manufacturer = "Spoofed Vendor"; // must not beat the manifest
untrusted_read.serial_number = "SN-9"; // but fills an empty field
merge_identity(merged, untrusted_read, "plugin", cfg);

EXPECT_EQ(merged.manufacturer, "Siemens");
EXPECT_EQ(merged.provenance.at("manufacturer"), "manifest");
EXPECT_EQ(merged.serial_number, "SN-9");
EXPECT_EQ(merged.provenance.at("serial_number"), "plugin");
}

TEST(MergeIdentity, ConfigurablePrecedenceOrder) {
// Flip the default so manifest outranks opcua.
IdentityMergeConfig cfg;
Expand Down
56 changes: 56 additions & 0 deletions src/ros2_medkit_gateway/test/test_discovery_handlers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ manifest_version: "1.0"
description: "Vehicle control unit"
tags: ["compute", "control"]
depends_on: ["lidar_unit", "ghost_component"]
identity:
manufacturer: "Acme Robotics"
model: "ECU-9000"
serial_number: "SN-MAIN-001"
role: "controller"
extra:
slot: "1"
- id: "lidar_unit"
name: "Lidar Unit"
namespace: "/sensors"
Expand Down Expand Up @@ -435,6 +442,55 @@ TEST_F(DiscoveryHandlersFixtureTest, ListComponentsReturnsMetadata) {
EXPECT_EQ(body["items"][0]["x-medkit"]["source"], "manifest");
}

// @verifies REQ_INTEROP_003
// INV2: manifest-driven asset identity surfaces over SOVD with per-field
// provenance. The list handler emits x-medkit.identity for a component whose
// manifest declares an identity block.
TEST_F(DiscoveryHandlersFixtureTest, ListComponentsIncludesManifestIdentity) {
httplib::Request req;
TypedRequest typed_req(req);

auto result = handlers_->get_components(typed_req);
auto body = body_json(result);
ASSERT_EQ(body["items"].size(), 1u);
const auto & identity = body["items"][0]["x-medkit"]["identity"];
// Typed fields serialize camelCase (AssetIdentity::to_json).
EXPECT_EQ(identity["manufacturer"], "Acme Robotics");
EXPECT_EQ(identity["model"], "ECU-9000");
EXPECT_EQ(identity["serialNumber"], "SN-MAIN-001");
EXPECT_EQ(identity["role"], "controller");
EXPECT_EQ(identity["extra"]["slot"], "1");
// Provenance keys use the internal snake_case field names.
EXPECT_EQ(identity["_provenance"]["manufacturer"], "manifest");
EXPECT_EQ(identity["_provenance"]["serial_number"], "manifest");
EXPECT_EQ(identity["_provenance"]["extra.slot"], "manifest");
}

// @verifies REQ_INTEROP_003
// INV2: the same identity is present on the component detail endpoint.
TEST_F(DiscoveryHandlersFixtureTest, GetComponentIncludesManifestIdentity) {
httplib::Request req;
auto typed_req = make_typed_request(req, "/api/v1/components/main_ecu", R"(/api/v1/components/([^/]+))");

auto result = handlers_->get_component(typed_req);
auto body = body_json(result);
const auto & identity = body["x-medkit"]["identity"];
EXPECT_EQ(identity["manufacturer"], "Acme Robotics");
EXPECT_EQ(identity["role"], "controller");
EXPECT_EQ(identity["_provenance"]["model"], "manifest");
}

// @verifies REQ_INTEROP_003
// A component without a manifest identity block emits no x-medkit.identity.
TEST_F(DiscoveryHandlersFixtureTest, GetComponentWithoutIdentityOmitsField) {
httplib::Request req;
auto typed_req = make_typed_request(req, "/api/v1/components/lidar_unit", R"(/api/v1/components/([^/]+))");

auto result = handlers_->get_component(typed_req);
auto body = body_json(result);
EXPECT_FALSE(body["x-medkit"].contains("identity"));
}

// @verifies REQ_INTEROP_003
TEST_F(DiscoveryHandlersValidationTest, GetComponentInvalidIdReturns400) {
httplib::Request req;
Expand Down
53 changes: 53 additions & 0 deletions src/ros2_medkit_gateway/test/test_manifest_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,59 @@ manifest_version: "1.0"
EXPECT_EQ(comp.tags.size(), 1);
}

// INV2: a manifest component may declare an asset-identity nameplate. Parsed
// fields land on Component.identity with per-field provenance stamped
// "manifest" (so identity is attributable even in MANIFEST_ONLY mode).
TEST_F(ManifestParserTest, ParseComponentIdentity) {
const std::string yaml = R"(
manifest_version: "1.0"
components:
- id: "plc_1"
name: "Line PLC"
identity:
manufacturer: "Siemens"
model: "S7-1500"
serial_number: "SN-42"
hardware_revision: "HW-3"
firmware_version: "2.9.4"
software_version: "app-1.0"
network_endpoint: "opc.tcp://plc:4840"
role: "plc"
extra:
slot: "3"
)";

auto manifest = parser_.parse_string(yaml);
ASSERT_EQ(manifest.components.size(), 1);
const auto & id = manifest.components[0].identity;

EXPECT_EQ(id.manufacturer, "Siemens");
EXPECT_EQ(id.model, "S7-1500");
EXPECT_EQ(id.serial_number, "SN-42");
EXPECT_EQ(id.hardware_revision, "HW-3");
EXPECT_EQ(id.firmware_version, "2.9.4");
EXPECT_EQ(id.software_version, "app-1.0");
EXPECT_EQ(id.network_endpoint, "opc.tcp://plc:4840");
EXPECT_EQ(id.role, "plc");
EXPECT_EQ(id.extra.at("slot"), "3");

EXPECT_EQ(id.provenance.at("manufacturer"), "manifest");
EXPECT_EQ(id.provenance.at("serial_number"), "manifest");
EXPECT_EQ(id.provenance.at("extra.slot"), "manifest");
}

TEST_F(ManifestParserTest, ParseComponentWithoutIdentityIsEmpty) {
const std::string yaml = R"(
manifest_version: "1.0"
components:
- id: "plain"
name: "Plain"
)";
auto manifest = parser_.parse_string(yaml);
ASSERT_EQ(manifest.components.size(), 1);
EXPECT_TRUE(manifest.components[0].identity.empty());
}

TEST_F(ManifestParserTest, ParseApps) {
const std::string yaml = R"(
manifest_version: "1.0"
Expand Down
Loading
Loading