Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8c0c1b3
feat(report): expose read-only canonical static DataSet to bound RCB …
masarray Sep 27, 2026
11ce02f
feat(report): fail-closed no-write static RCB selection from ordered …
masarray Sep 27, 2026
4f2f7c0
feat(browser): post-discovery Signal Catalog and no-write static Data…
masarray Sep 27, 2026
c7acc9d
feat(browser): inspect ordered Dataset Signals and bound RCB without …
masarray Sep 27, 2026
281423f
test(report): exercise positive and fail-closed static Dataset Signal…
masarray Sep 27, 2026
44e8faa
test(browser): lock Signal Catalog and read-only Dataset-to-RCB workf…
masarray Sep 27, 2026
74d5a1a
test(browser): assert status routing in workspace rather than pane
masarray Sep 27, 2026
d9ede09
fix(report): require verified non-deletable static directory for no-w…
masarray Sep 27, 2026
c2d6a44
fix(browser): only offer static RCB pivot for verified non-deletable …
masarray Sep 27, 2026
aaf6d59
test(report): lock verified static directory prerequisites for read-o…
masarray Sep 27, 2026
d33fdf6
fix(browser): offer discovery catalog only on active IED; defer hidde…
masarray Sep 27, 2026
cb3a052
test(browser): keep background IED catalog from intercepting active tab
masarray Sep 27, 2026
4887b3a
test(browser): render discovery Signal Catalog and verify canonical o…
masarray Sep 27, 2026
260567f
fix(report): preserve verified SCL DataSet binding in offline RCB inv…
masarray Sep 27, 2026
d8e81f3
test(browser): diagnose and assert offline RCB binding provenance wit…
masarray Sep 27, 2026
93ef679
fix(browser): close model-scoped Signal Catalog on tab switch or sour…
masarray Sep 27, 2026
fd7c9f9
test(browser): assert discovery popup closes on tab change without lo…
masarray Sep 27, 2026
1d3899e
test(browser): prevent overlay from closed or background IED context
masarray Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/ied_simulator/qml/BrowserDataSetPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand All @@ -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"
Expand Down
110 changes: 110 additions & 0 deletions apps/ied_simulator/qml/IedBrowserWorkspace.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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
Expand Down Expand Up @@ -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") }
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
60 changes: 58 additions & 2 deletions apps/ied_simulator/src/MmsReportController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions apps/ied_simulator/src/MmsReportController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 26 additions & 1 deletion apps/ied_simulator/src/ied_report_workspace_qa.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
Expand Down Expand Up @@ -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) ||
Expand Down
Loading
Loading