diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index da5a1e12d..2c28fe560 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -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 + 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) { @@ -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 @@ -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 } } @@ -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 diff --git a/apps/ied_simulator/src/MmsEngineeringEvidence.hpp b/apps/ied_simulator/src/MmsEngineeringEvidence.hpp index 905193ca7..09a131265 100644 --- a/apps/ied_simulator/src/MmsEngineeringEvidence.hpp +++ b/apps/ied_simulator/src/MmsEngineeringEvidence.hpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -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) { diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 41843f1ed..c929f338a 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -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" @@ -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 value) { if (!value.has_value()) return QStringLiteral("—"); @@ -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(); @@ -477,8 +481,46 @@ ReportUi buildSubscriptionUi( map.insert(QStringLiteral("decoderMode"), QString::fromStdString(frame.decoder_mode)); map.insert(QStringLiteral("valueCount"), static_cast(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); + 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(streamIt->state.duplicate_count)); map.insert(QStringLiteral("gapCount"), static_cast(streamIt->state.gap_count)); 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 16b562f70..baa9d7fa8 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -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)); diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index a0cc5135c..2e460b182 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -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', @@ -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.', ]