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
7 changes: 6 additions & 1 deletion .github/workflows/ied-simulator-qt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions apps/ied_simulator/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions apps/ied_simulator/qml/IedBrowserWorkspace.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/ied_simulator/src/SclWorkspaceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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_; }
Expand All @@ -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;

Expand Down
11 changes: 11 additions & 0 deletions apps/ied_simulator/src/SclWorkspaceController_part_00.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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 <QFile>
Expand Down Expand Up @@ -61,6 +62,7 @@ std::optional<scl::SclEdition> requestedEdition(
std::optional<scl::SclExportProfile> 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;
Expand All @@ -69,6 +71,7 @@ std::optional<scl::SclExportProfile> 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");
}
Expand Down Expand Up @@ -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<scl::SclDocument> document;
Expand Down
12 changes: 12 additions & 0 deletions apps/ied_simulator/src/SclWorkspaceController_part_01.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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_);
}
Expand Down
154 changes: 154 additions & 0 deletions apps/ied_simulator/src/SclWorkspaceController_part_02.inc
Original file line number Diff line number Diff line change
Expand Up @@ -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<SclWorkspaceController> 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<std::size_t>(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<qsizetype>(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."));
Expand Down
Loading
Loading