From cfa021043123030e16ab9ff2ab5d434f9a310574 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Wed, 1 Jul 2026 19:48:56 +0200 Subject: [PATCH 1/9] opcua: populate asset identity from device-info nameplate Read ServerStatus/BuildInfo and the OPC UA DI nameplate on connect and map them onto the Component identity, feeding the discovery merge so a connected device shows its nameplate over SOVD. The PLC runtime component now carries source="opcua" (preserved by the plugin layer) so the nameplate ranks at protocol-tag precedence in the identity merge, with per-field provenance "opcua". Also parse a manifest identity block and emit x-medkit.identity on the component endpoints. Adds unit and no-HW integration tests. Refs #489 --- .../discovery/manifest/manifest_parser.hpp | 3 + .../ros2_medkit_gateway/dto/x_medkit.hpp | 8 +- .../discovery/manifest/manifest_parser.cpp | 30 ++ .../src/http/handlers/discovery_handlers.cpp | 6 + .../test/test_discovery_handlers.cpp | 56 +++ .../test/test_manifest_parser.cpp | 53 +++ .../ros2_medkit_opcua/CMakeLists.txt | 53 +++ .../ros2_medkit_opcua/device_identity.hpp | 36 ++ .../ros2_medkit_opcua/opcua_client.hpp | 32 ++ .../ros2_medkit_opcua/opcua_plugin.hpp | 7 + .../ros2_medkit_opcua/src/device_identity.cpp | 53 +++ .../ros2_medkit_opcua/src/opcua_client.cpp | 178 ++++++++ .../ros2_medkit_opcua/src/opcua_plugin.cpp | 25 +- .../test_alarm_server/test_alarm_server.cpp | 81 ++++ .../test/test_device_identity.cpp | 110 +++++ .../test/test_opcua_identity.cpp | 407 ++++++++++++++++++ 16 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp create mode 100644 src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp create mode 100644 src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp create mode 100644 src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/manifest_parser.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/manifest_parser.hpp index d8de2d572..f9904281b 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/manifest_parser.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/manifest_parser.hpp @@ -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; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp index 4340f84ff..734914d13 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/dto/x_medkit.hpp @@ -118,7 +118,11 @@ struct XMedkitComponent { std::optional description; std::optional> contributors; std::optional capabilities; // free-form JSON array - std::optional 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 identity; + std::optional missing; // broken reference sentinel }; template <> @@ -128,7 +132,7 @@ inline constexpr auto dto_fields = 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"; diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp index 404052959..0c6c55031 100644 --- a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp +++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp @@ -14,6 +14,8 @@ #include "ros2_medkit_gateway/core/discovery/manifest/manifest_parser.hpp" +#include "ros2_medkit_gateway/core/discovery/identity_merge.hpp" + #include #include @@ -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"); @@ -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; + } + 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()] = kv.second.as(); + } + } + // 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"); diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 7ffa29623..d8a177ba1 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -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(); + } item.x_medkit = x_medkit_comp; response.items.push_back(std::move(item)); @@ -685,6 +688,9 @@ http::Result 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 caps = { diff --git a/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp b/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp index 7cccea826..7377b07ca 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp @@ -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" @@ -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; diff --git a/src/ros2_medkit_gateway/test/test_manifest_parser.cpp b/src/ros2_medkit_gateway/test/test_manifest_parser.cpp index 4bd075ed7..a29798785 100644 --- a/src/ros2_medkit_gateway/test/test_manifest_parser.cpp +++ b/src/ros2_medkit_gateway/test/test_manifest_parser.cpp @@ -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" diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt index 563d257a2..36c8f4f73 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt @@ -94,6 +94,7 @@ add_library(ros2_medkit_opcua_plugin MODULE src/opcua_plugin.cpp src/opcua_plugin_exports.cpp src/opcua_client.cpp + src/device_identity.cpp src/node_map.cpp src/opcua_poller.cpp ) @@ -166,6 +167,7 @@ if(BUILD_TESTING) test/test_opcua_plugin.cpp src/opcua_plugin.cpp src/opcua_client.cpp + src/device_identity.cpp src/node_map.cpp src/opcua_poller.cpp TIMEOUT 240 @@ -239,6 +241,24 @@ if(BUILD_TESTING) ) medkit_set_test_domain(test_alarm_state_machine) + # INV2: pure device-info -> AssetIdentity mapping tests (no server). + ament_add_gtest(test_device_identity + test/test_device_identity.cpp + src/device_identity.cpp + ) + target_include_directories(test_device_identity PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ) + medkit_target_dependencies(test_device_identity + ros2_medkit_gateway + rclcpp + ) + target_link_libraries(test_device_identity + open62541pp::open62541pp + nlohmann_json::nlohmann_json + ) + medkit_set_test_domain(test_device_identity) + # ---- test_alarm_server fixture ------------------------------------------ # Standalone OPC-UA server emitting AlarmConditionType events for # integration testing of native alarm subscriptions (issue #386). @@ -355,6 +375,39 @@ if(BUILD_TESTING) LABELS "integration" SKIP_RETURN_CODE 77 TIMEOUT 300) + + # INV2: end-to-end identity test. Boots test_alarm_server as a subprocess and + # proves connect -> device-info read -> AssetIdentity via both OpcuaClient and + # OpcuaPlugin::introspect(). GTEST_SKIP when the fixture binary is missing. + ament_add_gtest(test_opcua_identity + test/test_opcua_identity.cpp + src/opcua_plugin.cpp + src/opcua_client.cpp + src/device_identity.cpp + src/node_map.cpp + src/opcua_poller.cpp + TIMEOUT 120 + ) + add_dependencies(test_opcua_identity test_alarm_server) + target_include_directories(test_opcua_identity PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ) + target_compile_definitions(test_opcua_identity PRIVATE + MEDKIT_ALARM_SERVER_BIN="${CMAKE_BINARY_DIR}/test_alarm_server") + medkit_target_dependencies(test_opcua_identity + ros2_medkit_msgs + ros2_medkit_gateway + ros2_medkit_fault_detection + rclcpp + std_msgs + ) + target_link_libraries(test_opcua_identity + open62541pp::open62541pp + nlohmann_json::nlohmann_json + yaml-cpp::yaml-cpp + OpenSSL::SSL OpenSSL::Crypto + ) + medkit_set_test_domain(test_opcua_identity) endif() ros2_medkit_relax_vendor_warnings() diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp new file mode 100644 index 000000000..74f8c68ec --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp @@ -0,0 +1,36 @@ +// Copyright 2026 mfaferek93 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include "ros2_medkit_opcua/opcua_client.hpp" + +#include + +#include + +namespace ros2_medkit_gateway { + +/// Map an OPC-UA server DeviceInfo (ServerStatus/BuildInfo + optional DI +/// nameplate) onto an AssetIdentity, stamping per-field provenance to "opcua". +/// +/// DI nameplate values are device-specific, so they take precedence over the +/// server-level BuildInfo for manufacturer / model / software version. +/// BuildInfo.BuildNumber has no typed field and is carried as the ``extra`` +/// entry ``buildNumber``. ``endpoint_url`` populates the network endpoint. +/// Empty inputs are skipped, so the result is ::AssetIdentity::empty() when the +/// server exposes no device-info at all. +AssetIdentity opcua_device_info_to_identity(const OpcuaClient::DeviceInfo & info, const std::string & endpoint_url); + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp index 3c46f69c2..466b3522f 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp @@ -348,6 +348,38 @@ class OpcuaClient { /// rejected before contacting the server. static bool security_config_conflict(const OpcuaClientConfig & config); + /// Device identity read from an OPC-UA server's information model. + /// + /// Two tiers, both best-effort (empty string when the server does not expose + /// a field): + /// - ServerStatus/BuildInfo: present on every compliant server. Describes + /// the OPC-UA *server* (which for an embedded PLC IS the device). + /// - OPC-UA DI nameplate (companion spec ``http://opcfoundation.org/UA/DI/``): + /// the standard per-device identification properties, present only when the + /// server implements the DI model. More specific than BuildInfo. + struct DeviceInfo { + // ServerStatus/BuildInfo (ns=0 well-known nodes) + std::string manufacturer_name; ///< BuildInfo.ManufacturerName + std::string product_name; ///< BuildInfo.ProductName + std::string software_version; ///< BuildInfo.SoftwareVersion + std::string build_number; ///< BuildInfo.BuildNumber + + // OPC-UA DI DeviceType identification (only when the DI namespace is present) + std::string di_manufacturer; ///< DeviceType.Manufacturer + std::string di_model; ///< DeviceType.Model + std::string di_serial_number; ///< DeviceType.SerialNumber + std::string di_hardware_revision; ///< DeviceType.HardwareRevision + std::string di_software_revision; ///< DeviceType.SoftwareRevision + }; + + /// Read device identity (nameplate) from the connected server. + /// + /// Reads ServerStatus/BuildInfo unconditionally and, when the server exposes + /// the OPC-UA DI companion namespace, the DeviceSet nameplate. Best-effort: + /// returns whatever was readable and never throws. Returns an all-empty + /// struct when not connected. + DeviceInfo read_device_info(); + private: struct Impl; std::unique_ptr impl_; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp index 3fda44993..c03b9c993 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp @@ -18,6 +18,7 @@ #include "ros2_medkit_opcua/opcua_client.hpp" #include "ros2_medkit_opcua/opcua_poller.hpp" +#include #include #include #include @@ -142,6 +143,12 @@ class OpcuaPlugin : public ros2_medkit_gateway::GatewayPlugin, std::unique_ptr client_; NodeMap node_map_; + // INV2: asset-identity nameplate read once from the server's device-info + // (ServerStatus/BuildInfo + optional OPC-UA DI nameplate) on the first + // connected introspect, then reused. Empty until a successful read. + AssetIdentity device_identity_; + bool device_identity_loaded_{false}; + // ROS 2 service clients for fault reporting struct FaultClients; std::unique_ptr fault_clients_; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp new file mode 100644 index 000000000..21aa91b14 --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp @@ -0,0 +1,53 @@ +// Copyright 2026 mfaferek93 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_opcua/device_identity.hpp" + +namespace ros2_medkit_gateway { + +namespace { +constexpr const char * kOpcuaSource = "opcua"; +} // namespace + +AssetIdentity opcua_device_info_to_identity(const OpcuaClient::DeviceInfo & info, const std::string & endpoint_url) { + AssetIdentity identity; + auto set_field = [&](std::string AssetIdentity::*member, const std::string & value, const char * prov_key) { + if (!value.empty()) { + identity.*member = value; + identity.provenance[prov_key] = kOpcuaSource; + } + }; + + // DI nameplate (per-device) wins over the server-level BuildInfo. + const std::string & manufacturer = !info.di_manufacturer.empty() ? info.di_manufacturer : info.manufacturer_name; + const std::string & model = !info.di_model.empty() ? info.di_model : info.product_name; + const std::string & software_version = + !info.di_software_revision.empty() ? info.di_software_revision : info.software_version; + + set_field(&AssetIdentity::manufacturer, manufacturer, "manufacturer"); + set_field(&AssetIdentity::model, model, "model"); + set_field(&AssetIdentity::serial_number, info.di_serial_number, "serial_number"); + set_field(&AssetIdentity::hardware_revision, info.di_hardware_revision, "hardware_revision"); + set_field(&AssetIdentity::software_version, software_version, "software_version"); + set_field(&AssetIdentity::network_endpoint, endpoint_url, "network_endpoint"); + + if (!info.build_number.empty()) { + identity.extra["buildNumber"] = info.build_number; + identity.provenance["extra.buildNumber"] = kOpcuaSource; + } + + return identity; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp index 9d7df4628..a07cbac3a 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp @@ -27,6 +27,7 @@ #include #include +#include #include #include #include @@ -1032,6 +1033,183 @@ std::vector OpcuaClient::read_source_condit return out; } +// ---------------------------------------------------------------------------- +// INV2: device-info (nameplate) reads. ServerStatus/BuildInfo is universal; +// the OPC-UA DI companion nameplate (Manufacturer/Model/SerialNumber/...) is +// read best-effort when the server exposes the DI namespace. The raw C API is +// used for the browse + attribute reads so the code is independent of the +// open62541pp high-level browse surface (unavailable in v0.16). +// ---------------------------------------------------------------------------- + +namespace { + +constexpr const char * kDiNamespaceUri = "http://opcfoundation.org/UA/DI/"; + +// Read a scalar String / LocalizedText node value. Empty on any error or on a +// non-string type. The caller holds ``client_mutex``. +std::string read_string_attribute(UA_Client * client, const UA_NodeId & node_id) { + UA_Variant value; + UA_Variant_init(&value); + std::string out; + if (UA_Client_readValueAttribute(client, node_id, &value) == UA_STATUSCODE_GOOD) { + if (UA_Variant_hasScalarType(&value, &UA_TYPES[UA_TYPES_STRING])) { + const auto * s = static_cast(value.data); + out.assign(reinterpret_cast(s->data), s->length); + } else if (UA_Variant_hasScalarType(&value, &UA_TYPES[UA_TYPES_LOCALIZEDTEXT])) { + const auto * lt = static_cast(value.data); + out.assign(reinterpret_cast(lt->text.data), lt->text.length); + } + } + UA_Variant_clear(&value); + return out; +} + +// One forward hierarchical child: its browse name and target NodeId (owned). +struct ChildRef { + uint16_t namespace_index{0}; + std::string name; + UA_NodeId node_id{}; +}; + +void clear_child_refs(std::vector & children) { + for (auto & child : children) { + UA_NodeId_clear(&child.node_id); + } +} + +// Browse forward hierarchical children of ``parent``, returning their browse +// names and NodeIds. Empty on failure. The caller must ``clear_child_refs``. +std::vector browse_forward_children(UA_Client * client, const UA_NodeId & parent) { + std::vector children; + UA_BrowseRequest request; + UA_BrowseRequest_init(&request); + request.nodesToBrowse = UA_BrowseDescription_new(); + if (request.nodesToBrowse == nullptr) { + UA_BrowseRequest_clear(&request); + return children; + } + request.nodesToBrowseSize = 1; + UA_NodeId_copy(&parent, &request.nodesToBrowse[0].nodeId); + request.nodesToBrowse[0].browseDirection = UA_BROWSEDIRECTION_FORWARD; + request.nodesToBrowse[0].referenceTypeId = UA_NODEID_NUMERIC(0, UA_NS0ID_HIERARCHICALREFERENCES); + request.nodesToBrowse[0].includeSubtypes = true; + request.nodesToBrowse[0].resultMask = UA_BROWSERESULTMASK_BROWSENAME; + + UA_BrowseResponse response = UA_Client_Service_browse(client, request); + if (response.responseHeader.serviceResult == UA_STATUSCODE_GOOD && response.resultsSize == 1) { + const UA_BrowseResult & result = response.results[0]; + for (size_t i = 0; i < result.referencesSize; ++i) { + const UA_ReferenceDescription & ref = result.references[i]; + ChildRef child; + child.namespace_index = ref.browseName.namespaceIndex; + child.name.assign(reinterpret_cast(ref.browseName.name.data), ref.browseName.name.length); + UA_NodeId_copy(&ref.nodeId.nodeId, &child.node_id); + children.push_back(std::move(child)); + } + } + UA_BrowseResponse_clear(&response); + UA_BrowseRequest_clear(&request); + return children; +} + +// Namespace index of a companion-spec URI in the server's NamespaceArray, or -1. +int find_namespace_index(UA_Client * client, const char * uri) { + UA_Variant value; + UA_Variant_init(&value); + int index = -1; + if (UA_Client_readValueAttribute(client, UA_NODEID_NUMERIC(0, UA_NS0ID_SERVER_NAMESPACEARRAY), &value) == + UA_STATUSCODE_GOOD && + UA_Variant_hasArrayType(&value, &UA_TYPES[UA_TYPES_STRING])) { + const auto * entries = static_cast(value.data); + for (size_t i = 0; i < value.arrayLength; ++i) { + std::string ns(reinterpret_cast(entries[i].data), entries[i].length); + if (ns == uri) { + index = static_cast(i); + break; + } + } + } + UA_Variant_clear(&value); + return index; +} + +// Fill the DI nameplate fields of ``info`` from the first device under +// Objects/DeviceSet that carries any identification property. Best-effort. +void read_di_nameplate(UA_Client * client, uint16_t di_ns, OpcuaClient::DeviceInfo & info) { + auto objects_children = browse_forward_children(client, UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER)); + UA_NodeId device_set = UA_NODEID_NULL; + for (const auto & child : objects_children) { + if (child.namespace_index == di_ns && child.name == "DeviceSet") { + UA_NodeId_copy(&child.node_id, &device_set); + break; + } + } + clear_child_refs(objects_children); + if (UA_NodeId_isNull(&device_set)) { + return; + } + + auto devices = browse_forward_children(client, device_set); + UA_NodeId_clear(&device_set); + + for (const auto & device : devices) { + auto props = browse_forward_children(client, device.node_id); + auto read_prop = [&](const char * name) -> std::string { + for (const auto & prop : props) { + if (prop.namespace_index == di_ns && prop.name == name) { + return read_string_attribute(client, prop.node_id); + } + } + return {}; + }; + std::string manufacturer = read_prop("Manufacturer"); + std::string model = read_prop("Model"); + std::string serial = read_prop("SerialNumber"); + std::string hardware_revision = read_prop("HardwareRevision"); + std::string software_revision = read_prop("SoftwareRevision"); + clear_child_refs(props); + + if (!manufacturer.empty() || !model.empty() || !serial.empty() || !hardware_revision.empty() || + !software_revision.empty()) { + info.di_manufacturer = std::move(manufacturer); + info.di_model = std::move(model); + info.di_serial_number = std::move(serial); + info.di_hardware_revision = std::move(hardware_revision); + info.di_software_revision = std::move(software_revision); + break; + } + } + clear_child_refs(devices); +} + +} // namespace + +OpcuaClient::DeviceInfo OpcuaClient::read_device_info() { + std::lock_guard lock(impl_->client_mutex); + DeviceInfo info; + if (!impl_->connected) { + return info; + } + UA_Client * client = impl_->client.handle(); + + // Tier 1: ServerStatus/BuildInfo (ns=0 well-known nodes, always present). + info.manufacturer_name = + read_string_attribute(client, UA_NODEID_NUMERIC(0, UA_NS0ID_SERVER_SERVERSTATUS_BUILDINFO_MANUFACTURERNAME)); + info.product_name = + read_string_attribute(client, UA_NODEID_NUMERIC(0, UA_NS0ID_SERVER_SERVERSTATUS_BUILDINFO_PRODUCTNAME)); + info.software_version = + read_string_attribute(client, UA_NODEID_NUMERIC(0, UA_NS0ID_SERVER_SERVERSTATUS_BUILDINFO_SOFTWAREVERSION)); + info.build_number = + read_string_attribute(client, UA_NODEID_NUMERIC(0, UA_NS0ID_SERVER_SERVERSTATUS_BUILDINFO_BUILDNUMBER)); + + // Tier 2: OPC-UA DI nameplate (best-effort, only when the DI namespace exists). + const int di_ns = find_namespace_index(client, kDiNamespaceUri); + if (di_ns > 0) { + read_di_nameplate(client, static_cast(di_ns), info); + } + return info; +} + // ---------------------------------------------------------------------------- // Issue #386: native OPC-UA AlarmCondition event subscription primitives. // open62541pp v0.16 has no native EventFilter / event subscription API, so the diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp index ad957a7a8..7d4f9300c 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp @@ -14,6 +14,8 @@ #include "ros2_medkit_opcua/opcua_plugin.hpp" +#include "ros2_medkit_opcua/device_identity.hpp" + #include #include #include @@ -415,8 +417,29 @@ IntrospectionResult OpcuaPlugin::introspect(const IntrospectionInput & /*input*/ comp.namespace_path = "/" + node_map_.area_id(); comp.fqn = "/" + node_map_.area_id() + "/" + node_map_.component_id(); comp.area = node_map_.area_id(); - comp.source = "plugin"; + // Protocol tag, preserved by the plugin layer (it only stamps "plugin" on + // empty sources), so the identity merge ranks this component's nameplate at + // "opcua" precedence instead of the generic "plugin". + comp.source = "opcua"; comp.description = "PLC runtime connected at " + client_config_.endpoint_url; + + // INV2: fill the asset-identity nameplate from the live server's device-info + // (ServerStatus/BuildInfo + optional OPC-UA DI nameplate). Read once on the + // first connected introspect and cached, since it is stable for a connection. + // Provenance is stamped "opcua" per field (in the mapping) so a live device + // read outranks a hand-authored manifest in the identity merge. + if (client_ && client_->is_connected() && !device_identity_loaded_) { + device_identity_ = opcua_device_info_to_identity(client_->read_device_info(), client_config_.endpoint_url); + device_identity_loaded_ = true; + if (!device_identity_.empty()) { + log_info("Populated asset identity from OPC-UA device-info (manufacturer='" + device_identity_.manufacturer + + "', model='" + device_identity_.model + "')"); + } + } + if (!device_identity_.empty()) { + comp.identity = device_identity_; + } + result.new_entities.components.push_back(std::move(comp)); // Register x-plc-status on the PLC runtime component only, so non-PLC diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp index d6ec6f6ab..84b2fa213 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp @@ -482,6 +482,83 @@ UA_StatusCode configure_secure(UA_ServerConfig * config, UA_UInt16 port, const s #endif // UA_ENABLE_ENCRYPTION +// INV2: pin explicit ServerStatus/BuildInfo so the identity integration test can +// assert known nameplate values instead of open62541's build-time defaults. +void set_build_info(UA_ServerConfig * config) { + auto set = [](UA_String * dst, const char * value) { + UA_String_clear(dst); + *dst = UA_STRING_ALLOC(value); + }; + set(&config->buildInfo.manufacturerName, "SelfPatch Test Manufacturer"); + set(&config->buildInfo.productName, "SelfPatch Test PLC"); + set(&config->buildInfo.softwareVersion, "1.2.3"); + set(&config->buildInfo.buildNumber, "build-4567"); +} + +// INV2: expose a minimal OPC-UA DI nameplate (Objects/DeviceSet/TestDevice with +// the standard identification properties) so the identity read exercises the DI +// path (SerialNumber / HardwareRevision) that ServerStatus/BuildInfo lacks. +void add_di_nameplate(UA_Server * server) { + UA_UInt16 di_ns = UA_Server_addNamespace(server, "http://opcfoundation.org/UA/DI/"); + + UA_NodeId device_set_id = UA_NODEID_NULL; + { + UA_ObjectAttributes oa = UA_ObjectAttributes_default; + oa.displayName = UA_LOCALIZEDTEXT(const_cast("en"), const_cast("DeviceSet")); + UA_Server_addObjectNode(server, UA_NODEID_NULL, UA_NODEID_NUMERIC(0, UA_NS0ID_OBJECTSFOLDER), + UA_NODEID_NUMERIC(0, UA_NS0ID_ORGANIZES), + UA_QUALIFIEDNAME(di_ns, const_cast("DeviceSet")), + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEOBJECTTYPE), oa, nullptr, &device_set_id); + } + + UA_NodeId device_id = UA_NODEID_NULL; + { + UA_ObjectAttributes oa = UA_ObjectAttributes_default; + oa.displayName = UA_LOCALIZEDTEXT(const_cast("en"), const_cast("TestDevice")); + UA_Server_addObjectNode(server, UA_NODEID_NULL, device_set_id, UA_NODEID_NUMERIC(0, UA_NS0ID_HASCOMPONENT), + UA_QUALIFIEDNAME(di_ns, const_cast("TestDevice")), + UA_NODEID_NUMERIC(0, UA_NS0ID_BASEOBJECTTYPE), oa, nullptr, &device_id); + } + + auto add_prop = [&](const char * name, const UA_DataType * type, UA_Variant value) { + UA_VariableAttributes va = UA_VariableAttributes_default; + va.displayName = UA_LOCALIZEDTEXT(const_cast("en"), const_cast(name)); + va.accessLevel = UA_ACCESSLEVELMASK_READ; + va.dataType = type->typeId; + va.value = value; + UA_Server_addVariableNode(server, UA_NODEID_NULL, device_id, UA_NODEID_NUMERIC(0, UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(di_ns, const_cast(name)), + UA_NODEID_NUMERIC(0, UA_NS0ID_PROPERTYTYPE), va, nullptr, nullptr); + }; + + // Per OPC-UA DI: Manufacturer / Model are LocalizedText; SerialNumber and the + // revisions are String. + UA_LocalizedText manufacturer = UA_LOCALIZEDTEXT(const_cast("en"), const_cast("SelfPatch Devices")); + UA_Variant v_manufacturer; + UA_Variant_setScalar(&v_manufacturer, &manufacturer, &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); + add_prop("Manufacturer", &UA_TYPES[UA_TYPES_LOCALIZEDTEXT], v_manufacturer); + + UA_LocalizedText model = UA_LOCALIZEDTEXT(const_cast("en"), const_cast("SPX-1000")); + UA_Variant v_model; + UA_Variant_setScalar(&v_model, &model, &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); + add_prop("Model", &UA_TYPES[UA_TYPES_LOCALIZEDTEXT], v_model); + + UA_String serial = UA_STRING(const_cast("SN-0001-TEST")); + UA_Variant v_serial; + UA_Variant_setScalar(&v_serial, &serial, &UA_TYPES[UA_TYPES_STRING]); + add_prop("SerialNumber", &UA_TYPES[UA_TYPES_STRING], v_serial); + + UA_String hardware = UA_STRING(const_cast("HW-A2")); + UA_Variant v_hardware; + UA_Variant_setScalar(&v_hardware, &hardware, &UA_TYPES[UA_TYPES_STRING]); + add_prop("HardwareRevision", &UA_TYPES[UA_TYPES_STRING], v_hardware); + + UA_String software = UA_STRING(const_cast("SW-3.4.5")); + UA_Variant v_software; + UA_Variant_setScalar(&v_software, &software, &UA_TYPES[UA_TYPES_STRING]); + add_prop("SoftwareRevision", &UA_TYPES[UA_TYPES_STRING], v_software); +} + void cli_loop(UA_Server * server, UA_UInt16 ns) { std::string line; while (g_running && std::getline(std::cin, line)) { @@ -638,12 +715,16 @@ int main(int argc, char ** argv) { } else { UA_ServerConfig_setMinimal(config, port, nullptr); } + set_build_info(config); UA_UInt16 ns = UA_Server_addNamespace(server, NS_URI); if (add_variable(server, ns) != UA_STATUSCODE_GOOD) { UA_LOG_WARNING(UA_Log_Stdout, UA_LOGCATEGORY_USERLAND, "Failed to register Tank.Level variable"); } + // INV2: standard device-info nameplate for the identity integration test. + add_di_nameplate(server); + Condition op, oh, sl; if (add_condition(server, "Overpressure", ns, op) != UA_STATUSCODE_GOOD || add_condition(server, "Overheat", ns, oh) != UA_STATUSCODE_GOOD || diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp new file mode 100644 index 000000000..397506f25 --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp @@ -0,0 +1,110 @@ +// Copyright 2026 mfaferek93 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Unit tests for the OPC-UA device-info -> AssetIdentity mapping (INV2). Pure +// function, no server: covers the BuildInfo path, the DI-nameplate-wins +// precedence, extras, endpoint, and per-field "opcua" provenance stamping. + +#include "ros2_medkit_opcua/device_identity.hpp" + +#include + +namespace ros2_medkit_gateway { + +namespace { +OpcuaClient::DeviceInfo make_build_info() { + OpcuaClient::DeviceInfo info; + info.manufacturer_name = "open62541"; + info.product_name = "Test Alarm PLC"; + info.software_version = "1.2.3"; + info.build_number = "build-4567"; + return info; +} +} // namespace + +TEST(DeviceIdentityMap, EmptyInfoYieldsEmptyIdentity) { + OpcuaClient::DeviceInfo info; + auto id = opcua_device_info_to_identity(info, ""); + EXPECT_TRUE(id.empty()); + EXPECT_TRUE(id.provenance.empty()); +} + +TEST(DeviceIdentityMap, BuildInfoMapsToTypedFields) { + auto id = opcua_device_info_to_identity(make_build_info(), "opc.tcp://plc:4840"); + + EXPECT_EQ(id.manufacturer, "open62541"); + EXPECT_EQ(id.model, "Test Alarm PLC"); // product name maps to model + EXPECT_EQ(id.software_version, "1.2.3"); + EXPECT_EQ(id.network_endpoint, "opc.tcp://plc:4840"); + // BuildNumber has no typed field -> carried as an extra. + ASSERT_TRUE(id.extra.count("buildNumber")); + EXPECT_EQ(id.extra.at("buildNumber"), "build-4567"); + // Serial / hardware revision only come from DI, absent here. + EXPECT_TRUE(id.serial_number.empty()); + EXPECT_TRUE(id.hardware_revision.empty()); +} + +TEST(DeviceIdentityMap, ProvenanceStampedOpcuaPerField) { + auto id = opcua_device_info_to_identity(make_build_info(), "opc.tcp://plc:4840"); + + EXPECT_EQ(id.provenance.at("manufacturer"), "opcua"); + EXPECT_EQ(id.provenance.at("model"), "opcua"); + EXPECT_EQ(id.provenance.at("software_version"), "opcua"); + EXPECT_EQ(id.provenance.at("network_endpoint"), "opcua"); + EXPECT_EQ(id.provenance.at("extra.buildNumber"), "opcua"); +} + +TEST(DeviceIdentityMap, DiNameplateWinsOverBuildInfo) { + OpcuaClient::DeviceInfo info = make_build_info(); + info.di_manufacturer = "SelfPatch Devices"; + info.di_model = "SPX-1000"; + info.di_serial_number = "SN-0001"; + info.di_hardware_revision = "HW-A2"; + info.di_software_revision = "SW-3.4.5"; + + auto id = opcua_device_info_to_identity(info, "opc.tcp://plc:4840"); + + // DI values (device-specific) override the server-level BuildInfo. + EXPECT_EQ(id.manufacturer, "SelfPatch Devices"); + EXPECT_EQ(id.model, "SPX-1000"); + EXPECT_EQ(id.software_version, "SW-3.4.5"); + // DI-only fields. + EXPECT_EQ(id.serial_number, "SN-0001"); + EXPECT_EQ(id.hardware_revision, "HW-A2"); + // BuildNumber still carried from BuildInfo. + EXPECT_EQ(id.extra.at("buildNumber"), "build-4567"); +} + +TEST(DeviceIdentityMap, EmptyEndpointSkipsNetworkEndpoint) { + auto id = opcua_device_info_to_identity(make_build_info(), ""); + EXPECT_TRUE(id.network_endpoint.empty()); + EXPECT_EQ(id.provenance.count("network_endpoint"), 0u); +} + +TEST(DeviceIdentityMap, PartialDiFallsBackToBuildInfoPerField) { + // Only DI serial + hardware are present; manufacturer / model / software fall + // back to BuildInfo since the DI equivalents are empty. + OpcuaClient::DeviceInfo info = make_build_info(); + info.di_serial_number = "SN-42"; + info.di_hardware_revision = "HW-1"; + + auto id = opcua_device_info_to_identity(info, ""); + EXPECT_EQ(id.manufacturer, "open62541"); // BuildInfo fallback + EXPECT_EQ(id.model, "Test Alarm PLC"); // BuildInfo fallback + EXPECT_EQ(id.software_version, "1.2.3"); // BuildInfo fallback + EXPECT_EQ(id.serial_number, "SN-42"); // DI + EXPECT_EQ(id.hardware_revision, "HW-1"); // DI +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp new file mode 100644 index 000000000..aa2489dc4 --- /dev/null +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp @@ -0,0 +1,407 @@ +// Copyright 2026 mfaferek93 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// INV2 end-to-end (no HW): boot the test_alarm_server OPC-UA fixture, connect, +// and prove the asset-identity nameplate is filled from the server's device-info +// (ServerStatus/BuildInfo + the OPC-UA DI DeviceSet nameplate) with no manual +// entry. Exercises both the raw OpcuaClient::read_device_info read and the full +// OpcuaPlugin::introspect() path that lands identity on the SOVD Component. + +#include "ros2_medkit_opcua/device_identity.hpp" +#include "ros2_medkit_opcua/opcua_client.hpp" +#include "ros2_medkit_opcua/opcua_plugin.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ros2_medkit_gateway/plugins/ros_plugin_context.hpp" + +#ifndef MEDKIT_ALARM_SERVER_BIN +#define MEDKIT_ALARM_SERVER_BIN "" +#endif + +// -- Stub PluginRequest/PluginResponse (mirrors test_opcua_plugin.cpp; the +// plugin translation unit references them but the HTTP layer is not linked) -- + +namespace ros2_medkit_gateway { + +PluginRequest::PluginRequest(const void * impl) : impl_(impl) { +} +std::string PluginRequest::path_param(size_t) const { + return {}; +} +std::string PluginRequest::header(const std::string &) const { + return {}; +} +const std::string & PluginRequest::path() const { + static const std::string empty; + return empty; +} +const std::string & PluginRequest::body() const { + static const std::string empty; + return empty; +} +std::string PluginRequest::query_param(const std::string &) const { + return {}; +} + +PluginResponse::PluginResponse(void * impl) : impl_(impl) { +} +void PluginResponse::send_json(const nlohmann::json &) { +} +void PluginResponse::send_error(int, const std::string &, const std::string &, const nlohmann::json &) { +} + +// -- FakePluginContext: node() is null (no ROS graph), just enough for +// set_context() + introspect() to run. -- + +class FakePluginContext : public RosPluginContext { + public: + std::unordered_map entities; + + rclcpp::Node * node() const override { + return nullptr; + } + std::optional get_entity(const std::string & id) const override { + auto it = entities.find(id); + return it != entities.end() ? std::optional(it->second) : std::nullopt; + } + std::vector get_child_apps(const std::string &) const override { + return {}; + } + nlohmann::json list_entity_faults(const std::string &) const override { + return nlohmann::json{{"faults", nlohmann::json::array()}}; + } + std::optional validate_entity_for_route(const PluginRequest &, PluginResponse &, + const std::string & entity_id) const override { + return get_entity(entity_id); + } + void register_capability(SovdEntityType, const std::string &) override { + } + void register_entity_capability(const std::string &, const std::string &) override { + } + std::vector get_type_capabilities(SovdEntityType) const override { + return {}; + } + std::vector get_entity_capabilities(const std::string &) const override { + return {}; + } + LockAccessResult check_lock(const std::string &, const std::string &, const std::string &) const override { + return {true, "", ""}; + } + tl::expected acquire_lock(const std::string &, const std::string &, + const std::vector &, int) override { + return tl::make_unexpected(LockError{"not supported", ""}); + } + tl::expected release_lock(const std::string &, const std::string &) override { + return tl::make_unexpected(LockError{"not supported", ""}); + } + IntrospectionInput get_entity_snapshot() const override { + return {}; + } + nlohmann::json list_all_faults() const override { + return nlohmann::json::object(); + } + void register_sampler( + const std::string &, + const std::function(const std::string &, const std::string &)> &) + override { + } + ResourceChangeNotifier * get_resource_change_notifier() override { + return nullptr; + } + ConditionRegistry * get_condition_registry() override { + return nullptr; + } +}; + +namespace { + +// Reserve an ephemeral loopback port and release it (best-effort; a race with +// the fixture bind is unlikely on a test host and retried by the caller). +int reserve_local_port() { + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) { + return 0; + } + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + int port = 0; + if (bind(sock, reinterpret_cast(&addr), sizeof(addr)) == 0) { + socklen_t len = sizeof(addr); + if (getsockname(sock, reinterpret_cast(&addr), &len) == 0) { + port = ntohs(addr.sin_port); + } + } + close(sock); + return port; +} + +// Boots the test_alarm_server binary as a child process and blocks until it +// prints the "READY " handshake line on stdout. SIGTERM on teardown. +class AlarmServer { + public: + ~AlarmServer() { + stop(); + } + + bool start(const std::string & binary, int port) { + int pipefd[2]; + if (pipe(pipefd) != 0) { + return false; + } + pid_ = fork(); + if (pid_ < 0) { + close(pipefd[0]); + close(pipefd[1]); + return false; + } + if (pid_ == 0) { + dup2(pipefd[1], STDOUT_FILENO); + dup2(pipefd[1], STDERR_FILENO); + close(pipefd[0]); + close(pipefd[1]); + std::string port_str = std::to_string(port); + execl(binary.c_str(), binary.c_str(), "--port", port_str.c_str(), static_cast(nullptr)); + _exit(127); + } + close(pipefd[1]); + read_fd_ = pipefd[0]; + return wait_for_ready(15000); + } + + void stop() { + if (pid_ > 0) { + kill(pid_, SIGTERM); + int status = 0; + waitpid(pid_, &status, 0); + pid_ = -1; + } + if (read_fd_ >= 0) { + close(read_fd_); + read_fd_ = -1; + } + } + + private: + bool wait_for_ready(int timeout_ms) { + std::string acc; + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (std::chrono::steady_clock::now() < deadline) { + pollfd pfd{read_fd_, POLLIN, 0}; + int remaining = static_cast( + std::chrono::duration_cast(deadline - std::chrono::steady_clock::now()).count()); + int rc = poll(&pfd, 1, remaining > 0 ? remaining : 0); + if (rc <= 0) { + continue; + } + char buf[256]; + ssize_t n = read(read_fd_, buf, sizeof(buf)); + if (n <= 0) { + return false; // EOF: child died before READY + } + acc.append(buf, static_cast(n)); + if (acc.find("READY ") != std::string::npos) { + return true; + } + } + return false; + } + + pid_t pid_{-1}; + int read_fd_{-1}; +}; + +std::string fixture_binary() { + return std::string(MEDKIT_ALARM_SERVER_BIN); +} + +bool fixture_available() { + const std::string bin = fixture_binary(); + return !bin.empty() && access(bin.c_str(), X_OK) == 0; +} + +} // namespace + +// -- Fixture that boots the alarm server once per test -- + +class OpcuaIdentityE2ETest : public ::testing::Test { + protected: + void SetUp() override { + if (!fixture_available()) { + GTEST_SKIP() << "test_alarm_server fixture not built at " << fixture_binary(); + } + port_ = reserve_local_port(); + ASSERT_NE(port_, 0); + ASSERT_TRUE(server_.start(fixture_binary(), port_)) << "test_alarm_server did not signal READY"; + endpoint_ = "opc.tcp://127.0.0.1:" + std::to_string(port_); + // The fixture prints READY before the OPC-UA listen socket is fully + // accepting, so probe until a real connection succeeds. Once connectable it + // stays so, making every per-test connect (and the plugin's) race-free. + ASSERT_TRUE(wait_until_connectable()) << "fixture never became connectable at " << endpoint_; + } + + void TearDown() override { + server_.stop(); + } + + bool wait_until_connectable() { + for (int attempt = 0; attempt < 50; ++attempt) { + OpcuaClient probe; + OpcuaClientConfig config; + config.endpoint_url = endpoint_; + config.connect_timeout = std::chrono::milliseconds(1000); + if (probe.connect(config)) { + probe.disconnect(); + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + return false; + } + + AlarmServer server_; + int port_{0}; + std::string endpoint_; +}; + +TEST_F(OpcuaIdentityE2ETest, ClientReadsServerBuildInfo) { + OpcuaClient client; + OpcuaClientConfig config; + config.endpoint_url = endpoint_; + config.connect_timeout = std::chrono::milliseconds(5000); + ASSERT_TRUE(client.connect(config)); + + auto info = client.read_device_info(); + // The fixture pins explicit BuildInfo values. + EXPECT_EQ(info.manufacturer_name, "SelfPatch Test Manufacturer"); + EXPECT_EQ(info.product_name, "SelfPatch Test PLC"); + EXPECT_EQ(info.software_version, "1.2.3"); + EXPECT_EQ(info.build_number, "build-4567"); + client.disconnect(); +} + +TEST_F(OpcuaIdentityE2ETest, ClientReadsDiNameplate) { + OpcuaClient client; + OpcuaClientConfig config; + config.endpoint_url = endpoint_; + config.connect_timeout = std::chrono::milliseconds(5000); + ASSERT_TRUE(client.connect(config)); + + auto info = client.read_device_info(); + // The fixture exposes an OPC-UA DI DeviceSet nameplate. + EXPECT_EQ(info.di_manufacturer, "SelfPatch Devices"); + EXPECT_EQ(info.di_model, "SPX-1000"); + EXPECT_EQ(info.di_serial_number, "SN-0001-TEST"); + EXPECT_EQ(info.di_hardware_revision, "HW-A2"); + EXPECT_EQ(info.di_software_revision, "SW-3.4.5"); + client.disconnect(); +} + +TEST_F(OpcuaIdentityE2ETest, MappedIdentityFromLiveServer) { + OpcuaClient client; + OpcuaClientConfig config; + config.endpoint_url = endpoint_; + config.connect_timeout = std::chrono::milliseconds(5000); + ASSERT_TRUE(client.connect(config)); + + auto id = opcua_device_info_to_identity(client.read_device_info(), endpoint_); + // DI nameplate wins over BuildInfo for manufacturer / model / software. + EXPECT_EQ(id.manufacturer, "SelfPatch Devices"); + EXPECT_EQ(id.model, "SPX-1000"); + EXPECT_EQ(id.serial_number, "SN-0001-TEST"); + EXPECT_EQ(id.hardware_revision, "HW-A2"); + EXPECT_EQ(id.software_version, "SW-3.4.5"); + EXPECT_EQ(id.network_endpoint, endpoint_); + EXPECT_EQ(id.extra.at("buildNumber"), "build-4567"); + EXPECT_EQ(id.provenance.at("serial_number"), "opcua"); + EXPECT_EQ(id.provenance.at("network_endpoint"), "opcua"); + client.disconnect(); +} + +TEST_F(OpcuaIdentityE2ETest, PluginIntrospectPopulatesIdentity) { + // Minimal node map so introspect() can name the area / component. The single + // node points at a nonexistent address-space node; the poller's failed reads + // do not drop the connection (BadNodeIdUnknown != disconnect). + const std::string yaml_path = "/tmp/test_opcua_identity_nodemap.yaml"; + { + std::ofstream f(yaml_path); + f << R"( +area_id: test_plc +area_name: Test PLC Area +component_id: test_runtime +component_name: Test PLC Runtime +nodes: + - node_id: "ns=2;i=9999" + entity_id: tank + data_name: level + display_name: Tank Level + data_type: float + writable: false +)"; + } + + ros2_medkit_gateway::OpcuaPlugin plugin; + nlohmann::json config; + config["node_map_path"] = yaml_path; + config["endpoint_url"] = endpoint_; + plugin.configure(config); + + FakePluginContext ctx; + plugin.set_context(ctx); + + auto result = plugin.introspect(IntrospectionInput{}); + ASSERT_FALSE(result.new_entities.components.empty()); + + const auto & comp = result.new_entities.components.front(); + // The protocol tag survives the plugin layer (it only stamps empty sources) + // and gives the nameplate "opcua" precedence in the identity merge. + EXPECT_EQ(comp.source, "opcua"); + ASSERT_FALSE(comp.identity.empty()) << "identity should be filled from the OPC-UA device-info"; + EXPECT_EQ(comp.identity.manufacturer, "SelfPatch Devices"); + EXPECT_EQ(comp.identity.model, "SPX-1000"); + EXPECT_EQ(comp.identity.serial_number, "SN-0001-TEST"); + EXPECT_EQ(comp.identity.network_endpoint, endpoint_); + EXPECT_EQ(comp.identity.provenance.at("manufacturer"), "opcua"); + + // Serialized SOVD JSON carries the nameplate under x-medkit.identity. + auto j = comp.to_json(); + ASSERT_TRUE(j["x-medkit"].contains("identity")); + EXPECT_EQ(j["x-medkit"]["identity"]["serialNumber"], "SN-0001-TEST"); + EXPECT_EQ(j["x-medkit"]["identity"]["_provenance"]["manufacturer"], "opcua"); + + std::remove(yaml_path.c_str()); +} + +} // namespace ros2_medkit_gateway From 20fa6a0020c932d0245d6b635be67325facd5fdd Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 14:17:52 +0200 Subject: [PATCH 2/9] fix(opcua): re-point security policy loggers after client config move open62541 1.3 policies store the address of the config's embedded logger; moving the stack-built ClientConfig into the client leaves it dangling and the gateway segfaults on the first policy log line (SecureChannel setup). Refs #489 --- .../ros2_medkit_opcua/src/opcua_client.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp index a07cbac3a..f5e24ff93 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp @@ -442,6 +442,19 @@ bool apply_security_config(opcua::Client & client, const OpcuaClientConfig & cfg #endif } + // open62541 1.3 security policies store &config->logger, and the configs + // built above are moved into the client, leaving those pointers dangling + // (segfault on the first policy log line, e.g. SecureChannel creation). + // open62541pp re-points them only for <= 1.2; 1.4+ uses a heap logger. +#if defined(UA_OPEN62541_VER_MAJOR) && UA_OPEN62541_VER_MAJOR == 1 && UA_OPEN62541_VER_MINOR == 3 + { + UA_ClientConfig * final_config = client.config().handle(); + for (size_t i = 0; i < final_config->securityPoliciesSize; ++i) { + final_config->securityPolicies[i].logger = &final_config->logger; + } + } +#endif + // User identity token (applied regardless of channel encryption). switch (cfg.user_auth_mode) { case UserAuthMode::Anonymous: From a1700e6ecb868a2c65da3353572fcff911e06b53 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 14:42:13 +0200 Subject: [PATCH 3/9] opcua: address identity test and docs review findings Fail hard when the in-package test_alarm_server fixture is missing instead of skipping, include for std::remove, and document x-medkit.identity on component responses plus the manifest identity block. Refs #489 --- docs/api/rest.rst | 38 +++++++++++++++++++ docs/config/manifest-schema.rst | 23 +++++++++++ .../test/test_opcua_identity.cpp | 9 +++-- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 41b4a5c21..e5097da9d 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -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). diff --git a/docs/config/manifest-schema.rst b/docs/config/manifest-schema.rst index af0675a2a..5832c38a9 100644 --- a/docs/config/manifest-schema.rst +++ b/docs/config/manifest-schema.rst @@ -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 + : 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) @@ -309,6 +321,17 @@ 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. Exposed over REST as ``x-medkit.identity``. Common Component Types ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp index aa2489dc4..63521734d 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -259,9 +260,11 @@ bool fixture_available() { class OpcuaIdentityE2ETest : public ::testing::Test { protected: void SetUp() override { - if (!fixture_available()) { - GTEST_SKIP() << "test_alarm_server fixture not built at " << fixture_binary(); - } + // The fixture is built by this package's own CMake and is a declared + // dependency of this target; a missing binary means the build is broken, + // so fail hard instead of skipping (run_ctest.py does the same). + ASSERT_TRUE(fixture_available()) << "test_alarm_server fixture missing or not executable at '" << fixture_binary() + << "'"; port_ = reserve_local_port(); ASSERT_NE(port_, 0); ASSERT_TRUE(server_.start(fixture_binary(), port_)) << "test_alarm_server did not signal READY"; From d626c10d94911b0293c51f87776619fb2bdd7dd2 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 15:42:29 +0200 Subject: [PATCH 4/9] fix(ci): suppress clang-tidy in vendored headers matched by the header filter The quality job's -header-filter='.*ros2_medkit.*' matches vendored headers because their install paths contain the package name, so any TU including them (e.g. via lock_manager.hpp) fails with third-party warnings. Wrap them in NOLINTBEGIN/NOLINTEND. --- .../src/vendored/tl_expected/include/tl/expected.hpp | 3 +++ .../ros2_medkit_serialization/vendored/dynmsg/config.hpp | 3 +++ .../vendored/dynmsg/message_reading.hpp | 3 +++ .../ros2_medkit_serialization/vendored/dynmsg/msg_parser.hpp | 3 +++ .../ros2_medkit_serialization/vendored/dynmsg/string_utils.hpp | 3 +++ .../include/ros2_medkit_serialization/vendored/dynmsg/types.h | 3 +++ .../ros2_medkit_serialization/vendored/dynmsg/typesupport.hpp | 3 +++ .../ros2_medkit_serialization/vendored/dynmsg/vector_utils.hpp | 3 +++ .../ros2_medkit_serialization/vendored/dynmsg/yaml_utils.hpp | 3 +++ 9 files changed, 27 insertions(+) diff --git a/src/ros2_medkit_gateway/src/vendored/tl_expected/include/tl/expected.hpp b/src/ros2_medkit_gateway/src/vendored/tl_expected/include/tl/expected.hpp index 59e59aa1b..244e63b20 100644 --- a/src/ros2_medkit_gateway/src/vendored/tl_expected/include/tl/expected.hpp +++ b/src/ros2_medkit_gateway/src/vendored/tl_expected/include/tl/expected.hpp @@ -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) @@ -2473,3 +2475,4 @@ void swap(expected &lhs, } // namespace tl #endif +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/config.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/config.hpp index 56170e243..2bca4e043 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/config.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/config.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2021 Christophe Bedard // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -43,3 +45,4 @@ #endif // DYNMSG_PARSER_DEBUG #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__CONFIG_HPP_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/message_reading.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/message_reading.hpp index 0682bec84..dcd7a66da 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/message_reading.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/message_reading.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2020 Open Source Robotics Foundation, Inc. // Copyright 2021 Christophe Bedard // @@ -57,3 +59,4 @@ YAML::Node message_to_yaml(const RosMessage_Cpp & message); } // namespace dynmsg #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__MESSAGE_READING_HPP_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/msg_parser.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/msg_parser.hpp index ef29ab68a..912d44ed5 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/msg_parser.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/msg_parser.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2020 Open Source Robotics Foundation, Inc. // Copyright 2021 Christophe Bedard // @@ -86,3 +88,4 @@ void yaml_and_typeinfo_to_rosmsg( } // namespace dynmsg #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__MSG_PARSER_HPP_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/string_utils.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/string_utils.hpp index d242ddf0f..487c3db70 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/string_utils.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/string_utils.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2020 Open Source Robotics Foundation, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -27,3 +29,4 @@ std::string u16string_to_string(const std::u16string & input); } // extern "C" #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__STRING_UTILS_HPP_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/types.h b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/types.h index 49d45078d..283e2117e 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/types.h +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/types.h @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2020 Open Source Robotics Foundation, Inc. // Copyright 2021 Christophe Bedard // @@ -27,3 +29,4 @@ typedef rcutils_ret_t dynmsg_ret_t; #define DYNMSG_RET_ERROR RCUTILS_RET_ERROR #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__TYPES_H_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/typesupport.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/typesupport.hpp index 43aad97be..03584facb 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/typesupport.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/typesupport.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2020 Open Source Robotics Foundation, Inc. // Copyright 2021 Christophe Bedard // @@ -161,3 +163,4 @@ void ros_message_destroy_with_allocator(RosMessage_Cpp * ros_msg, rcutils_alloca } // namespace dynmsg #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__TYPESUPPORT_HPP_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/vector_utils.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/vector_utils.hpp index c60896061..0c1778837 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/vector_utils.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/vector_utils.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2021 Christophe Bedard // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -34,3 +36,4 @@ size_t get_vector_size(const uint8_t * vector, size_t element_size); } // namespace dynmsg #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__VECTOR_UTILS_HPP_ +// NOLINTEND diff --git a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/yaml_utils.hpp b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/yaml_utils.hpp index a5f8e92b7..e523eae6c 100644 --- a/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/yaml_utils.hpp +++ b/src/ros2_medkit_serialization/include/ros2_medkit_serialization/vendored/dynmsg/yaml_utils.hpp @@ -1,3 +1,5 @@ +// Vendored third-party header, excluded from clang-tidy lint (installed path matches the CI header filter). +// NOLINTBEGIN // Copyright 2021 Christophe Bedard // // Licensed under the Apache License, Version 2.0 (the "License"); @@ -40,3 +42,4 @@ std::string yaml_to_string( } // namespace dynmsg #endif // ROS2_MEDKIT_SERIALIZATION__VENDORED__DYNMSG__YAML_UTILS_HPP_ +// NOLINTEND From b1d2b2ad907a94d682c09460c2b4086a2abe5ba0 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 15:42:37 +0200 Subject: [PATCH 5/9] fix(test): raise flaky fault-surface deadline in secured OPC-UA test On slow CI runners the fired alarm does not always propagate through report -> fault_manager -> REST within 40s. Raise the wait to 120s and the CTest timeout to 420s so a failing run still prints diagnostics. Passing runs exit the poll early and are unaffected. --- src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt | 5 ++++- .../test/integration/test_opcua_secured.test.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt index 36c8f4f73..525619259 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt @@ -371,10 +371,13 @@ if(BUILD_TESTING) "${CMAKE_CURRENT_SOURCE_DIR}/test/integration/test_opcua_secured.test.py" "${CMAKE_BINARY_DIR}/test_alarm_server" "${CMAKE_CURRENT_SOURCE_DIR}/test/integration/gen_test_certs.sh") + # TIMEOUT must exceed the sum of the script's internal wait deadlines so a + # slow failing run still reaches its own FAIL diagnostics instead of being + # killed by CTest. set_tests_properties(test_opcua_secured PROPERTIES LABELS "integration" SKIP_RETURN_CODE 77 - TIMEOUT 300) + TIMEOUT 420) # INV2: end-to-end identity test. Boots test_alarm_server as a subprocess and # proves connect -> device-info read -> AssetIdentity via both OpcuaClient and diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/integration/test_opcua_secured.test.py b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/integration/test_opcua_secured.test.py index fa6b889c2..3498ea552 100755 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/integration/test_opcua_secured.test.py +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/integration/test_opcua_secured.test.py @@ -358,10 +358,13 @@ def main(): # Secured A&C: fire an alarm over the server stdin -> CONFIRMED fault. server.stdin.write('fire Overpressure 750\n') server.stdin.flush() + # Generous deadline for slow CI runners: the alarm has already fired on + # the server, but report -> fault_manager -> REST propagation can take + # a while under load. A longer deadline only slows the failure path. faults = wait_json( f'{base}/faults', lambda j: any(i.get('fault_code') == ALARM_CODE and i.get('status') == 'CONFIRMED' - for i in j.get('items', [])), deadline=40) + for i in j.get('items', [])), deadline=120) if not (faults and any(i.get('fault_code') == ALARM_CODE and i.get('status') == 'CONFIRMED' for i in faults.get('items', []))): print('FAIL: alarm did not surface as a CONFIRMED fault', file=sys.stderr) From fbd91b9a2e853e54eac8fbf77a473b9f6027ba15 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 16:39:51 +0200 Subject: [PATCH 6/9] fix(opcua): mark bundled open62541 include dirs as SYSTEM Only open62541pp's own interface dirs were marked SYSTEM; the bundled open62541 target's dirs stayed plain -I, so including a header like client_highlevel.h turns its old-style casts into hard clang-tidy errors via -Werror=old-style-cast. --- .../ros2_medkit_opcua/CMakeLists.txt | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt index 525619259..a83b79b90 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt @@ -74,19 +74,23 @@ fetchcontent_makeavailable(open62541pp) set_directory_properties(PROPERTIES COMPILE_OPTIONS "${_medkit_saved_compile_options}") unset(_medkit_saved_compile_options) -# Mark open62541pp's interface include directories as SYSTEM so consumers -# (our plugin and tests) do not propagate strict warnings into third-party -# header code. CMake propagates SYSTEM-ness only when the interface property -# is explicitly marked; we do that here on the open62541pp target so downstream -# target_link_libraries picks it up automatically. -if(TARGET open62541pp) - get_target_property(_op62_includes open62541pp INTERFACE_INCLUDE_DIRECTORIES) - if(_op62_includes) - set_target_properties(open62541pp PROPERTIES - INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${_op62_includes}") +# Mark the interface include directories of open62541pp AND the bundled +# open62541 target as SYSTEM so consumers (our plugin and tests) do not +# propagate strict warnings into third-party header code. CMake propagates +# SYSTEM-ness only when the interface property is explicitly marked. The +# open62541 target matters for clang-tidy: its headers (e.g. +# client_highlevel.h) contain old-style casts that -Werror=old-style-cast +# turns into hard errors when reached through a plain -I path. +foreach(_op62_target open62541pp open62541) + if(TARGET ${_op62_target}) + get_target_property(_op62_includes ${_op62_target} INTERFACE_INCLUDE_DIRECTORIES) + if(_op62_includes) + set_target_properties(${_op62_target} PROPERTIES + INTERFACE_SYSTEM_INCLUDE_DIRECTORIES "${_op62_includes}") + endif() + unset(_op62_includes) endif() - unset(_op62_includes) -endif() +endforeach() # ---- MODULE target: loaded via dlopen at runtime by PluginManager ---- # Symbols from ros2_medkit_gateway are resolved from the host process at runtime. From b3fbc7ff3abdaa0db8efbdae5dc596f8ad907b6d Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 16:40:00 +0200 Subject: [PATCH 7/9] fix(opcua): resolve clang-tidy findings in plugin sources Complete the special-member sets of OpcuaClient, OpcuaPlugin and OpcuaPoller with deleted moves, drop a no-effect std::move on the trivially copyable Subscription handle, build event-field Variants via the deep-copy constructor, and convert an index loop to range-for. --- .../include/ros2_medkit_opcua/opcua_client.hpp | 2 ++ .../include/ros2_medkit_opcua/opcua_plugin.hpp | 5 +++++ .../include/ros2_medkit_opcua/opcua_poller.hpp | 2 ++ .../ros2_medkit_opcua/src/opcua_client.cpp | 17 +++++++---------- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp index 466b3522f..726cba7b6 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp @@ -110,6 +110,8 @@ class OpcuaClient { OpcuaClient(const OpcuaClient &) = delete; OpcuaClient & operator=(const OpcuaClient &) = delete; + OpcuaClient(OpcuaClient &&) = delete; + OpcuaClient & operator=(OpcuaClient &&) = delete; /// Connect to OPC-UA server /// @return true if connected successfully diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp index c03b9c993..8888876ea 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp @@ -72,6 +72,11 @@ class OpcuaPlugin : public ros2_medkit_gateway::GatewayPlugin, OpcuaPlugin(); ~OpcuaPlugin() override; + OpcuaPlugin(const OpcuaPlugin &) = delete; + OpcuaPlugin & operator=(const OpcuaPlugin &) = delete; + OpcuaPlugin(OpcuaPlugin &&) = delete; + OpcuaPlugin & operator=(OpcuaPlugin &&) = delete; + // -- GatewayPlugin interface -- std::string name() const override { return "opcua"; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp index eb3f9712e..83b502a0e 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_poller.hpp @@ -130,6 +130,8 @@ class OpcuaPoller { OpcuaPoller(const OpcuaPoller &) = delete; OpcuaPoller & operator=(const OpcuaPoller &) = delete; + OpcuaPoller(OpcuaPoller &&) = delete; + OpcuaPoller & operator=(OpcuaPoller &&) = delete; /// Start the poller thread void start(const PollerConfig & config); diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp index f5e24ff93..332fc61ce 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp @@ -778,7 +778,7 @@ uint32_t OpcuaClient::create_subscription(double publish_interval_ms, DataChange uint32_t sub_id = sub.subscriptionId(); std::lock_guard sub_lock(impl_->sub_mutex); - impl_->subscriptions.push_back({std::move(sub), std::move(callback)}); + impl_->subscriptions.push_back({sub, std::move(callback)}); return sub_id; } catch (const opcua::BadStatus &) { @@ -1309,16 +1309,13 @@ static void on_event_trampoline_c(UA_Client * /*client*/, UA_UInt32 sub_id, void return; } - // Copy the UA_Variant fields into open62541pp wrappers. UA_Variant_copy - // duplicates the underlying buffer, which the opcua::Variant destructor - // will free. + // Copy the UA_Variant fields into open62541pp wrappers. The Variant + // lvalue constructor deep-copies the underlying buffer, which the + // opcua::Variant destructor will free. std::vector values; values.reserve(n_fields); for (size_t i = 0; i < n_fields; ++i) { - UA_Variant copy; - UA_Variant_init(©); - UA_Variant_copy(&fields[i], ©); - values.emplace_back(opcua::Variant{std::move(copy)}); + values.emplace_back(opcua::Variant{fields[i]}); } // Auto-prepended positions (matching make_event_filter): @@ -1540,8 +1537,8 @@ OpcuaClient::call_method(const opcua::NodeId & object_id, const opcua::NodeId & auto arg_results = result.getInputArgumentResults(); std::vector arg_codes; arg_codes.reserve(arg_results.size()); - for (size_t i = 0; i < arg_results.size(); ++i) { - arg_codes.push_back(arg_results[i].get()); + for (const auto & arg_result : arg_results) { + arg_codes.push_back(arg_result.get()); } if (client_debug_enabled()) { // RCLCPP_DEBUG_STREAM constructs its std::stringstream unconditionally, From 3ba16fe774b3762342e4704ad0a0dd56d24d7862 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Thu, 2 Jul 2026 22:59:03 +0200 Subject: [PATCH 8/9] opcua: address identity review findings (trust gate, reconnect, paging) Re-read the nameplate per session via a connection generation counter, gate the opcua source tag on an authenticated channel (plugin ranks below manifest for unauthenticated reads), follow BrowseNext continuation points, and document x-medkit.identity in the README. Refs #489 --- docs/config/manifest-schema.rst | 5 +- .../core/discovery/identity_merge.hpp | 29 +-- .../test/test_asset_identity.cpp | 21 +++ .../ros2_medkit_opcua/CMakeLists.txt | 4 +- .../ros2_medkit_opcua/README.md | 42 +++++ .../ros2_medkit_opcua/device_identity.hpp | 13 ++ .../ros2_medkit_opcua/opcua_client.hpp | 6 + .../ros2_medkit_opcua/opcua_plugin.hpp | 11 +- .../ros2_medkit_opcua/src/device_identity.cpp | 4 + .../ros2_medkit_opcua/src/opcua_client.cpp | 75 +++++++- .../ros2_medkit_opcua/src/opcua_plugin.cpp | 37 ++-- .../test_alarm_server/test_alarm_server.cpp | 26 ++- .../test/test_device_identity.cpp | 33 ++++ .../test/test_opcua_identity.cpp | 170 ++++++++++++++++-- 14 files changed, 420 insertions(+), 56 deletions(-) diff --git a/docs/config/manifest-schema.rst b/docs/config/manifest-schema.rst index 5832c38a9..0c40eedc5 100644 --- a/docs/config/manifest-schema.rst +++ b/docs/config/manifest-schema.rst @@ -331,7 +331,10 @@ Fields 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. Exposed over REST as ``x-medkit.identity``. + 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 ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/identity_merge.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/identity_merge.hpp index 1dc39aefe..da10451a2 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/identity_merge.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/identity_merge.hpp @@ -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 source_precedence{"opcua", "s7", "ethernet_ip", "modbus", "ads", - "profinet", "plugin", "manifest", "inventory", "config", - "runtime", "node", "topic", "heuristic"}; + std::vector source_precedence{"opcua", "s7", "ethernet_ip", "modbus", "ads", + "profinet", "manifest", "inventory", "config", "plugin", + "runtime", "node", "topic", "heuristic"}; }; /** diff --git a/src/ros2_medkit_gateway/test/test_asset_identity.cpp b/src/ros2_medkit_gateway/test/test_asset_identity.cpp index 1596e9d1f..f83545164 100644 --- a/src/ros2_medkit_gateway/test/test_asset_identity.cpp +++ b/src/ros2_medkit_gateway/test/test_asset_identity.cpp @@ -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; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt index a83b79b90..e73e05f89 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt @@ -245,10 +245,12 @@ if(BUILD_TESTING) ) medkit_set_test_domain(test_alarm_state_machine) - # INV2: pure device-info -> AssetIdentity mapping tests (no server). + # INV2: device-info -> AssetIdentity mapping + identity trust-gate tests. + # No server; opcua_client.cpp is link-only (requires_secure_channel). ament_add_gtest(test_device_identity test/test_device_identity.cpp src/device_identity.cpp + src/opcua_client.cpp ) target_include_directories(test_device_identity PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md index ea41353a5..704c9973e 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/README.md @@ -56,6 +56,48 @@ Area: plc_systems Operations: set_valve_position ``` +## Asset identity (x-medkit.identity) + +The plugin auto-populates the PLC component's asset-identity nameplate from the +live server and serves it as `x-medkit.identity` on the component (see +`docs/api/rest.rst` for the JSON shape). Two sources are read, best-effort: + +- `Server/ServerStatus/BuildInfo` (present on every compliant server): + `ManufacturerName`, `ProductName`, `SoftwareVersion`, `BuildNumber` (carried + as the `extra.buildNumber` key). +- The OPC UA DI companion nameplate (`http://opcfoundation.org/UA/DI/`, only + when the server exposes that namespace): `Manufacturer`, `Model`, + `SerialNumber`, `HardwareRevision`, `SoftwareRevision` from the first device + under `Objects/DeviceSet` that carries identification properties. DI values + are device-specific and win over the server-level BuildInfo per field. + +The read happens once per session - on the first introspect after a connect - +and is refreshed after every reconnect: the cached nameplate is tied to the +OPC UA session, so a PLC reboot, firmware update, or device swap is picked up +on the next discovery refresh instead of latching the first read forever. + +**Trust-gated precedence.** How the live nameplate ranks against a +hand-authored manifest `identity:` block depends on whether the session +authenticates the server: + +- Secured channel (`security_mode: Sign`/`SignAndEncrypt`) **and** + `reject_untrusted: true`: the component is tagged with the protocol source + `opcua`, which outranks `manifest` in the identity merge - an authenticated + live read overrides stale manifest fields. +- Anything else (`SecurityPolicy=None`, or `reject_untrusted: false` / + accept-any cert): the nameplate is spoofable by a rogue endpoint, so the + component keeps the generic `plugin` source, which ranks below `manifest` - + the read fills fields the operator left empty but never overrides them. + +Per-field `_provenance` records `opcua` in both cases, so consumers always see +where a value was read even when it merged with low authority. + +Note: identity (model, serial number, firmware/software versions) is served +unauthenticated on the default gateway configuration (`auth.enabled` defaults +to `false`) like every other discovery resource - it is a device fingerprint, +useful for reconnaissance. Enable gateway authentication (`auth.enabled: true`) +or front the API with an authenticating proxy to gate access to it. + ## REST API ### Vendor Endpoints diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp index 74f8c68ec..1addf2a72 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/device_identity.hpp @@ -33,4 +33,17 @@ namespace ros2_medkit_gateway { /// server exposes no device-info at all. AssetIdentity opcua_device_info_to_identity(const OpcuaClient::DeviceInfo & info, const std::string & endpoint_url); +/// Whether a live nameplate read over this connection may outrank the +/// operator-authored manifest in the identity merge. +/// +/// True only when the server's identity is actually authenticated: a secured +/// SecureChannel (Sign / SignAndEncrypt) AND certificate validation against the +/// trust list (``reject_untrusted``). An unsecured channel, or a secured one +/// with ``reject_untrusted: false`` (accept-any cert), can be served by a rogue +/// endpoint, so its nameplate must not override the manifest - the plugin then +/// tags the component with the generic "plugin" source, which ranks below +/// "manifest" (it fills gaps but never overrides). Per-field provenance stays +/// "opcua" either way for transparency about where a value was read. +bool opcua_identity_trusted(const OpcuaClientConfig & config); + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp index 726cba7b6..7358b4790 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_client.hpp @@ -123,6 +123,12 @@ class OpcuaClient { /// Check if currently connected bool is_connected() const; + /// Number of successfully established sessions so far (0 before the first + /// connect). Increments once per fresh connect, NOT on a connect() call that + /// finds the session already open. Lets callers detect a reconnect and + /// refresh per-session state (e.g. re-read the device-info nameplate). + uint64_t connection_generation() const; + /// Get the endpoint URL (for status reporting) std::string endpoint_url() const; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp index 8888876ea..2656fc295 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/include/ros2_medkit_opcua/opcua_plugin.hpp @@ -148,11 +148,14 @@ class OpcuaPlugin : public ros2_medkit_gateway::GatewayPlugin, std::unique_ptr client_; NodeMap node_map_; - // INV2: asset-identity nameplate read once from the server's device-info - // (ServerStatus/BuildInfo + optional OPC-UA DI nameplate) on the first - // connected introspect, then reused. Empty until a successful read. + // INV2: asset-identity nameplate read once per session from the server's + // device-info (ServerStatus/BuildInfo + optional OPC-UA DI nameplate) on the + // first connected introspect, then reused until the client reconnects. + // device_identity_generation_ stores the OpcuaClient::connection_generation + // the nameplate was read at (0 = never read), so a poller reconnect triggers + // a fresh read on the next introspect without hammering the server. AssetIdentity device_identity_; - bool device_identity_loaded_{false}; + uint64_t device_identity_generation_{0}; // ROS 2 service clients for fault reporting struct FaultClients; diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp index 21aa91b14..050d4bf06 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/device_identity.cpp @@ -50,4 +50,8 @@ AssetIdentity opcua_device_info_to_identity(const OpcuaClient::DeviceInfo & info return identity; } +bool opcua_identity_trusted(const OpcuaClientConfig & config) { + return OpcuaClient::requires_secure_channel(config) && config.reject_untrusted; +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp index 332fc61ce..2079281b8 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp @@ -222,6 +222,12 @@ struct OpcuaClient::Impl { // late-arriving events from a defunct subscription cannot reach user code. std::atomic generation{0}; + // Successful-session counter: bumped once per fresh connect, never on the + // already-connected fast path. Exposed via connection_generation() so the + // plugin can detect a poller reconnect and refresh per-session state + // (device-info nameplate) without re-reading on every introspect. + std::atomic connect_generation{0}; + // Heap-owned contexts for raw-C event monitored items. unique_ptr keeps // the ctx alive exactly as long as the entry sits in the map; we release // entries only after the server ACKs DeleteMonitoredItem (or when we tear @@ -504,6 +510,7 @@ bool OpcuaClient::connect(const OpcuaClientConfig & config) { impl_->client.config().setTimeout(static_cast(config.connect_timeout.count())); impl_->client.connect(config.endpoint_url); impl_->connected = true; + impl_->connect_generation.fetch_add(1, std::memory_order_release); try { opcua::Node node(impl_->client, opcua::NodeId(0, UA_NS0ID_SERVER_SERVERSTATUS_BUILDINFO_PRODUCTNAME)); @@ -569,6 +576,10 @@ bool OpcuaClient::is_connected() const { return impl_->connected.load(); } +uint64_t OpcuaClient::connection_generation() const { + return impl_->connect_generation.load(std::memory_order_acquire); +} + OpcuaClientConfig OpcuaClient::current_config() const { std::lock_guard lock(impl_->client_mutex); return impl_->config; @@ -1090,8 +1101,29 @@ void clear_child_refs(std::vector & children) { } } +// Append the references of one browse result to ``children`` and hand back its +// continuation point (owned copy; UA_BYTESTRING_NULL when the result is done). +UA_ByteString collect_browse_result(const UA_BrowseResult & result, std::vector & children) { + for (size_t i = 0; i < result.referencesSize; ++i) { + const UA_ReferenceDescription & ref = result.references[i]; + ChildRef child; + child.namespace_index = ref.browseName.namespaceIndex; + child.name.assign(reinterpret_cast(ref.browseName.name.data), ref.browseName.name.length); + UA_NodeId_copy(&ref.nodeId.nodeId, &child.node_id); + children.push_back(std::move(child)); + } + UA_ByteString continuation = UA_BYTESTRING_NULL; + if (result.continuationPoint.length > 0) { + UA_ByteString_copy(&result.continuationPoint, &continuation); + } + return continuation; +} + // Browse forward hierarchical children of ``parent``, returning their browse // names and NodeIds. Empty on failure. The caller must ``clear_child_refs``. +// Follows BrowseNext continuation points so a server that caps references per +// browse (OperationLimits.MaxReferencesPerNode) cannot silently truncate a +// large folder (e.g. a DeviceSet with many devices). std::vector browse_forward_children(UA_Client * client, const UA_NodeId & parent) { std::vector children; UA_BrowseRequest request; @@ -1108,20 +1140,45 @@ std::vector browse_forward_children(UA_Client * client, const UA_NodeI request.nodesToBrowse[0].includeSubtypes = true; request.nodesToBrowse[0].resultMask = UA_BROWSERESULTMASK_BROWSENAME; + UA_ByteString continuation = UA_BYTESTRING_NULL; UA_BrowseResponse response = UA_Client_Service_browse(client, request); if (response.responseHeader.serviceResult == UA_STATUSCODE_GOOD && response.resultsSize == 1) { - const UA_BrowseResult & result = response.results[0]; - for (size_t i = 0; i < result.referencesSize; ++i) { - const UA_ReferenceDescription & ref = result.references[i]; - ChildRef child; - child.namespace_index = ref.browseName.namespaceIndex; - child.name.assign(reinterpret_cast(ref.browseName.name.data), ref.browseName.name.length); - UA_NodeId_copy(&ref.nodeId.nodeId, &child.node_id); - children.push_back(std::move(child)); - } + continuation = collect_browse_result(response.results[0], children); } UA_BrowseResponse_clear(&response); UA_BrowseRequest_clear(&request); + + // Iteration cap guards against a misbehaving server that keeps returning a + // continuation point; the server releases the point itself when it returns + // the final (empty-continuation) batch. + for (int iteration = 0; continuation.length > 0 && iteration < 1000; ++iteration) { + UA_BrowseNextRequest next_request; + UA_BrowseNextRequest_init(&next_request); + next_request.releaseContinuationPoints = false; + next_request.continuationPoints = &continuation; // borrowed; freed manually below + next_request.continuationPointsSize = 1; + + UA_BrowseNextResponse next_response = UA_Client_Service_browseNext(client, next_request); + UA_ByteString next_continuation = UA_BYTESTRING_NULL; + if (next_response.responseHeader.serviceResult == UA_STATUSCODE_GOOD && next_response.resultsSize == 1) { + next_continuation = collect_browse_result(next_response.results[0], children); + } + UA_BrowseNextResponse_clear(&next_response); + UA_ByteString_clear(&continuation); + continuation = next_continuation; + } + if (continuation.length > 0) { + // Early exit (iteration cap): release the server-side continuation point + // instead of leaking it until session teardown. Best effort. + UA_BrowseNextRequest release_request; + UA_BrowseNextRequest_init(&release_request); + release_request.releaseContinuationPoints = true; + release_request.continuationPoints = &continuation; + release_request.continuationPointsSize = 1; + UA_BrowseNextResponse release_response = UA_Client_Service_browseNext(client, release_request); + UA_BrowseNextResponse_clear(&release_response); + UA_ByteString_clear(&continuation); + } return children; } diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp index 7d4f9300c..341831737 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp @@ -417,23 +417,32 @@ IntrospectionResult OpcuaPlugin::introspect(const IntrospectionInput & /*input*/ comp.namespace_path = "/" + node_map_.area_id(); comp.fqn = "/" + node_map_.area_id() + "/" + node_map_.component_id(); comp.area = node_map_.area_id(); - // Protocol tag, preserved by the plugin layer (it only stamps "plugin" on - // empty sources), so the identity merge ranks this component's nameplate at - // "opcua" precedence instead of the generic "plugin". - comp.source = "opcua"; + // Identity authority is gated on channel trust: only an authenticated + // session (secured channel + certificate validation) gets the protocol tag + // "opcua", which outranks the operator manifest in the identity merge. An + // unauthenticated session (None channel or accept-any cert) could be a rogue + // server spoofing the nameplate, so it gets the generic "plugin" tag, which + // ranks below "manifest" (fills gaps, never overrides). Both tags survive + // the plugin layer (it only stamps "plugin" on empty sources); per-field + // provenance stays "opcua" for transparency in both cases. + comp.source = opcua_identity_trusted(client_config_) ? "opcua" : "plugin"; comp.description = "PLC runtime connected at " + client_config_.endpoint_url; // INV2: fill the asset-identity nameplate from the live server's device-info - // (ServerStatus/BuildInfo + optional OPC-UA DI nameplate). Read once on the - // first connected introspect and cached, since it is stable for a connection. - // Provenance is stamped "opcua" per field (in the mapping) so a live device - // read outranks a hand-authored manifest in the identity merge. - if (client_ && client_->is_connected() && !device_identity_loaded_) { - device_identity_ = opcua_device_info_to_identity(client_->read_device_info(), client_config_.endpoint_url); - device_identity_loaded_ = true; - if (!device_identity_.empty()) { - log_info("Populated asset identity from OPC-UA device-info (manufacturer='" + device_identity_.manufacturer + - "', model='" + device_identity_.model + "')"); + // (ServerStatus/BuildInfo + optional OPC-UA DI nameplate). Read once per + // session (it is stable for a connection) and refreshed after a reconnect: + // the poller reestablishes the session in the background, and a server that + // registers its DI namespace or fills BuildInfo lazily after boot would + // otherwise latch whatever the first read got. + if (client_ && client_->is_connected()) { + const uint64_t session_generation = client_->connection_generation(); + if (session_generation != device_identity_generation_) { + device_identity_ = opcua_device_info_to_identity(client_->read_device_info(), client_config_.endpoint_url); + device_identity_generation_ = session_generation; + if (!device_identity_.empty()) { + log_info("Populated asset identity from OPC-UA device-info (manufacturer='" + device_identity_.manufacturer + + "', model='" + device_identity_.model + "')"); + } } } if (!device_identity_.empty()) { diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp index 84b2fa213..9c491755d 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/fixtures/test_alarm_server/test_alarm_server.cpp @@ -498,7 +498,9 @@ void set_build_info(UA_ServerConfig * config) { // INV2: expose a minimal OPC-UA DI nameplate (Objects/DeviceSet/TestDevice with // the standard identification properties) so the identity read exercises the DI // path (SerialNumber / HardwareRevision) that ServerStatus/BuildInfo lacks. -void add_di_nameplate(UA_Server * server) { +// ``serial`` is overridable (--serial) so a restarted fixture can present a +// different nameplate, proving the client re-reads identity per session. +void add_di_nameplate(UA_Server * server, const std::string & serial) { UA_UInt16 di_ns = UA_Server_addNamespace(server, "http://opcfoundation.org/UA/DI/"); UA_NodeId device_set_id = UA_NODEID_NULL; @@ -543,9 +545,9 @@ void add_di_nameplate(UA_Server * server) { UA_Variant_setScalar(&v_model, &model, &UA_TYPES[UA_TYPES_LOCALIZEDTEXT]); add_prop("Model", &UA_TYPES[UA_TYPES_LOCALIZEDTEXT], v_model); - UA_String serial = UA_STRING(const_cast("SN-0001-TEST")); + UA_String serial_value = UA_STRING(const_cast(serial.c_str())); UA_Variant v_serial; - UA_Variant_setScalar(&v_serial, &serial, &UA_TYPES[UA_TYPES_STRING]); + UA_Variant_setScalar(&v_serial, &serial_value, &UA_TYPES[UA_TYPES_STRING]); add_prop("SerialNumber", &UA_TYPES[UA_TYPES_STRING], v_serial); UA_String hardware = UA_STRING(const_cast("HW-A2")); @@ -676,9 +678,18 @@ int main(int argc, char ** argv) { bool secure = false; std::string cert_path, key_path, trust_path, username = "medkit", password = "secret"; std::string app_uri = "urn:test:alarms:server"; + std::string di_serial = "SN-0001-TEST"; + UA_UInt32 max_refs_per_node = 0; // 0 = server default (unlimited) for (int i = 1; i < argc; ++i) { if (std::strcmp(argv[i], "--port") == 0 && i + 1 < argc) { port = static_cast(std::atoi(argv[++i])); + } else if (std::strcmp(argv[i], "--serial") == 0 && i + 1 < argc) { + di_serial = argv[++i]; + } else if (std::strcmp(argv[i], "--max-refs-per-node") == 0 && i + 1 < argc) { + // Caps references per Browse result so every larger browse pages via + // BrowseNext continuation points (regression fixture for the client's + // continuation-point handling). + max_refs_per_node = static_cast(std::atoi(argv[++i])); } else if (std::strcmp(argv[i], "--secure") == 0) { secure = true; } else if (std::strcmp(argv[i], "--cert") == 0 && i + 1 < argc) { @@ -723,7 +734,7 @@ int main(int argc, char ** argv) { } // INV2: standard device-info nameplate for the identity integration test. - add_di_nameplate(server); + add_di_nameplate(server, di_serial); Condition op, oh, sl; if (add_condition(server, "Overpressure", ns, op) != UA_STATUSCODE_GOOD || @@ -747,6 +758,13 @@ int main(int argc, char ** argv) { return 1; } + // Apply the browse cap only AFTER all nodes exist: open62541's own AddNode / + // createCondition machinery browses internally and fails with + // BadNoContinuationPoints when the cap is active during setup. + if (max_refs_per_node > 0) { + config->maxReferencesPerNode = max_refs_per_node; + } + std::cout << "READY port=" << port << " namespace=" << ns << " secure=" << (secure ? "true" : "false") << std::endl; std::thread cli(cli_loop, server, ns); diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp index 397506f25..637637428 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_device_identity.cpp @@ -107,4 +107,37 @@ TEST(DeviceIdentityMap, PartialDiFallsBackToBuildInfoPerField) { EXPECT_EQ(id.hardware_revision, "HW-1"); // DI } +// Trust gate for identity authority: only a secured channel WITH certificate +// validation lets the live nameplate outrank the operator manifest. +TEST(DeviceIdentityTrust, DefaultUnsecuredConfigIsUntrusted) { + OpcuaClientConfig config; // SecurityPolicy=None, MessageSecurityMode=None + EXPECT_FALSE(opcua_identity_trusted(config)); +} + +TEST(DeviceIdentityTrust, SecuredChannelWithCertValidationIsTrusted) { + OpcuaClientConfig config; + config.security_policy = SecurityPolicy::Basic256Sha256; + config.security_mode = SecurityMode::SignAndEncrypt; + config.reject_untrusted = true; + EXPECT_TRUE(opcua_identity_trusted(config)); +} + +TEST(DeviceIdentityTrust, AcceptAnyCertIsUntrustedEvenWhenEncrypted) { + // Encrypted but accept-any server certificate: a rogue endpoint is accepted + // on every connect, so the channel does not authenticate the server. + OpcuaClientConfig config; + config.security_policy = SecurityPolicy::Basic256Sha256; + config.security_mode = SecurityMode::SignAndEncrypt; + config.reject_untrusted = false; + EXPECT_FALSE(opcua_identity_trusted(config)); +} + +TEST(DeviceIdentityTrust, CertValidationWithoutSecureChannelIsUntrusted) { + // reject_untrusted alone means nothing on a None channel: no server + // certificate is exchanged, so there is nothing to validate. + OpcuaClientConfig config; + config.reject_untrusted = true; + EXPECT_FALSE(opcua_identity_trusted(config)); +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp index 63521734d..4e7a00970 100644 --- a/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp +++ b/src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_opcua_identity.cpp @@ -177,7 +177,7 @@ class AlarmServer { stop(); } - bool start(const std::string & binary, int port) { + bool start(const std::string & binary, int port, const std::vector & extra_args = {}) { int pipefd[2]; if (pipe(pipefd) != 0) { return false; @@ -194,7 +194,12 @@ class AlarmServer { close(pipefd[0]); close(pipefd[1]); std::string port_str = std::to_string(port); - execl(binary.c_str(), binary.c_str(), "--port", port_str.c_str(), static_cast(nullptr)); + std::vector argv_vec{binary.c_str(), "--port", port_str.c_str()}; + for (const auto & arg : extra_args) { + argv_vec.push_back(arg.c_str()); + } + argv_vec.push_back(nullptr); + execv(binary.c_str(), const_cast(argv_vec.data())); _exit(127); } close(pipefd[1]); @@ -294,6 +299,17 @@ class OpcuaIdentityE2ETest : public ::testing::Test { return false; } + // Tear the fixture down and boot a fresh instance on the SAME port with + // different CLI args (e.g. a new --serial). Simulates a PLC reboot / + // device swap for the per-session identity refresh test. + bool restart_server(const std::vector & extra_args) { + server_.stop(); + if (!server_.start(fixture_binary(), port_, extra_args)) { + return false; + } + return wait_until_connectable(); + } + AlarmServer server_; int port_{0}; std::string endpoint_; @@ -353,14 +369,15 @@ TEST_F(OpcuaIdentityE2ETest, MappedIdentityFromLiveServer) { client.disconnect(); } -TEST_F(OpcuaIdentityE2ETest, PluginIntrospectPopulatesIdentity) { - // Minimal node map so introspect() can name the area / component. The single - // node points at a nonexistent address-space node; the poller's failed reads - // do not drop the connection (BadNodeIdUnknown != disconnect). +namespace { + +// Minimal node map so introspect() can name the area / component. The single +// node points at a nonexistent address-space node; the poller's failed reads +// do not drop the connection (BadNodeIdUnknown != disconnect). +std::string write_minimal_node_map() { const std::string yaml_path = "/tmp/test_opcua_identity_nodemap.yaml"; - { - std::ofstream f(yaml_path); - f << R"( + std::ofstream f(yaml_path); + f << R"( area_id: test_plc area_name: Test PLC Area component_id: test_runtime @@ -373,7 +390,13 @@ component_name: Test PLC Runtime data_type: float writable: false )"; - } + return yaml_path; +} + +} // namespace + +TEST_F(OpcuaIdentityE2ETest, PluginIntrospectPopulatesIdentity) { + const std::string yaml_path = write_minimal_node_map(); ros2_medkit_gateway::OpcuaPlugin plugin; nlohmann::json config; @@ -388,9 +411,11 @@ component_name: Test PLC Runtime ASSERT_FALSE(result.new_entities.components.empty()); const auto & comp = result.new_entities.components.front(); - // The protocol tag survives the plugin layer (it only stamps empty sources) - // and gives the nameplate "opcua" precedence in the identity merge. - EXPECT_EQ(comp.source, "opcua"); + // The fixture session is unsecured (SecurityPolicy=None), so the component + // gets the generic "plugin" tag: the spoofable nameplate may fill gaps in an + // operator manifest but never override it. Per-field provenance still says + // "opcua" so the read origin stays visible. + EXPECT_EQ(comp.source, "plugin"); ASSERT_FALSE(comp.identity.empty()) << "identity should be filled from the OPC-UA device-info"; EXPECT_EQ(comp.identity.manufacturer, "SelfPatch Devices"); EXPECT_EQ(comp.identity.model, "SPX-1000"); @@ -407,4 +432,123 @@ component_name: Test PLC Runtime std::remove(yaml_path.c_str()); } +TEST_F(OpcuaIdentityE2ETest, PluginSourceTagIsTrustGated) { + const std::string yaml_path = write_minimal_node_map(); + + // Secured + certificate-validated profile: the protocol tag "opcua" is + // stamped, giving the nameplate authority over the manifest. The cert paths + // do not exist, so the connect fails fast without contacting a server - the + // source tag is decided by configuration, not by connection state. + ros2_medkit_gateway::OpcuaPlugin plugin; + nlohmann::json config; + config["node_map_path"] = yaml_path; + config["endpoint_url"] = endpoint_; + config["security_policy"] = "Basic256Sha256"; + config["security_mode"] = "SignAndEncrypt"; + config["client_cert_path"] = "/nonexistent/client_cert.der"; + config["client_key_path"] = "/nonexistent/client_key.pem"; + config["reject_untrusted"] = true; + plugin.configure(config); + + FakePluginContext ctx; + plugin.set_context(ctx); + + auto result = plugin.introspect(IntrospectionInput{}); + ASSERT_FALSE(result.new_entities.components.empty()); + EXPECT_EQ(result.new_entities.components.front().source, "opcua"); + + // Same secured profile but accept-any server cert: a rogue endpoint would be + // accepted, so the identity authority drops back to the generic "plugin" tag. + ros2_medkit_gateway::OpcuaPlugin accept_any_plugin; + config["reject_untrusted"] = false; + accept_any_plugin.configure(config); + FakePluginContext ctx2; + accept_any_plugin.set_context(ctx2); + auto accept_any_result = accept_any_plugin.introspect(IntrospectionInput{}); + ASSERT_FALSE(accept_any_result.new_entities.components.empty()); + EXPECT_EQ(accept_any_result.new_entities.components.front().source, "plugin"); + + std::remove(yaml_path.c_str()); +} + +TEST_F(OpcuaIdentityE2ETest, IdentityRefreshedAfterReconnect) { + const std::string yaml_path = write_minimal_node_map(); + + ros2_medkit_gateway::OpcuaPlugin plugin; + nlohmann::json config; + config["node_map_path"] = yaml_path; + config["endpoint_url"] = endpoint_; + plugin.configure(config); + + FakePluginContext ctx; + plugin.set_context(ctx); + + auto result = plugin.introspect(IntrospectionInput{}); + ASSERT_FALSE(result.new_entities.components.empty()); + ASSERT_EQ(result.new_entities.components.front().identity.serial_number, "SN-0001-TEST"); + + // Reboot the "PLC" on the same port with a different nameplate. The plugin's + // poller detects the drop and reconnects in the background; the next + // introspect on the new session must re-read the device-info instead of + // serving the value latched from the first session. + ASSERT_TRUE(restart_server({"--serial", "SN-0002-RECONNECT"})); + + std::string observed_serial; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); + while (std::chrono::steady_clock::now() < deadline) { + auto refreshed = plugin.introspect(IntrospectionInput{}); + ASSERT_FALSE(refreshed.new_entities.components.empty()); + observed_serial = refreshed.new_entities.components.front().identity.serial_number; + if (observed_serial == "SN-0002-RECONNECT") { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + EXPECT_EQ(observed_serial, "SN-0002-RECONNECT") << "identity not refreshed after reconnect"; + + std::remove(yaml_path.c_str()); +} + +TEST_F(OpcuaIdentityE2ETest, ClientConnectionGenerationCountsSessions) { + OpcuaClient client; + OpcuaClientConfig config; + config.endpoint_url = endpoint_; + config.connect_timeout = std::chrono::milliseconds(5000); + + EXPECT_EQ(client.connection_generation(), 0u); + ASSERT_TRUE(client.connect(config)); + EXPECT_EQ(client.connection_generation(), 1u); + // Re-connect on an already-open session is not a new session. + ASSERT_TRUE(client.connect(config)); + EXPECT_EQ(client.connection_generation(), 1u); + + client.disconnect(); + ASSERT_TRUE(client.connect(config)); + EXPECT_EQ(client.connection_generation(), 2u); + client.disconnect(); +} + +TEST_F(OpcuaIdentityE2ETest, DiNameplateReadFollowsBrowseContinuationPoints) { + // Cap the server at 2 references per Browse result: every folder on the DI + // nameplate path (ObjectsFolder, DeviceSet, TestDevice) now pages through + // BrowseNext continuation points. Without continuation handling the + // DeviceSet lookup truncates after the first two ObjectsFolder children and + // the DI fields come back empty. + ASSERT_TRUE(restart_server({"--max-refs-per-node", "2"})); + + OpcuaClient client; + OpcuaClientConfig config; + config.endpoint_url = endpoint_; + config.connect_timeout = std::chrono::milliseconds(5000); + ASSERT_TRUE(client.connect(config)); + + auto info = client.read_device_info(); + EXPECT_EQ(info.di_manufacturer, "SelfPatch Devices"); + EXPECT_EQ(info.di_model, "SPX-1000"); + EXPECT_EQ(info.di_serial_number, "SN-0001-TEST"); + EXPECT_EQ(info.di_hardware_revision, "HW-A2"); + EXPECT_EQ(info.di_software_revision, "SW-3.4.5"); + client.disconnect(); +} + } // namespace ros2_medkit_gateway From 33b391329123e20b5247bc8b77d1b4ec698f5677 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Fri, 3 Jul 2026 10:45:12 +0200 Subject: [PATCH 9/9] test(opcua): suppress benign open62541 mktime tz race under TSan open62541's UA_DateTime_localTimeUtcOffset calls glibc mktime(), whose first-call tzset() lazily inits the process-global tz cache unsynchronised. The poller and a second client race on that one-time init; narrow the suppression to the vendored/glibc frame (same class as the rcl logging one). --- tsan_suppressions.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tsan_suppressions.txt b/tsan_suppressions.txt index f78177770..236485a95 100644 --- a/tsan_suppressions.txt +++ b/tsan_suppressions.txt @@ -27,6 +27,16 @@ race:rclcpp::signal_handler race:rcl_logging_* race:rcutils_logging_* +# open62541 (vendored) UA_DateTime_localTimeUtcOffset calls glibc mktime(), +# whose first-call tzset() lazily initialises the process-global timezone +# cache without synchronisation. When the OPC UA poller thread and another +# thread (a second client, or the in-process test server) issue their first +# mktime() concurrently, TSan reports a write-write race on the libc tz +# globals. Benign one-time lazy init in third-party code (same class as the +# rcl/rcutils logging suppression above); the racing write is inside +# open62541/glibc, not our code. +race:UA_DateTime_localTimeUtcOffset + # cpp-httplib: data race in Server::stop() and create_server_socket() # on internal socket fd between listen and stop threads. # System-packaged libcpp-httplib.so, not our code. The .so is shipped