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
42 changes: 41 additions & 1 deletion apps/ied_simulator/qml/BrowserReportPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ Rectangle {
property bool attributesExpanded: true
property bool informationExpanded: true
property bool dataExpanded: true
property int selectedReportMemberIndex: -1
// Preserve selection through repeat snapshots of the same report frame;
// reset only when the actual ReportID/sequence/entry identity changes.
readonly property string latestReportIdentity: !hasLastReport ? "" : [
latestReport.reportId || "", latestReport.sequence || "",
latestReport.subSequence || "", latestReport.entryId || ""
].join("|")
onLatestReportIdentityChanged: selectedReportMemberIndex = -1
Comment on lines +24 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset selection for reports without optional identifiers

When an RCB omits sequence-number, segmentation, and entry-id from OptFlds, successive reports all produce the same identity (RptID|—|—|), so this handler never resets the selected row. The highlight then remains while the evidence silently changes to the same row in a newer frame; use a stable per-frame occurrence identifier rather than only optional wire fields.

AGENTS.md reference: AGENTS.md:L74-L86

Useful? React with 👍 / 👎.

function selectedReportMemberEvidence() {
if (!hasLastReport || selectedReportMemberIndex < 0 ||
!latestReport.valueEvidence) return ({})
const entries = latestReport.valueEvidence
for (let index = 0; index < entries.length; ++index) {
const entry = entries[index]
if (entry.index === selectedReportMemberIndex)
return entry.evidence || ({})
}
return ({})
}
color: theme.background

function asText(value) {
Expand Down Expand Up @@ -213,7 +232,9 @@ Rectangle {
readonly property int stripe: index
Layout.fillWidth: true
implicitHeight: 25
color: stripe % 2 ? root.theme.surfaceSoft : root.theme.surface
color: received && root.selectedReportMemberIndex === index
? root.theme.accentSoft
: stripe % 2 ? root.theme.surfaceSoft : root.theme.surface
RowLayout {
anchors.fill: parent
anchors.leftMargin: 9
Expand Down Expand Up @@ -249,6 +270,12 @@ Rectangle {
MouseArea { id: dataValueMouse; anchors.fill: parent; hoverEnabled: true; acceptedButtons: Qt.NoButton }
}
}
MouseArea {
anchors.fill: parent
enabled: dataRow.received
cursorShape: Qt.PointingHandCursor
onClicked: root.selectedReportMemberIndex = dataRow.index
}
Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: root.theme.lineSoft }
}

Expand Down Expand Up @@ -399,6 +426,19 @@ Rectangle {
model: !root.hasLastReport ? root.configuredMembers.slice(0, 256) : []
delegate: DataRow { received: false }
}
BrowserEngineeringEvidence {
theme: root.theme
engineeringEvidence: root.selectedReportMemberEvidence()
}
Label {
visible: root.hasLastReport &&
root.latestReport.valueEvidenceTruncated === true
Layout.fillWidth: true
leftPadding: 9
text: "Some report q/t details were omitted by the bounded projection."
color: theme.muted
font.pixelSize: 9
}
Label {
visible: root.hasLastReport ? !root.latestReport.values || root.latestReport.values.length === 0
: root.configuredMembers.length === 0
Expand Down
6 changes: 5 additions & 1 deletion apps/ied_simulator/src/MmsEngineeringEvidence.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <QVariantList>
#include <QVariantMap>

#include <algorithm>
#include <chrono>
#include <cstdint>
#include <variant>
Expand All @@ -27,7 +28,10 @@ inline void addEvidenceRow(QVariantList& rows, const QString& label, const QVari
[[nodiscard]] inline QVariantMap decodeEngineeringEvidence(
const QString& mmsItem, const ar::iec61850::mms::MmsDataValue& value) {
using namespace ar::iec61850::mms;
const QString leaf = mmsItem.mid(mmsItem.lastIndexOf(QLatin1Char('$')) + 1);
// Accept both typed MMS $FC$DO$q and IEC LN.DO.q report-member paths.
const int leafSeparator = std::max(mmsItem.lastIndexOf(QLatin1Char('$')),
mmsItem.lastIndexOf(QLatin1Char('.')));
const QString leaf = mmsItem.mid(leafSeparator + 1);
QVariantList rows;

if (leaf == QStringLiteral("q") && value.kind() == MmsDataKind::bit_string) {
Expand Down
44 changes: 43 additions & 1 deletion apps/ied_simulator/src/MmsReportController.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "MmsReportController.hpp"
#include "MmsEngineeringEvidence.hpp"

#include "ariec61850/mms/data_codec.hpp"
#include "ariec61850/mms/dynamic_data_set.hpp"
Expand All @@ -26,6 +27,8 @@ namespace mms = ar::iec61850::mms;

namespace {
constexpr int kMaximumVisibleReports = 128;
constexpr int kMaximumTypedReportEvidence = 96;
constexpr int kMaximumVisibleReportMembers = 256;

QString boolText(const std::optional<bool> value) {
if (!value.has_value()) return QStringLiteral("—");
Expand Down Expand Up @@ -460,6 +463,7 @@ ReportUi buildSubscriptionUi(
}
while (ui.events.size() > 256) ui.events.removeFirst();

int typedEvidenceBudget = kMaximumTypedReportEvidence;
for (auto streamIt = subscription.streams.rbegin();
streamIt != subscription.streams.rend(); ++streamIt) {
for (auto frameIt = streamIt->recent_frames.rbegin();
Expand All @@ -477,8 +481,46 @@ ReportUi buildSubscriptionUi(
map.insert(QStringLiteral("decoderMode"), QString::fromStdString(frame.decoder_mode));
map.insert(QStringLiteral("valueCount"), static_cast<int>(frame.values.size()));
QStringList values;
for (const auto& value : frame.values) values.push_back(reportValueText(value));
QVariantList typedEvidence;
bool typedEvidenceTruncated = false;
int memberIndex = 0;
for (const auto& item : frame.values) {
values.push_back(reportValueText(item));
if (item.value && memberIndex < kMaximumVisibleReportMembers) {
QString memberRef = QString::fromStdString(item.data_reference);
if (memberRef.isEmpty() && item.member)
memberRef = QString::fromStdString(item.member->user_reference);
if (memberRef.isEmpty() && item.member)
memberRef = QString::fromStdString(item.member->mms_reference);
Comment on lines +490 to +494

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cross-check DataRef before using it as the semantic type

When a report supplies a DataReference that disagrees with the directory member already mapped by inclusion index, this gives the untrusted wire string priority. A malformed endpoint can therefore make an unrelated 13-bit BIT STRING appear as IEC Quality merely by ending DataRef in .q; validate the reference against item.member or fail closed on a mismatch before decoding engineering evidence.

AGENTS.md reference: AGENTS.md:L55-L66

Useful? React with 👍 / 👎.

if (typedEvidenceBudget > 0) {
const auto evidence = arstack::iedsim::decodeEngineeringEvidence(
memberRef, *item.value);
if (!evidence.isEmpty()) {
typedEvidence.push_back(QVariantMap{
{QStringLiteral("index"), memberIndex},
{QStringLiteral("evidence"), evidence}});
--typedEvidenceBudget;
}
} else {
// Mark truncation only for real q/t typed members, not
// unrelated BIT STRING or UTC-valued attributes.
const auto kind = item.value->kind();
const auto separator = std::max(
memberRef.lastIndexOf(QLatin1Char('$')),
memberRef.lastIndexOf(QLatin1Char('.')));
const QString leaf = memberRef.mid(separator + 1);
if ((leaf == QStringLiteral("q") &&
kind == mms::MmsDataKind::bit_string) ||
(leaf == QStringLiteral("t") &&
kind == mms::MmsDataKind::utc_time))
typedEvidenceTruncated = true;
}
}
++memberIndex;
}
map.insert(QStringLiteral("values"), values);
map.insert(QStringLiteral("valueEvidence"), typedEvidence);
map.insert(QStringLiteral("valueEvidenceTruncated"), typedEvidenceTruncated);
map.insert(QStringLiteral("valuesSummary"), values.mid(0, 3).join(QStringLiteral(" | ")));
map.insert(QStringLiteral("duplicateCount"), static_cast<qulonglong>(streamIt->state.duplicate_count));
map.insert(QStringLiteral("gapCount"), static_cast<qulonglong>(streamIt->state.gap_count));
Expand Down
9 changes: 9 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 @@ -251,6 +251,15 @@ int main(int argc, char** argv) {
QStringLiteral("LLN19$MX$DO99$q"),
ar::iec61850::mms::MmsDataValue::bit_string(3U, bits));
const auto qualityRows = qualityEvidence.value(QStringLiteral("rows")).toList();
// RCB member references can use IEC LN.DO.q rather than MMS $FC$DO$q.
// Both must use exactly the same typed decoder and return equal results.
const auto iecQualityEvidence = arstack::iedsim::decodeEngineeringEvidence(
QStringLiteral("LD4/LLN19.DO99.q"),
ar::iec61850::mms::MmsDataValue::bit_string(3U, bits));
if (iecQualityEvidence != qualityEvidence) {
std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL report_iec_quality_alias\n";
return 26;
}
const auto malformedQuality = arstack::iedsim::decodeEngineeringEvidence(
QStringLiteral("LLN19$MX$DO99$q"),
ar::iec61850::mms::MmsDataValue::bit_string(2U, bits));
Expand Down
22 changes: 22 additions & 0 deletions apps/ied_simulator/test_browser_convergence.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,24 @@
if 'root.memberNode(modelData)' in data_set:
raise SystemExit("BROWSER_DATASET_HIERARCHY_FAIL unnormalized per-row linear lookup")

report_controller = (workspace_path.parent.parent / "src" / "MmsReportController.cpp").read_text(encoding="utf-8")
for token in [
'kMaximumTypedReportEvidence = 96',
'kMaximumVisibleReportMembers = 256',
'decodeEngineeringEvidence(',
'QStringLiteral("valueEvidence")',
'QStringLiteral("valueEvidenceTruncated")',
]:
if token not in report_controller:
raise SystemExit("BROWSER_REPORT_TYPED_EVIDENCE_FAIL missing=" + token)

# Protect the shared typed decoder from truncated/doubled source,
# not just presence of UI tokens.
typed_decoder = (workspace_path.parent.parent / "src" / "MmsEngineeringEvidence.hpp").read_text(encoding="utf-8")
if typed_decoder.count("decodeEngineeringEvidence(") != 1 or (
"const QString leaf = mmsItem.mid(leafSeparator + 1);" not in typed_decoder):
raise SystemExit("BROWSER_REPORT_TYPED_EVIDENCE_FAIL invalid shared decoder source")

report_required = [
'objectName: "browserReportUnifiedInspector"',
'Control Block Attributes',
Expand All @@ -100,6 +118,10 @@
'delegate: PropertyRow { lastReportSection: true }',
'delegate: DataRow { received: false }',
'reports.selectedRcbDataSetMembers',
'selectedReportMemberEvidence()',
'root.selectedReportMemberIndex = dataRow.index',
'onLatestReportIdentityChanged: selectedReportMemberIndex = -1',
'BrowserEngineeringEvidence {',
'String(frame.reportId) === wanted',
'Offline values are not fabricated.',
]
Expand Down
Loading