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
3 changes: 3 additions & 0 deletions apps/ied_simulator/qml/BrowserDataModelPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,9 @@ Rectangle {
model: [
{label: "Reference", value: root.currentNode.reference},
{label: "Value", value: root.currentNode.value},
{label: "Read state", value: root.currentNode.kind === "DA"
? (root.currentNode.hasReceivedValue === true ? "Received" : "Not read")
: ""},
{label: "Quality", value: root.currentNode.quality},
{label: "Timestamp", value: root.currentNode.timestamp},
{label: "Functional constraint", value: root.currentNode.functionalConstraint},
Expand Down
7 changes: 6 additions & 1 deletion apps/ied_simulator/qml/BrowserGlobalDataPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ Rectangle {
watchModel.setProperty(i, "value", node.value || "")
watchModel.setProperty(i, "quality", node.quality || "")
watchModel.setProperty(i, "timestamp", node.timestamp || "")
watchModel.setProperty(i, "status", context.online ? (node.readable ? "LIVE" : "UNRESOLVED") : "OFFLINE")
// Structural reachability is not a successful MMS sample.
// Legitimate empty-string values still count as received via the
// canonical model's explicit sample-ingress bit.
watchModel.setProperty(i, "status", !context.online ? "OFFLINE"
: !node.readable ? "UNRESOLVED"
: node.hasReceivedValue === true ? "LIVE" : "NOT READ")
} else if (row.kind === "DATASET") {
var members = row.membersText.length > 0 ? row.membersText.split("\n").length : 0
watchModel.setProperty(i, "status", context.online ? "READY" : "OFFLINE")
Expand Down
170 changes: 136 additions & 34 deletions apps/ied_simulator/qml/FleetGlobalDataPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -23,35 +23,99 @@ Rectangle {

ListModel { id: rows }

// Presentation only. Each IED retains its canonical watchlist, MMS
// association, and report authority; this pane never owns measurements.
property string filterText: ""
property int sourceCount: 0
readonly property int maximumPreviewLength: 256

function preview(value) {
const text = value === undefined || value === null ? "" : String(value)
return text.length <= maximumPreviewLength ? text
: text.slice(0, maximumPreviewLength - 1) + "…"
}

function requestRefresh() {
// Event-driven coalescing: a multi-value MMS update is one UI refresh.
Qt.callLater(root.refreshRows)
}

function refreshRows() {
rows.clear()
for (var index = 0; index < fleet.workspaceCount; ++index) {
var view = browserViews.itemAt(index)
var context = fleet.contextAt(index)
if (!view || !context)
continue
var entries = view.watchedRows()
for (var i = 0; i < entries.length; ++i) {
if (rows.count >= maximumRows) {
statusMessage = "Combined watchlist bound reached (2048); no more rows retained."
return
}
var row = entries[i]
rows.append({
const next = []
const needle = filterText.trim().toLowerCase()
let sourceTotal = 0
let truncated = false
for (let index = 0; index < fleet.workspaceCount; ++index) {
const view = browserViews.itemAt(index)
const context = fleet.contextAt(index)
if (!view || !context) continue
const entries = view.watchedRows()
const ied = context.iedName || ("IED " + String(index + 1))
for (let i = 0; i < entries.length; ++i) {
const row = entries[i]
++sourceTotal
const item = {
"slotIndex": index,
"iedName": context.iedName || ("IED " + String(index + 1)),
"iedName": ied,
"kind": row.kind || "",
"reference": row.reference || "",
"detail": row.detail || "",
"value": row.value || "",
"quality": row.quality || "",
"timestamp": row.timestamp || "",
"detail": preview(row.detail),
"value": preview(row.value),
"quality": preview(row.quality),
"timestamp": preview(row.timestamp),
"status": row.status || "OFFLINE"
})
}
// Search a bounded presentation preview, not a potentially
// megabyte-long MMS scalar or a second indexing/cache system.
if (needle.length > 0 &&
[item.iedName, item.kind, preview(item.reference),
item.detail, item.value, item.quality, item.timestamp,
item.status].join(" ").toLowerCase().indexOf(needle) < 0)
continue
if (next.length >= maximumRows) {
truncated = true
continue
}
next.push(item)
}
}

sourceCount = sourceTotal
// Keep virtualized delegates and scroll position stable for frequent
// real MMS value updates. Structural change is the only reset path.
let stable = rows.count === next.length
if (stable) {
for (let i = 0; i < next.length; ++i) {
const old = rows.get(i)
if (old.slotIndex !== next[i].slotIndex ||
old.kind !== next[i].kind ||
old.reference !== next[i].reference) {
stable = false
break
}
}
}
if (stable) {
const dynamicRoles = ["iedName", "detail", "value", "quality", "timestamp", "status"]
for (let i = 0; i < next.length; ++i) {
const before = rows.get(i)
for (let k = 0; k < dynamicRoles.length; ++k) {
const role = dynamicRoles[k]
if (before[role] !== next[i][role])
rows.setProperty(i, role, next[i][role])
}
}
} else {
rows.clear()
for (let i = 0; i < next.length; ++i)
rows.append(next[i])
}
statusMessage = String(rows.count) + " entries from "
+ String(fleet.workspaceCount) + " separate IED workspace(s)."
statusMessage = truncated
? "Visible result limit reached (2048). Refine search."
: needle.length > 0
? String(rows.count) + " matches across " + String(sourceTotal) + " watched entries."
: String(sourceTotal) + " watched entries from "
+ String(fleet.workspaceCount) + " independent IED(s)."
}

function refreshSelected() {
Expand All @@ -68,7 +132,7 @@ Rectangle {

Connections {
target: fleet
function onWorkspacesChanged() { root.refreshRows() }
function onWorkspacesChanged() { root.requestRefresh() }
}

ColumnLayout {
Expand Down Expand Up @@ -106,9 +170,29 @@ Rectangle {
}
}

TextField {
id: signalSearch
objectName: "fleetGlobalDataSearch"
Layout.preferredWidth: 215
Layout.maximumWidth: 270
maximumLength: 128
placeholderText: "Search IED, signal, value, quality…"
selectByMouse: true
onTextChanged: {
root.filterText = text
root.requestRefresh()
}
ToolTip.visible: hovered
ToolTip.text: "Filter this view only. Never changes watched signals or starts MMS traffic."
}
Button {
text: "Clear"
visible: signalSearch.length > 0
onClicked: signalSearch.clear()
}
ComboBox {
id: selectedIed
Layout.preferredWidth: 185
Layout.preferredWidth: 160
model: fleet
textRole: "label"
}
Expand Down Expand Up @@ -146,11 +230,13 @@ Rectangle {
anchors.leftMargin: 12
anchors.rightMargin: 12
spacing: 9
Label { Layout.preferredWidth: 110; text: "IED"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 70; text: "TYPE"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.fillWidth: true; text: "CANONICAL REFERENCE"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 150; text: "VALUE"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 90; text: "STATUS"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 96; text: "IED"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 58; text: "TYPE"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.fillWidth: true; text: "IEC REFERENCE"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 120; text: "VALUE"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 110; text: "QUALITY"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 145; text: "IED TIMESTAMP"; color: theme.muted; font.pixelSize: 8 }
Label { Layout.preferredWidth: 90; text: "STATE"; color: theme.muted; font.pixelSize: 8 }
}
}

Expand Down Expand Up @@ -186,14 +272,14 @@ Rectangle {
anchors.rightMargin: 12
spacing: 9
Label {
Layout.preferredWidth: 110
Layout.preferredWidth: 96
text: iedName
color: theme.text
font.pixelSize: 8
elide: Text.ElideRight
}
Label {
Layout.preferredWidth: 70
Layout.preferredWidth: 58
text: kind
color: theme.muted
font.pixelSize: 8
Expand All @@ -206,12 +292,26 @@ Rectangle {
elide: Text.ElideMiddle
}
Label {
Layout.preferredWidth: 150
text: value
Layout.preferredWidth: 120
text: value.length ? value : "—"
color: theme.text
font.pixelSize: 8
elide: Text.ElideRight
}
Label {
Layout.preferredWidth: 110
text: quality.length ? quality : "—"
color: quality.length ? theme.textSoft : theme.muted
font.pixelSize: 8
elide: Text.ElideRight
}
Label {
Layout.preferredWidth: 145
text: timestamp.length ? timestamp : "—"
color: timestamp.length ? theme.textSoft : theme.muted
font.pixelSize: 8
elide: Text.ElideRight
}
Label {
Layout.preferredWidth: 90
text: status
Expand All @@ -235,7 +335,9 @@ Rectangle {
anchors.centerIn: parent
visible: rows.count === 0
width: Math.max(250, parent.width - 80)
text: "No watched signals yet. Open an IED tab and add DataAttributes, DataSets, Reports, or GOOSE to its Global Data."
text: root.filterText.length > 0
? "No watched signals match this search. Clear the filter to see all IEDs."
: "No watched signals yet. Open an IED tab and add DataAttributes, DataSets, Reports, or GOOSE to its Global Data."
color: theme.muted
font.pixelSize: 9
horizontalAlignment: Text.AlignHCenter
Expand Down
2 changes: 1 addition & 1 deletion apps/ied_simulator/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,7 @@ ApplicationWindow {
utilities: root.browserFleet.utilitiesAt(index)
controls: root.browserFleet.controlsAt(index)
engineering: root.browserFleet.engineeringAt(index)
onWatchedDataChanged: fleetGlobalData.refreshRows()
onWatchedDataChanged: fleetGlobalData.requestRefresh()
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion apps/ied_simulator/src/MmsLiveTreeModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,7 @@ QVariantMap MmsLiveTreeModel::nodeMap(const int nodeIndex) const {
}
}
result.insert(QStringLiteral("value"), displayValue);
result.insert(QStringLiteral("hasReceivedValue"), node.hasReceivedValue);
result.insert(QStringLiteral("engineeringEvidence"), node.engineeringEvidence);
result.insert(QStringLiteral("hasChildren"), !node.children.isEmpty());
result.insert(QStringLiteral("childCount"), node.children.size());
Expand Down Expand Up @@ -667,7 +668,9 @@ void MmsLiveTreeModel::applyReadValue(
if (found == mmsIndex_.cend()) return;
const auto nodeIndex = found.value();
auto& node = nodes_[nodeIndex];
if (node.value == displayValue && node.engineeringEvidence == engineeringEvidence) return;
if (node.hasReceivedValue && node.value == displayValue &&
node.engineeringEvidence == engineeringEvidence) return;
node.hasReceivedValue = true;
node.value = displayValue;
// Evidence is immutable per received sample; a later untyped update must
// clear previous bit/clock details rather than reuse stale measurements.
Expand Down
1 change: 1 addition & 0 deletions apps/ied_simulator/src/MmsLiveTreeModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ class MmsLiveTreeModel : public QAbstractListModel {
QString typeStatus;
QString description;
QString value;
bool hasReceivedValue{}; // updated only at actual MMS sample ingress
QVariantMap engineeringEvidence;
bool writable{};
};
Expand Down
13 changes: 13 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 @@ -201,6 +201,19 @@ int main(int argc, char** argv) {

const QString liveKey = QStringLiteral("LD4") + QChar(0x1f) +
QStringLiteral("LLN19$MX$DO99$DA4");
// ARSAS parity: discovered/readable is not the same as measured. An
// empty-string MMS sample is still a real received value, not "Not read".
if (model.nodeForReference(QStringLiteral("LD4/LLN19.DO99.DA4"))
.value(QStringLiteral("hasReceivedValue")).toBool()) {
std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL unread_sample_false_live\n";
return 30;
}
model.applyReadValue(liveKey, QString{});
if (!model.nodeForReference(QStringLiteral("LD4/LLN19.DO99.DA4"))
.value(QStringLiteral("hasReceivedValue")).toBool()) {
std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL empty_sample_received\n";
return 31;
}
model.applyReadValue(liveKey, QStringLiteral("23"));
if (model.nodeForReference(QStringLiteral("LD4/LLN19.DO99.DA4 [MX]"))
.value(QStringLiteral("value")).toString() != QStringLiteral("23")) {
Expand Down
34 changes: 33 additions & 1 deletion apps/ied_simulator/test_browser_convergence.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
navigation = Path(sys.argv[2]).read_text(encoding="utf-8")
status = Path(sys.argv[3]).read_text(encoding="utf-8")
data_model = Path(sys.argv[4]).read_text(encoding="utf-8")
fleet_global = (workspace_path.parent / "FleetGlobalDataPane.qml").read_text(encoding="utf-8")
per_ied_global = (workspace_path.parent / "BrowserGlobalDataPane.qml").read_text(encoding="utf-8")
canonical_header = (workspace_path.parent.parent / "src" / "MmsLiveTreeModel.hpp").read_text(encoding="utf-8")
canonical_tree = (workspace_path.parent.parent / "src" / "MmsLiveTreeModel.cpp").read_text(encoding="utf-8")
large_model_qa = (workspace_path.parent.parent / "src" / "ied_browser_large_model_qa.cpp").read_text(encoding="utf-8")

workspace_required = [
'component RibbonAction: Button',
Expand Down Expand Up @@ -105,11 +110,38 @@
if 'readEngineeringSelected()' in data_set or 'readEngineeringSelected()' in data_model:
raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL passive inspector initiated MMS Read")

# M2K — migrate ARSAS live-grid presentation while retaining one authoritative
# per-IED canonical MMS tree and rejecting "readable means LIVE" shortcuts.
for token in [
'objectName: "fleetGlobalDataSearch"',
'text: "QUALITY"',
'text: "IED TIMESTAMP"',
'function requestRefresh()',
'Qt.callLater(root.refreshRows)',
'rows.setProperty(i, role, next[i][role])',
'readonly property int maximumPreviewLength: 256',
'onDoubleClicked: root.iedRequested(slotIndex)',
]:
if token not in fleet_global:
raise SystemExit("BROWSER_ARSAS_LIVE_GRID_FAIL missing=" + token)
if ("node.hasReceivedValue === true ? \"LIVE\" : \"NOT READ\"" not in per_ied_global or
"bool hasReceivedValue{};" not in canonical_header or
'QStringLiteral("hasReceivedValue")' not in canonical_tree or
"node.hasReceivedValue = true;" not in canonical_tree or
"unread_sample_false_live" not in large_model_qa or
"empty_sample_received" not in large_model_qa):
Comment thread
masarray marked this conversation as resolved.
Resolved
raise SystemExit("BROWSER_ARSAS_SSOT_FAIL unread values fabricated as LIVE")
main_qml = (workspace_path.parent / "Main.qml").read_text(encoding="utf-8")
if 'onWatchedDataChanged: fleetGlobalData.requestRefresh()' not in main_qml:
raise SystemExit("BROWSER_ARSAS_LIVE_GRID_FAIL uncoalesced cross-IED burst")

if "Timer {" in fleet_global or "readEngineeringSelected()" in fleet_global:
raise SystemExit("BROWSER_ARSAS_SSOT_FAIL implicit MMS polling or read")

# Typed MMS evidence from the initial trusted-SCL snapshot must survive into
# both canonical trees. The large-model QA exercises valid/invalid wire cases.
snapshot_projection = (workspace_path.parent.parent / "src" / "MmsSclClientProjection.hpp").read_text(encoding="utf-8")
mms_client = (workspace_path.parent.parent / "src" / "MmsClientController.cpp").read_text(encoding="utf-8")
large_model_qa = (workspace_path.parent.parent / "src" / "ied_browser_large_model_qa.cpp").read_text(encoding="utf-8")
if ("isSclTypedEvidenceLeaf(item)" not in snapshot_projection or
"item[item.size() - 2] == '$'" not in snapshot_projection or
"kMaximumSclInitialTypedEvidence = 96U" not in snapshot_projection or
Expand Down
9 changes: 8 additions & 1 deletion apps/ied_simulator/test_multi_ied_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def require(haystack, words, label):
"onOpenSourceRequested: function(fileUrl)",
"fleet.prepareForNewSource()",
"FleetGlobalDataPane {",
"onWatchedDataChanged: fleetGlobalData.refreshRows()",
"onWatchedDataChanged: fleetGlobalData.requestRefresh()",
), "shell routing")
require(browser, (
"required property var fleet",
Expand All @@ -91,7 +91,14 @@ def require(haystack, words, label):
"view.watchedRows()",
"view.refreshWatchedGlobalData()",
"maximumRows: 2048",
"function requestRefresh()",
"Qt.callLater(root.refreshRows)",
"rows.setProperty(i, role, next[i][role])",
'text: "QUALITY"',
'text: "IED TIMESTAMP"',
), "all-IED Global Data")
if 'onWatchedDataChanged: fleetGlobalData.refreshRows()' in main:
raise SystemExit("P6E_FAIL uncoalesced cross-IED watch refresh")
require(global_data, (
"signal watchSnapshotChanged()",
"function snapshotRows()",
Expand Down
Loading