diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 607ee9ae9..3362ae142 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -61,7 +61,7 @@ jobs: - name: Build run: >- cmake --build build-ied-simulator-qt - --target arstack_ied_simulator ariec61850_mms_read_probe ariec61850_live_discover ariec61850_mms_sgcb_probe ied_setting_control_manifest_qa ied_simulator_guardrails_qa ied_simulator_responsiveness_qa ied_simulator_commissioning_qa ied_simulator_runtime_commissioning_qa ied_simulator_goose_publication_qa ied_goose_workspace_qa ied_mms_client_workbench_qa ied_report_workspace_qa ied_file_settings_workspace_qa ied_scl_workspace_qa ied_product_hardening_qa ied_browser_session_qa ied_browser_large_model_qa ied_engineering_context_qa --parallel 2 + --target arstack_ied_simulator ariec61850_mms_read_probe ariec61850_live_discover ariec61850_mms_sgcb_probe ied_setting_control_manifest_qa ied_simulator_guardrails_qa ied_simulator_responsiveness_qa ied_simulator_commissioning_qa ied_simulator_runtime_commissioning_qa ied_simulator_goose_publication_qa ied_goose_workspace_qa ied_mms_client_workbench_qa ied_report_workspace_qa ied_file_settings_workspace_qa ied_scl_workspace_qa ied_product_hardening_qa ied_browser_session_qa ied_browser_large_model_qa ied_engineering_context_qa ied_discovery_scl_export_qa --parallel 2 - name: Bounded activity and child-output guardrails run: ./build-ied-simulator-qt/ied_simulator_guardrails_qa @@ -142,6 +142,11 @@ jobs: QT_QPA_PLATFORM: offscreen run: ./build-ied-simulator-qt/ied_engineering_context_qa + - name: Discovery canonical Save SCL round-trip gates + env: + QT_QPA_PLATFORM: offscreen + run: ./build-ied-simulator-qt/ied_discovery_scl_export_qa + - name: Prove interactive SCL import uses bounded async path env: QT_QPA_PLATFORM: offscreen diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 950dd37f0..02df93694 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -396,6 +396,22 @@ target_include_directories(ied_engineering_context_qa PRIVATE ${CMAKE_CURRENT_LIST_DIR}/src) target_compile_features(ied_engineering_context_qa PRIVATE cxx_std_20) +add_executable(ied_discovery_scl_export_qa + src/ied_discovery_scl_export_qa.cpp + src/SclWorkspaceController.cpp + src/SclWorkspaceController.hpp + src/IedEngineeringContextController.cpp + src/IedEngineeringContextController.hpp + src/MmsLiveTreeModel.cpp + src/MmsLiveTreeModel.hpp) +target_link_libraries(ied_discovery_scl_export_qa PRIVATE + Qt6::Core + Qt6::Qml + ARIEC61850::core) +target_include_directories(ied_discovery_scl_export_qa PRIVATE + ${CMAKE_CURRENT_LIST_DIR}/src) +target_compile_features(ied_discovery_scl_export_qa PRIVATE cxx_std_20) + # The regular library intentionally keeps a strict portable connection runtime. # The desktop simulator needs the separate compatibility runtime validated # against common engineering-client AARQ, selector, browse, Identify and diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 7ec7cc4c0..686aa38c3 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -80,6 +80,20 @@ Item { onAccepted: engineering.openFile(selectedFile) } + FileDialog { + id: saveSclDialog + title: "Save canonical IED model" + fileMode: FileDialog.SaveFile + defaultSuffix: "iid" + nameFilters: [ + "IEC 61850 IID (*.iid)", + "IEC 61850 ICD (*.icd)", + "IEC 61850 CID (*.cid)", + "IEC 61850 SCD (*.scd)" + ] + onAccepted: engineering.exportEngineeringContext(selectedFile, "ed2") + } + Rectangle { anchors.fill: parent; color: root.theme.background } ColumnLayout { @@ -125,6 +139,18 @@ Item { ToolTip.text: "Open SCL/CID/SCD/IID/ICD into the persistent Browser model." } + Button { + text: engineering.busy ? "Saving…" : "Save SCL" + visible: context.loaded && context.authorityKey === "live-discovery" + enabled: engineering.engineeringContextExportSupported + && !engineering.busy + onClicked: saveSclDialog.open() + ToolTip.visible: hovered + ToolTip.text: context.online + ? "Save the cached canonical IED model locally; no rediscovery is performed." + : "Save the persistent canonical IED model while offline." + } + TextField { id: hostField Layout.preferredWidth: 205 diff --git a/apps/ied_simulator/src/SclWorkspaceController.hpp b/apps/ied_simulator/src/SclWorkspaceController.hpp index eeac27f1d..d2a406fe2 100644 --- a/apps/ied_simulator/src/SclWorkspaceController.hpp +++ b/apps/ied_simulator/src/SclWorkspaceController.hpp @@ -44,6 +44,7 @@ class SclWorkspaceController : public QObject { Q_PROPERTY(bool reconstructionSupported READ reconstructionSupported NOTIFY workspaceChanged) Q_PROPERTY(bool editionConversionSupported READ editionConversionSupported NOTIFY workspaceChanged) Q_PROPERTY(bool profileConversionSupported READ profileConversionSupported NOTIFY workspaceChanged) + Q_PROPERTY(bool engineeringContextExportSupported READ engineeringContextExportSupported NOTIFY workspaceChanged) Q_PROPERTY(QStringList preservationReport READ preservationReport NOTIFY workspaceChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) Q_PROPERTY(bool lastExportVerified READ lastExportVerified NOTIFY exportChanged) @@ -79,6 +80,11 @@ class SclWorkspaceController : public QObject { [[nodiscard]] bool reconstructionSupported() const noexcept; [[nodiscard]] bool editionConversionSupported() const noexcept; [[nodiscard]] bool profileConversionSupported() const noexcept; + [[nodiscard]] bool engineeringContextExportSupported() const noexcept { + return engineeringContext_ != nullptr && engineeringContext_->loaded() && + !engineeringContext_->selectionRequired() && + engineeringContext_->authorityKey() == QStringLiteral("live-discovery"); + } [[nodiscard]] QStringList preservationReport() const; [[nodiscard]] QString lastError() const { return lastError_; } [[nodiscard]] bool lastExportVerified() const noexcept { return lastExportVerified_; } @@ -91,6 +97,9 @@ class SclWorkspaceController : public QObject { Q_INVOKABLE bool openFile(const QUrl& fileUrl); Q_INVOKABLE bool saveAs(const QUrl& fileUrl, const QString& targetEdition = QStringLiteral("preserve")); Q_INVOKABLE bool exportCanonical(const QUrl& fileUrl, const QString& targetEdition = QStringLiteral("preserve")); + Q_INVOKABLE bool exportEngineeringContext( + const QUrl& fileUrl, + const QString& targetEdition = QStringLiteral("ed2")); Q_INVOKABLE void cancelOperation(); Q_INVOKABLE QString diagnosticsText() const; diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc index 467119ee6..8b603bd02 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_00.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_00.inc @@ -2,6 +2,7 @@ #include "SclWorkspaceController.hpp" #include "ariec61850/scl/exporter.hpp" +#include "ariec61850/scl/live_model_projection.hpp" #include "ariec61850/scl/parser.hpp" #include @@ -61,6 +62,7 @@ std::optional requestedEdition( std::optional profileFromPath(const QString& path) { const auto suffix = QFileInfo(path).suffix().toLower(); if (suffix == QStringLiteral("scd")) return scl::SclExportProfile::scd; + if (suffix == QStringLiteral("iid")) return scl::SclExportProfile::iid; if (suffix == QStringLiteral("icd")) return scl::SclExportProfile::icd; if (suffix == QStringLiteral("cid")) return scl::SclExportProfile::cid; return std::nullopt; @@ -69,6 +71,7 @@ std::optional profileFromPath(const QString& path) { QString profileLabel(const scl::SclExportProfile profile) { switch (profile) { case scl::SclExportProfile::scd: return QStringLiteral("SCD"); + case scl::SclExportProfile::iid: return QStringLiteral("IID"); case scl::SclExportProfile::icd: return QStringLiteral("ICD"); case scl::SclExportProfile::cid: return QStringLiteral("CID"); } @@ -193,6 +196,14 @@ bool modeledSemanticsEquivalent(const scl::SclDocument& left, const scl::SclDocu sampledValuesEquivalent(left.sampled_values_streams, right.sampled_values_streams); } +bool discoveredProjectionEquivalent( + const scl::SclDocument& left, + const scl::SclDocument& right) { + return modeledSemanticsEquivalent(left, right) && + left.mms_access_points == right.mms_access_points && + left.setting_controls == right.setting_controls; +} + struct OpenResult final { QByteArray bytes; std::optional document; diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_01.inc b/apps/ied_simulator/src/SclWorkspaceController_part_01.inc index 071077f1b..39f392272 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_01.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_01.inc @@ -27,8 +27,20 @@ SclWorkspaceController::SclWorkspaceController(QObject* parent) void SclWorkspaceController::setEngineeringContext(IedEngineeringContextController* value) { if (engineeringContext_ == value) return; + if (engineeringContext_) disconnect(engineeringContext_, nullptr, this, nullptr); engineeringContext_ = value; + if (engineeringContext_) { + connect( + engineeringContext_, + &IedEngineeringContextController::contextChanged, + this, + [this] { + emit workspaceChanged(); + emit stateChanged(); + }); + } emit engineeringContextChanged(); + emit workspaceChanged(); if (engineeringContext_ && document_) { engineeringContext_->publishSclDocument(*document_, sourcePath_); } diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_02.inc b/apps/ied_simulator/src/SclWorkspaceController_part_02.inc index 44c8de055..df7e890b0 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_02.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_02.inc @@ -203,6 +203,160 @@ bool SclWorkspaceController::exportCanonical(const QUrl& fileUrl, const QString& return true; } +bool SclWorkspaceController::exportEngineeringContext( + const QUrl& fileUrl, + const QString& targetEdition) { + if (busy_ || !engineeringContext_ || !engineeringContext_->loaded() || + engineeringContext_->selectionRequired()) { + return false; + } + if (engineeringContext_->authorityKey() != QStringLiteral("live-discovery")) { + setFailure(QStringLiteral( + "Opened SCL sources must use exact-source Save As; discovery reconstruction is not substituted.")); + return false; + } + const auto* liveModel = engineeringContext_->modelSnapshot(); + if (liveModel == nullptr) { + setFailure(QStringLiteral("No canonical IED model is available for Save SCL.")); + return false; + } + + const auto path = localPath(fileUrl).trimmed(); + if (path.isEmpty()) { + setFailure(QStringLiteral("A local Save SCL path is required.")); + return false; + } + const auto edition = requestedEdition(targetEdition, scl::SclEdition::edition2); + if (!edition || *edition == scl::SclEdition::unknown) { + setFailure(QStringLiteral("Save SCL requires Ed1, Ed2 or Ed2.1.")); + return false; + } + const auto profile = profileFromPath(path); + if (!profile) { + setFailure(QStringLiteral( + "Save SCL target must use .iid, .icd, .cid or .scd.")); + return false; + } + + const auto projection = scl::project_live_model_to_scl(*liveModel, *edition); + if (!projection.success) { + setFailure(QString::fromStdString(projection.error)); + return false; + } + + scl::SclCanonicalExportOptions options; + options.edition = *edition; + options.profile = *profile; + const auto validation = scl::SclExporter::validate(projection.document, options); + if (!validation.success) { + setFailure(QString::fromStdString(validation.error)); + return false; + } + + const auto generation = ++generation_; + const auto stop = replaceStopSource(); + const auto expectedDocument = projection.document; + const auto projectionWarnings = projection.warnings; + busy_ = true; + operationText_ = QStringLiteral("Saving canonical IED model"); + lastError_.clear(); + lastExportVerified_ = false; + lastExportPath_.clear(); + lastExportMode_.clear(); + emit stateChanged(); + emit exportChanged(); + + QPointer self{this}; + ioPool_.start([self, path, expectedDocument, projectionWarnings, options, generation, stop] { + QString error; + bool verified = false; + QStringList warnings; + for (const auto& warning : projectionWarnings) { + warnings.push_back(QString::fromStdString(warning)); + } + + if (stop->stop_requested()) { + error = QStringLiteral("Save SCL cancelled before local projection."); + } else { + const auto generated = scl::SclExporter::canonical(expectedDocument, options); + if (!generated.success) { + error = QString::fromStdString(generated.error); + } else if (generated.xml.size() > static_cast(maximumSclBytes)) { + error = QStringLiteral("Save SCL exceeds the bounded 64 MiB workspace limit."); + } else if (stop->stop_requested()) { + error = QStringLiteral("Save SCL cancelled before write."); + } else { + const QByteArray bytes{ + generated.xml.data(), + static_cast(generated.xml.size())}; + QSaveFile output(path); + if (!output.open(QIODevice::WriteOnly)) { + error = QStringLiteral("Unable to open Save SCL target: %1") + .arg(output.errorString()); + } else if (output.write(bytes) != bytes.size()) { + error = QStringLiteral("Unable to write complete Save SCL output: %1") + .arg(output.errorString()); + output.cancelWriting(); + } else if (stop->stop_requested()) { + error = QStringLiteral("Save SCL cancelled before commit."); + output.cancelWriting(); + } else if (!output.commit()) { + error = QStringLiteral("Unable to commit Save SCL output: %1") + .arg(output.errorString()); + } else { + auto reread = readAndParse(path, stop->get_token()); + if (!reread.error.isEmpty() || !reread.document) { + error = reread.error.isEmpty() + ? QStringLiteral("Saved discovery model could not be reparsed.") + : reread.error; + } else if (reread.document->edition != options.edition) { + error = QStringLiteral( + "Saved discovery model reparsed with the wrong IEC 61850 edition."); + } else if (!discoveredProjectionEquivalent( + expectedDocument, *reread.document)) { + error = QStringLiteral( + "Saved discovery model failed semantic round-trip validation."); + } else { + verified = true; + for (const auto& warning : generated.warnings) { + warnings.push_back(QString::fromStdString(warning)); + } + } + } + } + } + + const auto mode = QStringLiteral("Discovery %1 %2") + .arg(profileLabel(options.profile), editionKey(options.edition)); + QMetaObject::invokeMethod( + self, + [self, path, generation, error = std::move(error), + warnings = std::move(warnings), mode, verified] { + if (!self || generation != self->generation_) return; + self->busy_ = false; + self->operationText_.clear(); + self->lastExportVerified_ = verified; + self->lastExportPath_ = verified ? path : QString{}; + self->lastExportMode_ = verified ? mode : QString{}; + self->lastError_ = error; + if (verified) { + self->appendDiagnostic( + QStringLiteral("Verified %1 Save SCL · %2").arg(mode, path)); + for (const auto& warning : warnings) { + self->appendDiagnostic(QStringLiteral("NOTE · %1").arg(warning)); + } + } else { + self->appendDiagnostic( + QStringLiteral("Save SCL rejected · %1").arg(error)); + } + emit self->exportChanged(); + emit self->stateChanged(); + }, + Qt::QueuedConnection); + }); + return true; +} + void SclWorkspaceController::cancelOperation() { if (stopSource_) stopSource_->request_stop(); appendDiagnostic(QStringLiteral("Cancellation requested for active SCL operation.")); diff --git a/apps/ied_simulator/src/ied_discovery_scl_export_qa.cpp b/apps/ied_simulator/src/ied_discovery_scl_export_qa.cpp new file mode 100644 index 000000000..0df08777d --- /dev/null +++ b/apps/ied_simulator/src/ied_discovery_scl_export_qa.cpp @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "IedEngineeringContextController.hpp" +#include "SclWorkspaceController.hpp" + +#include "ariec61850/scl/parser.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace mms = ar::iec61850::mms; +namespace scl = ar::iec61850::scl; + +namespace { + +bool waitFor(const std::function& predicate, const int timeoutMs = 8'000) { + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < timeoutMs) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + if (predicate()) return true; + QThread::msleep(10); + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 20); + return predicate(); +} + +mms::MmsLiveModelDocument makeLiveModel() { + mms::MmsLiveModelDocument model; + model.source = "LiveMmsDiscovery"; + model.endpoint = {"192.0.2.44", 8102U}; + model.identity.ied_name = "IEDLIVE"; + model.identity.source = "MultiDomainNamingConsensus"; + model.identity.confidence = mms::MmsLiveModelConfidence::high; + model.identity.candidate_names = {"IEDLIVE"}; + model.identity.logical_device_aliases.emplace("IEDLIVELD0", "LD0"); + model.access_point_name = "P1"; + + mms::MmsLiveLogicalDevice device; + device.mms_domain = "IEDLIVELD0"; + device.instance = "LD0"; + + mms::MmsLiveLogicalNode node; + node.name = "LLN0"; + node.logical_node_class = "LLN0"; + + mms::MmsLiveDataObject object; + object.name = "Mod"; + object.reference = "IEDLIVELD0/LLN0.Mod"; + object.inferred_cdc = "INC"; + object.cdc_confidence = 1.0; + object.confidence = mms::MmsLiveModelConfidence::exact; + + mms::MmsLiveDataAttribute stVal; + stVal.object_reference = "IEDLIVELD0/LLN0.Mod.stVal"; + stVal.attribute_path = "stVal"; + stVal.functional_constraint = "ST"; + stVal.mms_reference = "IEDLIVELD0/LLN0$ST$Mod$stVal"; + stVal.mms_item_name = "LLN0$ST$Mod$stVal"; + stVal.source = "GetVariableAccessAttributes"; + stVal.scl_basic_type = "INT32"; + stVal.mms_type = "integer"; + stVal.type_discovery_status = "Exact"; + stVal.type_source = "GetVariableAccessAttributes"; + stVal.type_confidence = mms::MmsLiveModelConfidence::exact; + object.attributes.push_back(stVal); + + mms::MmsLiveDataAttribute quality; + quality.object_reference = "IEDLIVELD0/LLN0.Mod.q"; + quality.attribute_path = "q"; + quality.functional_constraint = "ST"; + quality.mms_reference = "IEDLIVELD0/LLN0$ST$Mod$q"; + quality.mms_item_name = "LLN0$ST$Mod$q"; + quality.source = "GetVariableAccessAttributes"; + quality.scl_basic_type = "Quality"; + quality.mms_type = "bit-string"; + quality.type_discovery_status = "Exact"; + quality.type_source = "GetVariableAccessAttributes"; + quality.type_confidence = mms::MmsLiveModelConfidence::exact; + object.attributes.push_back(quality); + + node.data_objects.push_back(object); + node.functional_constraint_counts["ST"] = 2U; + device.logical_nodes.push_back(node); + model.logical_devices.push_back(device); + + mms::MmsLiveDataSet dataSet; + dataSet.reference = "IEDLIVELD0/LLN0.Status"; + dataSet.domain = "IEDLIVELD0"; + dataSet.logical_node = "LLN0"; + dataSet.name = "Status"; + dataSet.members.push_back({ + 0U, stVal.object_reference, "ST", stVal.mms_reference, + mms::MmsLiveModelConfidence::exact}); + dataSet.members.push_back({ + 1U, quality.object_reference, "ST", quality.mms_reference, + mms::MmsLiveModelConfidence::exact}); + dataSet.used_by_report_controls.push_back("IEDLIVELD0/LLN0.RP.StatusReport"); + dataSet.used_by_goose_controls.push_back("IEDLIVELD0/LLN0.GO.StatusGoose"); + model.data_sets.push_back(dataSet); + + mms::MmsLiveReportControl report; + report.reference = "IEDLIVELD0/LLN0.RP.StatusReport"; + report.domain = "IEDLIVELD0"; + report.logical_node = "LLN0"; + report.name = "StatusReport"; + report.buffered = false; + report.data_set_reference = dataSet.reference; + report.data_set_binding_status = "Bound"; + report.report_id = "IEDLIVE/Status"; + report.configuration_revision = "7"; + report.buffer_time_ms = "20"; + report.integrity_period_ms = "1000"; + model.report_controls.push_back(report); + + mms::MmsLiveControlBlock goose; + goose.kind = "GSEControl"; + goose.reference = "IEDLIVELD0/LLN0.GO.StatusGoose"; + goose.domain = "IEDLIVELD0"; + goose.logical_node = "LLN0"; + goose.name = "StatusGoose"; + goose.functional_constraint = "GO"; + goose.data_set_reference = dataSet.reference; + goose.data_set_reference_status = "ValueRead"; + goose.control_id = "IEDLIVE/StatusGoose"; + goose.app_id = "1001"; + goose.configuration_revision = "3"; + goose.minimum_time_ms = "4"; + goose.maximum_time_ms = "1000"; + goose.discovery_status = "ValueReadComplete"; + model.goose_control_blocks.push_back(goose); + + mms::MmsLiveControlBlock setting; + setting.kind = "SettingGroupControl"; + setting.reference = "IEDLIVELD0/LLN0.SP.SGCB"; + setting.domain = "IEDLIVELD0"; + setting.logical_node = "LLN0"; + setting.name = "SGCB"; + setting.functional_constraint = "SP"; + setting.discovery_status = "ValueReadComplete"; + setting.runtime_attributes.push_back( + {"ActSG", "IEDLIVELD0/LLN0$SP$SGCB$ActSG", "2", "ValueRead", std::nullopt}); + setting.runtime_attributes.push_back( + {"NumOfSG", "IEDLIVELD0/LLN0$SP$SGCB$NumOfSG", "4", "ValueRead", std::nullopt}); + model.setting_group_controls.push_back(setting); + + model.coverage.logical_device_count = 1U; + model.coverage.logical_node_count = 1U; + model.coverage.data_object_count = 1U; + model.coverage.data_attribute_count = 2U; + model.coverage.exact_functional_constraint_count = 2U; + model.coverage.exact_mms_type_count = 2U; + model.coverage.data_set_count = 1U; + model.coverage.report_control_count = 1U; + model.coverage.unbuffered_report_control_count = 1U; + model.coverage.report_control_bound_count = 1U; + model.coverage.goose_control_block_count = 1U; + model.coverage.setting_group_control_count = 1U; + return model; +} + +} // namespace + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + QTemporaryDir temp; + if (!temp.isValid()) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL temporary_directory\n"; + return 2; + } + + const auto live = makeLiveModel(); + IedEngineeringContextController context; + context.publishLiveDiscovery(live); + const auto fingerprintBeforeDisconnect = context.structuralFingerprint(); + context.setRuntimeOnline(false); + if (!context.loaded() || context.online() || + context.structuralFingerprint() != fingerprintBeforeDisconnect) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL offline_context\n"; + return 3; + } + + SclWorkspaceController workspace; + workspace.setEngineeringContext(&context); + const auto target = temp.filePath(QStringLiteral("IEDLIVE-discovered.iid")); + if (!workspace.engineeringContextExportSupported() || + !workspace.exportEngineeringContext(QUrl::fromLocalFile(target), QStringLiteral("ed2")) || + !waitFor([&] { return !workspace.busy(); })) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL export_start\n"; + return 4; + } + if (!workspace.lastExportVerified() || !workspace.lastError().isEmpty()) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL export_verify " + << workspace.lastError().toStdString() << "\n"; + return 5; + } + + scl::SclParser parser; + const auto reopened = parser.load(target.toStdString()); + if (reopened.edition != scl::SclEdition::edition2 || + reopened.ieds.size() != 1U || + reopened.ieds.front().name != "IEDLIVE" || + reopened.mms_access_points.size() != 1U || + reopened.mms_access_points.front().access_point_name != "P1" || + reopened.mms_access_points.front().ip_address != "192.0.2.44" || + reopened.mms_access_points.front().tcp_port != 8102U || + reopened.logical_nodes.size() != 1U || + reopened.model_entries.size() != 2U || + reopened.data_sets.size() != 1U || + reopened.data_sets.front().entries.size() != 2U || + reopened.data_sets.front().entries[0].da_name != "stVal" || + reopened.data_sets.front().entries[1].da_name != "q" || + reopened.report_controls.size() != 1U || + reopened.goose_streams.size() != 1U || + reopened.setting_controls.size() != 1U || + !reopened.setting_controls.front().valid() || + *reopened.setting_controls.front().number_of_setting_groups != 4U || + *reopened.setting_controls.front().active_setting_group != 2U) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL reopened_semantics\n"; + return 6; + } + + IedEngineeringContextController reopenedContext; + if (!reopenedContext.publishSclDocument( + reopened, QStringLiteral("IEDLIVE-discovered.iid")) || + !reopenedContext.loaded() || + reopenedContext.iedName() != QStringLiteral("IEDLIVE") || + reopenedContext.endpointHost() != QStringLiteral("192.0.2.44") || + reopenedContext.endpointPort() != 8102 || + reopenedContext.logicalDeviceCount() != 1 || + reopenedContext.logicalNodeCount() != 1 || + reopenedContext.dataAttributeCount() != 2 || + reopenedContext.dataSetCount() != 1 || + reopenedContext.reportCount() != 1 || + reopenedContext.gooseCount() != 1 || + reopenedContext.settingGroupCount() != 1) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL reopen_context\n"; + return 7; + } + + auto unresolved = live; + unresolved.logical_devices.front().logical_nodes.front() + .data_objects.front().inferred_cdc.clear(); + context.publishLiveDiscovery(unresolved); + const auto rejected = temp.filePath(QStringLiteral("unresolved.iid")); + if (workspace.exportEngineeringContext( + QUrl::fromLocalFile(rejected), QStringLiteral("ed2")) || + workspace.lastError().isEmpty()) { + std::cerr << "DISCOVERY_SCL_EXPORT_FAIL unresolved_not_rejected\n"; + return 8; + } + + std::cout << "DISCOVERY_SCL_EXPORT_PASS" + << " offline_save=true" + << " iid=true" + << " endpoint=192.0.2.44:8102" + << " dataset_order=true" + << " reports=true" + << " goose=true" + << " setting_groups=true" + << " reopen=true" + << " unresolved=fail_closed\n"; + return 0; +} diff --git a/apps/ied_simulator/src/ied_scl_workspace_qa.cpp b/apps/ied_simulator/src/ied_scl_workspace_qa.cpp index cfaa46e64..874dd794d 100644 --- a/apps/ied_simulator/src/ied_scl_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_scl_workspace_qa.cpp @@ -180,7 +180,10 @@ int main(int argc, char** argv) { return 16; } - for (const auto& extension : {QStringLiteral("icd"), QStringLiteral("cid")}) { + for (const auto& extension : { + QStringLiteral("iid"), + QStringLiteral("icd"), + QStringLiteral("cid")}) { const QString path = temp.filePath(QStringLiteral("converted.") + extension); if (!workspace.exportCanonical(QUrl::fromLocalFile(path), QStringLiteral("preserve")) || !waitFor([&] { return !workspace.busy(); }) || !workspace.lastExportVerified() || @@ -190,7 +193,7 @@ int main(int argc, char** argv) { } } - const QString unsupported = temp.filePath(QStringLiteral("rejected.iid")); + const QString unsupported = temp.filePath(QStringLiteral("rejected.ssd")); if (workspace.exportCanonical(QUrl::fromLocalFile(unsupported), QStringLiteral("preserve")) || workspace.lastError().isEmpty() || QFile::exists(unsupported)) { std::cerr << "Unsupported canonical profile did not fail closed.\n"; diff --git a/include/ariec61850/scl/exporter_part_00.inc b/include/ariec61850/scl/exporter_part_00.inc index d4378d836..992149007 100644 --- a/include/ariec61850/scl/exporter_part_00.inc +++ b/include/ariec61850/scl/exporter_part_00.inc @@ -18,6 +18,7 @@ namespace ar::iec61850::scl { enum class SclExportProfile { scd, + iid, icd, cid, }; @@ -114,6 +115,7 @@ inline std::string edition_attributes(const SclEdition edition) { inline std::string profile_name(const SclExportProfile profile) { switch (profile) { case SclExportProfile::scd: return "SCD"; + case SclExportProfile::iid: return "IID"; case SclExportProfile::icd: return "ICD"; case SclExportProfile::cid: return "CID"; } diff --git a/include/ariec61850/scl/exporter_part_01.inc b/include/ariec61850/scl/exporter_part_01.inc index 024834fd6..b8fdae597 100644 --- a/include/ariec61850/scl/exporter_part_01.inc +++ b/include/ariec61850/scl/exporter_part_01.inc @@ -26,7 +26,9 @@ inline bool validate_model( error = "Canonical SCL export requires a resolved DataTypeTemplates leaf model."; return false; } - if ((options.profile == SclExportProfile::icd || options.profile == SclExportProfile::cid) && + if ((options.profile == SclExportProfile::iid || + options.profile == SclExportProfile::icd || + options.profile == SclExportProfile::cid) && document.ieds.size() != 1U) { error = profile_name(options.profile) + " export requires exactly one modeled IED."; return false; @@ -211,10 +213,17 @@ inline void write_address( } inline void write_communication(std::ostringstream& out, const SclDocument& document) { - if (document.goose_streams.empty() && document.sampled_values_streams.empty()) return; + if (document.mms_access_points.empty() && + document.goose_streams.empty() && + document.sampled_values_streams.empty()) { + return; + } out << " \n" << " \n"; for (const auto& ied : document.ieds) { + const auto access_point = std::find_if( + document.mms_access_points.begin(), document.mms_access_points.end(), + [&](const auto& value) { return value.ied_name == ied.name; }); bool has_stream = false; for (const auto& stream : document.goose_streams) { if (stream.ied_name == ied.name) { has_stream = true; break; } @@ -224,7 +233,43 @@ inline void write_communication(std::ostringstream& out, const SclDocument& docu if (stream.ied_name == ied.name) { has_stream = true; break; } } } - if (!has_stream) continue; - out << " \n"; + if (access_point == document.mms_access_points.end() && !has_stream) continue; + const auto ap_name = + access_point != document.mms_access_points.end() && + !access_point->access_point_name.empty() + ? access_point->access_point_name + : std::string{"AP1"}; + out << " \n"; + if (access_point != document.mms_access_points.end() && + (!access_point->ip_address.empty() || access_point->tcp_port != 102U)) { + out << "
\n"; + if (!access_point->ip_address.empty()) { + out << "

" + << xml_escape(access_point->ip_address) << "

\n"; + } + if (access_point->tcp_port != 102U) { + out << "

" + << access_point->tcp_port << "

\n"; + } + out << "
\n"; + } for (const auto& stream : document.goose_streams) { if (stream.ied_name != ied.name) continue; + out << " \n"; + write_address(out, stream.address, 10); + out << " \n"; + } + for (const auto& stream : document.sampled_values_streams) { + if (stream.ied_name != ied.name) continue; + out << " \n"; + write_address(out, stream.address, 10); + out << " \n"; + } + out << "
\n"; + } + out << "
\n" + << "
\n"; +} diff --git a/include/ariec61850/scl/exporter_part_02.inc b/include/ariec61850/scl/exporter_part_02.inc index cfb65e8d2..72c684e68 100644 --- a/include/ariec61850/scl/exporter_part_02.inc +++ b/include/ariec61850/scl/exporter_part_02.inc @@ -1,21 +1,3 @@ - out << " \n"; - write_address(out, stream.address, 10); - out << " \n"; - } - for (const auto& stream : document.sampled_values_streams) { - if (stream.ied_name != ied.name) continue; - out << " \n"; - write_address(out, stream.address, 10); - out << " \n"; - } - out << " \n"; - } - out << " \n" - << " \n"; -} - inline void write_fcda(std::ostringstream& out, const SclDataSetEntry& entry) { out << " number_of_setting_groups + << "\" actSG=\"" << *found->active_setting_group << "\" />\n"; +} + inline void write_logical_node( std::ostringstream& out, const SclDocument& document, @@ -156,6 +156,7 @@ inline void write_logical_node( out << " lnType=\"" << xml_escape(ln_type_id(ln)) << "\">\n"; write_data_sets(out, document, ln); write_goose_and_smv(out, document, ln); + write_setting_control(out, document, ln); write_reports(out, document, ln); write_configured_values(out, document, ln); out << " \n"; @@ -167,7 +168,16 @@ inline void write_ieds(std::ostringstream& out, const SclDocument& document) { if (!ied.manufacturer.empty()) out << " manufacturer=\"" << xml_escape(ied.manufacturer) << "\""; if (!ied.type.empty()) out << " type=\"" << xml_escape(ied.type) << "\""; if (!ied.config_version.empty()) out << " configVersion=\"" << xml_escape(ied.config_version) << "\""; - out << ">\n \n \n"; + const auto access_point = std::find_if( + document.mms_access_points.begin(), document.mms_access_points.end(), + [&](const auto& value) { return value.ied_name == ied.name; }); + const auto ap_name = + access_point != document.mms_access_points.end() && + !access_point->access_point_name.empty() + ? access_point->access_point_name + : std::string{"AP1"}; + out << ">\n \n \n"; std::vector ld_order; for (const auto& ln : document.logical_nodes) { diff --git a/include/ariec61850/scl/live_model_projection.hpp b/include/ariec61850/scl/live_model_projection.hpp new file mode 100644 index 000000000..afba1c1d3 --- /dev/null +++ b/include/ariec61850/scl/live_model_projection.hpp @@ -0,0 +1,560 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/live_model.hpp" +#include "ariec61850/scl/model.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::scl { + +struct SclLiveModelProjectionResult final { + bool success{}; + SclDocument document; + std::string error; + std::vector warnings; +}; + +namespace live_projection_detail { + +inline std::optional parse_u32(const std::string_view text) { + if (text.empty()) return std::nullopt; + std::uint32_t value{}; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value, 10); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return std::nullopt; + return value; +} + +inline std::optional parse_app_id(const std::string_view text) { + if (text.empty()) return std::nullopt; + auto value = std::string{text}; + int base = 16; + if (value.size() > 2U && value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { + value.erase(0U, 2U); + } + if (value.empty()) return std::nullopt; + std::uint32_t parsedValue{}; + const auto parsed = std::from_chars( + value.data(), value.data() + value.size(), parsedValue, base); + if (parsed.ec != std::errc{} || parsed.ptr != value.data() + value.size() || + parsedValue > 0xFFFFU) { + return std::nullopt; + } + return static_cast(parsedValue); +} + +inline std::string normalize_app_id(const std::string_view text) { + const auto parsed = parse_app_id(text); + if (!parsed) return std::string{text}; + constexpr char hex[] = "0123456789ABCDEF"; + std::string result{"0x0000"}; + result[2] = hex[(*parsed >> 12U) & 0xFU]; + result[3] = hex[(*parsed >> 8U) & 0xFU]; + result[4] = hex[(*parsed >> 4U) & 0xFU]; + result[5] = hex[*parsed & 0xFU]; + return result; +} + +inline std::string signal_reference( + const mms::MmsLiveModelDocument& model, + const mms::MmsLiveLogicalDevice& device, + const mms::MmsLiveLogicalNode& node, + const mms::MmsLiveDataObject& object, + const mms::MmsLiveDataAttribute& attribute) { + return model.identity.ied_name + "/" + device.instance + "/" + node.name + "." + + object.name + "." + attribute.attribute_path + " [" + + attribute.functional_constraint + "]"; +} + +inline std::string data_set_reference( + const mms::MmsLiveModelDocument& model, + const mms::MmsLiveLogicalDevice& device, + const mms::MmsLiveLogicalNode& node, + const std::string_view name) { + return model.identity.ied_name + device.instance + "/" + node.name + "$" + + std::string{name}; +} + +inline std::string data_set_key( + const mms::MmsLiveModelDocument& model, + const mms::MmsLiveLogicalDevice& device, + const mms::MmsLiveLogicalNode& node, + const std::string_view name) { + return model.identity.ied_name + "|" + device.instance + "|" + node.name + "|" + + std::string{name}; +} + +inline std::string tail_name(const std::string_view reference) { + const auto dot = reference.find_last_of(".$"); + if (dot == std::string_view::npos || dot + 1U >= reference.size()) return std::string{reference}; + return std::string{reference.substr(dot + 1U)}; +} + +inline const mms::MmsLiveLogicalDevice* find_device( + const mms::MmsLiveModelDocument& model, + const std::string_view domain) { + const auto found = std::find_if( + model.logical_devices.begin(), model.logical_devices.end(), + [&](const auto& device) { return device.mms_domain == domain; }); + return found == model.logical_devices.end() ? nullptr : &*found; +} + +inline const mms::MmsLiveLogicalNode* find_node( + const mms::MmsLiveLogicalDevice& device, + const std::string_view name) { + const auto found = std::find_if( + device.logical_nodes.begin(), device.logical_nodes.end(), + [&](const auto& node) { return node.name == name; }); + return found == device.logical_nodes.end() ? nullptr : &*found; +} + +inline std::optional entry_from_attribute( + const mms::MmsLiveModelDocument& model, + const mms::MmsLiveLogicalDevice& device, + const mms::MmsLiveLogicalNode& node, + const mms::MmsLiveDataObject& object, + const mms::MmsLiveDataAttribute& attribute, + std::string& error) { + if (object.name.empty() || object.name.find('.') != std::string::npos) { + error = "Live-to-SCL projection requires a resolved top-level DataObject name: " + + object.reference; + return std::nullopt; + } + if (object.inferred_cdc.empty()) { + error = "Live-to-SCL projection refuses to invent an unresolved CDC for " + + object.reference; + return std::nullopt; + } + if (attribute.attribute_path.empty() || attribute.functional_constraint.empty()) { + error = "Live-to-SCL projection requires resolved DA path and FC for " + + attribute.object_reference; + return std::nullopt; + } + if (attribute.scl_basic_type.empty()) { + error = "Live-to-SCL projection refuses to invent an SCL basic type for " + + attribute.object_reference; + return std::nullopt; + } + if (attribute.scl_basic_type == "Enum" && + attribute.attribute_path != "ctlModel" && + !attribute.attribute_path.ends_with(".ctlModel")) { + error = "Live-to-SCL projection cannot reconstruct unknown Enum ordinals for " + + attribute.object_reference; + return std::nullopt; + } + + SclDataSetEntry entry; + entry.signal_reference = signal_reference(model, device, node, object, attribute); + entry.ied_name = model.identity.ied_name; + entry.ld_inst = device.instance; + entry.prefix = node.prefix; + entry.ln_class = node.logical_node_class; + entry.ln_inst = node.instance; + entry.do_name = object.name; + entry.da_name = attribute.attribute_path; + entry.functional_constraint = attribute.functional_constraint; + entry.cdc = object.inferred_cdc; + entry.basic_type = attribute.scl_basic_type; + if (entry.basic_type == "Enum") { + entry.enum_type = "CtlModelKind"; + entry.type_id = "CtlModelKind"; + } + entry.is_quality = entry.basic_type == "Quality"; + entry.is_timestamp = entry.basic_type == "Timestamp" || entry.basic_type == "EntryTime"; + return entry; +} + +inline std::string node_key( + const std::string_view domain, + const std::string_view node) { + return std::string{domain} + "\x1f" + std::string{node}; +} + +inline std::string attribute_key(const std::string_view reference) { + return std::string{reference}; +} + +inline std::string dataset_name(const mms::MmsLiveDataSet& dataSet) { + return !dataSet.name.empty() ? dataSet.name : tail_name(dataSet.reference); +} + +inline SclDataSetBindingStatus binding_status(const std::string_view value) { + if (value == "Bound") return SclDataSetBindingStatus::resolved; + if (value == "Unbound") return SclDataSetBindingStatus::resolved_empty; + if (value == "ReadFailed") return SclDataSetBindingStatus::unresolved; + return SclDataSetBindingStatus::not_specified; +} + +inline std::optional runtime_u32( + const mms::MmsLiveControlBlock& control, + const std::string_view attributePath) { + const auto found = std::find_if( + control.runtime_attributes.begin(), control.runtime_attributes.end(), + [&](const auto& attribute) { + return attribute.attribute_path == attributePath && + attribute.status == "ValueRead"; + }); + if (found == control.runtime_attributes.end()) return std::nullopt; + return parse_u32(found->value); +} + +} // namespace live_projection_detail + +inline SclLiveModelProjectionResult project_live_model_to_scl( + const mms::MmsLiveModelDocument& model, + const SclEdition edition = SclEdition::edition2) { + using namespace live_projection_detail; + SclLiveModelProjectionResult result; + + if (edition == SclEdition::unknown) { + result.error = "Live-to-SCL projection requires an explicit target edition."; + return result; + } + if (model.identity.ied_name.empty() || model.identity.ambiguous) { + result.error = + "Live-to-SCL projection requires one unambiguous discovered IED identity."; + return result; + } + if (model.logical_devices.empty()) { + result.error = "Live-to-SCL projection requires at least one discovered LogicalDevice."; + return result; + } + + auto& document = result.document; + document.source_name = model.identity.ied_name + ".discovered"; + document.header_id = "ARSTACK_DISCOVERY_" + model.identity.ied_name; + document.header_version = "1"; + document.header_revision = "0"; + document.edition = edition; + document.ieds.push_back({model.identity.ied_name, {}, {}, {}}); + + if (!model.endpoint.host.empty()) { + SclMmsAccessPoint endpoint; + endpoint.ied_name = model.identity.ied_name; + endpoint.access_point_name = + model.access_point_name.empty() ? "AP1" : model.access_point_name; + endpoint.ip_address = model.endpoint.host; + endpoint.tcp_port = model.endpoint.port; + document.mms_access_points.push_back(std::move(endpoint)); + } + + std::map> attributeByMmsReference; + std::map> attributeByObjectReference; + std::map> objectByReference; + std::map, std::less<>> objectLeavesByReference; + + for (const auto& device : model.logical_devices) { + if (device.instance.empty()) { + result.error = "Live-to-SCL projection found an unresolved LogicalDevice instance for " + + device.mms_domain; + return result; + } + for (const auto& node : device.logical_nodes) { + if (node.name.empty() || node.logical_node_class.empty()) { + result.error = "Live-to-SCL projection found an unresolved LogicalNode in " + + device.mms_domain; + return result; + } + SclLogicalNode logicalNode; + logicalNode.ied_name = model.identity.ied_name; + logicalNode.ld_inst = device.instance; + logicalNode.prefix = node.prefix; + logicalNode.ln_class = node.logical_node_class; + logicalNode.ln_inst = node.instance; + logicalNode.name = node.name; + document.logical_nodes.push_back(std::move(logicalNode)); + for (const auto& object : node.data_objects) { + for (const auto& attribute : object.attributes) { + std::string error; + auto projected = entry_from_attribute( + model, device, node, object, attribute, error); + if (!projected) { + result.error = std::move(error); + return result; + } + projected->index = document.model_entries.size() + 1U; + document.model_entries.push_back(*projected); + if (!attribute.mms_reference.empty()) { + attributeByMmsReference[attribute_key(attribute.mms_reference)] = *projected; + } + if (!attribute.object_reference.empty()) { + attributeByObjectReference[attribute_key(attribute.object_reference)] = *projected; + } + if (!object.reference.empty()) { + objectLeavesByReference[object.reference].push_back(*projected); + if (objectByReference.find(object.reference) == objectByReference.end()) { + auto wholeObject = *projected; + wholeObject.signal_reference = + model.identity.ied_name + "/" + device.instance + "/" + + node.name + "." + object.name + " [" + + attribute.functional_constraint + "]"; + wholeObject.da_name.clear(); + wholeObject.basic_type.clear(); + wholeObject.type_id.clear(); + wholeObject.enum_type.clear(); + wholeObject.is_quality = false; + wholeObject.is_timestamp = false; + objectByReference.emplace(object.reference, std::move(wholeObject)); + } + } + } + } + } + } + + std::map> dataSetsByLiveReference; + for (const auto& dataSet : model.data_sets) { + const auto* device = find_device(model, dataSet.domain); + const auto* node = device ? find_node(*device, dataSet.logical_node) : nullptr; + if (!device || !node || dataSet.reference.empty()) { + result.error = "Live-to-SCL projection cannot resolve DataSet owner " + dataSet.reference; + return result; + } + + SclDataSet projected; + projected.ied_name = model.identity.ied_name; + projected.ld_inst = device->instance; + projected.logical_node_path = node->name; + projected.name = dataset_name(dataSet); + projected.key = data_set_key(model, *device, *node, projected.name); + projected.reference = data_set_reference(model, *device, *node, projected.name); + + std::size_t configuredIndex = 1U; + std::size_t expandedIndex = 1U; + for (const auto& member : dataSet.members) { + auto found = attributeByMmsReference.end(); + if (!member.mms_reference.empty()) { + found = attributeByMmsReference.find(attribute_key(member.mms_reference)); + } + if (found == attributeByMmsReference.end() && !member.reference.empty()) { + const auto attributeFound = + attributeByObjectReference.find(attribute_key(member.reference)); + if (attributeFound != attributeByObjectReference.end()) { + auto entry = attributeFound->second; + entry.index = configuredIndex++; + projected.entries.push_back(entry); + entry.index = expandedIndex++; + projected.expanded_entries.push_back(std::move(entry)); + continue; + } + const auto objectFound = objectByReference.find(member.reference); + if (objectFound != objectByReference.end()) { + auto entry = objectFound->second; + entry.index = configuredIndex++; + entry.functional_constraint = member.functional_constraint.empty() + ? entry.functional_constraint + : member.functional_constraint; + entry.signal_reference = + model.identity.ied_name + "/" + device->instance + "/" + + node->name + "." + entry.do_name + " [" + + entry.functional_constraint + "]"; + projected.entries.push_back(entry); + + const auto leaves = objectLeavesByReference.find(member.reference); + const auto expandedBefore = projected.expanded_entries.size(); + if (leaves != objectLeavesByReference.end()) { + for (auto expanded : leaves->second) { + if (!member.functional_constraint.empty() && + expanded.functional_constraint != member.functional_constraint) { + continue; + } + expanded.index = expandedIndex++; + expanded.functional_constraint = entry.functional_constraint; + expanded.signal_reference = + model.identity.ied_name + "/" + device->instance + "/" + + node->name + "." + expanded.do_name + "." + + expanded.da_name + " [" + + expanded.functional_constraint + "]"; + projected.expanded_entries.push_back(std::move(expanded)); + } + } + if (projected.expanded_entries.size() == expandedBefore) { + result.error = + "Live-to-SCL projection cannot expand whole-DataObject member " + + member.reference + " for FC " + entry.functional_constraint; + return result; + } + continue; + } + } + if (found == attributeByMmsReference.end()) { + result.error = + "Live-to-SCL projection cannot resolve ordered DataSet member " + + std::to_string(configuredIndex) + " of " + dataSet.reference; + return result; + } + auto entry = found->second; + entry.index = configuredIndex++; + projected.entries.push_back(entry); + entry.index = expandedIndex++; + projected.expanded_entries.push_back(std::move(entry)); + } + + const auto projectedIndex = document.data_sets.size(); + document.data_sets.push_back(std::move(projected)); + dataSetsByLiveReference[dataSet.reference] = projectedIndex; + } + + const auto findDataSet = [&](const std::string& reference) -> const SclDataSet* { + if (const auto found = dataSetsByLiveReference.find(reference); + found != dataSetsByLiveReference.end() && + found->second < document.data_sets.size()) { + return &document.data_sets[found->second]; + } + return nullptr; + }; + + for (const auto& report : model.report_controls) { + const auto* device = find_device(model, report.domain); + const auto* node = device ? find_node(*device, report.logical_node) : nullptr; + if (!device || !node || report.name.empty()) { + result.error = "Live-to-SCL projection cannot resolve ReportControl owner " + + report.reference; + return result; + } + SclReportControl projected; + projected.ied_name = model.identity.ied_name; + projected.ld_inst = device->instance; + projected.logical_node_path = node->name; + projected.name = report.name; + projected.report_id = report.report_id; + projected.data_set_binding_status = binding_status(report.data_set_binding_status); + projected.control_block_reference = + model.identity.ied_name + device->instance + "/" + node->name + + (report.buffered ? "$BR$" : "$RP$") + report.name; + projected.buffered = report.buffered; + projected.indexed = false; + if (const auto value = parse_u32(report.configuration_revision)) { + projected.configuration_revision = *value; + } + if (const auto value = parse_u32(report.buffer_time_ms)) { + projected.buffer_time_milliseconds = *value; + } + if (const auto value = parse_u32(report.integrity_period_ms)) { + projected.integrity_period_milliseconds = *value; + } + if (const auto* dataSet = findDataSet(report.data_set_reference)) { + projected.data_set_name = dataSet->name; + projected.data_set_reference = dataSet->reference; + projected.entries = dataSet->entries; + } + document.report_controls.push_back(std::move(projected)); + } + + for (const auto& control : model.goose_control_blocks) { + const auto* device = find_device(model, control.domain); + const auto* node = device ? find_node(*device, control.logical_node) : nullptr; + if (!device || !node || control.name.empty()) { + result.error = "Live-to-SCL projection cannot resolve GOOSE owner " + + control.reference; + return result; + } + SclGooseStream projected; + projected.kind = "GOOSE"; + projected.ied_name = model.identity.ied_name; + projected.ld_inst = device->instance; + projected.control_name = control.name; + projected.control_block_reference = + model.identity.ied_name + device->instance + "/LLN0$GO$" + control.name; + if (const auto* dataSet = findDataSet(control.data_set_reference)) { + projected.data_set_name = dataSet->name; + projected.data_set_reference = dataSet->reference; + projected.entries = dataSet->expanded_entries.empty() + ? dataSet->entries + : dataSet->expanded_entries; + } + if (const auto value = parse_u32(control.configuration_revision)) { + projected.configuration_revision = *value; + } + projected.go_id = control.control_id; + projected.address.app_id_text = normalize_app_id(control.app_id); + projected.address.app_id = parse_app_id(control.app_id); + if (const auto value = parse_u32(control.minimum_time_ms)) { + projected.min_time_milliseconds = *value; + } + if (const auto value = parse_u32(control.maximum_time_ms)) { + projected.max_time_milliseconds = *value; + } + document.goose_streams.push_back(std::move(projected)); + } + + for (const auto& control : model.sampled_value_control_blocks) { + const auto* device = find_device(model, control.domain); + const auto* node = device ? find_node(*device, control.logical_node) : nullptr; + if (!device || !node || control.name.empty()) { + result.error = "Live-to-SCL projection cannot resolve SampledValue owner " + + control.reference; + return result; + } + SclSampledValuesStream projected; + projected.kind = "SMV"; + projected.ied_name = model.identity.ied_name; + projected.ld_inst = device->instance; + projected.control_name = control.name; + projected.control_block_reference = + model.identity.ied_name + device->instance + "/LLN0$SV$" + control.name; + if (const auto* dataSet = findDataSet(control.data_set_reference)) { + projected.data_set_name = dataSet->name; + projected.data_set_reference = dataSet->reference; + projected.entries = dataSet->expanded_entries.empty() + ? dataSet->entries + : dataSet->expanded_entries; + } + if (const auto value = parse_u32(control.configuration_revision)) { + projected.configuration_revision = *value; + } + projected.smv_id = control.smv_id; + projected.sv_id = control.smv_id; + projected.address.app_id_text = normalize_app_id(control.app_id); + projected.address.app_id = parse_app_id(control.app_id); + if (const auto value = parse_u32(control.sample_rate); + value && *value <= 0xFFFFU) { + projected.sample_rate = static_cast(*value); + } + projected.sample_mode = control.sample_mode; + if (const auto value = parse_u32(control.number_of_asdu); + value && *value > 0U && *value <= 0xFFFFU) { + projected.no_asdu = static_cast(*value); + } + document.sampled_values_streams.push_back(std::move(projected)); + } + + for (const auto& control : model.setting_group_controls) { + const auto* device = find_device(model, control.domain); + const auto* node = device ? find_node(*device, control.logical_node) : nullptr; + if (!device || !node) continue; + const auto num = runtime_u32(control, "NumOfSG"); + const auto active = runtime_u32(control, "ActSG"); + if (!num || !active || *num == 0U || *active == 0U || *active > *num) { + result.warnings.push_back( + "SettingGroupControl " + control.reference + + " has incomplete live ActSG/NumOfSG evidence and is omitted from SCL."); + continue; + } + SclSettingControl projected; + projected.ied_name = model.identity.ied_name; + projected.ld_inst = device->instance; + projected.logical_node_path = node->name; + projected.control_block_reference = + model.identity.ied_name + device->instance + "/" + node->name + "$SP$SGCB"; + projected.number_of_setting_groups = *num; + projected.active_setting_group = *active; + document.setting_controls.push_back(std::move(projected)); + } + + document.warnings.push_back( + "Projected from live MMS discovery; vendor XML extensions, original type IDs, descriptions and Substation topology are not recoverable from MMS."); + result.warnings = document.warnings; + result.success = true; + return result; +} + +} // namespace ar::iec61850::scl