diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 54fb89cb7..2a1213b1a 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,19 @@ Rectangle { font.weight: Font.DemiBold } Item { Layout.fillWidth: true } + ActionButton { + 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 + 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" diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 496bf6c27..4698153e0 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 @@ -72,7 +93,39 @@ 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() + else signalCatalogDialog.close() + } + Connections { + target: context + function onContextChanged() { + if (!context.loaded || context.selectionRequired || + context.authorityKey !== "live-discovery") + signalCatalogDialog.close() + root.offerSignalCatalog() + } + } + + 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 +143,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 +274,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 +709,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") diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 32b1b88ff..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(); @@ -875,6 +903,34 @@ 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("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; + } + 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) { 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, 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) || diff --git a/apps/ied_simulator/src/main.cpp b/apps/ied_simulator/src/main.cpp index 929edae80..c9f029e23 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,92 @@ 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); + 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; + } + 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; + } + // 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 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" diff --git a/apps/ied_simulator/test_browser_report_authoring.py b/apps/ied_simulator/test_browser_report_authoring.py index 67ac4d0c3..485b67c43 100644 --- a/apps/ied_simulator/test_browser_report_authoring.py +++ b/apps/ied_simulator/test_browser_report_authoring.py @@ -37,6 +37,11 @@ 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)", + 'dataSet.value(QStringLiteral("directoryAvailable")).toBool()', + 'dataSet.value(QStringLiteral("deletable")).toBool()', + "control.value(QStringLiteral(\"dynamicBinding\")).toBool()", ): require(controller, token, "Report controller") @@ -67,6 +72,17 @@ def require(text: str, token: str, label: str) -> None: 'text: "Author…"', "BrowserDataSetAuthoringDialog {", "BrowserReportAuthoringDialog {", + "function openDatasetSignals()", + "function offerSignalCatalog()", + "root.fleet.activeContext !== root.context", + "onVisibleChanged:", + "else signalCatalogDialog.close()", + "function inspectBoundStaticRcb()", + "reports.selectStaticRcbForDataSet(reports.selectedDataSetIndex)", + "routeMessage: root.datasetRouteStatus", + 'objectName: "iedBrowserSignalCatalog"', + 'text: "Dataset Signals"', + 'text: "Browse Data Model"', ): require(workspace, token, "Browser workspace") @@ -100,12 +116,17 @@ def require(text: str, token: str, label: str) -> None: "DYNAMIC OWNED", "STATIC / READ-ONLY", "Membership is immutable", + "signal inspectStaticReportRequested()", + 'text: "Inspect bound RCB…"', + "root.selectedDataSet.directoryAvailable === true", + "root.selectedDataSet.deletable !== true", ): require(dataset_pane, token, "DataSet pane") for token in ( "ownedDynamicDataSets", "enableSelectedAuthored", + "selectStaticRcbForDataSet", "createDynamicDataSet", "deleteDynamicDataSet", ):