Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 9 additions & 1 deletion apps/ied_simulator/qml/BrowserGlobalDataPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 {
Expand Down
87 changes: 61 additions & 26 deletions apps/ied_simulator/src/MmsLiveTreeModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -591,35 +591,70 @@ QVector<MmsLiveTreeModel::ReadTarget> MmsLiveTreeModel::readTargetsForVisibleRan
QVector<MmsLiveTreeModel::ReadTarget> MmsLiveTreeModel::readTargetsForReferences(
const QStringList& references, const int maximumTargets) const {
QVector<ReadTarget> result;
if (references.isEmpty() || maximumTargets <= 0) return result;

QStringList wanted;
QSet<QString> 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<QString> 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<QString> 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize opened-SCL FCDA references before lookup

In the SCL-assisted-connect path, this exact lookup cannot resolve DataSet members: build_signal_reference() produces references such as IED/LD/LN.DO.DA [MX], while IedEngineeringContextController::dataSets() appends another FC suffix and the tree index uses the MMS domain form IEDLD/LN.DO.DA. splitDataSetMemberReference() removes only the final suffix, leaving a key that never exists, and the $ fallback does not apply, so refreshing an opened-SCL DataSet returns no read targets. Convert the canonical SCL reference to the indexed MMS-domain identity, or pass/index the member's existing mms_reference, so Open SCL and live discovery resolve through the same semantics.

AGENTS.md reference: AGENTS.md:L299-L301

Useful? React with 👍 / 👎.

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;
}
Expand Down
25 changes: 25 additions & 0 deletions apps/ied_simulator/src/ied_browser_large_model_qa.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions apps/ied_simulator/test_browser_global_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Loading