From 54cc8e4793199594743b4e4cabdabd25868250f1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 19:24:18 +0700 Subject: [PATCH] feat(workbench): typed IEC 61850 quality and UTC time evidence in Browser Decode 13-bit quality and UTC time quality from confirmed MMS DataValue rather than GUI hex strings. Keep typed q/t metadata sparse, canonical and tied to generation-checked MMS Read callbacks; clear on new IED documents and leave offline states unknown. Expose readable Quality Details and Time Quality on Data Model leaves and DataSet DA summaries; add malformed/wrong-type/bitfield/time/canonical-stale QA. No MMS protocol, RCB lifecycle, PTP, SV or firmware modifications. --- .../qml/BrowserDataModelPane.qml | 67 +++++++++++++ apps/ied_simulator/qml/BrowserDataSetPane.qml | 8 +- .../ied_simulator/src/MmsClientController.cpp | 13 ++- apps/ied_simulator/src/MmsLiveTreeModel.cpp | 30 +++++- apps/ied_simulator/src/MmsLiveTreeModel.hpp | 5 +- apps/ied_simulator/src/MmsSemanticValue.hpp | 81 ++++++++++++++++ .../src/ied_browser_large_model_qa.cpp | 94 +++++++++++++++++++ .../ied_simulator/test_browser_convergence.py | 11 +++ 8 files changed, 300 insertions(+), 9 deletions(-) create mode 100644 apps/ied_simulator/src/MmsSemanticValue.hpp diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml index f22c10a16..b425d0c4d 100644 --- a/apps/ied_simulator/qml/BrowserDataModelPane.qml +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -267,6 +267,73 @@ Rectangle { } } + // Semantic fields are present only for verified typed MMS + // quality/UTC values. No false flags for unread/offline data. + Repeater { + model: [ + {heading: "Quality Details", data: root.currentNode.qualityDetails}, + {heading: "Time Quality", data: root.currentNode.timestampDetails} + ] + delegate: ColumnLayout { + id: semanticSection + required property int index + required property var modelData + visible: modelData.data !== undefined && + modelData.data.fields !== undefined + Layout.fillWidth: true + spacing: 0 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: visible ? 24 : 0 + color: theme.surfaceRaised + Label { + anchors.fill: parent + leftPadding: 8 + verticalAlignment: Text.AlignVCenter + text: modelData.heading + " ยท MMS Read" + font.pixelSize: 10 + font.weight: Font.DemiBold + color: theme.text + } + } + Repeater { + model: semanticSection.modelData.data && + semanticSection.modelData.data.fields + ? semanticSection.modelData.data.fields : [] + delegate: Rectangle { + required property int index + required property var modelData + Layout.fillWidth: true + Layout.preferredHeight: 25 + color: index % 2 ? theme.surfaceSoft : theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 15 + anchors.rightMargin: 8 + Label { + text: modelData.label + Layout.preferredWidth: Math.max(150, root.width * 0.34) + color: theme.textSoft + font.pixelSize: 10 + } + Label { + text: root.text(modelData.value) + Layout.fillWidth: true + color: theme.text + font.pixelSize: 10 + } + } + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: theme.lineSoft + } + } + } + } + } + Label { visible: !root.currentNode.kind Layout.fillWidth: true diff --git a/apps/ied_simulator/qml/BrowserDataSetPane.qml b/apps/ied_simulator/qml/BrowserDataSetPane.qml index 5040db476..dcdf38423 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -247,7 +247,13 @@ Rectangle { } Label { Layout.preferredWidth: Math.max(180, root.width * 0.42) - text: root.displayValue(attributeRow.modelData.value) + text: attributeRow.modelData.qualityDetails && + attributeRow.modelData.qualityDetails.summary + ? attributeRow.modelData.qualityDetails.summary + : attributeRow.modelData.timestampDetails && + attributeRow.modelData.timestampDetails.summary + ? attributeRow.modelData.timestampDetails.summary + : root.displayValue(attributeRow.modelData.value) color: theme.textSoft font.pixelSize: 10 elide: Text.ElideRight diff --git a/apps/ied_simulator/src/MmsClientController.cpp b/apps/ied_simulator/src/MmsClientController.cpp index 2ebc0c301..8f89765bc 100644 --- a/apps/ied_simulator/src/MmsClientController.cpp +++ b/apps/ied_simulator/src/MmsClientController.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsClientController.hpp" #include "MmsSclClientProjection.hpp" +#include "MmsSemanticValue.hpp" #include "ariec61850/mms/data_codec.hpp" #include "ariec61850/mms/live_discovery.hpp" @@ -34,6 +35,7 @@ std::span responsePayload(const mms::MmsConfirmedExchangeRes struct ReadValue final { QString key; QString display; + QVariantMap typedEvidence; }; QVector readTargets( @@ -67,7 +69,8 @@ QVector readTargets( if (!result.success()) continue; values.push_back({ targets.at(index).key, - QString::fromStdString(mms::MmsDataCodec::to_display_string(*result.value))}); + QString::fromStdString(mms::MmsDataCodec::to_display_string(*result.value)), + arstack::iedsim::decodeMmsSemanticAttribute(*result.value, targets.at(index).item)}); } if (values.isEmpty()) throw std::runtime_error("MMS Read returned no successful AccessResult."); return values; @@ -517,10 +520,10 @@ void MmsClientController::startRead( QMetaObject::invokeMethod(self, [self, generation, values, operationName] { if (!self || self->generation_ != generation) return; for (const auto& value : values) { - self->treeModel_.applyReadValue(value.key, value.display); + self->treeModel_.applyReadValue(value.key, value.display, value.typedEvidence); if (self->engineeringContext_) { self->engineeringContext_->treeModel()->applyReadValue( - value.key, value.display); + value.key, value.display, value.typedEvidence); } } self->setOperationBusy(false); @@ -670,10 +673,10 @@ bool MmsClientController::writeSelected(const QString& textValue) { QMetaObject::invokeMethod(self, [self, generation, reference, values] { if (!self || self->generation_ != generation) return; for (const auto& read : values) { - self->treeModel_.applyReadValue(read.key, read.display); + self->treeModel_.applyReadValue(read.key, read.display, read.typedEvidence); if (self->engineeringContext_) { self->engineeringContext_->treeModel()->applyReadValue( - read.key, read.display); + read.key, read.display, read.typedEvidence); } } self->setOperationBusy(false); diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index 63c4b8575..e839275b2 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -140,6 +140,7 @@ void MmsLiveTreeModel::applyDocument(const ar::iec61850::mms::MmsLiveModelDocume visible_.clear(); mmsIndex_.clear(); referenceIndex_.clear(); + typedEvidence_.clear(); selectedNode_ = -1; Node root; @@ -228,6 +229,7 @@ void MmsLiveTreeModel::clear() { visible_.clear(); mmsIndex_.clear(); referenceIndex_.clear(); + typedEvidence_.clear(); selectedNode_ = -1; endResetModel(); emit countsChanged(); @@ -348,6 +350,26 @@ QVariantMap MmsLiveTreeModel::nodeMap(const int nodeIndex) const { result.insert(QStringLiteral("childCount"), node.children.size()); result.insert(QStringLiteral("quality"), siblingValue(nodeIndex, QStringLiteral("q"))); result.insert(QStringLiteral("timestamp"), siblingValue(nodeIndex, QStringLiteral("t"))); + // Typed evidence is held only for MMS Read-verified q/t leaves. The + // inspector borrows it from a sibling under the same canonical DO. + if (node.kind == NodeKind::dataAttribute && node.parent >= 0) { + const auto borrow = [this, nodeIndex](const QString& wanted) -> QVariantMap { + const auto& selected = nodes_.at(nodeIndex); + const auto matching = [this, &wanted](int index) { + const auto& candidate = nodes_.at(index); + return candidate.label.compare(wanted, Qt::CaseInsensitive) == 0 + || leafName(candidate.attributePath).compare(wanted, Qt::CaseInsensitive) == 0; + }; + if (matching(nodeIndex)) return typedEvidence_.value(nodeIndex); + for (const int sibling : nodes_.at(selected.parent).children) + if (matching(sibling)) return typedEvidence_.value(sibling); + return {}; + }; + const auto q = borrow(QStringLiteral("q")); + const auto t = borrow(QStringLiteral("t")); + if (!q.isEmpty()) result.insert(QStringLiteral("qualityDetails"), q); + if (!t.isEmpty()) result.insert(QStringLiteral("timestampDetails"), t); + } result.insert(QStringLiteral("writable"), node.writable); result.insert(QStringLiteral("readable"), node.kind == NodeKind::dataAttribute && !node.item.isEmpty()); QString objectReference; @@ -659,13 +681,17 @@ QVector MmsLiveTreeModel::readTargetsForReferences return result; } -void MmsLiveTreeModel::applyReadValue(const QString& key, const QString& displayValue) { +void MmsLiveTreeModel::applyReadValue( + const QString& key, const QString& displayValue, const QVariantMap& typedEvidence) { const auto found = mmsIndex_.constFind(key); if (found == mmsIndex_.cend()) return; const auto nodeIndex = found.value(); auto& node = nodes_[nodeIndex]; - if (node.value == displayValue) return; + const bool evidenceChanged = typedEvidence_.value(nodeIndex) != typedEvidence; + if (node.value == displayValue && !evidenceChanged) return; node.value = displayValue; + if (!typedEvidence.isEmpty()) typedEvidence_.insert(nodeIndex, typedEvidence); + else typedEvidence_.remove(nodeIndex); detailModel_->nodeValueChanged(nodeIndex); emit valuesChanged(); const auto row = visibleRowForNode(nodeIndex); diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.hpp b/apps/ied_simulator/src/MmsLiveTreeModel.hpp index b7d90a4fc..1054a9cbf 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.hpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.hpp @@ -157,7 +157,7 @@ class MmsLiveTreeModel : public QAbstractListModel { [[nodiscard]] QVector readTargetsForReferences( const QStringList& references, int maximumTargets = 64) const; - void applyReadValue(const QString& key, const QString& displayValue); + void applyReadValue(const QString& key, const QString& displayValue, const QVariantMap& typedEvidence = {}); signals: void filterTextChanged(); @@ -211,6 +211,9 @@ class MmsLiveTreeModel : public QAbstractListModel { QVector visible_; QHash mmsIndex_; QHash> referenceIndex_; + // Sparse: only successfully decoded live q/t leaves own a map. + // Do not allocate expensive QVariantMaps for every discovered node. + QHash typedEvidence_; QString filterText_; int selectedNode_{-1}; static constexpr int maximumProjectedChildren_{4096}; diff --git a/apps/ied_simulator/src/MmsSemanticValue.hpp b/apps/ied_simulator/src/MmsSemanticValue.hpp new file mode 100644 index 000000000..c85b9f1c7 --- /dev/null +++ b/apps/ied_simulator/src/MmsSemanticValue.hpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/data_value.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +// Typed IEC 61850 evidence, shared by Browser Data Model/DataSets. +// Never parse hex display text or infer quality flags from missing reads. +namespace arstack::iedsim { + +inline QVariantMap decodeMmsSemanticAttribute( + const ar::iec61850::mms::MmsDataValue& data, const QString& mmsItem) { + namespace mms = ar::iec61850::mms; + const QString leaf = mmsItem.mid(mmsItem.lastIndexOf(QLatin1Char('$')) + 1); + const auto append = [](QVariantList& fields, const QString& name, const QVariant& value) { + fields.append(QVariantMap{{QStringLiteral("label"), name}, + {QStringLiteral("value"), value}}); + }; + if (leaf == QStringLiteral("q") && data.kind() == mms::MmsDataKind::bit_string) { + const auto& raw = data.raw_value(); // BER content: unused-bit count + payload + if (raw.size() != 3U || raw[0] != 3U || (raw[2] & 0x07U) != 0U) + return {}; // quality is exactly 13 meaningful bits; invalid evidence stays unknown + const std::uint16_t bits = (std::uint16_t(raw[1]) << 8U) | raw[2]; + const auto active = [bits](unsigned shift) -> bool { + return (bits & (std::uint16_t(0x8000U) >> shift)) != 0U; + }; + const unsigned validity = (active(0) ? 2U : 0U) | (active(1) ? 1U : 0U); + const QString validityName = validity == 0U ? QStringLiteral("good") + : validity == 1U ? QStringLiteral("invalid") + : validity == 2U ? QStringLiteral("reserved") + : QStringLiteral("questionable"); + QVariantList fields; + append(fields, QStringLiteral("Validity"), validityName); + constexpr const char* labels[] = { + "Overflow", "OutOfRange", "BadReference", "Oscillatory", + "Failure", "OldData", "Inconsistent", "Inaccurate" + }; + for (unsigned offset = 0U; offset < 8U; ++offset) + append(fields, QString::fromLatin1(labels[offset]), active(offset + 2U)); + append(fields, QStringLiteral("Source"), + active(10U) ? QStringLiteral("substituted") : QStringLiteral("process")); + append(fields, QStringLiteral("Test"), active(11U)); + append(fields, QStringLiteral("OperatorBlocked"), active(12U)); + return {{QStringLiteral("kind"), QStringLiteral("quality")}, + {QStringLiteral("summary"), validityName}, + {QStringLiteral("evidence"), QStringLiteral("MMS Read")}, + {QStringLiteral("fields"), fields}}; + } + if (leaf == QStringLiteral("t") && data.kind() == mms::MmsDataKind::utc_time) { + const auto* utc = std::get_if(&data.value()); + if (!utc) return {}; + const auto epochMs = std::chrono::duration_cast( + utc->value.time_since_epoch()).count(); + const QString timeIso = QDateTime::fromMSecsSinceEpoch(epochMs, Qt::UTC) + .toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz 'UTC'")); + const unsigned accuracy = utc->quality & 0x1FU; + QVariantList fields; + append(fields, QStringLiteral("UTC time"), timeIso); + append(fields, QStringLiteral("LeapSecondsKnown"), (utc->quality & 0x80U) != 0U); + append(fields, QStringLiteral("ClockFailure"), (utc->quality & 0x40U) != 0U); + append(fields, QStringLiteral("ClockNotSynchronized"), (utc->quality & 0x20U) != 0U); + append(fields, QStringLiteral("TimeAccuracy"), + accuracy == 31U ? QStringLiteral("unspecified") + : QString::number(accuracy)); + return {{QStringLiteral("kind"), QStringLiteral("timestamp")}, + {QStringLiteral("summary"), timeIso}, + {QStringLiteral("evidence"), QStringLiteral("MMS Read")}, + {QStringLiteral("fields"), fields}}; + } + return {}; +} + +} // namespace arstack::iedsim 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 816e4e2a5..1e96db5b4 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -1,5 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsLiveTreeModel.hpp" +#include "MmsSemanticValue.hpp" +#include +#include #include #include @@ -71,6 +74,46 @@ int main(int argc, char** argv) { model.applyDocument(document); const auto applyMs = applyTimer.elapsed(); + // Typed IEC 61850 Quality: exactly 13 bits. 01=invalid; + // substituted/test/operatorBlocked are independently asserted. + const std::array qualityBytes{0x40U, 0x38U}; + const auto quality = arstack::iedsim::decodeMmsSemanticAttribute( + ar::iec61850::mms::MmsDataValue::bit_string(3U, qualityBytes), + QStringLiteral("LLN0$ST$Pos$q")); + const auto malformed = arstack::iedsim::decodeMmsSemanticAttribute( + ar::iec61850::mms::MmsDataValue::bit_string(0U, qualityBytes), + QStringLiteral("LLN0$ST$Pos$q")); + const auto wrongLeaf = arstack::iedsim::decodeMmsSemanticAttribute( + ar::iec61850::mms::MmsDataValue::bit_string(3U, qualityBytes), + QStringLiteral("LLN0$ST$Pos$stVal")); + const QVariantList qualityFields = quality.value(QStringLiteral("fields")).toList(); + if (quality.value(QStringLiteral("summary")).toString() != QStringLiteral("invalid") || + qualityFields.size() != 12 || + qualityFields.at(9).toMap().value(QStringLiteral("value")).toString() != + QStringLiteral("substituted") || + !qualityFields.at(10).toMap().value(QStringLiteral("value")).toBool() || + !qualityFields.at(11).toMap().value(QStringLiteral("value")).toBool() || + !malformed.isEmpty() || !wrongLeaf.isEmpty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL typed_quality_semantics\n"; + return 24; + } + const ar::iec61850::mms::Iec61850UtcTime utc{ + std::chrono::system_clock::time_point{std::chrono::seconds{1'730'000'000}}, + 0xA5U}; + const auto time = arstack::iedsim::decodeMmsSemanticAttribute( + ar::iec61850::mms::MmsDataValue::utc_time(utc), + QStringLiteral("LLN0$ST$Pos$t")); + const auto timeFields = time.value(QStringLiteral("fields")).toList(); + if (timeFields.size() != 5 || + !timeFields.at(1).toMap().value(QStringLiteral("value")).toBool() || + timeFields.at(2).toMap().value(QStringLiteral("value")).toBool() || + !timeFields.at(3).toMap().value(QStringLiteral("value")).toBool() || + timeFields.at(4).toMap().value(QStringLiteral("value")).toString() != QStringLiteral("5") || + !time.value(QStringLiteral("summary")).toString().contains(QStringLiteral("UTC"))) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL typed_time_quality\n"; + return 25; + } + constexpr int expectedAttributes = 5 * 20 * 100 * 5; const int expectedMinimumNodes = expectedAttributes + 5 * 20 * 100 + 5 * 20 + 5 + 1; if (model.totalNodeCount() < expectedMinimumNodes) { @@ -78,6 +121,13 @@ int main(int argc, char** argv) { return 2; } + // Metadata is initially unknown and must not be fabricated. + const auto beforeQuality = model.nodeForReference(QStringLiteral("LD4/LLN19.DO99.DA4 [MX]")); + if (beforeQuality.contains(QStringLiteral("qualityDetails")) || + beforeQuality.contains(QStringLiteral("timestampDetails"))) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL invented_quality\n"; + return 26; + } // Root and LDs are open, but LNs remain collapsed. Large online models must // not eagerly instantiate every DO/DA row into the visible projection. if (model.visibleNodeCount() > 256) { @@ -239,6 +289,50 @@ int main(int argc, char** argv) { return 20; } + // Sparse canonical metadata must be shared by all siblings and cleared + // when a new IED document replaces the previous one. + ar::iec61850::mms::MmsLiveModelDocument small; + ar::iec61850::mms::MmsLiveLogicalDevice sd; + sd.mms_domain = "LD0"; sd.instance = "LD0"; + ar::iec61850::mms::MmsLiveLogicalNode sn; + sn.name = "LLN0"; + ar::iec61850::mms::MmsLiveDataObject so; + so.name = "Pos"; so.reference = "LD0/LLN0.Pos"; + for (const auto& leaf : {"stVal", "q", "t"}) { + ar::iec61850::mms::MmsLiveDataAttribute da; + da.attribute_path = std::string("Pos.") + leaf; + da.object_reference = so.reference; + da.functional_constraint = "ST"; + da.mms_item_name = std::string("LLN0$ST$Pos$") + leaf; + da.mms_reference = std::string("LD0/") + da.mms_item_name; + da.mms_type = std::string(leaf) == "t" ? "utc-time" + : std::string(leaf) == "q" ? "bit-string" : "integer"; + da.type_discovery_status = "Exact"; + so.attributes.push_back(std::move(da)); + } + sn.data_objects.push_back(std::move(so)); + sd.logical_nodes.push_back(std::move(sn)); + small.logical_devices.push_back(std::move(sd)); + MmsLiveTreeModel typedTree; + typedTree.applyDocument(small); + const QString keyBase = QStringLiteral("LD0") + QChar(0x1f) + QStringLiteral("LLN0$ST$Pos$"); + typedTree.applyReadValue(keyBase + QStringLiteral("q"), QStringLiteral("034038"), quality); + typedTree.applyReadValue(keyBase + QStringLiteral("t"), QStringLiteral("UTC time"), time); + const auto sibling = typedTree.nodeForReference(QStringLiteral("LD0/LLN0.Pos.stVal [ST]")); + const auto typedQ = sibling.value(QStringLiteral("qualityDetails")).toMap(); + const auto typedT = sibling.value(QStringLiteral("timestampDetails")).toMap(); + if (typedQ.value(QStringLiteral("summary")).toString() != QStringLiteral("invalid") || + typedT.value(QStringLiteral("kind")).toString() != QStringLiteral("timestamp")) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL typed_canonical_siblings\n"; + return 27; + } + typedTree.applyDocument(small); + if (typedTree.nodeForReference(QStringLiteral("LD0/LLN0.Pos.q [ST]")) + .contains(QStringLiteral("qualityDetails"))) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL typed_stale_document\n"; + return 28; + } + const bool passes = applyMs <= 5000 && filterMs <= 1500 && selectMs <= 500; std::cout << "IED_BROWSER_LARGE_MODEL_" << (passes ? "PASS" : "FAIL") << " nodes=" << model.totalNodeCount() diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index bccbe9267..eb27a59fb 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -81,6 +81,17 @@ if 'root.memberNode(modelData)' in data_set: raise SystemExit("BROWSER_DATASET_HIERARCHY_FAIL unnormalized per-row linear lookup") +model_quality_required = [ + 'Quality Details', + 'Time Quality', + 'root.currentNode.qualityDetails', + 'root.currentNode.timestampDetails', + 'modelData.data.fields', +] +model_missing = [term for term in model_quality_required if term not in data_model] +if model_missing: + raise SystemExit("BROWSER_TYPED_QT_INSPECTOR_FAIL missing=" + ",".join(model_missing)) + report_required = [ 'objectName: "browserReportUnifiedInspector"', 'Control Block Attributes',