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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::pair<const char *, std::string AssetIdentity::*>, 8> & identity_fields() {
static const std::array<std::pair<const char *, std::string AssetIdentity::*>, 8> fields{{
inline const std::array<std::pair<const char *, std::string AssetIdentity::*>, 9> & identity_fields() {
static const std::array<std::pair<const char *, std::string AssetIdentity::*>, 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},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,6 +62,7 @@ enum class AssetColumn {
kId,
kManufacturer,
kModel,
kOrderCode,
kSerial,
kHardwareRev,
kFirmware,
Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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();
}

/**
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
39 changes: 39 additions & 0 deletions src/ros2_medkit_gateway/test/test_asset_identity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Expand Down
19 changes: 19 additions & 0 deletions src/ros2_medkit_gateway/test/test_asset_inventory.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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";
Expand All @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions src/ros2_medkit_gateway/test/test_manifest_parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
28 changes: 27 additions & 1 deletion src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 + "')");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,23 @@ 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) 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<char *>("en"), const_cast<char *>("OrderNumber"));
va_order.accessLevel = UA_ACCESSLEVELMASK_READ;
va_order.dataType = UA_TYPES[UA_TYPES_STRING].typeId;
UA_String order = UA_STRING(const_cast<char *>(" 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<char *>("OrderNumber")),
UA_NODEID_NUMERIC(0, UA_NS0ID_PROPERTYTYPE), va_order, nullptr, nullptr);
}

void cli_loop(UA_Server * server, UA_UInt16 ns) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading
Loading