From 3d3ec5dfe7dedd0ecbc9c3753023847fcac3f6bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:45:59 +0700 Subject: [PATCH 01/24] P6E: define bounded multi-IED workspace owner --- .../src/IedBrowserFleetController.hpp | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 apps/ied_simulator/src/IedBrowserFleetController.hpp diff --git a/apps/ied_simulator/src/IedBrowserFleetController.hpp b/apps/ied_simulator/src/IedBrowserFleetController.hpp new file mode 100644 index 000000000..c39efa9ca --- /dev/null +++ b/apps/ied_simulator/src/IedBrowserFleetController.hpp @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "IedBrowserSessionController.hpp" +#include "SclWorkspaceController.hpp" + +#include +#include +#include + +#include +#include + +// A slot owns every Browser service for exactly one engineering context. +// Active selection changes only the presentation pointer. It never rebinds +// one live MMS association to a different IED or tears down another slot. +class IedBrowserFleetController : public QAbstractListModel { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(int workspaceCount READ workspaceCount NOTIFY workspacesChanged) + Q_PROPERTY(int activeIndex READ activeIndex NOTIFY activeChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) + Q_PROPERTY(IedEngineeringContextController* activeContext READ activeContext NOTIFY activeChanged) + Q_PROPERTY(IedBrowserSessionController* activeSession READ activeSession NOTIFY activeChanged) + Q_PROPERTY(MmsClientController* activeClient READ activeClient NOTIFY activeChanged) + Q_PROPERTY(MmsReportController* activeReports READ activeReports NOTIFY activeChanged) + Q_PROPERTY(MmsControlController* activeControls READ activeControls NOTIFY activeChanged) + Q_PROPERTY(MmsFileSettingsController* activeUtilities READ activeUtilities NOTIFY activeChanged) + Q_PROPERTY(SclWorkspaceController* activeEngineering READ activeEngineering NOTIFY activeChanged) + +public: + enum Role { + SlotIdRole = Qt::UserRole + 1, + LabelRole, + EndpointRole, + OnlineRole, + BusyRole, + LoadedRole, + AuthorityRole, + }; + static constexpr int maximumWorkspaces = 8; + + explicit IedBrowserFleetController(QObject* parent = nullptr); + ~IedBrowserFleetController() override; + + [[nodiscard]] int rowCount(const QModelIndex& parent = {}) const override; + [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; + [[nodiscard]] QHash roleNames() const override; + + [[nodiscard]] int workspaceCount() const noexcept { + return static_cast(entries_.size()); + } + [[nodiscard]] int activeIndex() const noexcept { return activeIndex_; } + [[nodiscard]] QString lastError() const { return lastError_; } + [[nodiscard]] IedEngineeringContextController* activeContext() const; + [[nodiscard]] IedBrowserSessionController* activeSession() const; + [[nodiscard]] MmsClientController* activeClient() const; + [[nodiscard]] MmsReportController* activeReports() const; + [[nodiscard]] MmsControlController* activeControls() const; + [[nodiscard]] MmsFileSettingsController* activeUtilities() const; + [[nodiscard]] SclWorkspaceController* activeEngineering() const; + + Q_INVOKABLE IedEngineeringContextController* contextAt(int index) const; + Q_INVOKABLE IedBrowserSessionController* sessionAt(int index) const; + Q_INVOKABLE MmsClientController* clientAt(int index) const; + Q_INVOKABLE MmsReportController* reportsAt(int index) const; + Q_INVOKABLE MmsControlController* controlsAt(int index) const; + Q_INVOKABLE MmsFileSettingsController* utilitiesAt(int index) const; + Q_INVOKABLE SclWorkspaceController* engineeringAt(int index) const; + Q_INVOKABLE bool switchTo(int index); + Q_INVOKABLE bool newWorkspace(); + Q_INVOKABLE bool prepareForNewSource(); + Q_INVOKABLE bool openSclIedInNewWorkspace(const QString& iedName); + Q_INVOKABLE bool closeWorkspace(int index); + +signals: + void workspacesChanged(); + void activeChanged(); + void stateChanged(); + +private: + struct Entry; + [[nodiscard]] Entry* entryAt(int index) const noexcept; + [[nodiscard]] Entry* activeEntry() const noexcept; + void connectEntry(Entry* entry); + void emitEntryChanged(const Entry* entry); + bool fail(const QString& message); + void clearError(); + + std::vector> entries_; + int activeIndex_{-1}; + quint64 nextSlotId_{1}; + QString lastError_; +}; From 289dc69f085106a90be39f117f00ef21af83d05d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:46:47 +0700 Subject: [PATCH 02/24] P6E: own independent IED contexts and service associations per workspace --- .../src/IedBrowserFleetController.cpp | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 apps/ied_simulator/src/IedBrowserFleetController.cpp diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp new file mode 100644 index 000000000..cfa8e837b --- /dev/null +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "IedBrowserFleetController.hpp" + +#include +#include + +struct IedBrowserFleetController::Entry final { + explicit Entry(const quint64 identifier) + : id(identifier), + context(std::make_unique()), + client(std::make_unique()), + reports(std::make_unique()), + utilities(std::make_unique()), + controls(std::make_unique()), + engineering(std::make_unique()), + session(std::make_unique()) { + session->setClient(client.get()); + session->setReports(reports.get()); + session->setUtilities(utilities.get()); + session->setControls(controls.get()); + session->setEngineeringContext(context.get()); + engineering->setEngineeringContext(context.get()); + } + + quint64 id{}; + std::unique_ptr context; + std::unique_ptr client; + std::unique_ptr reports; + std::unique_ptr utilities; + std::unique_ptr controls; + std::unique_ptr engineering; + // Destructed first: it holds only observing pointers to the above services. + std::unique_ptr session; +}; + +IedBrowserFleetController::IedBrowserFleetController(QObject* parent) + : QAbstractListModel(parent) { + entries_.push_back(std::make_unique(nextSlotId_++)); + activeIndex_ = 0; + connectEntry(entries_.front().get()); +} + +IedBrowserFleetController::~IedBrowserFleetController() = default; + +int IedBrowserFleetController::rowCount(const QModelIndex& parent) const { + return parent.isValid() ? 0 : workspaceCount(); +} + +QHash IedBrowserFleetController::roleNames() const { + return { + {SlotIdRole, "slotId"}, + {LabelRole, "label"}, + {EndpointRole, "endpoint"}, + {OnlineRole, "online"}, + {BusyRole, "busy"}, + {LoadedRole, "loaded"}, + {AuthorityRole, "authority"}, + }; +} + +QVariant IedBrowserFleetController::data(const QModelIndex& index, const int role) const { + const auto* entry = entryAt(index.row()); + if (!entry || !index.isValid() || index.column() != 0) return {}; + const auto* context = entry->context.get(); + const auto* session = entry->session.get(); + switch (role) { + case SlotIdRole: return static_cast(entry->id); + case LabelRole: + if (!context->iedName().isEmpty()) return context->iedName(); + if (context->selectionRequired()) return QStringLiteral("Select IED · %1").arg(entry->id); + return QStringLiteral("IED %1").arg(entry->id); + case EndpointRole: return session->endpoint(); + case OnlineRole: return session->connected(); + case BusyRole: return session->busy() || entry->engineering->busy(); + case LoadedRole: return context->loaded(); + case AuthorityRole: return context->authorityKey(); + default: return {}; + } +} + +IedBrowserFleetController::Entry* IedBrowserFleetController::entryAt(const int index) const noexcept { + return index >= 0 && index < workspaceCount() + ? entries_[static_cast(index)].get() : nullptr; +} +IedBrowserFleetController::Entry* IedBrowserFleetController::activeEntry() const noexcept { + return entryAt(activeIndex_); +} +IedEngineeringContextController* IedBrowserFleetController::contextAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->context.get() : nullptr; +} +IedBrowserSessionController* IedBrowserFleetController::sessionAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->session.get() : nullptr; +} +MmsClientController* IedBrowserFleetController::clientAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->client.get() : nullptr; +} +MmsReportController* IedBrowserFleetController::reportsAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->reports.get() : nullptr; +} +MmsControlController* IedBrowserFleetController::controlsAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->controls.get() : nullptr; +} +MmsFileSettingsController* IedBrowserFleetController::utilitiesAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->utilities.get() : nullptr; +} +SclWorkspaceController* IedBrowserFleetController::engineeringAt(const int index) const { + auto* entry = entryAt(index); + return entry ? entry->engineering.get() : nullptr; +} +IedEngineeringContextController* IedBrowserFleetController::activeContext() const { + return contextAt(activeIndex_); +} +IedBrowserSessionController* IedBrowserFleetController::activeSession() const { + return sessionAt(activeIndex_); +} +MmsClientController* IedBrowserFleetController::activeClient() const { + return clientAt(activeIndex_); +} +MmsReportController* IedBrowserFleetController::activeReports() const { + return reportsAt(activeIndex_); +} +MmsControlController* IedBrowserFleetController::activeControls() const { + return controlsAt(activeIndex_); +} +MmsFileSettingsController* IedBrowserFleetController::activeUtilities() const { + return utilitiesAt(activeIndex_); +} +SclWorkspaceController* IedBrowserFleetController::activeEngineering() const { + return engineeringAt(activeIndex_); +} + +void IedBrowserFleetController::connectEntry(Entry* entry) { + const auto id = entry->id; + const auto notify = [this, id] { + const auto found = std::find_if(entries_.begin(), entries_.end(), [id](const auto& item) { + return item->id == id; + }); + if (found == entries_.end()) return; + const auto row = static_cast(std::distance(entries_.begin(), found)); + emit dataChanged(index(row), index(row)); + }; + connect(entry->context.get(), &IedEngineeringContextController::contextChanged, this, notify); + connect(entry->context.get(), &IedEngineeringContextController::runtimeChanged, this, notify); + connect(entry->session.get(), &IedBrowserSessionController::stateChanged, this, notify); + connect(entry->session.get(), &IedBrowserSessionController::configurationChanged, this, notify); + connect(entry->engineering.get(), &SclWorkspaceController::stateChanged, this, notify); +} + +bool IedBrowserFleetController::fail(const QString& message) { + lastError_ = message; + emit stateChanged(); + return false; +} +void IedBrowserFleetController::clearError() { + if (lastError_.isEmpty()) return; + lastError_.clear(); + emit stateChanged(); +} + +bool IedBrowserFleetController::switchTo(const int index) { + if (!entryAt(index)) return fail(QStringLiteral("The requested IED workspace does not exist.")); + clearError(); + if (activeIndex_ == index) return true; + activeIndex_ = index; + emit activeChanged(); + return true; +} + +bool IedBrowserFleetController::newWorkspace() { + if (workspaceCount() >= maximumWorkspaces) { + return fail(QStringLiteral("Maximum of %1 concurrent IED workspaces reached.") + .arg(maximumWorkspaces)); + } + const int next = workspaceCount(); + beginInsertRows({}, next, next); + entries_.push_back(std::make_unique(nextSlotId_++)); + endInsertRows(); + connectEntry(entries_.back().get()); + clearError(); + emit workspacesChanged(); + activeIndex_ = next; + emit activeChanged(); + return true; +} + +bool IedBrowserFleetController::prepareForNewSource() { + const auto* entry = activeEntry(); + if (!entry) return fail(QStringLiteral("No active IED workspace.")); + if (!entry->context->hasSource() && !entry->session->connected() && + !entry->session->busy() && !entry->engineering->busy()) { + clearError(); + return true; + } + return newWorkspace(); +} + +bool IedBrowserFleetController::openSclIedInNewWorkspace(const QString& iedName) { + const auto* original = activeEntry(); + if (!original || original->context->authorityKey() != QStringLiteral("scl") || + !original->context->sourceSclDocument()) { + return fail(QStringLiteral("Open an SCL engineering source before adding another IED.")); + } + const auto name = iedName.trimmed(); + if (name.isEmpty() || !original->context->candidateIeds().contains(name)) { + return fail(QStringLiteral("Selected IED is not present in the active SCL source.")); + } + const auto sourcePath = original->context->sourcePath(); + for (int row = 0; row < workspaceCount(); ++row) { + const auto* existing = contextAt(row); + if (existing->authorityKey() == QStringLiteral("scl") && + existing->sourcePath() == sourcePath && existing->iedName() == name) { + return switchTo(row); + } + } + const auto* source = original->context->sourceSclDocument(); + if (!newWorkspace()) return false; + if (activeContext()->publishSclDocument(*source, sourcePath, name)) return true; + const auto error = activeContext()->lastError(); + const auto createdIndex = activeIndex_; + // Failed source creation is discarded; the original slot remains intact. + static_cast(closeWorkspace(createdIndex)); + return fail(error.isEmpty() ? QStringLiteral("Cannot activate requested SCL IED.") : error); +} + +bool IedBrowserFleetController::closeWorkspace(const int index) { + auto* entry = entryAt(index); + if (!entry) return fail(QStringLiteral("The requested IED workspace does not exist.")); + if (workspaceCount() <= 1) { + return fail(QStringLiteral("Keep at least one IED workspace open.")); + } + if (entry->session->connected() || entry->session->busy() || + entry->reports->connected() || entry->reports->busy() || + entry->reports->cleanupRequired() || + entry->utilities->connected() || entry->utilities->busy() || + entry->controls->connected() || entry->controls->busy() || + entry->engineering->busy()) { + return fail(QStringLiteral( + "Disconnect this IED and finish pending service operations before closing its workspace.")); + } + beginRemoveRows({}, index, index); + entries_.erase(entries_.begin() + index); + endRemoveRows(); + clearError(); + emit workspacesChanged(); + if (activeIndex_ > index) --activeIndex_; + else if (activeIndex_ == index) activeIndex_ = std::min(index, workspaceCount() - 1); + emit activeChanged(); + return true; +} From 82a5175ca6d93c7eedb337deaa88c4937c55a6e2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:47:11 +0700 Subject: [PATCH 03/24] P6E: expose read-only SCL source for per-IED workspace fork --- apps/ied_simulator/src/IedEngineeringContextController.hpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ied_simulator/src/IedEngineeringContextController.hpp b/apps/ied_simulator/src/IedEngineeringContextController.hpp index 85a156ea5..31a19250d 100644 --- a/apps/ied_simulator/src/IedEngineeringContextController.hpp +++ b/apps/ied_simulator/src/IedEngineeringContextController.hpp @@ -112,6 +112,12 @@ class IedEngineeringContextController : public QObject { return model_.get(); } + // Read-only SCL authority for opening another IED from the same source in + // a distinct workspace; the originating context retains its own model. + [[nodiscard]] const ar::iec61850::scl::SclDocument* sourceSclDocument() const noexcept { + return authority_ == Authority::openedScl ? sclSource_.get() : nullptr; + } + signals: void contextChanged(); void runtimeChanged(); From 953c0a66b87abf20318ae23aa2936778edaf8b34 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:48:15 +0700 Subject: [PATCH 04/24] P6E: render independent persistent IED Browser tabs and active service pointers --- apps/ied_simulator/qml/Main.qml | 195 ++++++++++++++++++++++++-------- 1 file changed, 150 insertions(+), 45 deletions(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 2dd57b91f..4c61c3433 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -20,6 +20,13 @@ ApplicationWindow { property bool persistenceReady: false property bool mmsWasConnected: false property bool simulatorAutoSelectPending: true + property var iedContext: fleet.activeContext + property var mmsClient: fleet.activeClient + property var reports: fleet.activeReports + property var controls: fleet.activeControls + property var utilities: fleet.activeUtilities + property var browserSession: fleet.activeSession + property var sclWorkspace: fleet.activeEngineering onWorkspaceIndexChanged: { if (root.persistenceReady) hardening.workspaceIndex = root.workspaceIndex @@ -30,46 +37,14 @@ ApplicationWindow { id: hardening objectName: "productHardeningBackend" } - IedEngineeringContextController { - id: iedContext - objectName: "iedEngineeringContextBackend" + IedBrowserFleetController { + id: fleet + objectName: "iedBrowserFleetBackend" } IedFleetController { id: simulator objectName: "simulatorBackend" } - MmsClientController { - id: mmsClient - objectName: "mmsClientBackend" - engineeringContext: iedContext - } - MmsReportController { - id: reports - objectName: "mmsReportBackend" - } - MmsControlController { - id: controls - objectName: "mmsControlBackend" - engineeringContext: iedContext - } - MmsFileSettingsController { - id: utilities - objectName: "mmsFileSettingsBackend" - } - IedBrowserSessionController { - id: browserSession - objectName: "iedBrowserSessionBackend" - client: mmsClient - reports: reports - utilities: utilities - controls: controls - engineeringContext: iedContext - } - SclWorkspaceController { - id: sclWorkspace - objectName: "sclWorkspaceBackend" - engineeringContext: iedContext - } GooseMonitorController { id: gooseMonitor objectName: "gooseMonitorBackend" @@ -85,6 +60,13 @@ ApplicationWindow { root.persistenceReady = true } + Connections { + target: fleet + function onActiveChanged() { + root.mmsWasConnected = mmsClient.connected + } + } + Connections { target: mmsClient function onStateChanged() { @@ -141,6 +123,8 @@ ApplicationWindow { title: "Open IEC 61850 engineering model" nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] onAccepted: { + if (!fleet.prepareForNewSource()) + return simulator.loadFileAsync(selectedFile) sclWorkspace.openFile(selectedFile) } @@ -326,16 +310,137 @@ ApplicationWindow { onSnifferRequested: root.workspaceIndex = 3 } - IedBrowserWorkspace { - theme: appTheme - productState: hardening - session: browserSession - client: mmsClient - context: iedContext - reports: reports - utilities: utilities - controls: controls - engineering: sclWorkspace + Item { + ColumnLayout { + anchors.fill: parent + spacing: 0 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 34 + color: appTheme.chrome + border.width: 1 + border.color: appTheme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + spacing: 6 + ListView { + id: iedWorkspaceTabs + Layout.fillWidth: true + Layout.fillHeight: true + orientation: ListView.Horizontal + clip: true + spacing: 4 + model: fleet + delegate: Rectangle { + required property int index + required property string label + required property bool online + required property bool busy + required property string endpoint + width: Math.min(200, Math.max(125, tabTitle.implicitWidth + 63)) + height: 29 + radius: 4 + color: index === fleet.activeIndex + ? appTheme.accentSoft : appTheme.surface + border.width: 1 + border.color: index === fleet.activeIndex + ? appTheme.accent : appTheme.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: 7 + anchors.rightMargin: 4 + spacing: 4 + Rectangle { + width: 6; height: 6; radius: 3 + color: online ? appTheme.green + : busy ? appTheme.amber : appTheme.muted + } + Label { + id: tabTitle + Layout.fillWidth: true + text: label + elide: Text.ElideRight + color: appTheme.text + font.pixelSize: 9 + } + Button { + text: "×" + implicitWidth: 22 + implicitHeight: 23 + enabled: fleet.workspaceCount > 1 && !online && !busy + onClicked: fleet.closeWorkspace(index) + ToolTip.visible: hovered + ToolTip.text: "Close this offline IED workspace; disconnect active services first." + } + } + MouseArea { + anchors.fill: parent + anchors.rightMargin: 27 + onClicked: fleet.switchTo(index) + } + ToolTip.visible: tabMouse.containsMouse + ToolTip.text: endpoint + MouseArea { + id: tabMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + } + Button { + text: "+ IED" + enabled: fleet.workspaceCount < 8 + onClicked: fleet.newWorkspace() + ToolTip.visible: hovered + ToolTip.text: "Create an independent IED context and Browser session (max 8)." + } + Label { + visible: fleet.lastError.length > 0 + text: fleet.lastError + color: appTheme.red + font.pixelSize: 8 + Layout.maximumWidth: 260 + elide: Text.ElideRight + ToolTip.visible: fleetErrorMouse.containsMouse + ToolTip.text: fleet.lastError + MouseArea { + id: fleetErrorMouse + anchors.fill: parent + hoverEnabled: true + } + } + } + } + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: fleet.activeIndex + + // Every delegate remains alive while its IED slot exists: + // independent navigation, Global Data, report and control + // dialogs never migrate to another IED on tab switch. + Repeater { + model: fleet + IedBrowserWorkspace { + required property int index + theme: appTheme + productState: hardening + fleet: fleet + session: fleet.sessionAt(index) + client: fleet.clientAt(index) + context: fleet.contextAt(index) + reports: fleet.reportsAt(index) + utilities: fleet.utilitiesAt(index) + controls: fleet.controlsAt(index) + engineering: fleet.engineeringAt(index) + } + } + } + } } Item { From 662793cff33e291f3105a02d4e1c60b0037ce5e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:49:01 +0700 Subject: [PATCH 05/24] P6E: add per-IED SCL source fork and new Browser workspace flow --- .../ied_simulator/qml/IedBrowserWorkspace.qml | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 4e73050c5..732970110 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -8,6 +8,7 @@ Item { id: root required property var theme + required property var fleet required property var session required property var client required property var context @@ -84,7 +85,10 @@ Item { title: "Open IEC 61850 engineering model" fileMode: FileDialog.OpenFile nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] - onAccepted: engineering.openFile(selectedFile) + onAccepted: { + if (fleet.prepareForNewSource()) + fleet.activeEngineering.openFile(selectedFile) + } } FileDialog { @@ -263,9 +267,9 @@ Item { } Rectangle { - visible: context.selectionRequired + visible: context.authorityKey === "scl" && context.candidateIeds.length > 1 Layout.fillWidth: true - Layout.preferredHeight: context.selectionRequired ? 42 : 0 + Layout.preferredHeight: visible ? 42 : 0 color: root.theme.amberSoft border.width: 1 border.color: root.theme.amber @@ -276,7 +280,9 @@ Item { anchors.rightMargin: 14 spacing: 8 Label { - text: "Multi-IED SCL · select the active IED" + text: context.selectionRequired + ? "Multi-IED SCL · select active IED" + : "Multi-IED SCL · open another IED in its own workspace" color: root.theme.text font.pixelSize: 9 font.weight: Font.DemiBold @@ -289,10 +295,19 @@ Item { } Button { text: "Use IED" + visible: context.selectionRequired enabled: !session.configurationLocked && activeIedPicker.currentIndex >= 0 onClicked: context.selectIed(activeIedPicker.currentText) } + Button { + text: "Open in new workspace" + enabled: activeIedPicker.currentIndex >= 0 + && fleet.workspaceCount < 8 + onClicked: fleet.openSclIedInNewWorkspace(activeIedPicker.currentText) + ToolTip.visible: hovered + ToolTip.text: "Keep this IED intact; open the selected IED from the same SCL source in a separate Browser slot." + } Item { Layout.fillWidth: true } Label { text: context.lastError From 72403e9b5e49a7e9e5c173e0550c97191a2ecdf7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:49:25 +0700 Subject: [PATCH 06/24] P6E: retain trusted SCL provenance for each cloned IED --- apps/ied_simulator/src/IedBrowserFleetController.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp index cfa8e837b..57ba41f6e 100644 --- a/apps/ied_simulator/src/IedBrowserFleetController.cpp +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -220,7 +220,12 @@ bool IedBrowserFleetController::openSclIedInNewWorkspace(const QString& iedName) } const auto* source = original->context->sourceSclDocument(); if (!newWorkspace()) return false; - if (activeContext()->publishSclDocument(*source, sourcePath, name)) return true; + if (activeContext()->publishSclDocument(*source, sourcePath, name)) { + // Each cloned context retains the exact file provenance for its own + // independent SCL-assisted association, without re-reading the file. + activeSession()->setTrustedSclPath(sourcePath); + return true; + } const auto error = activeContext()->lastError(); const auto createdIndex = activeIndex_; // Failed source creation is discarded; the original slot remains intact. From 57b93ba3884ee610bf30b68df969334facd38698 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:50:30 +0700 Subject: [PATCH 07/24] P6E: build fleet owner in app and Browser session QA --- apps/ied_simulator/CMakeLists.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 1e75dac6f..7a108b9fa 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -70,6 +70,8 @@ qt_add_qml_module(arstack_ied_simulator src/MmsControlController.hpp src/IedBrowserSessionController.cpp src/IedBrowserSessionController.hpp + src/IedBrowserFleetController.cpp + src/IedBrowserFleetController.hpp src/MmsReportController.cpp src/MmsReportController.hpp src/MmsSettingGroupPolicy.hpp @@ -355,6 +357,10 @@ add_executable(ied_browser_session_qa src/ied_browser_session_qa.cpp src/IedBrowserSessionController.cpp src/IedBrowserSessionController.hpp + src/IedBrowserFleetController.cpp + src/IedBrowserFleetController.hpp + src/SclWorkspaceController.cpp + src/SclWorkspaceController.hpp src/MmsLiveTreeModel.cpp src/MmsLiveTreeModel.hpp src/IedEngineeringContextController.cpp From 709e12d4ac76b9208e275726234ba526aefa3067 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:51:19 +0700 Subject: [PATCH 08/24] P6E: behavioral QA for isolated IED slots, switching, SCL fork and bounds --- .../src/ied_browser_session_qa.cpp | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/apps/ied_simulator/src/ied_browser_session_qa.cpp b/apps/ied_simulator/src/ied_browser_session_qa.cpp index 8dd0e36be..268c8ca24 100644 --- a/apps/ied_simulator/src/ied_browser_session_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_session_qa.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "IedBrowserSessionController.hpp" +#include "IedBrowserFleetController.hpp" #include @@ -198,6 +199,127 @@ int main(int argc, char** argv) { return 8; } + // P6E: each live Browser service and canonical engineering authority is + // independently owned. Switching the presentation never retargets a + // previously created controller or destroys another IED's model. + IedBrowserFleetController fleet; + auto multi = document; + multi.ieds.push_back({"BACKUPIED", "ARStack", "QA", "1"}); + auto secondAccessPoint = accessPoint; + secondAccessPoint.ied_name = "BACKUPIED"; + secondAccessPoint.ip_address = "192.0.2.88"; + multi.mms_access_points.push_back(secondAccessPoint); + auto secondLn = lln0; + secondLn.ied_name = "BACKUPIED"; + multi.logical_nodes.push_back(secondLn); + auto secondStatus = status; + secondStatus.ied_name = "BACKUPIED"; + secondStatus.signal_reference = "BACKUPIEDLD0/LLN0.Mod.stVal"; + multi.model_entries.push_back(secondStatus); + + if (fleet.workspaceCount() != 1 || fleet.activeIndex() != 0 || + !fleet.activeContext()->publishSclDocument( + multi, QStringLiteral("/tmp/multi-ied.scd"), + QStringLiteral("BROWSERIED"))) { + std::cerr << "P6E initial canonical workspace failed.\\n"; + return 20; + } + auto* originalContext = fleet.activeContext(); + auto* originalSession = fleet.activeSession(); + auto* originalClient = fleet.activeClient(); + auto* originalReports = fleet.activeReports(); + auto* originalControls = fleet.activeControls(); + auto* originalUtilities = fleet.activeUtilities(); + auto* originalEngineering = fleet.activeEngineering(); + if (originalSession->host() != QStringLiteral("192.0.2.77") || + !fleet.openSclIedInNewWorkspace(QStringLiteral("BACKUPIED")) || + fleet.workspaceCount() != 2 || fleet.activeIndex() != 1 || + fleet.activeContext() == originalContext || + fleet.activeClient() == originalClient || + fleet.activeReports() == originalReports || + fleet.activeControls() == originalControls || + fleet.activeUtilities() == originalUtilities || + fleet.activeEngineering() == originalEngineering || + fleet.activeSession() == originalSession || + fleet.activeContext()->iedName() != QStringLiteral("BACKUPIED") || + fleet.activeSession()->host() != QStringLiteral("192.0.2.88") || + fleet.activeClient()->engineeringContext() != fleet.activeContext() || + fleet.activeReports()->engineeringContext() != fleet.activeContext() || + fleet.activeControls()->engineeringContext() != fleet.activeContext() || + fleet.activeUtilities()->engineeringContext() != fleet.activeContext() || + fleet.activeSession()->trustedSclPath() != QStringLiteral("/tmp/multi-ied.scd")) { + std::cerr << "P6E independent context/service fork failed.\\n"; + return 21; + } + auto* secondContext = fleet.activeContext(); + auto* secondSession = fleet.activeSession(); + secondSession->setHost(QStringLiteral("192.0.2.199")); + if (!fleet.switchTo(0) || + fleet.activeContext() != originalContext || + fleet.activeSession() != originalSession || + fleet.activeClient() != originalClient || + fleet.activeContext()->iedName() != QStringLiteral("BROWSERIED") || + fleet.activeSession()->host() != QStringLiteral("192.0.2.77") || + fleet.activeContext()->treeModel() == secondContext->treeModel()) { + std::cerr << "P6E switch leaked the other IED endpoint or tree.\\n"; + return 22; + } + if (!fleet.openSclIedInNewWorkspace(QStringLiteral("BACKUPIED")) || + fleet.workspaceCount() != 2 || fleet.activeIndex() != 1 || + fleet.activeContext() != secondContext || + fleet.activeSession()->host() != QStringLiteral("192.0.2.199")) { + std::cerr << "P6E duplicate SCL IED was not deduplicated.\\n"; + return 23; + } + if (!fleet.newWorkspace() || fleet.workspaceCount() != 3 || + fleet.activeContext()->hasSource() || + !fleet.prepareForNewSource() || fleet.workspaceCount() != 3) { + std::cerr << "P6E new/empty workspace allocation failed.\\n"; + return 24; + } + if (!fleet.activeContext()->publishSclDocument( + document, QStringLiteral("/tmp/another.cid")) || + !fleet.prepareForNewSource() || fleet.workspaceCount() != 4 || + fleet.activeContext()->hasSource() || + fleet.contextAt(0) != originalContext || + fleet.contextAt(1) != secondContext) { + std::cerr << "P6E new source overwrote an existing workspace.\\n"; + return 25; + } + if (!fleet.closeWorkspace(2) || fleet.workspaceCount() != 3 || + fleet.contextAt(0) != originalContext || + fleet.contextAt(1) != secondContext || + fleet.activeContext() == nullptr || + !fleet.switchTo(1) || + fleet.activeSession() != secondSession || + fleet.activeSession()->host() != QStringLiteral("192.0.2.199")) { + std::cerr << "P6E offline close or index adjustment failed.\\n"; + return 26; + } + while (fleet.workspaceCount() < IedBrowserFleetController::maximumWorkspaces) { + if (!fleet.newWorkspace()) { + std::cerr << "P6E bounded slot creation failed.\\n"; + return 27; + } + } + if (fleet.newWorkspace() || fleet.lastError().isEmpty() || + fleet.workspaceCount() != IedBrowserFleetController::maximumWorkspaces || + fleet.switchTo(-1)) { + std::cerr << "P6E workspace capacity/invalid-index guard failed.\\n"; + return 28; + } + while (fleet.workspaceCount() > 1) { + if (!fleet.closeWorkspace(fleet.workspaceCount() - 1)) { + std::cerr << "P6E offline workspace close failed.\\n"; + return 29; + } + } + if (fleet.closeWorkspace(0) || fleet.workspaceCount() != 1 || + fleet.activeContext() != originalContext) { + std::cerr << "P6E final workspace invariant failed.\\n"; + return 30; + } + std::cout << "IED_BROWSER_SESSION_PASS" << " endpoint=relay.local:102" << " propagation=pass" From b5af7cec7027287b1d5e509c8d99b9ba836544bf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:54:06 +0700 Subject: [PATCH 09/24] P6E: expose event-driven Global Data snapshot per IED --- .../qml/BrowserGlobalDataPane.qml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml index d19a28dcb..ae5150a4a 100644 --- a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml +++ b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml @@ -13,6 +13,7 @@ Rectangle { readonly property int maximumEntries: 256 readonly property int watchCount: watchModel.count + signal watchSnapshotChanged() property int boundContextGeneration: context.contextGeneration property string statusMessage: "Add DataAttributes, DataSets, Reports, or configured GOOSE from the Browser." @@ -20,6 +21,23 @@ Rectangle { ListModel { id: watchModel } + function snapshotRows() { + var entries = [] + for (var i = 0; i < watchModel.count; ++i) { + var row = watchModel.get(i) + entries.push({ + "kind": row.kind, + "reference": row.reference, + "detail": row.detail, + "value": row.value, + "quality": row.quality, + "timestamp": row.timestamp, + "status": row.status + }) + } + return entries + } + function indexFor(kind, reference) { for (var i = 0; i < watchModel.count; ++i) { var row = watchModel.get(i) @@ -102,6 +120,7 @@ Rectangle { if (row < 0 || row >= watchModel.count) return watchModel.remove(row) + root.watchSnapshotChanged() statusMessage = watchModel.count > 0 ? String(watchModel.count) + " monitored object(s)." : "Global Data is empty." @@ -109,6 +128,7 @@ Rectangle { function clearAll() { watchModel.clear() + root.watchSnapshotChanged() statusMessage = "Global Data cleared." } @@ -178,6 +198,7 @@ Rectangle { watchModel.setProperty(i, "status", context.online ? "CONFIGURED" : "OFFLINE") } } + root.watchSnapshotChanged() } Connections { From 1a82ffffc74bbf9a24c9bcd88dce958d0e0f3f3a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:54:29 +0700 Subject: [PATCH 10/24] P6E: expose per-IED Global Data events to fleet view --- apps/ied_simulator/qml/IedBrowserWorkspace.qml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 732970110..e45370e16 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -22,6 +22,11 @@ Item { property string activeSectionTitle: "Data Model" property var selectedModelNode: context.treeModel.selectedNode + signal watchedDataChanged() + + function watchedRows() { return globalDataPane.snapshotRows() } + function refreshWatchedGlobalData() { return globalDataPane.refreshWatched() } + function activeIedLabel() { return context.iedName && context.iedName.length ? context.iedName : "IED" } @@ -62,6 +67,11 @@ Item { root.activeSectionTitle = title } + Connections { + target: globalDataPane + function onWatchSnapshotChanged() { root.watchedDataChanged() } + } + onActiveSectionChanged: ensureActiveService() Shortcut { sequence: "Alt+1"; enabled: root.visible; onActivated: root.selectSection(0, "Data Model") } From af2aa1592c0485f606e396c3f7859049b09930ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:55:10 +0700 Subject: [PATCH 11/24] P6E: aggregate event-driven Global Data from independent IED slots --- .../ied_simulator/qml/FleetGlobalDataPane.qml | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 apps/ied_simulator/qml/FleetGlobalDataPane.qml diff --git a/apps/ied_simulator/qml/FleetGlobalDataPane.qml b/apps/ied_simulator/qml/FleetGlobalDataPane.qml new file mode 100644 index 000000000..94b792d50 --- /dev/null +++ b/apps/ied_simulator/qml/FleetGlobalDataPane.qml @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Cross-IED presentation of the existing P6A per-IED watchlists. Every value +// retains its source slot; refresh routes to that slot's original MMS client. +// No timer, hidden polling, service sharing, or cross-IED write path exists. +Rectangle { + id: root + + required property var theme + required property var fleet + required property var browserViews + + signal iedRequested(int index) + + readonly property int maximumRows: 2048 + readonly property int totalRows: rows.count + property string statusMessage: "Combined, event-driven view of each IED's Global Data." + + color: theme.background + + ListModel { id: rows } + + function refreshRows() { + rows.clear() + for (var index = 0; index < fleet.workspaceCount; ++index) { + var view = browserViews.itemAt(index) + var context = fleet.contextAt(index) + if (!view || !context) + continue + var entries = view.watchedRows() + for (var i = 0; i < entries.length; ++i) { + if (rows.count >= maximumRows) { + statusMessage = "Combined watchlist bound reached (2048); no more rows retained." + return + } + var row = entries[i] + rows.append({ + "slotIndex": index, + "iedName": context.iedName || ("IED " + String(index + 1)), + "kind": row.kind || "", + "reference": row.reference || "", + "detail": row.detail || "", + "value": row.value || "", + "quality": row.quality || "", + "timestamp": row.timestamp || "", + "status": row.status || "OFFLINE" + }) + } + } + statusMessage = String(rows.count) + " entries from " + + String(fleet.workspaceCount) + " separate IED workspace(s)." + } + + function refreshSelected() { + var index = selectedIed.currentIndex + if (index < 0 || index >= fleet.workspaceCount) + return + var view = browserViews.itemAt(index) + if (!view) return + var accepted = view.refreshWatchedGlobalData() + statusMessage = accepted + ? "Explicit bounded refresh requested on " + (fleet.contextAt(index).iedName || "selected IED") + "." + : "Refresh was not started; inspect that IED's Global Data for details." + } + + Connections { + target: fleet + function onWorkspacesChanged() { root.refreshRows() } + } + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 68 + color: theme.chrome + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 13 + anchors.rightMargin: 13 + spacing: 9 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "All IEDs · Global Data" + color: theme.text + font.pixelSize: 12 + font.weight: Font.DemiBold + } + Label { + text: root.statusMessage + color: theme.muted + font.pixelSize: 8 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + ComboBox { + id: selectedIed + Layout.preferredWidth: 185 + model: fleet + textRole: "label" + } + Button { + text: "Refresh selected IED" + enabled: selectedIed.currentIndex >= 0 + && fleet.clientAt(selectedIed.currentIndex) + && fleet.clientAt(selectedIed.currentIndex).connected + && !fleet.clientAt(selectedIed.currentIndex).operationBusy + onClicked: root.refreshSelected() + ToolTip.visible: hovered + ToolTip.text: "Explicit bounded MMS Read on one IED association only; no automatic polling." + } + Button { + text: "Open selected IED" + enabled: selectedIed.currentIndex >= 0 + onClicked: root.iedRequested(selectedIed.currentIndex) + } + Label { + text: String(rows.count) + " / " + String(root.maximumRows) + color: theme.muted + font.pixelSize: 8 + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 9 + Label { Layout.preferredWidth: 110; text: "IED"; color: theme.muted; font.pixelSize: 8 } + Label { Layout.preferredWidth: 70; text: "TYPE"; color: theme.muted; font.pixelSize: 8 } + Label { Layout.fillWidth: true; text: "CANONICAL REFERENCE"; color: theme.muted; font.pixelSize: 8 } + Label { Layout.preferredWidth: 150; text: "VALUE"; color: theme.muted; font.pixelSize: 8 } + Label { Layout.preferredWidth: 90; text: "STATUS"; color: theme.muted; font.pixelSize: 8 } + } + } + + ListView { + id: combinedList + Layout.fillWidth: true + Layout.fillHeight: true + model: rows + clip: true + reuseItems: true + spacing: 1 + ScrollBar.vertical: ScrollBar {} + + delegate: Rectangle { + required property int index + required property int slotIndex + required property string iedName + required property string kind + required property string reference + required property string detail + required property string value + required property string quality + required property string timestamp + required property string status + + width: combinedList.width + height: 37 + color: index % 2 ? theme.surfaceSoft : theme.surface + + RowLayout { + anchors.fill: parent + anchors.leftMargin: 12 + anchors.rightMargin: 12 + spacing: 9 + Label { + Layout.preferredWidth: 110 + text: iedName + color: theme.text + font.pixelSize: 8 + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 70 + text: kind + color: theme.muted + font.pixelSize: 8 + } + Label { + Layout.fillWidth: true + text: reference + color: theme.textSoft + font.pixelSize: 8 + elide: Text.ElideMiddle + } + Label { + Layout.preferredWidth: 150 + text: value + color: theme.text + font.pixelSize: 8 + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 90 + text: status + color: status === "LIVE" || status === "ACTIVE" + ? theme.green : theme.muted + font.pixelSize: 8 + } + } + MouseArea { + anchors.fill: parent + onDoubleClicked: root.iedRequested(slotIndex) + ToolTip.visible: containsMouse + ToolTip.text: reference + "\n" + detail + + (quality.length ? "\nQuality: " + quality : "") + + (timestamp.length ? "\nTimestamp: " + timestamp : "") + hoverEnabled: true + } + } + + Label { + anchors.centerIn: parent + visible: rows.count === 0 + width: Math.max(250, parent.width - 80) + text: "No watched signals yet. Open an IED tab and add DataAttributes, DataSets, Reports, or GOOSE to its Global Data." + color: theme.muted + font.pixelSize: 9 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + } + } + } +} From ada2d3216cfb549907f128b9b51bd7edd07c4808 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:56:01 +0700 Subject: [PATCH 12/24] P6E: expose all-IED Global Data without merging device associations --- apps/ied_simulator/qml/Main.qml | 74 ++++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 4c61c3433..1211de9e7 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -20,6 +20,7 @@ ApplicationWindow { property bool persistenceReady: false property bool mmsWasConnected: false property bool simulatorAutoSelectPending: true + property bool allIedMonitor: false property var iedContext: fleet.activeContext property var mmsClient: fleet.activeClient property var reports: fleet.activeReports @@ -64,6 +65,7 @@ ApplicationWindow { target: fleet function onActiveChanged() { root.mmsWasConnected = mmsClient.connected + root.allIedMonitor = false } } @@ -379,7 +381,10 @@ ApplicationWindow { MouseArea { anchors.fill: parent anchors.rightMargin: 27 - onClicked: fleet.switchTo(index) + onClicked: { + root.allIedMonitor = false + fleet.switchTo(index) + } } ToolTip.visible: tabMouse.containsMouse ToolTip.text: endpoint @@ -391,6 +396,18 @@ ApplicationWindow { } } } + Button { + text: "All IEDs · Global Data" + checkable: true + checked: root.allIedMonitor + onClicked: { + root.allIedMonitor = !root.allIedMonitor + if (root.allIedMonitor) + fleetGlobalData.refreshRows() + } + ToolTip.visible: hovered + ToolTip.text: "Combined live values from the independent per-IED watchlists; no hidden polling." + } Button { text: "+ IED" enabled: fleet.workspaceCount < 8 @@ -418,28 +435,47 @@ ApplicationWindow { StackLayout { Layout.fillWidth: true Layout.fillHeight: true - currentIndex: fleet.activeIndex - - // Every delegate remains alive while its IED slot exists: - // independent navigation, Global Data, report and control - // dialogs never migrate to another IED on tab switch. - Repeater { - model: fleet - IedBrowserWorkspace { - required property int index + currentIndex: root.allIedMonitor ? 1 : 0 + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: fleet.activeIndex + + // Delegates persist per slot: each IED retains its own + // navigation and P6A Global Data watchlist. + Repeater { + id: iedBrowserRepeater + model: fleet + IedBrowserWorkspace { + required property int index + theme: appTheme + productState: hardening + fleet: fleet + session: fleet.sessionAt(index) + client: fleet.clientAt(index) + context: fleet.contextAt(index) + reports: fleet.reportsAt(index) + utilities: fleet.utilitiesAt(index) + controls: fleet.controlsAt(index) + engineering: fleet.engineeringAt(index) + onWatchedDataChanged: fleetGlobalData.refreshRows() + } + } + } + + FleetGlobalDataPane { + id: fleetGlobalData theme: appTheme - productState: hardening fleet: fleet - session: fleet.sessionAt(index) - client: fleet.clientAt(index) - context: fleet.contextAt(index) - reports: fleet.reportsAt(index) - utilities: fleet.utilitiesAt(index) - controls: fleet.controlsAt(index) - engineering: fleet.engineeringAt(index) + browserViews: iedBrowserRepeater + onIedRequested: function(index) { + fleet.switchTo(index) + root.allIedMonitor = false + } } } - } + } } From 5f2ea120069f250ff4c941ec05fbad31a3c7329a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:56:26 +0700 Subject: [PATCH 13/24] P6E: package cross-IED Global Data view --- apps/ied_simulator/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 7a108b9fa..688a18167 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -105,6 +105,7 @@ qt_add_qml_module(arstack_ied_simulator qml/BrowserDataSetPane.qml qml/BrowserGoosePane.qml qml/BrowserGlobalDataPane.qml + qml/FleetGlobalDataPane.qml qml/BrowserControlDialog.qml qml/BrowserDataSetAuthoringDialog.qml qml/BrowserReportAuthoringDialog.qml From 9de66fbea7d7f02b60c943bb731cb89670e9b328 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:57:46 +0700 Subject: [PATCH 14/24] P6E: route File Home SCL ingress into a fresh isolated IED slot --- apps/ied_simulator/qml/FileWorkspace.qml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/ied_simulator/qml/FileWorkspace.qml b/apps/ied_simulator/qml/FileWorkspace.qml index 1790fd11f..f21578435 100644 --- a/apps/ied_simulator/qml/FileWorkspace.qml +++ b/apps/ied_simulator/qml/FileWorkspace.qml @@ -16,6 +16,7 @@ Item { property int viewIndex: 0 signal browserRequested() + signal openSourceRequested(url fileUrl) signal discoverRequested(string host, int port) signal endpointRequested(string host, int port) signal simulatorRequested() @@ -32,10 +33,7 @@ Item { title: "Open IEC 61850 engineering model" fileMode: FileDialog.OpenFile nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] - onAccepted: { - if (workspace.openFile(selectedFile)) - root.browserRequested() - } + onAccepted: root.openSourceRequested(selectedFile) } Dialog { @@ -164,14 +162,14 @@ Item { theme: root.theme text: "Open SCL" primary: true - enabled: !workspace.busy && !browserSession.configurationLocked + enabled: !workspace.busy onClicked: openDialog.open() } ActionButton { theme: root.theme text: "Discover IED" - enabled: !browserSession.configurationLocked + enabled: true onClicked: discoverDialog.open() } @@ -245,7 +243,7 @@ Item { theme: root.theme text: "Open SCL" primary: true - enabled: !workspace.busy && !browserSession.configurationLocked + enabled: !workspace.busy onClicked: openDialog.open() } } @@ -271,7 +269,7 @@ Item { ActionButton { theme: root.theme text: "Discover IED" - enabled: !browserSession.configurationLocked + enabled: true onClicked: discoverDialog.open() } } From 35d8c990974a152121959d71c9cf8d0a70f6590e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:58:10 +0700 Subject: [PATCH 15/24] P6E: isolate every File/Home SCL and Discover ingress --- apps/ied_simulator/qml/Main.qml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 1211de9e7..577d2a884 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -125,10 +125,9 @@ ApplicationWindow { title: "Open IEC 61850 engineering model" nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] onAccepted: { - if (!fleet.prepareForNewSource()) - return simulator.loadFileAsync(selectedFile) - sclWorkspace.openFile(selectedFile) + if (fleet.prepareForNewSource()) + sclWorkspace.openFile(selectedFile) } } @@ -291,7 +290,15 @@ ApplicationWindow { browserSession: browserSession context: iedContext onBrowserRequested: root.workspaceIndex = 1 + onOpenSourceRequested: function(fileUrl) { + if (!fleet.prepareForNewSource()) + return + if (sclWorkspace.openFile(fileUrl)) + root.workspaceIndex = 1 + } onDiscoverRequested: function(host, port) { + if (!fleet.prepareForNewSource()) + return if (!browserSession.configurationLocked) { browserSession.host = host browserSession.port = port @@ -301,6 +308,8 @@ ApplicationWindow { } } onEndpointRequested: function(host, port) { + if (!fleet.prepareForNewSource()) + return if (!browserSession.configurationLocked) { browserSession.host = host browserSession.port = port From f5096a2cab77c598a81805e94ad2f566749b5a35 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 14:59:45 +0700 Subject: [PATCH 16/24] P6E: isolate recent SCL/IED ingress even while another slot is online --- apps/ied_simulator/qml/FileWorkspace.qml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/ied_simulator/qml/FileWorkspace.qml b/apps/ied_simulator/qml/FileWorkspace.qml index f21578435..73a0bc409 100644 --- a/apps/ied_simulator/qml/FileWorkspace.qml +++ b/apps/ied_simulator/qml/FileWorkspace.qml @@ -439,10 +439,8 @@ Item { hoverEnabled: true enabled: hardening.recentResourceExists(index) && !workspace.busy - && !browserSession.configurationLocked onClicked: { - if (workspace.openFile(hardening.recentResourceUrl(index))) - root.browserRequested() + root.openSourceRequested(hardening.recentResourceUrl(index)) } } } @@ -530,7 +528,7 @@ Item { id: discoveredMouse anchors.fill: parent hoverEnabled: true - enabled: !browserSession.configurationLocked + enabled: true onClicked: root.endpointRequested( hardening.recentDiscoveredIedHost(index), hardening.recentDiscoveredIedPort(index)) From 10cc053c40b51ec6023b635f5b497ebaba09e97a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:00:59 +0700 Subject: [PATCH 17/24] P6E: support exact SCL provenance adoption per workspace --- apps/ied_simulator/src/SclWorkspaceController.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/ied_simulator/src/SclWorkspaceController.hpp b/apps/ied_simulator/src/SclWorkspaceController.hpp index d2a406fe2..a27b2cea3 100644 --- a/apps/ied_simulator/src/SclWorkspaceController.hpp +++ b/apps/ied_simulator/src/SclWorkspaceController.hpp @@ -94,6 +94,11 @@ class SclWorkspaceController : public QObject { void setEngineeringContext(IedEngineeringContextController* value); + // Fork the already-validated exact SCL source into another IED slot. + // This copies source evidence without reparsing or republishing a different + // active IED over that slot's canonical engineering model. + bool adoptSourceFrom(const SclWorkspaceController& source); + 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")); From ddfab198e9c7356f6804aa549e4b3813689090ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:01:23 +0700 Subject: [PATCH 18/24] P6E: copy exact SCL evidence without changing active IED context --- .../src/SclWorkspaceController_part_01.inc | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/ied_simulator/src/SclWorkspaceController_part_01.inc b/apps/ied_simulator/src/SclWorkspaceController_part_01.inc index 39f392272..ad5d8cf8a 100644 --- a/apps/ied_simulator/src/SclWorkspaceController_part_01.inc +++ b/apps/ied_simulator/src/SclWorkspaceController_part_01.inc @@ -46,6 +46,29 @@ void SclWorkspaceController::setEngineeringContext(IedEngineeringContextControll } } +bool SclWorkspaceController::adoptSourceFrom(const SclWorkspaceController& source) { + if (busy_ || source.busy_ || !source.document_ || source.sourcePath_.isEmpty() || + !engineeringContext_ || + engineeringContext_->authorityKey() != QStringLiteral("scl") || + engineeringContext_->sourcePath() != source.sourcePath_) { + return false; + } + document_ = source.document_; + sourceBytes_ = source.sourceBytes_; + sourcePath_ = source.sourcePath_; + sourceName_ = source.sourceName_; + sourceFormat_ = source.sourceFormat_; + lastError_.clear(); + lastExportVerified_ = false; + lastExportPath_.clear(); + lastExportMode_.clear(); + appendDiagnostic(QStringLiteral("Adopted verified SCL source for independent IED workspace.")); + emit workspaceChanged(); + emit exportChanged(); + emit stateChanged(); + return true; +} + SclWorkspaceController::~SclWorkspaceController() { if (stopSource_) stopSource_->request_stop(); ioPool_.clear(); From e7af8039eb278d7694c45fdf2bf3b5640c9dbc6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:01:46 +0700 Subject: [PATCH 19/24] P6E: retain per-IED exact-source Save As capability on SCL fork --- apps/ied_simulator/src/IedBrowserFleetController.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp index 57ba41f6e..37154429f 100644 --- a/apps/ied_simulator/src/IedBrowserFleetController.cpp +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -224,6 +224,9 @@ bool IedBrowserFleetController::openSclIedInNewWorkspace(const QString& iedName) // Each cloned context retains the exact file provenance for its own // independent SCL-assisted association, without re-reading the file. activeSession()->setTrustedSclPath(sourcePath); + // Preserve exact source bytes when this SCL came from a parsed file. + // No file reload is needed and the selected IED is not republished. + static_cast(activeEngineering()->adoptSourceFrom(*original->engineering)); return true; } const auto error = activeContext()->lastError(); From e7aa919081e78a4edc758c8c525c0d3258c8e942 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:02:41 +0700 Subject: [PATCH 20/24] P6E: permanent multi-IED ownership and routing contract --- .../ied_simulator/test_multi_ied_workspace.py | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/ied_simulator/test_multi_ied_workspace.py diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py new file mode 100644 index 000000000..7784d1a0d --- /dev/null +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""P6E permanent source ownership / routing regression contract.""" +from pathlib import Path +import sys + +if len(sys.argv) != 10: + raise SystemExit( + "usage: test_multi_ied_workspace.py Fleet.cpp Fleet.hpp Main.qml " + "IedBrowserWorkspace.qml FileWorkspace.qml FleetGlobalDataPane.qml " + "BrowserGlobalDataPane.qml SclWorkspaceController_part_01.inc " + "MmsReportController.cpp" + ) +(fleet_cpp, fleet_hpp, main, browser, home, all_data, global_data, + scl_source, reports) = (Path(p).read_text(encoding="utf-8") for p in sys.argv[1:]) + +def require(haystack, words, label): + for word in words: + if word not in haystack: + raise SystemExit(f"P6E_FAIL {label}: missing {word}") + +require(fleet_cpp, ( + "maximumWorkspaces", + "std::make_unique()", + "std::make_unique()", + "std::make_unique()", + "std::make_unique()", + "std::make_unique()", + "std::make_unique()", + "std::make_unique()", + "session->setClient(client.get())", + "session->setEngineeringContext(context.get())", + "session->setReports(reports.get())", + "session->setControls(controls.get())", + "session->setUtilities(utilities.get())", + "bool IedBrowserFleetController::switchTo(", + "bool IedBrowserFleetController::prepareForNewSource(", + "bool IedBrowserFleetController::openSclIedInNewWorkspace(", + "bool IedBrowserFleetController::closeWorkspace(", + "entry->reports->cleanupRequired()", + "activeEngineering()->adoptSourceFrom(*original->engineering)", +), "fleet authority") +require(fleet_hpp, ( + "QAbstractListModel", + "maximumWorkspaces = 8", + "Q_PROPERTY(IedEngineeringContextController* activeContext", + "Q_PROPERTY(IedBrowserSessionController* activeSession", + "Q_PROPERTY(MmsClientController* activeClient", + "Q_PROPERTY(MmsReportController* activeReports", + "Q_PROPERTY(MmsControlController* activeControls", + "Q_PROPERTY(MmsFileSettingsController* activeUtilities", + "Q_PROPERTY(SclWorkspaceController* activeEngineering", +), "fleet surface") +require(main, ( + "IedBrowserFleetController {", + "property var iedContext: fleet.activeContext", + "property var browserSession: fleet.activeSession", + "property var sclWorkspace: fleet.activeEngineering", + "model: fleet", + "currentIndex: fleet.activeIndex", + "fleet.contextAt(index)", + "fleet.sessionAt(index)", + "fleet.clientAt(index)", + "fleet.reportsAt(index)", + "fleet.controlsAt(index)", + "fleet.utilitiesAt(index)", + "fleet.engineeringAt(index)", + "onOpenSourceRequested: function(fileUrl)", + "fleet.prepareForNewSource()", + "FleetGlobalDataPane {", + "onWatchedDataChanged: fleetGlobalData.refreshRows()", +), "shell routing") +require(browser, ( + "required property var fleet", + "fleet.prepareForNewSource()", + "fleet.openSclIedInNewWorkspace(activeIedPicker.currentText)", + "function watchedRows()", + "function refreshWatchedGlobalData()", +), "Browser source routing") +require(home, ( + "signal openSourceRequested(url fileUrl)", + "root.openSourceRequested(selectedFile)", + "root.openSourceRequested(hardening.recentResourceUrl(index))", +), "File Home source routing") +require(all_data, ( + "browserViews.itemAt(index)", + "fleet.contextAt(index)", + "view.watchedRows()", + "view.refreshWatchedGlobalData()", + "maximumRows: 2048", +), "all-IED Global Data") +require(global_data, ( + "signal watchSnapshotChanged()", + "function snapshotRows()", + "root.watchSnapshotChanged()", +), "per-IED Global Data") +require(scl_source, ( + "SclWorkspaceController::adoptSourceFrom(", + "sourceBytes_ = source.sourceBytes_", + "engineeringContext_->sourcePath() != source.sourcePath_", +), "SCL provenance") +require(reports, ( + "options.selection.allow_polling_fallback = false", + "options.subscription.write_data_set_reference = false", + "MmsDynamicDataSetDeletePolicy::owned_only", +), "P6C static reporting safety") +for typename in ( + "IedEngineeringContextController", + "IedBrowserSessionController", + "MmsClientController", + "MmsReportController", + "MmsControlController", + "MmsFileSettingsController", + "SclWorkspaceController", +): + if f"{typename} {{" in main: + raise SystemExit(f"P6E_FAIL singleton {typename} remained in Main.qml") +if "Timer {" in all_data: + raise SystemExit("P6E_FAIL aggregate Global Data timer/polling introduced") +if "setEngineeringContext(fleet.activeContext)" in main: + raise SystemExit("P6E_FAIL one live service was rebound across IEDs") +print( + "P6E_MULTI_IED_PASS isolated_contexts=true isolated_services=true " + "persistent_tabs=true bounded_slots=8 cross_ied_monitor=event_driven " + "source_provenance=true static_report_only=preserved" +) From 6f109c8371b72e01f283579d39892a08e6b48094 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:03:05 +0700 Subject: [PATCH 21/24] P6E: gate multi-IED architecture and static reporting protection in CI --- .github/workflows/ied-simulator-qt.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index e0192e4eb..1e29f2661 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -268,6 +268,19 @@ jobs: apps/ied_simulator/qml/GooseWorkspace.qml apps/ied_simulator/qml/CommissioningWorkspace.qml + - name: P6E Multi-IED workspace authority contract + run: >- + python3 apps/ied_simulator/test_multi_ied_workspace.py + apps/ied_simulator/src/IedBrowserFleetController.cpp + apps/ied_simulator/src/IedBrowserFleetController.hpp + apps/ied_simulator/qml/Main.qml + apps/ied_simulator/qml/IedBrowserWorkspace.qml + apps/ied_simulator/qml/FileWorkspace.qml + apps/ied_simulator/qml/FleetGlobalDataPane.qml + apps/ied_simulator/qml/BrowserGlobalDataPane.qml + apps/ied_simulator/src/SclWorkspaceController_part_01.inc + apps/ied_simulator/src/MmsReportController.cpp + - name: P6D passive MMS capture and decoder contract run: >- python3 apps/ied_simulator/test_passive_mms_sniffer.py From 128b0fdab2c1fa389ec2c9099e6f2ca076f82817 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:06:58 +0700 Subject: [PATCH 22/24] P6E: retire closed IED services after model and QML bindings update --- apps/ied_simulator/src/IedBrowserFleetController.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp index 37154429f..1a8b0dd7b 100644 --- a/apps/ied_simulator/src/IedBrowserFleetController.cpp +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -252,6 +252,7 @@ bool IedBrowserFleetController::closeWorkspace(const int index) { "Disconnect this IED and finish pending service operations before closing its workspace.")); } beginRemoveRows({}, index, index); + auto retired = std::move(entries_[static_cast(index)]); entries_.erase(entries_.begin() + index); endRemoveRows(); clearError(); @@ -259,5 +260,8 @@ bool IedBrowserFleetController::closeWorkspace(const int index) { if (activeIndex_ > index) --activeIndex_; else if (activeIndex_ == index) activeIndex_ = std::min(index, workspaceCount() - 1); emit activeChanged(); + // Retire services only AFTER Qt views process the row removal and all + // active QObject bindings have been retargeted away from the removed IED. + retired.reset(); return true; } From d3d08a14733a42515e4405d3550d530abcad6634 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:07:23 +0700 Subject: [PATCH 23/24] P6E: guard post-view lifetime order when closing IED slots --- apps/ied_simulator/test_multi_ied_workspace.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ied_simulator/test_multi_ied_workspace.py b/apps/ied_simulator/test_multi_ied_workspace.py index 7784d1a0d..7d92ebfbf 100644 --- a/apps/ied_simulator/test_multi_ied_workspace.py +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -38,6 +38,8 @@ def require(haystack, words, label): "bool IedBrowserFleetController::closeWorkspace(", "entry->reports->cleanupRequired()", "activeEngineering()->adoptSourceFrom(*original->engineering)", + "auto retired = std::move(entries_[static_cast(index)])", + "retired.reset();", ), "fleet authority") require(fleet_hpp, ( "QAbstractListModel", From ca51539199feefbf12bfe4c79b876af88e904c6d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sat, 26 Sep 2026 15:09:24 +0700 Subject: [PATCH 24/24] P6E: retain P6B control contract with per-IED service instances --- apps/ied_simulator/test_browser_control_ux.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/test_browser_control_ux.py b/apps/ied_simulator/test_browser_control_ux.py index 7820f5773..db6d800cd 100644 --- a/apps/ied_simulator/test_browser_control_ux.py +++ b/apps/ied_simulator/test_browser_control_ux.py @@ -84,11 +84,21 @@ def require(text: str, token: str, label: str) -> None: ): require(tree, token, "Canonical tree control projection") -for token in ( - 'MmsControlController {', - 'controls: controls', -): - require(main, token, "Application shell") +# P6E turns the previous singleton into an independently owned controller +# per IED slot. Both shell shapes must still wire the proven P6B service. +if 'IedBrowserFleetController {' in main: + for token in ( + 'controls: fleet.controlsAt(index)', + 'session: fleet.sessionAt(index)', + 'context: fleet.contextAt(index)', + ): + require(main, token, "Per-IED application shell") +else: + for token in ( + 'MmsControlController {', + 'controls: controls', + ): + require(main, token, "Application shell") for token in ( 'src/MmsControlController.cpp',