From 8c0c1b3e37cf8798a966e022521f42c32d3b2968 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:16:38 +0700 Subject: [PATCH 01/18] feat(report): expose read-only canonical static DataSet to bound RCB route --- apps/ied_simulator/src/MmsReportController.hpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.hpp b/apps/ied_simulator/src/MmsReportController.hpp index a7a03cfe9..82062e050 100644 --- a/apps/ied_simulator/src/MmsReportController.hpp +++ b/apps/ied_simulator/src/MmsReportController.hpp @@ -78,6 +78,9 @@ class MmsReportController : public QObject { Q_INVOKABLE bool reconnect(); Q_INVOKABLE bool selectRcb(int row); Q_INVOKABLE bool selectDataSet(int row); + // Read-only route from canonical ordered static DataSet to its bound RCB. + // Never selects an arbitrary RCB and never writes/rebinds DatSet. + Q_INVOKABLE bool selectStaticRcbForDataSet(int row); Q_INVOKABLE bool enableSelected(bool requestGeneralInterrogation = true); Q_INVOKABLE bool enableSelectedAuthored( const QString& dataSetReference, From 11ce02f7e1d751a95a92f3fdc35d6e9c2bded99e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:16:42 +0700 Subject: [PATCH 02/18] feat(report): fail-closed no-write static RCB selection from ordered DataSet --- .../ied_simulator/src/MmsReportController.cpp | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 32b1b88ff..cd262afb3 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -875,6 +875,31 @@ bool MmsReportController::selectDataSet(const int row) { return true; } +bool MmsReportController::selectStaticRcbForDataSet(const int row) { + if (row < 0 || row >= dataSets_.size()) return false; + const auto dataSet = dataSets_.at(row).toMap(); + const auto reference = dataSet.value(QStringLiteral("reference")).toString(); + // A browse pivot is not authorization to modify a dynamic/foreign DataSet. + if (reference.isEmpty() || dataSet.value(QStringLiteral("dynamicOwned")).toBool() || + !dataSet.value(QStringLiteral("immutable")).toBool() || + dataSet.value(QStringLiteral("members")).toStringList().isEmpty()) { + return false; + } + for (int rcbRow = 0; rcbRow < reportControls_.size(); ++rcbRow) { + const auto control = reportControls_.at(rcbRow).toMap(); + const auto bound = control.value(QStringLiteral("dataSet")).toString(); + if (bound.isEmpty() || control.value(QStringLiteral("dynamicBinding")).toBool() || + !sameDataSetReference(bound, reference)) { + continue; + } + // Selection changes only after a verified exact canonical match. + selectedRcbIndex_ = rcbRow; + refreshSelection(); + return true; + } + return false; +} + bool MmsReportController::createDynamicDataSet( const QString& dataSetReference, const QVariantList& canonicalMembers) { From 4f2f7c0ddb44707c321df839d884303487c8b891 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:18:03 +0700 Subject: [PATCH 03/18] feat(browser): post-discovery Signal Catalog and no-write static DataSet to RCB navigation --- .../ied_simulator/qml/IedBrowserWorkspace.qml | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 496bf6c27..9a175ba2e 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -21,6 +21,9 @@ Item { property int activeSection: 0 property string activeSectionTitle: "Data Model" property var selectedModelNode: context.treeModel.selectedNode + property string offeredCatalogKey: "" + property string datasetRouteStatus: "" + signal watchedDataChanged() @@ -31,6 +34,24 @@ Item { return context.iedName && context.iedName.length ? context.iedName : "IED" } + // Shared read-only entry after either Open SCL or live MMS discovery. + function openDatasetSignals() { + if (!context.loaded || context.selectionRequired) return false + root.datasetRouteStatus = "" + root.selectSection(1, "Dataset Signals") + return true + } + + function inspectBoundStaticRcb() { + if (!reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)) { + root.datasetRouteStatus = "No verified static RCB is bound to this DataSet. Membership remains read-only." + return false + } + root.datasetRouteStatus = "" + root.selectSection(2, "Reports") + return true + } + function ensureActiveService() { if (!session.connected) return @@ -73,6 +94,24 @@ Item { } onActiveSectionChanged: ensureActiveService() + Connections { + target: context + function onContextChanged() { + if (!context.loaded || context.selectionRequired || + context.authorityKey !== "live-discovery" || context.dataSetCount <= 0) + return + const key = context.structuralFingerprint + "|" + context.iedName + if (!context.structuralFingerprint.length || key === root.offeredCatalogKey) + return + root.offeredCatalogKey = key + signalCatalogDialog.open() + } + } + + Connections { + target: reports + function onSelectionChanged() { root.datasetRouteStatus = "" } + } Shortcut { sequence: "Alt+1"; enabled: root.visible; onActivated: root.selectSection(0, "Data Model") } Shortcut { sequence: "Alt+2"; enabled: root.visible; onActivated: root.selectSection(1, "DataSets") } @@ -90,6 +129,53 @@ Item { } } + // A discovery creates a model, not a subscription. This catalog only + // chooses the next view; no polling, DataSet write, RCB enable or GI. + Dialog { + id: signalCatalogDialog + objectName: "iedBrowserSignalCatalog" + title: "Signal Catalog" + modal: true + width: 430 + anchors.centerIn: Overlay.overlay + standardButtons: Dialog.NoButton + contentItem: ColumnLayout { + spacing: 10 + Label { + Layout.fillWidth: true + text: context.iedName + " · " + context.dataSetCount + + " discovered DataSet(s)" + color: root.theme.text + font.pixelSize: root.theme.labelSize + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: "Choose ordered static Dataset Signals, or browse the complete IED model. Neither choice enables reporting." + color: root.theme.textSoft + font.pixelSize: root.theme.captionSize + wrapMode: Text.WordWrap + } + ActionButton { + theme: root.theme + text: "Dataset Signals" + enabled: context.dataSetCount > 0 + onClicked: { + root.openDatasetSignals() + signalCatalogDialog.close() + } + } + ActionButton { + theme: root.theme + text: "Browse Data Model" + onClicked: { + root.selectSection(0, "Data Model") + signalCatalogDialog.close() + } + } + } + } + FileDialog { id: browserSclDialog title: "Open IEC 61850 engineering model" @@ -174,6 +260,14 @@ Item { ToolTip.visible: hovered ToolTip.text: "Open SCL/CID/SCD/IID/ICD into the persistent Browser model." } + ActionButton { + theme: root.theme + text: "Signal Catalog" + visible: context.loaded && !context.selectionRequired + onClicked: signalCatalogDialog.open() + ToolTip.visible: hovered + ToolTip.text: "Choose Dataset Signals or the complete Data Model; no automatic RCB enable." + } ActionButton { theme: root.theme text: engineering.busy ? "Saving…" : "Save SCL" @@ -601,6 +695,8 @@ Item { reports: root.reports context: root.context client: root.client + routeMessage: root.datasetRouteStatus + onInspectStaticReportRequested: root.inspectBoundStaticRcb() onInspectRequested: function(reference) { if (root.context.treeModel.selectReference(reference)) root.selectSection(0, "Data Model") From c7acc9dea8c12a9542a662e0ea88a09422ce9f2a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:18:19 +0700 Subject: [PATCH 04/18] feat(browser): inspect ordered Dataset Signals and bound RCB without automatic enable --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 54fb89cb7..4167a715b 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -11,8 +11,10 @@ Rectangle { required property var client property int valueRevision: 0 + property string routeMessage: "" signal inspectRequested(string reference) + signal inspectStaticReportRequested() color: theme.background @@ -96,6 +98,15 @@ Rectangle { } } + Label { + Layout.fillWidth: true + visible: root.routeMessage.length > 0 + text: root.routeMessage + color: theme.amber + font.pixelSize: theme.captionSize + wrapMode: Text.WordWrap + } + RowLayout { Layout.fillWidth: true Label { @@ -105,6 +116,17 @@ Rectangle { font.weight: Font.DemiBold } Item { Layout.fillWidth: true } + ActionButton { + theme: root.theme + text: "Inspect bound RCB…" + enabled: root.selectedDataSet !== null + && root.selectedDataSet.immutable === true + && root.selectedDataSet.dynamicOwned !== true + && reports.selectedDataSetMembers.length > 0 + onClicked: root.inspectStaticReportRequested() + ToolTip.visible: hovered + ToolTip.text: "Navigate to an RCB bound to this exact static DataSet. No write, enable or GI." + } ActionButton { theme: root.theme text: root.client.operationBusy ? "Reading…" : "Read members" From 281423f607ed72158f0b03f9cfb39b1ed94e0189 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:18:59 +0700 Subject: [PATCH 05/18] test(report): exercise positive and fail-closed static Dataset Signals pivot before GI --- .../src/ied_report_workspace_qa.cpp | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/ied_report_workspace_qa.cpp b/apps/ied_simulator/src/ied_report_workspace_qa.cpp index 1dde7b2e5..e8c6e19ec 100644 --- a/apps/ied_simulator/src/ied_report_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_report_workspace_qa.cpp @@ -179,7 +179,8 @@ int main(int argc, char* argv[]) { MmsReportController negative; const bool noConnectionEnableRejected = !negative.enableSelected(true); const bool inactiveDisableRejected = !negative.disableSelected(); - const bool invalidSelectionRejected = !negative.selectRcb(0) && !negative.selectDataSet(0); + const bool invalidSelectionRejected = !negative.selectRcb(0) && !negative.selectDataSet(0) && + !negative.selectStaticRcbForDataSet(0); const bool authoredEnableRejected = !negative.enableSelectedAuthored( QStringLiteral("LD0/LLN0.Test"), {QStringLiteral("data-change")}, @@ -322,6 +323,30 @@ int main(int argc, char* argv[]) { << "datasets=" + sets.join(QLatin1Char(',')); return 10; } + // Dataset Signals → Reports must only select a canonical static match. + // Selecting is read-only and must not enable reporting or rewrite DatSet. + const int staticDataSetRow = reports.selectedDataSetIndex(); + const auto originalRcb = reports.selectedRcb().value(QStringLiteral("reference")).toString(); + const bool staticPivotValid = staticDataSetRow >= 0 && + reports.selectStaticRcbForDataSet(staticDataSetRow) && + reports.selectedDataSetIndex() == staticDataSetRow && + !reports.selectedDataSetMembers().isEmpty() && + !reports.active(); + const int matchedRcb = reports.selectedRcbIndex(); + const bool invalidPivotRejected = + !reports.selectStaticRcbForDataSet(-1) && + !reports.selectStaticRcbForDataSet(dataSets.size()) && + reports.selectedRcbIndex() == matchedRcb; + if (!staticPivotValid || !invalidPivotRejected || !reports.selectRcb(selected) || + reports.selectedRcb().value(QStringLiteral("reference")).toString() != originalRcb) { + qCritical() << "REPORTS_WORKBENCH_FAIL dataset_signals_static_rcb_pivot" + << staticDataSetRow << matchedRcb << originalRcb; + return 29; + } + qInfo() << "DATASET_SIGNALS_STATIC_PIVOT_PASS" + << "dataset=" << staticDataSetRow << "bound_rcb=" << matchedRcb + << "read_only=true no_gi=true"; + const int memberCount = reports.selectedDataSetMembers().size(); if (!reports.enableSelected(true) || From 44e8faaff1b65d121f8c786ce56137ed4577184b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:19:16 +0700 Subject: [PATCH 06/18] test(browser): lock Signal Catalog and read-only Dataset-to-RCB workflow contract --- apps/ied_simulator/test_browser_report_authoring.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 67ac4d0c3..45f7e6dea 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -37,6 +37,9 @@ def require(text: str, token: str, label: str) -> None: "options.selection.allow_polling_fallback = false", "Enable + GI requires general-interrogation in authored TrgOps", "Static/non-owned DataSet binding is immutable", + "bool MmsReportController::selectStaticRcbForDataSet(", + "sameDataSetReference(bound, reference)", + "control.value(QStringLiteral(\"dynamicBinding\")).toBool()", ): require(controller, token, "Report controller") @@ -67,6 +70,12 @@ def require(text: str, token: str, label: str) -> None: 'text: "Author…"', "BrowserDataSetAuthoringDialog {", "BrowserReportAuthoringDialog {", + "function openDatasetSignals()", + "function inspectBoundStaticRcb()", + "reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)", + 'objectName: "iedBrowserSignalCatalog"', + 'text: "Dataset Signals"', + 'text: "Browse Data Model"', ): require(workspace, token, "Browser workspace") @@ -100,12 +109,16 @@ def require(text: str, token: str, label: str) -> None: "DYNAMIC OWNED", "STATIC / READ-ONLY", "Membership is immutable", + "signal inspectStaticReportRequested()", + 'text: "Inspect bound RCB…"', + "routeMessage: root.datasetRouteStatus", ): require(dataset_pane, token, "DataSet pane") for token in ( "ownedDynamicDataSets", "enableSelectedAuthored", + "selectStaticRcbForDataSet", "createDynamicDataSet", "deleteDynamicDataSet", ): From 74d5a1a8aa5f0d869d7be03e48e4b381a4642584 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:19:29 +0700 Subject: [PATCH 07/18] test(browser): assert status routing in workspace rather than pane --- apps/ied_simulator/test_browser_report_authoring.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 45f7e6dea..d34fb8c2c 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -73,6 +73,7 @@ def require(text: str, token: str, label: str) -> None: "function openDatasetSignals()", "function inspectBoundStaticRcb()", "reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)", + "routeMessage: root.datasetRouteStatus", 'objectName: "iedBrowserSignalCatalog"', 'text: "Dataset Signals"', 'text: "Browse Data Model"', @@ -111,7 +112,6 @@ def require(text: str, token: str, label: str) -> None: "Membership is immutable", "signal inspectStaticReportRequested()", 'text: "Inspect bound RCB…"', - "routeMessage: root.datasetRouteStatus", ): require(dataset_pane, token, "DataSet pane") From d9ede092329084a9f09acb10c3529ba36d6260f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:26:01 +0700 Subject: [PATCH 08/18] fix(report): require verified non-deletable static directory for no-write RCB pivot --- apps/ied_simulator/src/MmsReportController.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index cd262afb3..76b13ecbe 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -880,7 +880,10 @@ bool MmsReportController::selectStaticRcbForDataSet(const int row) { const auto dataSet = dataSets_.at(row).toMap(); const auto reference = dataSet.value(QStringLiteral("reference")).toString(); // A browse pivot is not authorization to modify a dynamic/foreign DataSet. - if (reference.isEmpty() || dataSet.value(QStringLiteral("dynamicOwned")).toBool() || + if (reference.isEmpty() || + !dataSet.value(QStringLiteral("directoryAvailable")).toBool() || + dataSet.value(QStringLiteral("deletable")).toBool() || + dataSet.value(QStringLiteral("dynamicOwned")).toBool() || !dataSet.value(QStringLiteral("immutable")).toBool() || dataSet.value(QStringLiteral("members")).toStringList().isEmpty()) { return false; From c2d6a447f97aad1ca15dedc538dfe68f0dab91c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:26:19 +0700 Subject: [PATCH 09/18] fix(browser): only offer static RCB pivot for verified non-deletable DataSet --- apps/ied_simulator/qml/BrowserDataSetPane.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 4167a715b..2a1213b1a 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -120,6 +120,8 @@ Rectangle { theme: root.theme text: "Inspect bound RCB…" enabled: root.selectedDataSet !== null + && root.selectedDataSet.directoryAvailable === true + && root.selectedDataSet.deletable !== true && root.selectedDataSet.immutable === true && root.selectedDataSet.dynamicOwned !== true && reports.selectedDataSetMembers.length > 0 From aaf6d591e97f1a190d740c2e4f2c3b224e1f6f47 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:26:39 +0700 Subject: [PATCH 10/18] test(report): lock verified static directory prerequisites for read-only pivot --- apps/ied_simulator/test_browser_report_authoring.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index d34fb8c2c..5158df51c 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -39,6 +39,8 @@ def require(text: str, token: str, label: str) -> None: "Static/non-owned DataSet binding is immutable", "bool MmsReportController::selectStaticRcbForDataSet(", "sameDataSetReference(bound, reference)", + 'dataSet.value(QStringLiteral("directoryAvailable")).toBool()', + 'dataSet.value(QStringLiteral("deletable")).toBool()', "control.value(QStringLiteral(\"dynamicBinding\")).toBool()", ): require(controller, token, "Report controller") @@ -112,6 +114,8 @@ def require(text: str, token: str, label: str) -> None: "Membership is immutable", "signal inspectStaticReportRequested()", 'text: "Inspect bound RCB…"', + "root.selectedDataSet.directoryAvailable === true", + "root.selectedDataSet.deletable !== true", ): require(dataset_pane, token, "DataSet pane") From d33fdf6244fd4264b5c210744d86c9190ea45aed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:30:14 +0700 Subject: [PATCH 11/18] fix(browser): offer discovery catalog only on active IED; defer hidden-tab prompt --- .../ied_simulator/qml/IedBrowserWorkspace.qml | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 9a175ba2e..e11ebc62c 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -93,19 +93,27 @@ Item { function onWatchSnapshotChanged() { root.watchedDataChanged() } } + function offerSignalCatalog() { + // An asynchronous discovery can finish in a background IED tab. + // Never display a hidden tab's modal over the operator's active IED. + if (!root.visible || root.fleet.activeContext !== root.context || + !context.loaded || context.selectionRequired || + context.authorityKey !== "live-discovery" || context.dataSetCount <= 0) + return + const key = context.structuralFingerprint + "|" + context.iedName + if (!context.structuralFingerprint.length || key === root.offeredCatalogKey) + return + root.offeredCatalogKey = key + signalCatalogDialog.open() + } + onActiveSectionChanged: ensureActiveService() + onVisibleChanged: { + if (root.visible) root.offerSignalCatalog() + } Connections { target: context - function onContextChanged() { - if (!context.loaded || context.selectionRequired || - context.authorityKey !== "live-discovery" || context.dataSetCount <= 0) - return - const key = context.structuralFingerprint + "|" + context.iedName - if (!context.structuralFingerprint.length || key === root.offeredCatalogKey) - return - root.offeredCatalogKey = key - signalCatalogDialog.open() - } + function onContextChanged() { root.offerSignalCatalog() } } Connections { From cb3a0520e08195d0e7bddd02e476f2bc9b156219 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 27 Sep 2026 19:30:28 +0700 Subject: [PATCH 12/18] test(browser): keep background IED catalog from intercepting active tab --- apps/ied_simulator/test_browser_report_authoring.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 5158df51c..e95991e24 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -73,6 +73,9 @@ def require(text: str, token: str, label: str) -> None: "BrowserDataSetAuthoringDialog {", "BrowserReportAuthoringDialog {", "function openDatasetSignals()", + "function offerSignalCatalog()", + "root.fleet.activeContext !== root.context", + "onVisibleChanged:", "function inspectBoundStaticRcb()", "reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)", "routeMessage: root.datasetRouteStatus", From 4887b3a2bdf067f0f6c081e3914bc271b873fd00 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 28 Sep 2026 05:06:21 +0700 Subject: [PATCH 13/18] test(browser): render discovery Signal Catalog and verify canonical offline/static dataset route --- apps/ied_simulator/src/main.cpp | 65 +++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 929edae80..be2b86e30 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -469,6 +469,27 @@ int main(int argc, char* argv[]) { signal.basic_type = "INT32"; signal.cdc = "INC"; signal.signal_reference = name + "LD0/LLN0.Mod.stVal"; + ar::iec61850::scl::SclDataSet dataSet; + dataSet.key = name + "LD0/LLN0.Status"; + dataSet.ied_name = name; + dataSet.ld_inst = "LD0"; + dataSet.logical_node_path = "LLN0"; + dataSet.name = "Status"; + dataSet.reference = dataSet.key; + dataSet.entries.push_back(signal); + document.data_sets.push_back(dataSet); + ar::iec61850::scl::SclReportControl report; + report.ied_name = name; + report.ld_inst = "LD0"; + report.logical_node_path = "LLN0"; + report.name = "StatusReport"; + report.report_id = name + "/Status"; + report.data_set_name = "Status"; + report.data_set_reference = dataSet.reference; + report.data_set_binding_status = + ar::iec61850::scl::SclDataSetBindingStatus::resolved; + report.control_block_reference = name + "LD0/LLN0.RP.StatusReport"; + document.report_controls.push_back(report); document.model_entries.push_back(std::move(signal)); } if (!fleet->contextAt(0)->publishSclDocument( @@ -652,6 +673,50 @@ int main(int argc, char* argv[]) { fail("active_model_lost_after_reindex", 70); return; } + // Exercise the actual QML post-discovery route, rather than + // accepting source-token assertions for a Dialog never opened. + auto* const activeReports = fleet->reportsAt(0); + if (!activeReports || !activeReports->selectDataSet(0) || + activeReports->selectedDataSetMembers().isEmpty() || + !QMetaObject::invokeMethod(retained, "openDatasetSignals") || + retained->property("activeSection").toInt() != 1 || + !activeReports->selectStaticRcbForDataSet(0) || + activeReports->selectedRcb().value(QStringLiteral("reference")).toString().isEmpty() || + activeReports->active()) { + fail("offline_dataset_signals_route", 71); + return; + } + const auto* const snapshot = selected->modelSnapshot(); + if (!snapshot || snapshot->data_sets.empty() || + snapshot->report_controls.empty()) { + fail("canonical_catalog_fixture_missing", 72); + return; + } + auto liveModel = *snapshot; + liveModel.source = "LiveMmsDiscovery"; + selected->publishLiveDiscovery(liveModel); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + auto* const catalog = retained->findChild( + QStringLiteral("iedBrowserSignalCatalog")); + if (!catalog || !catalog->property("visible").toBool() || + !selected->loaded() || + selected->authorityKey() != QStringLiteral("live-discovery") || + !QMetaObject::invokeMethod(retained, "openDatasetSignals") || + retained->property("activeSection").toInt() != 1 || + activeReports->selectedDataSetMembers().isEmpty() || + activeReports->active()) { + qCritical().noquote() << "BROWSER_SIGNAL_CATALOG_DIAG" + << "catalog=" << static_cast(catalog) + << "visible=" << (catalog ? catalog->property("visible").toBool() : false) + << "dataSets=" << activeReports->dataSets().size() + << "section=" << retained->property("activeSection").toInt(); + fail("discovery_catalog_rendered_route", 73); + return; + } + qInfo().noquote() + << "BROWSER_SIGNAL_CATALOG_PASS discovery_modal=visible" + << "offline_dataset=visible canonical_members=ordered" + << "static_rcb=matched no_write=true no_gi=true"; qInfo().noquote() << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" << "offline_signals=visible" << "model_values=visible" << "value_label=bound" << "toolbar_1024=visible" From 260567f109e43fa992941e13932b54d3eb0af4ec Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 28 Sep 2026 05:11:55 +0700 Subject: [PATCH 14/18] fix(report): preserve verified SCL DataSet binding in offline RCB inventory without claiming live probe --- .../ied_simulator/src/MmsReportController.cpp | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 76b13ecbe..cc90bc7f1 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -632,8 +632,36 @@ void MmsReportController::adoptEngineeringInventory() { return; } - const auto seed = buildContextDiscoverySeed(*engineeringContext_->modelSnapshot()); - const auto ui = buildDiscoveryUi(seed, QString{}); + const auto& canonical = *engineeringContext_->modelSnapshot(); + const auto seed = buildContextDiscoverySeed(canonical); + auto ui = buildDiscoveryUi(seed, QString{}); + // An opened SCL model has no live RCB probe response yet. Preserve its + // verified canonical DatSet binding for read-only Browser navigation. + // Never mark the RCB as probed or writable based on file evidence alone. + for (const auto& control : canonical.report_controls) { + const auto bound = QString::fromStdString(control.data_set_reference); + if (bound.isEmpty()) continue; + const auto knownStaticDataSet = std::any_of( + canonical.data_sets.begin(), canonical.data_sets.end(), + [&bound](const auto& dataSet) { + return sameDataSetReference( + QString::fromStdString(dataSet.reference), bound) && + !dataSet.members.empty() && !dataSet.deletable.value_or(false); + }); + if (!knownStaticDataSet) continue; + for (auto& projected : ui.reportControls) { + auto map = projected.toMap(); + if (map.value(QStringLiteral("reference")).toString() != + QString::fromStdString(control.reference)) { + continue; + } + map.insert(QStringLiteral("dataSet"), bound); + map.insert(QStringLiteral("canonicalDataSet"), bound); + map.insert(QStringLiteral("bindingSource"), QStringLiteral("CanonicalEngineeringContext")); + projected = map; + break; + } + } dataSets_ = ui.dataSets; reportControls_ = ui.reportControls; staticCandidates_.clear(); From d8e81f3c501e57357af8f8a200bce2b9b7a4dc23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 28 Sep 2026 05:12:24 +0700 Subject: [PATCH 15/18] test(browser): diagnose and assert offline RCB binding provenance without fake probe --- apps/ied_simulator/src/main.cpp | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index be2b86e30..1eaf10765 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -676,13 +676,33 @@ int main(int argc, char* argv[]) { // Exercise the actual QML post-discovery route, rather than // accepting source-token assertions for a Dialog never opened. auto* const activeReports = fleet->reportsAt(0); - if (!activeReports || !activeReports->selectDataSet(0) || - activeReports->selectedDataSetMembers().isEmpty() || - !QMetaObject::invokeMethod(retained, "openDatasetSignals") || - retained->property("activeSection").toInt() != 1 || - !activeReports->selectStaticRcbForDataSet(0) || + const bool selectedDataSet = activeReports && activeReports->selectDataSet(0); + const bool openedDataset = QMetaObject::invokeMethod( + retained, "openDatasetSignals"); + const bool matchedStaticRcb = selectedDataSet && + activeReports->selectStaticRcbForDataSet(0); + if (!selectedDataSet || activeReports->selectedDataSetMembers().isEmpty() || + !openedDataset || retained->property("activeSection").toInt() != 1 || + !matchedStaticRcb || activeReports->selectedRcb().value(QStringLiteral("reference")).toString().isEmpty() || + activeReports->selectedRcb().value(QStringLiteral("probeOk")).toBool() || + activeReports->selectedRcb().value(QStringLiteral("bindingSource")).toString() != + QStringLiteral("CanonicalEngineeringContext") || activeReports->active()) { + qCritical().noquote() << "BROWSER_DATASET_ROUTE_DIAG" + << "dataSets=" << (activeReports ? activeReports->dataSets().size() : -1) + << "members=" << (activeReports ? activeReports->selectedDataSetMembers().size() : -1) + << "reports=" << (activeReports ? activeReports->reportControls().size() : -1) + << "selected=" << selectedDataSet + << "opened=" << openedDataset + << "rcbMatched=" << matchedStaticRcb + << "bound=" << (activeReports + ? activeReports->selectedRcb().value(QStringLiteral("dataSet")).toString() + : QString{}) + << "probeOk=" << (activeReports + ? activeReports->selectedRcb().value(QStringLiteral("probeOk")).toBool() + : false) + << "section=" << retained->property("activeSection").toInt(); fail("offline_dataset_signals_route", 71); return; } From 93ef6794982520a862791e05587225f690970c52 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 28 Sep 2026 05:17:02 +0700 Subject: [PATCH 16/18] fix(browser): close model-scoped Signal Catalog on tab switch or source replacement --- apps/ied_simulator/qml/IedBrowserWorkspace.qml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index e11ebc62c..4698153e0 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -110,10 +110,16 @@ Item { onActiveSectionChanged: ensureActiveService() onVisibleChanged: { if (root.visible) root.offerSignalCatalog() + else signalCatalogDialog.close() } Connections { target: context - function onContextChanged() { root.offerSignalCatalog() } + function onContextChanged() { + if (!context.loaded || context.selectionRequired || + context.authorityKey !== "live-discovery") + signalCatalogDialog.close() + root.offerSignalCatalog() + } } Connections { From fd7c9f9ff7516287e822ebdf6db5aef047ae7d70 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 28 Sep 2026 05:17:48 +0700 Subject: [PATCH 17/18] test(browser): assert discovery popup closes on tab change without losing model --- apps/ied_simulator/src/main.cpp | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 1eaf10765..c9f029e23 100644 --- a/apps/ied_simulator/src/main.cpp +++ b/apps/ied_simulator/src/main.cpp @@ -733,10 +733,32 @@ int main(int argc, char* argv[]) { fail("discovery_catalog_rendered_route", 73); return; } + // Popup overlays outlive the visual IED panel. A tab switch + // must close the old IED's modal instead of hijacking the new tab. + if (!fleet->newWorkspace() || fleet->activeIndex() != 1) { + fail("catalog_background_tab_setup", 74); + return; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (retained->isVisible() || catalog->property("visible").toBool()) { + fail("background_catalog_stole_active_tab", 75); + return; + } + if (!fleet->switchTo(0)) { + fail("catalog_return_to_ied", 76); + return; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (!retained->isVisible() || catalog->property("visible").toBool() || + fleet->activeContext() != selected || + activeReports->active()) { + fail("catalog_tab_return_state", 77); + return; + } qInfo().noquote() << "BROWSER_SIGNAL_CATALOG_PASS discovery_modal=visible" << "offline_dataset=visible canonical_members=ordered" - << "static_rcb=matched no_write=true no_gi=true"; + << "static_rcb=matched no_write=true no_gi=true background_tab=isolated"; qInfo().noquote() << "BROWSER_FLEET_ROUTING_PASS active_tab=QA_IED_B" << "offline_signals=visible" << "model_values=visible" << "value_label=bound" << "toolbar_1024=visible" From 1d3899e5266a588a2602a81c0ffe06f148f4e467 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 28 Sep 2026 05:18:07 +0700 Subject: [PATCH 18/18] test(browser): prevent overlay from closed or background IED context --- apps/ied_simulator/test_browser_report_authoring.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index e95991e24..485b67c43 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -76,6 +76,7 @@ def require(text: str, token: str, label: str) -> None: "function offerSignalCatalog()", "root.fleet.activeContext !== root.context", "onVisibleChanged:", + "else signalCatalogDialog.close()", "function inspectBoundStaticRcb()", "reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)", "routeMessage: root.datasetRouteStatus",