diff --git a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml index ae5150a4a..9192f2763 100644 --- a/apps/ied_simulator/qml/BrowserGlobalDataPane.qml +++ b/apps/ied_simulator/qml/BrowserGlobalDataPane.qml @@ -20,6 +20,11 @@ Rectangle { color: theme.background ListModel { id: watchModel } + // Qt.callLater coalesces multiple events for the same method into one + // event-loop refresh. No timers, background polling, or automatic MMS reads. + function requestWatchUiRefresh() { + Qt.callLater(root.syncRows) + } function snapshotRows() { var entries = [] @@ -218,8 +223,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..63c4b8575 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -591,35 +591,70 @@ 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; + 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, effectiveFc); + } else if (node.kind == NodeKind::dataObject) { + for (const int child : node.children) { + append(child, effectiveFc); + 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..816e4e2a5 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,31 @@ 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); + 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"; + return 20; + } + const bool passes = applyMs <= 5000 && filterMs <= 1500 && selectMs <= 500; std::cout << "IED_BROWSER_LARGE_MODEL_" << (passes ? "PASS" : "FAIL") << " nodes=" << model.totalNodeCount() 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")