From 9f993772f35dc8697fc2fed95e00cacceec631e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 16:26:30 +0700 Subject: [PATCH 1/3] fix(workbench): make Global Data MMS reads resolve canonical IEC FCDA references Resolve watched DATA and DataSet DO/DA members through the indexed canonical IEC/MMS alias map instead of repeatedly scanning every node and comparing incompatible reference spellings. Deduplicate MMS targets, honor FC, hard-bound the requested read count, and fail closed on unmatched members. Coalesce observed UI value updates over 50ms without any cyclic MMS polling. Add large-model regression for IEC/MMS aliases, DO FC filtering, and bounded refresh. --- .../qml/BrowserGlobalDataPane.qml | 16 ++++- apps/ied_simulator/src/MmsLiveTreeModel.cpp | 63 +++++++++++-------- .../src/ied_browser_large_model_qa.cpp | 19 ++++++ 3 files changed, 71 insertions(+), 27 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml index ae5150a4a..fda0d8290 100644 --- a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml +++ b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml @@ -20,6 +20,17 @@ Rectangle { color: theme.background ListModel { id: watchModel } + // Coalesce hot MMS value changes to a bounded UI refresh cadence. + // This is change-driven (not cyclic MMS polling) and never delays reads. + Timer { + id: watchUiCoalescer + interval: 50 + repeat: false + onTriggered: root.syncRows() + } + function requestWatchUiRefresh() { + if (!watchUiCoalescer.running) watchUiCoalescer.start() + } function snapshotRows() { var entries = [] @@ -218,8 +229,11 @@ Rectangle { Connections { target: context.treeModel - function onDataChanged() { root.syncRows() } + function onDataChanged() { root.requestWatchUiRefresh() } function onSelectionChanged() { root.syncRows() } + // Read completions may update off-screen canonical nodes; coalesce + // storms without losing the final measured value. + function onValuesChanged() { root.requestWatchUiRefresh() } } Connections { diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index 1fd1fcb30..4f3581a84 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -591,35 +591,46 @@ QVector MmsLiveTreeModel::readTargetsForVisibleRan QVector MmsLiveTreeModel::readTargetsForReferences( const QStringList& references, const int maximumTargets) const { QVector result; - if (references.isEmpty() || maximumTargets <= 0) return result; - - QStringList wanted; - QSet seenReferences; - for (const auto& raw : references) { - const auto reference = raw.trimmed(); - if (reference.isEmpty() || seenReferences.contains(reference)) continue; - seenReferences.insert(reference); - wanted.push_back(reference); - } - - QSet seenTargets; - const auto matchesRequestedReference = [&wanted](const QString& nodeReference) { - for (const auto& reference : wanted) { - if (nodeReference == reference) return true; - if (nodeReference.startsWith(reference + QLatin1Char('.'))) return true; - if (nodeReference.startsWith(reference + QLatin1Char('$'))) return true; - } - return false; + if (maximumTargets <= 0 || references.isEmpty()) return result; + result.reserve(std::min(maximumTargets, 64)); + QSet uniqueTargets; + + const auto append = [this, &result, &uniqueTargets, maximumTargets]( + const int nodeIndex, const QString& fc) { + if (result.size() >= maximumTargets || nodeIndex < 0 || + nodeIndex >= nodes_.size()) return; + const auto& node = nodes_.at(nodeIndex); + if (node.kind != NodeKind::dataAttribute || + node.domain.isEmpty() || node.item.isEmpty()) return; + if (!fc.isEmpty() && node.functionalConstraint.compare(fc, Qt::CaseInsensitive) != 0) + return; + const QString key = nodeKey(node.domain, node.item); + if (uniqueTargets.contains(key)) return; + uniqueTargets.insert(key); + result.push_back({key, node.domain, node.item}); }; - for (const auto& node : nodes_) { + // Each user-selected IEC FCDA or MMS member resolves through the canonical + // hash index. A matching DO is expanded only to its actual FC-matching DA + // children; no broad prefix sweep, guessed target or unintended network read. + for (const auto& raw : references) { if (result.size() >= maximumTargets) break; - if (node.kind != NodeKind::dataAttribute || node.domain.isEmpty() || node.item.isEmpty()) continue; - if (!matchesRequestedReference(node.reference)) continue; - const auto key = nodeKey(node.domain, node.item); - if (seenTargets.contains(key)) continue; - seenTargets.insert(key); - result.push_back({key, node.domain, node.item}); + const auto [reference, fc] = splitDataSetMemberReference(raw); + if (reference.isEmpty()) continue; + const auto found = referenceIndex_.constFind(reference); + if (found == referenceIndex_.cend()) continue; + for (const int nodeIndex : found.value()) { + if (result.size() >= maximumTargets) break; + const auto& node = nodes_.at(nodeIndex); + if (node.kind == NodeKind::dataAttribute) { + append(nodeIndex, fc); + } else if (node.kind == NodeKind::dataObject) { + for (const int child : node.children) { + append(child, fc); + if (result.size() >= maximumTargets) break; + } + } + } } return result; } diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 681682328..5c46db674 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -214,6 +214,25 @@ int main(int argc, char** argv) { return 8; } + // Global Data must read true MMS targets even when a DataSet supplies the + // IEC FCDA spelling rather than the MMS $FC$ spelling. Unknown FCs fail + // closed. Duplicate aliases must not cause extra requests. + const QStringList iecWatched{ + QStringLiteral("LD4/LLN19.DO99.DA4 [MX]"), + QStringLiteral("LD4/LLN19$MX$DO99$DA4 [MX]"), + QStringLiteral("LD4/LLN19.DO99.DA3 [MX]")}; + const auto readIec = model.readTargetsForReferences(iecWatched, 64); + const auto readDo = model.readTargetsForReferences( + {QStringLiteral("LD4/LLN19.DO99 [MX]")}, 64); + const auto readInvalidFc = model.readTargetsForReferences( + {QStringLiteral("LD4/LLN19.DO99.DA4 [ST]")}, 64); + if (readIec.size() != 2 || readDo.size() != 4 || !readInvalidFc.isEmpty() || + readIec.constFirst().item != QStringLiteral("LLN19$MX$DO99$DA4") || + model.readTargetsForReferences(iecWatched, 1).size() != 1) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL canonical_global_watch_read\n"; + return 20; + } + const bool passes = applyMs <= 5000 && filterMs <= 1500 && selectMs <= 500; std::cout << "IED_BROWSER_LARGE_MODEL_" << (passes ? "PASS" : "FAIL") << " nodes=" << model.totalNodeCount() From 87182d9d47aea8673f116cbf2a810ec57404398f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 16:30:59 +0700 Subject: [PATCH 2/3] fix(workbench): preserve MMS FCD prefix read targets with exact FC validation Extend canonical indexed Global Data resolver for LD/LN$FC$DO references previously supported by literal prefix matching. Normalize to exact indexed IEC DO, enforce matching FC, and reject incomplete/ambiguous prefixes. Add regression for four MX children and FC mismatch negative case. --- apps/ied_simulator/src/MmsLiveTreeModel.cpp | 32 ++++++++++++++++--- .../src/ied_browser_large_model_qa.cpp | 8 ++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index 4f3581a84..63c4b8575 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -617,16 +617,40 @@ QVector MmsLiveTreeModel::readTargetsForReferences if (result.size() >= maximumTargets) break; const auto [reference, fc] = splitDataSetMemberReference(raw); if (reference.isEmpty()) continue; - const auto found = referenceIndex_.constFind(reference); - if (found == referenceIndex_.cend()) continue; + QString effectiveFc = fc; + auto found = referenceIndex_.constFind(reference); + if (found == referenceIndex_.cend()) { + // Also preserve the standard MMS FCD prefix LN$FC$DO: its + // canonical structural owner is the same indexed LD/LN.DO. + // Accept only a complete prefix, never a broad string match. + const int slash = reference.indexOf(QLatin1Char('/')); + const int first = slash >= 0 + ? reference.indexOf(QLatin1Char('$'), slash + 1) : -1; + const int second = first >= 0 + ? reference.indexOf(QLatin1Char('$'), first + 1) : -1; + if (second <= first) continue; + const QString prefixFc = reference.mid(first + 1, second - first - 1).toUpper(); + const QString objectName = reference.mid(second + 1); + if (prefixFc.size() < 2 || prefixFc.size() > 4 || + objectName.isEmpty() || objectName.contains(QLatin1Char('$'))) + continue; + bool validFc = true; + for (const auto ch : prefixFc) + if (!ch.isLetterOrNumber()) { validFc = false; break; } + if (!validFc || (!fc.isEmpty() && fc != prefixFc)) continue; + effectiveFc = prefixFc; + const QString iecDo = reference.left(first) + QLatin1Char('.') + objectName; + found = referenceIndex_.constFind(iecDo); + if (found == referenceIndex_.cend()) continue; + } for (const int nodeIndex : found.value()) { if (result.size() >= maximumTargets) break; const auto& node = nodes_.at(nodeIndex); if (node.kind == NodeKind::dataAttribute) { - append(nodeIndex, fc); + append(nodeIndex, effectiveFc); } else if (node.kind == NodeKind::dataObject) { for (const int child : node.children) { - append(child, fc); + append(child, effectiveFc); if (result.size() >= maximumTargets) break; } } diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 5c46db674..816e4e2a5 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -226,7 +226,13 @@ int main(int argc, char** argv) { {QStringLiteral("LD4/LLN19.DO99 [MX]")}, 64); const auto readInvalidFc = model.readTargetsForReferences( {QStringLiteral("LD4/LLN19.DO99.DA4 [ST]")}, 64); - if (readIec.size() != 2 || readDo.size() != 4 || !readInvalidFc.isEmpty() || + const auto readMmsFcd = model.readTargetsForReferences( + {QStringLiteral("LD4/LLN19$MX$DO99")}, 64); + const auto rejectMismatchedFcd = model.readTargetsForReferences( + {QStringLiteral("LD4/LLN19$MX$DO99 [ST]")}, 64); + if (readIec.size() != 2 || readDo.size() != 4 || + readMmsFcd.size() != 4 || !rejectMismatchedFcd.isEmpty() || + !readInvalidFc.isEmpty() || readIec.constFirst().item != QStringLiteral("LLN19$MX$DO99$DA4") || model.readTargetsForReferences(iecWatched, 1).size() != 1) { std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL canonical_global_watch_read\n"; From 5a5fdd2e55a26993b4cf0eed75317b81e4db0854 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 16:47:07 +0700 Subject: [PATCH 3/3] fix(workbench): coalesce Global Data notifications with Qt event loop Replace forbidden QML timer with Qt.callLater(root.syncRows), coalescing hot canonical dataChanged/valuesChanged notifications without cyclic polling, new runner schedules, or background reads. Extend P6A contract to enforce event-driven refresh. --- apps/ied_simulator/qml/BrowserGlobalDataPane.qml | 12 +++--------- apps/ied_simulator/test_browser_global_data.py | 4 ++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml index fda0d8290..9192f2763 100644 --- a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml +++ b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml @@ -20,16 +20,10 @@ Rectangle { color: theme.background ListModel { id: watchModel } - // Coalesce hot MMS value changes to a bounded UI refresh cadence. - // This is change-driven (not cyclic MMS polling) and never delays reads. - Timer { - id: watchUiCoalescer - interval: 50 - repeat: false - onTriggered: root.syncRows() - } + // Qt.callLater coalesces multiple events for the same method into one + // event-loop refresh. No timers, background polling, or automatic MMS reads. function requestWatchUiRefresh() { - if (!watchUiCoalescer.running) watchUiCoalescer.start() + Qt.callLater(root.syncRows) } function snapshotRows() { diff --git a/apps/ied_simulator/test_browser_global_data.py b/apps/ied_simulator/test_browser_global_data.py index e18bca52f..edc9cb838 100644 --- a/apps/ied_simulator/test_browser_global_data.py +++ b/apps/ied_simulator/test_browser_global_data.py @@ -55,6 +55,10 @@ def require(text: str, token: str, label: str) -> None: if 'Timer {' in pane: raise SystemExit("Global Data pane: background Timer/polling is forbidden") +if "function onValuesChanged() { root.requestWatchUiRefresh() }" not in pane: + raise SystemExit("Global Data pane: off-screen canonical read updates must be observed") +if "Qt.callLater(root.syncRows)" not in pane: + raise SystemExit("Global Data pane: update bursts must coalesce without a timer") if 'client.treeModel' in pane: raise SystemExit("Global Data pane: must consume canonical context.treeModel, not client.treeModel")