From 276b66ba4ce5dccf9403373d372c601117d0f044 Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Sat, 4 Jul 2026 18:54:07 +0200 Subject: [PATCH 1/2] feat(opcua): populate order_code identity field from DI OrderNumber Add a typed order_code field (AAS ManufacturerOrderCode) to AssetIdentity, distinct from model, and read it from the DI device OrderNumber node by BrowseName across namespaces. Verified on a Siemens CPU 1505SP F. --- .../core/discovery/identity_merge.hpp | 5 ++- .../discovery/manifest/asset_inventory.hpp | 9 +++-- .../core/discovery/models/asset_identity.hpp | 25 +++++++----- .../discovery/manifest/asset_inventory.cpp | 7 ++++ .../discovery/manifest/manifest_parser.cpp | 1 + .../test/test_asset_identity.cpp | 39 +++++++++++++++++++ .../test/test_asset_inventory.cpp | 19 +++++++++ .../test/test_manifest_parser.cpp | 2 + .../ros2_medkit_opcua/opcua_client.hpp | 5 +++ .../ros2_medkit_opcua/src/device_identity.cpp | 1 + .../ros2_medkit_opcua/src/opcua_client.cpp | 28 ++++++++++++- .../ros2_medkit_opcua/src/opcua_plugin.cpp | 2 +- .../test_alarm_server/test_alarm_server.cpp | 15 +++++++ .../test/test_device_identity.cpp | 20 ++++++++++ .../test/test_opcua_identity.cpp | 6 +++ 15 files changed, 168 insertions(+), 16 deletions(-) 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 da10451a2..8236a61ed 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 @@ -79,10 +79,11 @@ inline size_t source_rank(const std::string & source, const IdentityMergeConfig namespace detail { /// All typed identity fields as (provenance-key, member-pointer) pairs. -inline const std::array, 8> & identity_fields() { - static const std::array, 8> fields{{ +inline const std::array, 9> & identity_fields() { + static const std::array, 9> fields{{ {"manufacturer", &AssetIdentity::manufacturer}, {"model", &AssetIdentity::model}, + {"order_code", &AssetIdentity::order_code}, {"serial_number", &AssetIdentity::serial_number}, {"hardware_revision", &AssetIdentity::hardware_revision}, {"firmware_version", &AssetIdentity::firmware_version}, diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/asset_inventory.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/asset_inventory.hpp index 68449ac5c..31c2f87c2 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/asset_inventory.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/manifest/asset_inventory.hpp @@ -40,7 +40,8 @@ namespace discovery { struct AssetEntry { std::string id; ///< Stable asset id (required); merge key into the tree std::string manufacturer; ///< Vendor / OEM - std::string model; ///< Model / order code + std::string model; ///< Product designation + std::string order_code; ///< Manufacturer order code / MLFB (distinct from model) std::string serial; ///< Serial number std::string hardware_rev; ///< Hardware revision std::string firmware; ///< Firmware / software version @@ -61,6 +62,7 @@ enum class AssetColumn { kId, kManufacturer, kModel, + kOrderCode, kSerial, kHardwareRev, kFirmware, @@ -74,8 +76,9 @@ enum class AssetColumn { * * The single alias table shared by both import paths (CSV header cells and * manifest `assets:` keys): names are trimmed and matched case-insensitively; - * `serial_number`, `hardware_revision` / `hw_rev` and `firmware_version` / `fw` - * map to their typed fields. Empty names yield kIgnore; unknown names kExtra. + * `serial_number`, `hardware_revision` / `hw_rev`, `firmware_version` / `fw` + * and `order_code` / `order_number` map to their typed fields. Empty names + * yield kIgnore; unknown names kExtra. */ AssetColumn asset_column(const std::string & name); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/asset_identity.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/asset_identity.hpp index ce44ca8a3..61c219340 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/asset_identity.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/core/discovery/models/asset_identity.hpp @@ -43,7 +43,8 @@ using json = nlohmann::json; * - the asset itself <-> the AAS / Submodel "shell" (Component.id == localId) * - this identity block <-> Nameplate submodel (IDTA 02006) * manufacturer <-> ManufacturerName - * model <-> ManufacturerProductDesignation (a.k.a. order code) + * model <-> ManufacturerProductDesignation + * order_code <-> ManufacturerOrderCode * serial_number <-> SerialNumber * hardware_revision <-> HardwareVersion * firmware_version <-> FirmwareVersion @@ -55,7 +56,8 @@ using json = nlohmann::json; */ struct AssetIdentity { std::string manufacturer; ///< Manufacturer / vendor name (AAS ManufacturerName) - std::string model; ///< Product designation / order code (AAS ManufacturerProductDesignation) + std::string model; ///< Product designation (AAS ManufacturerProductDesignation) + std::string order_code; ///< Manufacturer order code / MLFB, distinct from model (AAS ManufacturerOrderCode) std::string serial_number; ///< Unit serial number (AAS SerialNumber) std::string hardware_revision; ///< Hardware revision (AAS HardwareVersion) std::string firmware_version; ///< Firmware version (AAS FirmwareVersion) @@ -73,9 +75,9 @@ struct AssetIdentity { /// True when no identity information is present (typed fields + extras all empty). /// Provenance alone does not make an identity non-empty. bool empty() const { - return manufacturer.empty() && model.empty() && serial_number.empty() && hardware_revision.empty() && - firmware_version.empty() && software_version.empty() && network_endpoint.empty() && role.empty() && - extra.empty(); + return manufacturer.empty() && model.empty() && order_code.empty() && serial_number.empty() && + hardware_revision.empty() && firmware_version.empty() && software_version.empty() && + network_endpoint.empty() && role.empty() && extra.empty(); } /** @@ -97,6 +99,9 @@ struct AssetIdentity { if (!model.empty()) { j["model"] = model; } + if (!order_code.empty()) { + j["orderCode"] = order_code; + } if (!serial_number.empty()) { j["serialNumber"] = serial_number; } @@ -141,6 +146,7 @@ struct AssetIdentity { }; get_str("manufacturer", id.manufacturer); get_str("model", id.model); + get_str("orderCode", id.order_code); get_str("serialNumber", id.serial_number); get_str("hardwareRevision", id.hardware_revision); get_str("firmwareVersion", id.firmware_version); @@ -166,10 +172,11 @@ struct AssetIdentity { }; inline bool operator==(const AssetIdentity & a, const AssetIdentity & b) { - return a.manufacturer == b.manufacturer && a.model == b.model && a.serial_number == b.serial_number && - a.hardware_revision == b.hardware_revision && a.firmware_version == b.firmware_version && - a.software_version == b.software_version && a.network_endpoint == b.network_endpoint && a.role == b.role && - a.extra == b.extra && a.provenance == b.provenance; + return a.manufacturer == b.manufacturer && a.model == b.model && a.order_code == b.order_code && + a.serial_number == b.serial_number && a.hardware_revision == b.hardware_revision && + a.firmware_version == b.firmware_version && a.software_version == b.software_version && + a.network_endpoint == b.network_endpoint && a.role == b.role && a.extra == b.extra && + a.provenance == b.provenance; } inline bool operator!=(const AssetIdentity & a, const AssetIdentity & b) { return !(a == b); diff --git a/src/ros2_medkit_gateway/src/core/discovery/manifest/asset_inventory.cpp b/src/ros2_medkit_gateway/src/core/discovery/manifest/asset_inventory.cpp index 09b01696b..a99f1d338 100644 --- a/src/ros2_medkit_gateway/src/core/discovery/manifest/asset_inventory.cpp +++ b/src/ros2_medkit_gateway/src/core/discovery/manifest/asset_inventory.cpp @@ -159,6 +159,9 @@ AssetColumn asset_column(const std::string & name) { if (header_lower == "model") { return AssetColumn::kModel; } + if (header_lower == "order_code" || header_lower == "order_number") { + return AssetColumn::kOrderCode; + } if (header_lower == "serial" || header_lower == "serial_number") { return AssetColumn::kSerial; } @@ -191,6 +194,9 @@ void assign_asset_field(AssetEntry & entry, AssetColumn column, const std::strin case AssetColumn::kModel: entry.model = value; break; + case AssetColumn::kOrderCode: + entry.order_code = value; + break; case AssetColumn::kSerial: entry.serial = value; break; @@ -297,6 +303,7 @@ Component asset_entry_to_component(const AssetEntry & entry) { }; set_field(&AssetIdentity::manufacturer, entry.manufacturer, "manufacturer"); set_field(&AssetIdentity::model, entry.model, "model"); + set_field(&AssetIdentity::order_code, entry.order_code, "order_code"); set_field(&AssetIdentity::serial_number, entry.serial, "serial_number"); set_field(&AssetIdentity::hardware_revision, entry.hardware_rev, "hardware_revision"); set_field(&AssetIdentity::firmware_version, entry.firmware, "firmware_version"); 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 0c6c55031..b6b01f9af 100644 --- a/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp +++ b/src/ros2_medkit_gateway/src/discovery/manifest/manifest_parser.cpp @@ -349,6 +349,7 @@ AssetIdentity ManifestParser::parse_identity(const YAML::Node & node) const { const YAML::Node & id_node = node["identity"]; identity.manufacturer = get_string(id_node, "manufacturer"); identity.model = get_string(id_node, "model"); + identity.order_code = get_string(id_node, "order_code"); 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"); diff --git a/src/ros2_medkit_gateway/test/test_asset_identity.cpp b/src/ros2_medkit_gateway/test/test_asset_identity.cpp index f83545164..72c10ab8a 100644 --- a/src/ros2_medkit_gateway/test/test_asset_identity.cpp +++ b/src/ros2_medkit_gateway/test/test_asset_identity.cpp @@ -100,10 +100,30 @@ TEST(AssetIdentityModel, ProvenanceKeysStaySnakeCaseWhileFieldKeysAreCamelCase) EXPECT_EQ(parsed, id); } +// order_code is a distinct typed field (AAS ManufacturerOrderCode), serialized +// under camelCase "orderCode" while its provenance key stays snake_case. +TEST(AssetIdentityModel, OrderCodeSerializesUnderCamelCaseAndRoundTrips) { + AssetIdentity id; + id.model = "CPU 1505SP F"; + id.order_code = "6ES7 672-5SC11-0YA0"; + id.provenance["order_code"] = "opcua"; + + auto j = id.to_json(); + EXPECT_EQ(j["model"], "CPU 1505SP F"); + EXPECT_EQ(j["orderCode"], "6ES7 672-5SC11-0YA0"); + EXPECT_EQ(j["_provenance"]["order_code"], "opcua"); + EXPECT_FALSE(j["_provenance"].contains("orderCode")); + + AssetIdentity parsed = AssetIdentity::from_json(j); + EXPECT_EQ(parsed.order_code, "6ES7 672-5SC11-0YA0"); + EXPECT_EQ(parsed, id); +} + TEST(AssetIdentityModel, JsonRoundTrip) { AssetIdentity id; id.manufacturer = "Siemens"; id.model = "6ES7"; + id.order_code = "6ES7 672-5SC11-0YA0"; id.serial_number = "SN-42"; id.hardware_revision = "A2"; id.firmware_version = "2.9.4"; @@ -186,6 +206,25 @@ TEST(MergeIdentity, MergesTwoSourcesWithProvenancePerField) { EXPECT_EQ(merged.provenance.at("firmware_version"), "opcua"); } +TEST(MergeIdentity, OrderCodeMergesAsTypedFieldWithProvenance) { + // order_code participates in the typed-field merge (identity_fields()), so an + // opcua read fills it with per-field provenance alongside model / serial_number. + IdentityMergeConfig cfg; + + AssetIdentity merged; + merged.model = "CPU 1505SP F"; + stamp_identity_provenance(merged, "manifest"); + + AssetIdentity opcua; + opcua.order_code = "6ES7 672-5SC11-0YA0"; + merge_identity(merged, opcua, "opcua", cfg); + + EXPECT_EQ(merged.model, "CPU 1505SP F"); + EXPECT_EQ(merged.order_code, "6ES7 672-5SC11-0YA0"); + EXPECT_EQ(merged.provenance.at("model"), "manifest"); + EXPECT_EQ(merged.provenance.at("order_code"), "opcua"); +} + TEST(MergeIdentity, HigherAuthoritySourceOverridesLowerForSameField) { IdentityMergeConfig cfg; diff --git a/src/ros2_medkit_gateway/test/test_asset_inventory.cpp b/src/ros2_medkit_gateway/test/test_asset_inventory.cpp index 97b679242..4e3a9ffe5 100644 --- a/src/ros2_medkit_gateway/test/test_asset_inventory.cpp +++ b/src/ros2_medkit_gateway/test/test_asset_inventory.cpp @@ -175,6 +175,23 @@ TEST(AssetCsvParseTest, CaseInsensitiveHeadersAndAliases) { EXPECT_EQ(e.hardware_rev, "C1"); } +TEST(AssetCsvParseTest, OrderCodeColumnAndOrderNumberAlias) { + // Both `order_code` and the `order_number` alias map to the typed order_code + // field and land on the structured identity with provenance "inventory". + const std::string csv = + "id,model,order_number\n" + "plc,CPU 1505SP F,6ES7 672-5SC11-0YA0\n"; + auto result = parse_asset_csv(csv); + ASSERT_EQ(result.entries.size(), 1u); + const auto & e = result.entries[0]; + EXPECT_EQ(e.model, "CPU 1505SP F"); + EXPECT_EQ(e.order_code, "6ES7 672-5SC11-0YA0"); + + Component comp = asset_entry_to_component(e); + EXPECT_EQ(comp.identity.order_code, "6ES7 672-5SC11-0YA0"); + EXPECT_EQ(comp.identity.provenance.at("order_code"), "inventory"); +} + TEST(AssetCsvParseTest, CrlfLineEndingsAndTrimming) { const std::string csv = "id, model \r\n" @@ -255,6 +272,7 @@ TEST(AssetEntryToComponentTest, FullMapping) { e.id = "plc_1"; e.manufacturer = "Siemens"; e.model = "S7-1500"; + e.order_code = "6ES7 672-5SC11-0YA0"; e.serial = "SN123"; e.hardware_rev = "A2"; e.firmware = "2.9.1"; @@ -274,6 +292,7 @@ TEST(AssetEntryToComponentTest, FullMapping) { // provenance "inventory" (not folded into description / variant / tags). EXPECT_EQ(comp.identity.manufacturer, "Siemens"); EXPECT_EQ(comp.identity.model, "S7-1500"); + EXPECT_EQ(comp.identity.order_code, "6ES7 672-5SC11-0YA0"); EXPECT_EQ(comp.identity.serial_number, "SN123"); EXPECT_EQ(comp.identity.hardware_revision, "A2"); EXPECT_EQ(comp.identity.firmware_version, "2.9.1"); diff --git a/src/ros2_medkit_gateway/test/test_manifest_parser.cpp b/src/ros2_medkit_gateway/test/test_manifest_parser.cpp index a29798785..d9c239d35 100644 --- a/src/ros2_medkit_gateway/test/test_manifest_parser.cpp +++ b/src/ros2_medkit_gateway/test/test_manifest_parser.cpp @@ -166,6 +166,7 @@ manifest_version: "1.0" identity: manufacturer: "Siemens" model: "S7-1500" + order_code: "6ES7 672-5SC11-0YA0" serial_number: "SN-42" hardware_revision: "HW-3" firmware_version: "2.9.4" @@ -182,6 +183,7 @@ manifest_version: "1.0" EXPECT_EQ(id.manufacturer, "Siemens"); EXPECT_EQ(id.model, "S7-1500"); + EXPECT_EQ(id.order_code, "6ES7 672-5SC11-0YA0"); EXPECT_EQ(id.serial_number, "SN-42"); EXPECT_EQ(id.hardware_revision, "HW-3"); EXPECT_EQ(id.firmware_version, "2.9.4"); 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 7358b4790..ecdb2e6c5 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 @@ -378,6 +378,11 @@ class OpcuaClient { std::string di_serial_number; ///< DeviceType.SerialNumber std::string di_hardware_revision; ///< DeviceType.HardwareRevision std::string di_software_revision; ///< DeviceType.SoftwareRevision + /// Manufacturer order code (AAS ManufacturerOrderCode). Vendor extension, + /// not a standard DI property: its BrowseName lives in the vendor namespace + /// (Siemens exposes it as ns=3;s=OrderNumber), so it is matched by + /// BrowseName only. Empty when the server does not expose it. + std::string di_order_number; }; /// Read device identity (nameplate) from the connected server. 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 050d4bf06..60fa8befc 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 @@ -37,6 +37,7 @@ AssetIdentity opcua_device_info_to_identity(const OpcuaClient::DeviceInfo & info set_field(&AssetIdentity::manufacturer, manufacturer, "manufacturer"); set_field(&AssetIdentity::model, model, "model"); + set_field(&AssetIdentity::order_code, info.di_order_number, "order_code"); 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"); 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 2079281b8..f83fe8496 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 @@ -1088,6 +1088,18 @@ std::string read_string_attribute(UA_Client * client, const UA_NodeId & node_id) return out; } +// Strip leading/trailing ASCII whitespace. Used for vendor identity fields +// exposed in a fixed-width, space-padded form (Siemens pads the MLFB +// OrderNumber to 20 chars) so the stored value is the bare order code. +std::string trim_ascii_ws(std::string s) { + const char * ws = " \t\r\n"; + const auto begin = s.find_first_not_of(ws); + if (begin == std::string::npos) { + return {}; + } + return s.substr(begin, s.find_last_not_of(ws) - begin + 1); +} + // One forward hierarchical child: its browse name and target NodeId (owned). struct ChildRef { uint16_t namespace_index{0}; @@ -1232,20 +1244,34 @@ void read_di_nameplate(UA_Client * client, uint16_t di_ns, OpcuaClient::DeviceIn } return {}; }; + // OrderNumber is a vendor extension, NOT a standard DI property: its + // BrowseName lives in the vendor namespace (Siemens exposes it as + // ns=3;s=OrderNumber, BrowseName ns=3:OrderNumber), not the DI namespace, + // so match by BrowseName only. Empty for vendors that do not expose it. + auto read_prop_any_ns = [&](const char * name) -> std::string { + for (const auto & prop : props) { + if (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"); + std::string order_number = trim_ascii_ws(read_prop_any_ns("OrderNumber")); clear_child_refs(props); if (!manufacturer.empty() || !model.empty() || !serial.empty() || !hardware_revision.empty() || - !software_revision.empty()) { + !software_revision.empty() || !order_number.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); + info.di_order_number = std::move(order_number); break; } } 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 341831737..db97bd49e 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 @@ -441,7 +441,7 @@ IntrospectionResult OpcuaPlugin::introspect(const IntrospectionInput & /*input*/ 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 + "')"); + "', model='" + device_identity_.model + "', orderCode='" + device_identity_.order_code + "')"); } } } 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 9c491755d..04f9e29c3 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 @@ -559,6 +559,21 @@ void add_di_nameplate(UA_Server * server, const std::string & serial) { 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); + + // Vendor extension: OrderNumber (AAS ManufacturerOrderCode) exposed under a + // vendor namespace (NOT the DI namespace) and space-padded to a fixed width, + // mirroring the Siemens S7-1500 (ns=3;s=OrderNumber, "6ES7 672-5SC11-0YA0 "). + // Exercises the match-by-BrowseName-across-namespaces + whitespace-trim path. + UA_UInt16 vendor_ns = UA_Server_addNamespace(server, "http://selfpatch.test/vendor"); + UA_VariableAttributes va_order = UA_VariableAttributes_default; + va_order.displayName = UA_LOCALIZEDTEXT(const_cast("en"), const_cast("OrderNumber")); + va_order.accessLevel = UA_ACCESSLEVELMASK_READ; + va_order.dataType = UA_TYPES[UA_TYPES_STRING].typeId; + UA_String order = UA_STRING(const_cast("6ES7-TEST-0YA0 ")); + UA_Variant_setScalar(&va_order.value, &order, &UA_TYPES[UA_TYPES_STRING]); + UA_Server_addVariableNode(server, UA_NODEID_NULL, device_id, UA_NODEID_NUMERIC(0, UA_NS0ID_HASPROPERTY), + UA_QUALIFIEDNAME(vendor_ns, const_cast("OrderNumber")), + UA_NODEID_NUMERIC(0, UA_NS0ID_PROPERTYTYPE), va_order, nullptr, nullptr); } void cli_loop(UA_Server * server, UA_UInt16 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 637637428..b914dfffd 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 @@ -86,6 +86,26 @@ TEST(DeviceIdentityMap, DiNameplateWinsOverBuildInfo) { EXPECT_EQ(id.extra.at("buildNumber"), "build-4567"); } +TEST(DeviceIdentityMap, DiOrderNumberMapsToOrderCodeWithOpcuaProvenance) { + // The vendor OrderNumber (AAS ManufacturerOrderCode) lands on the distinct + // order_code field, NOT folded into model, with per-field "opcua" provenance. + OpcuaClient::DeviceInfo info = make_build_info(); + info.di_model = "CPU 1505SP F"; + info.di_order_number = "6ES7 672-5SC11-0YA0"; + + auto id = opcua_device_info_to_identity(info, "opc.tcp://plc:4840"); + EXPECT_EQ(id.model, "CPU 1505SP F"); + EXPECT_EQ(id.order_code, "6ES7 672-5SC11-0YA0"); + EXPECT_EQ(id.provenance.at("order_code"), "opcua"); +} + +TEST(DeviceIdentityMap, AbsentOrderNumberLeavesOrderCodeEmpty) { + // A vendor that does not expose OrderNumber -> order_code stays empty, no provenance. + auto id = opcua_device_info_to_identity(make_build_info(), "opc.tcp://plc:4840"); + EXPECT_TRUE(id.order_code.empty()); + EXPECT_EQ(id.provenance.count("order_code"), 0u); +} + TEST(DeviceIdentityMap, EmptyEndpointSkipsNetworkEndpoint) { auto id = opcua_device_info_to_identity(make_build_info(), ""); EXPECT_TRUE(id.network_endpoint.empty()); 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 4e7a00970..4bca2775d 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 @@ -345,6 +345,9 @@ TEST_F(OpcuaIdentityE2ETest, ClientReadsDiNameplate) { 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"); + // OrderNumber lives in the vendor namespace (not DI) and is space-padded; + // it is matched by BrowseName across namespaces and trimmed. + EXPECT_EQ(info.di_order_number, "6ES7-TEST-0YA0"); client.disconnect(); } @@ -359,12 +362,14 @@ TEST_F(OpcuaIdentityE2ETest, MappedIdentityFromLiveServer) { // DI nameplate wins over BuildInfo for manufacturer / model / software. EXPECT_EQ(id.manufacturer, "SelfPatch Devices"); EXPECT_EQ(id.model, "SPX-1000"); + EXPECT_EQ(id.order_code, "6ES7-TEST-0YA0"); 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("order_code"), "opcua"); EXPECT_EQ(id.provenance.at("network_endpoint"), "opcua"); client.disconnect(); } @@ -548,6 +553,7 @@ TEST_F(OpcuaIdentityE2ETest, DiNameplateReadFollowsBrowseContinuationPoints) { 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"); + EXPECT_EQ(info.di_order_number, "6ES7-TEST-0YA0"); client.disconnect(); } From dc0b9ee1fb2eca565c471c31975c8275392c074b Mon Sep 17 00:00:00 2001 From: mfaferek93 Date: Sat, 4 Jul 2026 22:25:22 +0200 Subject: [PATCH 2/2] test(opcua): prove internal MLFB space survives order-code trim The E2E order-code fixture only padded the trailing edge with no internal space, so a whitespace-collapsing trim would not have failed any test. Use the real Siemens MLFB with leading + trailing pad and an internal space, and assert the read order_code keeps that space. --- .../fixtures/test_alarm_server/test_alarm_server.cpp | 10 ++++++---- .../ros2_medkit_opcua/test/test_opcua_identity.cpp | 11 ++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) 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 04f9e29c3..3b1ec06cb 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 @@ -561,15 +561,17 @@ void add_di_nameplate(UA_Server * server, const std::string & serial) { add_prop("SoftwareRevision", &UA_TYPES[UA_TYPES_STRING], v_software); // Vendor extension: OrderNumber (AAS ManufacturerOrderCode) exposed under a - // vendor namespace (NOT the DI namespace) and space-padded to a fixed width, - // mirroring the Siemens S7-1500 (ns=3;s=OrderNumber, "6ES7 672-5SC11-0YA0 "). - // Exercises the match-by-BrowseName-across-namespaces + whitespace-trim path. + // vendor namespace (NOT the DI namespace) as the real Siemens S7-1500 MLFB + // (ns=3;s=OrderNumber), padded with leading AND trailing spaces. The internal + // space in "6ES7 672-..." is load-bearing and must survive the edge trim, so a + // whitespace-collapsing reimplementation of trim_ascii_ws would fail the E2E + // assertion. Exercises match-by-BrowseName-across-namespaces + whitespace-trim. UA_UInt16 vendor_ns = UA_Server_addNamespace(server, "http://selfpatch.test/vendor"); UA_VariableAttributes va_order = UA_VariableAttributes_default; va_order.displayName = UA_LOCALIZEDTEXT(const_cast("en"), const_cast("OrderNumber")); va_order.accessLevel = UA_ACCESSLEVELMASK_READ; va_order.dataType = UA_TYPES[UA_TYPES_STRING].typeId; - UA_String order = UA_STRING(const_cast("6ES7-TEST-0YA0 ")); + UA_String order = UA_STRING(const_cast(" 6ES7 672-5SC11-0YA0 ")); UA_Variant_setScalar(&va_order.value, &order, &UA_TYPES[UA_TYPES_STRING]); UA_Server_addVariableNode(server, UA_NODEID_NULL, device_id, UA_NODEID_NUMERIC(0, UA_NS0ID_HASPROPERTY), UA_QUALIFIEDNAME(vendor_ns, const_cast("OrderNumber")), 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 4bca2775d..8a920dab8 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 @@ -345,9 +345,10 @@ TEST_F(OpcuaIdentityE2ETest, ClientReadsDiNameplate) { 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"); - // OrderNumber lives in the vendor namespace (not DI) and is space-padded; - // it is matched by BrowseName across namespaces and trimmed. - EXPECT_EQ(info.di_order_number, "6ES7-TEST-0YA0"); + // OrderNumber lives in the vendor namespace (not DI) with leading + trailing + // pad; it is matched by BrowseName across namespaces and edge-trimmed while the + // load-bearing internal space in the MLFB is preserved. + EXPECT_EQ(info.di_order_number, "6ES7 672-5SC11-0YA0"); client.disconnect(); } @@ -362,7 +363,7 @@ TEST_F(OpcuaIdentityE2ETest, MappedIdentityFromLiveServer) { // DI nameplate wins over BuildInfo for manufacturer / model / software. EXPECT_EQ(id.manufacturer, "SelfPatch Devices"); EXPECT_EQ(id.model, "SPX-1000"); - EXPECT_EQ(id.order_code, "6ES7-TEST-0YA0"); + EXPECT_EQ(id.order_code, "6ES7 672-5SC11-0YA0"); EXPECT_EQ(id.serial_number, "SN-0001-TEST"); EXPECT_EQ(id.hardware_revision, "HW-A2"); EXPECT_EQ(id.software_version, "SW-3.4.5"); @@ -553,7 +554,7 @@ TEST_F(OpcuaIdentityE2ETest, DiNameplateReadFollowsBrowseContinuationPoints) { 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"); - EXPECT_EQ(info.di_order_number, "6ES7-TEST-0YA0"); + EXPECT_EQ(info.di_order_number, "6ES7 672-5SC11-0YA0"); client.disconnect(); }