From 4d58201def069191c47527fc49bfe91b75e9bf23 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 20:26:18 +0700 Subject: [PATCH 1/2] feat(workbench): bounded typed q/t inspector for received RCB reports Project Quality/UTC-TimeQuality evidence directly from successful MmsReportValue payloads via existing canonical decoder; preserve report DataSet member index and ReportID provenance. Cap typed evidence at 96 per report snapshot without a second semantic cache. Inspect a selected received member in Browser Reports with the same reusable dense q/t panel, while offline values remain unknown. Validate IEC dotted references and add report source contracts. --- apps/ied_simulator/qml/BrowserReportPane.qml | 36 ++++++++++- .../src/MmsEngineeringEvidence.hpp | 64 ++++++++++++++++++- .../ied_simulator/src/MmsReportController.cpp | 36 ++++++++++- .../src/ied_browser_large_model_qa.cpp | 9 +++ .../ied_simulator/test_browser_convergence.py | 14 ++++ 5 files changed, 156 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index da5a1e12d..162670d09 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -18,6 +18,19 @@ Rectangle { property bool attributesExpanded: true property bool informationExpanded: true property bool dataExpanded: true + property int selectedReportMemberIndex: -1 + onLatestReportChanged: 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 +226,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 +264,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 +420,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..e335188ca 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,68 @@ 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); + // Live MMS paths use $FC$DO$DA; RCB report members may use IEC + // LD/LN.DO.DA form. Only the terminal typed q/t leaf is interpreted. + const int leafSeparator = std::max(mmsItem.lastIndexOf(QLatin1Char(' + QVariantList rows; + + if (leaf == QStringLiteral("q") && value.kind() == MmsDataKind::bit_string) { + const auto& bits = value.raw_value(); // [unused bits, octet 1, octet 2] + // IEC 61850 Quality is 13 bits. A malformed/short wire value is NOT + // translated to false flags (unknown != false). + if (bits.size() != 3U || bits[0] != 3U || (bits[2] & 0x07U) != 0U) + return {}; + const auto at = [&bits](const unsigned index) { + return ((bits[1U + index / 8U] >> (7U - index % 8U)) & 1U) != 0U; + }; + const unsigned validity = (at(0) ? 2U : 0U) | (at(1) ? 1U : 0U); + const QStringList meanings{ + QStringLiteral("good"), QStringLiteral("invalid"), + QStringLiteral("reserved"), QStringLiteral("questionable")}; + detail::addEvidenceRow(rows, QStringLiteral("Validity"), meanings.at(int(validity))); + static constexpr const char* flags[]{ + "Overflow", "OutOfRange", "BadReference", "Oscillatory", + "Failure", "OldData", "Inconsistent", "Inaccurate"}; + for (unsigned i = 0; i < 8U; ++i) + detail::addEvidenceRow(rows, QString::fromLatin1(flags[i]), at(i + 2U)); + const QString source = at(10U) ? QStringLiteral("substituted") : QStringLiteral("process"); + detail::addEvidenceRow(rows, QStringLiteral("Source"), source); + detail::addEvidenceRow(rows, QStringLiteral("Test"), at(11U)); + detail::addEvidenceRow(rows, QStringLiteral("OperatorBlocked"), at(12U)); + return {{QStringLiteral("kind"), QStringLiteral("quality")}, + {QStringLiteral("summary"), meanings.at(int(validity))}, + {QStringLiteral("rows"), rows}}; + } + + if (leaf == QStringLiteral("t") && value.kind() == MmsDataKind::utc_time) { + const auto& utc = std::get(value.value()); + const auto milliseconds = std::chrono::duration_cast( + utc.value.time_since_epoch()).count(); + const QString timestamp = QDateTime::fromMSecsSinceEpoch( + static_cast(milliseconds), Qt::UTC) + .toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz 'UTC'")); + const std::uint8_t timeQuality = utc.quality; + const unsigned accuracy = timeQuality & 0x1FU; + detail::addEvidenceRow(rows, QStringLiteral("UTC time"), timestamp); + detail::addEvidenceRow(rows, QStringLiteral("LeapSecondsKnown"), + (timeQuality & 0x80U) != 0U); + detail::addEvidenceRow(rows, QStringLiteral("ClockFailure"), + (timeQuality & 0x40U) != 0U); + detail::addEvidenceRow(rows, QStringLiteral("ClockNotSynchronized"), + (timeQuality & 0x20U) != 0U); + detail::addEvidenceRow(rows, QStringLiteral("TimeAccuracy"), + accuracy == 31U ? QVariant{QStringLiteral("unspecified")} + : QVariant{int(accuracy)}); + return {{QStringLiteral("kind"), QStringLiteral("timestamp")}, + {QStringLiteral("summary"), timestamp}, + {QStringLiteral("rows"), rows}}; + } + return {}; +} +} // namespace arstack::iedsim +)), + 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..613f0ac55 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,38 @@ 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 (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 { + // Indicate omitted typed details without manufacturing + // a decoded value or carrying unbounded metadata. + const auto kind = item.value->kind(); + if (kind == mms::MmsDataKind::bit_string || + 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..56a2b5900 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -85,6 +85,17 @@ 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) + report_required = [ 'objectName: "browserReportUnifiedInspector"', 'Control Block Attributes', @@ -100,6 +111,9 @@ 'delegate: PropertyRow { lastReportSection: true }', 'delegate: DataRow { received: false }', 'reports.selectedRcbDataSetMembers', + 'selectedReportMemberEvidence()', + 'root.selectedReportMemberIndex = dataRow.index', + 'BrowserEngineeringEvidence {', 'String(frame.reportId) === wanted', 'Offline values are not fabricated.', ] From c7a6841b652911e5d70d0015905c183cdc900e96 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 20:34:09 +0700 Subject: [PATCH 2/2] fix(workbench): compile-safe shared IEC report decoder and stable frame selection Correct typed decoder leaf extraction from both MMS and IEC reference paths (no duplicated/truncated header source), preserve selected report-member inspection through repeated snapshots of the same frame, precisely flag omitted q/t details, and fall back to canonical directory MMS references. Extend Browser source integrity contract; exact Git blob contents verified before push. --- apps/ied_simulator/qml/BrowserReportPane.qml | 8 ++- .../src/MmsEngineeringEvidence.hpp | 62 +------------------ .../ied_simulator/src/MmsReportController.cpp | 16 +++-- .../ied_simulator/test_browser_convergence.py | 8 +++ 4 files changed, 29 insertions(+), 65 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserReportPane.qml b/apps/ied_simulator/qml/BrowserReportPane.qml index 162670d09..2c28fe560 100644 --- a/apps/ied_simulator/qml/BrowserReportPane.qml +++ b/apps/ied_simulator/qml/BrowserReportPane.qml @@ -19,7 +19,13 @@ Rectangle { property bool informationExpanded: true property bool dataExpanded: true property int selectedReportMemberIndex: -1 - onLatestReportChanged: 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 ({}) diff --git a/apps/ied_simulator/src/MmsEngineeringEvidence.hpp b/apps/ied_simulator/src/MmsEngineeringEvidence.hpp index e335188ca..09a131265 100644 --- a/apps/ied_simulator/src/MmsEngineeringEvidence.hpp +++ b/apps/ied_simulator/src/MmsEngineeringEvidence.hpp @@ -28,66 +28,8 @@ 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; - // Live MMS paths use $FC$DO$DA; RCB report members may use IEC - // LD/LN.DO.DA form. Only the terminal typed q/t leaf is interpreted. - const int leafSeparator = std::max(mmsItem.lastIndexOf(QLatin1Char(' - QVariantList rows; - - if (leaf == QStringLiteral("q") && value.kind() == MmsDataKind::bit_string) { - const auto& bits = value.raw_value(); // [unused bits, octet 1, octet 2] - // IEC 61850 Quality is 13 bits. A malformed/short wire value is NOT - // translated to false flags (unknown != false). - if (bits.size() != 3U || bits[0] != 3U || (bits[2] & 0x07U) != 0U) - return {}; - const auto at = [&bits](const unsigned index) { - return ((bits[1U + index / 8U] >> (7U - index % 8U)) & 1U) != 0U; - }; - const unsigned validity = (at(0) ? 2U : 0U) | (at(1) ? 1U : 0U); - const QStringList meanings{ - QStringLiteral("good"), QStringLiteral("invalid"), - QStringLiteral("reserved"), QStringLiteral("questionable")}; - detail::addEvidenceRow(rows, QStringLiteral("Validity"), meanings.at(int(validity))); - static constexpr const char* flags[]{ - "Overflow", "OutOfRange", "BadReference", "Oscillatory", - "Failure", "OldData", "Inconsistent", "Inaccurate"}; - for (unsigned i = 0; i < 8U; ++i) - detail::addEvidenceRow(rows, QString::fromLatin1(flags[i]), at(i + 2U)); - const QString source = at(10U) ? QStringLiteral("substituted") : QStringLiteral("process"); - detail::addEvidenceRow(rows, QStringLiteral("Source"), source); - detail::addEvidenceRow(rows, QStringLiteral("Test"), at(11U)); - detail::addEvidenceRow(rows, QStringLiteral("OperatorBlocked"), at(12U)); - return {{QStringLiteral("kind"), QStringLiteral("quality")}, - {QStringLiteral("summary"), meanings.at(int(validity))}, - {QStringLiteral("rows"), rows}}; - } - - if (leaf == QStringLiteral("t") && value.kind() == MmsDataKind::utc_time) { - const auto& utc = std::get(value.value()); - const auto milliseconds = std::chrono::duration_cast( - utc.value.time_since_epoch()).count(); - const QString timestamp = QDateTime::fromMSecsSinceEpoch( - static_cast(milliseconds), Qt::UTC) - .toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz 'UTC'")); - const std::uint8_t timeQuality = utc.quality; - const unsigned accuracy = timeQuality & 0x1FU; - detail::addEvidenceRow(rows, QStringLiteral("UTC time"), timestamp); - detail::addEvidenceRow(rows, QStringLiteral("LeapSecondsKnown"), - (timeQuality & 0x80U) != 0U); - detail::addEvidenceRow(rows, QStringLiteral("ClockFailure"), - (timeQuality & 0x40U) != 0U); - detail::addEvidenceRow(rows, QStringLiteral("ClockNotSynchronized"), - (timeQuality & 0x20U) != 0U); - detail::addEvidenceRow(rows, QStringLiteral("TimeAccuracy"), - accuracy == 31U ? QVariant{QStringLiteral("unspecified")} - : QVariant{int(accuracy)}); - return {{QStringLiteral("kind"), QStringLiteral("timestamp")}, - {QStringLiteral("summary"), timestamp}, - {QStringLiteral("rows"), rows}}; - } - return {}; -} -} // namespace arstack::iedsim -)), + // 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; diff --git a/apps/ied_simulator/src/MmsReportController.cpp b/apps/ied_simulator/src/MmsReportController.cpp index 613f0ac55..c929f338a 100644 --- a/apps/ied_simulator/src/MmsReportController.cpp +++ b/apps/ied_simulator/src/MmsReportController.cpp @@ -490,6 +490,8 @@ ReportUi buildSubscriptionUi( 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); @@ -500,11 +502,17 @@ ReportUi buildSubscriptionUi( --typedEvidenceBudget; } } else { - // Indicate omitted typed details without manufacturing - // a decoded value or carrying unbounded metadata. + // Mark truncation only for real q/t typed members, not + // unrelated BIT STRING or UTC-valued attributes. const auto kind = item.value->kind(); - if (kind == mms::MmsDataKind::bit_string || - kind == mms::MmsDataKind::utc_time) + 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; } } diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index 56a2b5900..2e460b182 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -96,6 +96,13 @@ 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', @@ -113,6 +120,7 @@ 'reports.selectedRcbDataSetMembers', 'selectedReportMemberEvidence()', 'root.selectedReportMemberIndex = dataRow.index', + 'onLatestReportIdentityChanged: selectedReportMemberIndex = -1', 'BrowserEngineeringEvidence {', 'String(frame.reportId) === wanted', 'Offline values are not fabricated.',