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 diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 1e75dac6f..688a18167 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 @@ -103,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 @@ -355,6 +358,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 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 { diff --git a/apps/ied_simulator/qml/FileWorkspace.qml b/apps/ied_simulator/qml/FileWorkspace.qml index 1790fd11f..73a0bc409 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() } } @@ -441,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)) } } } @@ -532,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)) 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 + } + } + } +} diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 4e73050c5..e45370e16 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 @@ -21,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" } @@ -61,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") } @@ -84,7 +95,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 +277,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 +290,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 +305,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 diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 2dd57b91f..577d2a884 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -20,6 +20,14 @@ 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 + 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 +38,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 +61,14 @@ ApplicationWindow { root.persistenceReady = true } + Connections { + target: fleet + function onActiveChanged() { + root.mmsWasConnected = mmsClient.connected + root.allIedMonitor = false + } + } + Connections { target: mmsClient function onStateChanged() { @@ -142,7 +126,8 @@ ApplicationWindow { nameFilters: ["IEC 61850 engineering files (*.scl *.cid *.scd *.iid *.icd)", "All files (*)"] onAccepted: { simulator.loadFileAsync(selectedFile) - sclWorkspace.openFile(selectedFile) + if (fleet.prepareForNewSource()) + sclWorkspace.openFile(selectedFile) } } @@ -305,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 @@ -315,6 +308,8 @@ ApplicationWindow { } } onEndpointRequested: function(host, port) { + if (!fleet.prepareForNewSource()) + return if (!browserSession.configurationLocked) { browserSession.host = host browserSession.port = port @@ -326,16 +321,171 @@ 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: { + root.allIedMonitor = false + fleet.switchTo(index) + } + } + ToolTip.visible: tabMouse.containsMouse + ToolTip.text: endpoint + MouseArea { + id: tabMouse + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + } + 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 + 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: 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 + fleet: fleet + browserViews: iedBrowserRepeater + onIedRequested: function(index) { + fleet.switchTo(index) + root.allIedMonitor = false + } + } + } + + } } Item { diff --git a/apps/ied_simulator/src/IedBrowserFleetController.cpp b/apps/ied_simulator/src/IedBrowserFleetController.cpp new file mode 100644 index 000000000..1a8b0dd7b --- /dev/null +++ b/apps/ied_simulator/src/IedBrowserFleetController.cpp @@ -0,0 +1,267 @@ +// 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)) { + // 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(); + 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); + auto retired = std::move(entries_[static_cast(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(); + // 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; +} 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_; +}; 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(); 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")); 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(); 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" 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', 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..7d92ebfbf --- /dev/null +++ b/apps/ied_simulator/test_multi_ied_workspace.py @@ -0,0 +1,127 @@ +#!/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)", + "auto retired = std::move(entries_[static_cast(index)])", + "retired.reset();", +), "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" +)