diff --git a/.github/workflows/ied-simulator-release-hardening.yml b/.github/workflows/ied-simulator-release-hardening.yml index da767ee20..87d9b4fda 100644 --- a/.github/workflows/ied-simulator-release-hardening.yml +++ b/.github/workflows/ied-simulator-release-hardening.yml @@ -84,9 +84,32 @@ jobs: arch: 'win64_msvc2022_64' cache: true - - name: Install NSIS + - name: Install and verify NSIS shell: pwsh - run: choco install nsis --yes --no-progress + run: | + $ErrorActionPreference = 'Stop' + $candidates = @( + (Join-Path ${env:ProgramFiles(x86)} 'NSIS\\makensis.exe'), + (Join-Path ${env:ProgramFiles} 'NSIS\\makensis.exe'), + 'C:\\ProgramData\\chocolatey\\bin\\makensis.exe' + ) + $compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if (-not $compiler) { + for ($attempt = 1; $attempt -le 2; $attempt++) { + choco install nsis --yes --no-progress + $status = $LASTEXITCODE + $compiler = $candidates | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1 + if ($compiler) { break } + Write-Warning "NSIS installation failed (attempt $attempt/2, Chocolatey exit $status)" + if ($attempt -lt 2) { Start-Sleep -Seconds 12 } + } + } + if (-not $compiler) { + throw 'NSIS installation unavailable (Chocolatey feed). Abort before expensive Windows build.' + } + "MAKENSIS_PATH=$compiler" >> $env:GITHUB_ENV + & $compiler /VERSION + if ($LASTEXITCODE -ne 0) { throw 'makensis version check failed' } - name: Configure MSVC release build shell: pwsh @@ -123,8 +146,10 @@ jobs: Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip -Force $installer = Join-Path $release 'ARStack-IEC61850-Workbench-Setup-win64.exe' $icon = (Resolve-Path 'assets\favicon.ico').Path - $makensis = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' - if (-not (Test-Path -LiteralPath $makensis -PathType Leaf)) { throw "makensis not found: $makensis" } + $makensis = $env:MAKENSIS_PATH + if (-not $makensis -or -not (Test-Path -LiteralPath $makensis -PathType Leaf)) { + throw 'NSIS preflight did not provide a usable makensis.exe' + } & $makensis "/DSTAGE_DIR=$stage" "/DOUT_FILE=$installer" "/DAPP_ICON=$icon" 'apps\ied_simulator\packaging\windows\ARStackIEC61850Workbench.nsi' if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" } diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index a9a9372b3..da01ae28d 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -104,6 +104,7 @@ qt_add_qml_module(arstack_ied_simulator qml/BrowserStatusConsole.qml qml/BrowserDataModelPane.qml qml/BrowserDataSetPane.qml + qml/BrowserEngineeringEvidence.qml qml/BrowserReportPane.qml qml/BrowserGoosePane.qml qml/BrowserGlobalDataPane.qml diff --git a/apps/ied_simulator/qml/BrowserDataModelPane.qml b/apps/ied_simulator/qml/BrowserDataModelPane.qml index f22c10a16..b63005525 100644 --- a/apps/ied_simulator/qml/BrowserDataModelPane.qml +++ b/apps/ied_simulator/qml/BrowserDataModelPane.qml @@ -267,6 +267,11 @@ Rectangle { } } + BrowserEngineeringEvidence { + theme: root.theme + engineeringEvidence: root.currentNode.engineeringEvidence || ({}) + } + 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..3079b07a6 100644 --- a/apps/ied_simulator/qml/BrowserDataSetPane.qml +++ b/apps/ied_simulator/qml/BrowserDataSetPane.qml @@ -15,6 +15,7 @@ Rectangle { property int expandedMemberIndex: -1 property var expandedMemberChildren: [] property int valueRevision: 0 + property bool showSelectedEvidence: false readonly property int maximumExpandedChildren: 64 function memberParts(raw) { @@ -55,6 +56,7 @@ Rectangle { function onSelectionChanged() { root.expandedMemberIndex = -1 root.expandedMemberChildren = [] + root.showSelectedEvidence = false } } Connections { @@ -256,8 +258,11 @@ Rectangle { Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.lineSoft } MouseArea { anchors.fill: parent - onClicked: root.modelProvider.treeModel.selectMmsItem( - attributeRow.modelData.mmsDomain, attributeRow.modelData.mmsItem) + onClicked: { + root.modelProvider.treeModel.selectMmsItem( + attributeRow.modelData.mmsDomain, attributeRow.modelData.mmsItem) + root.showSelectedEvidence = true + } } } } @@ -278,6 +283,12 @@ Rectangle { } } } + BrowserEngineeringEvidence { + theme: root.theme + engineeringEvidence: root.showSelectedEvidence + ? (root.modelProvider.treeModel.selectedNode.engineeringEvidence || ({})) + : ({}) + } } } } diff --git a/apps/ied_simulator/qml/BrowserEngineeringEvidence.qml b/apps/ied_simulator/qml/BrowserEngineeringEvidence.qml new file mode 100644 index 000000000..470db4baf --- /dev/null +++ b/apps/ied_simulator/qml/BrowserEngineeringEvidence.qml @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +// Pure presentation over canonical typed evidence. No hex parsing or MMS reads. +ColumnLayout { + id: root + required property var theme + property var engineeringEvidence: ({}) + readonly property bool hasEvidence: Boolean(engineeringEvidence) + && (engineeringEvidence.kind === "quality" + || engineeringEvidence.kind === "timestamp") + visible: hasEvidence + spacing: 0 + Layout.fillWidth: true + + Rectangle { + Layout.fillWidth: true + implicitHeight: 27 + color: theme.surfaceRaised + RowLayout { + anchors.fill: parent + anchors.leftMargin: 8 + anchors.rightMargin: 8 + Label { + Layout.fillWidth: true + text: root.engineeringEvidence.kind === "quality" + ? "Quality Details · MMS BIT STRING" + : "Timestamp & TimeQuality · MMS UTC" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Label { + text: "READ EVIDENCE" + color: theme.muted + font.pixelSize: 9 + } + } + } + Repeater { + model: root.hasEvidence && root.engineeringEvidence.rows + ? root.engineeringEvidence.rows : [] + delegate: Rectangle { + id: evidenceRow + required property int index + required property var modelData + Layout.fillWidth: true + implicitHeight: 23 + color: index % 2 ? theme.surfaceSoft : theme.surface + RowLayout { + anchors.fill: parent + anchors.leftMargin: 9 + anchors.rightMargin: 9 + spacing: 6 + Label { + Layout.preferredWidth: Math.max(154, root.width * 0.4) + text: evidenceRow.modelData.label || "" + color: theme.textSoft + font.pixelSize: 10 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + text: evidenceRow.modelData.value === undefined + || evidenceRow.modelData.value === null + ? "Unknown" : String(evidenceRow.modelData.value) + color: theme.text + font.pixelSize: 10 + elide: Text.ElideMiddle + } + } + Rectangle { + anchors.bottom: parent.bottom + height: 1 + width: parent.width + color: theme.lineSoft + } + } + } +} diff --git a/apps/ied_simulator/src/MmsClientController.cpp b/apps/ied_simulator/src/MmsClientController.cpp index 2ebc0c301..d34165f9e 100644 --- a/apps/ied_simulator/src/MmsClientController.cpp +++ b/apps/ied_simulator/src/MmsClientController.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsClientController.hpp" +#include "MmsEngineeringEvidence.hpp" #include "MmsSclClientProjection.hpp" #include "ariec61850/mms/data_codec.hpp" @@ -34,6 +35,7 @@ std::span responsePayload(const mms::MmsConfirmedExchangeRes struct ReadValue final { QString key; QString display; + QVariantMap engineeringEvidence; }; QVector readTargets( @@ -67,7 +69,9 @@ 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::decodeEngineeringEvidence( + targets.at(index).item, *result.value)}); } if (values.isEmpty()) throw std::runtime_error("MMS Read returned no successful AccessResult."); return values; @@ -517,10 +521,11 @@ 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.engineeringEvidence); if (self->engineeringContext_) { self->engineeringContext_->treeModel()->applyReadValue( - value.key, value.display); + value.key, value.display, value.engineeringEvidence); } } self->setOperationBusy(false); @@ -670,10 +675,11 @@ 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.engineeringEvidence); if (self->engineeringContext_) { self->engineeringContext_->treeModel()->applyReadValue( - read.key, read.display); + read.key, read.display, read.engineeringEvidence); } } self->setOperationBusy(false); diff --git a/apps/ied_simulator/src/MmsEngineeringEvidence.hpp b/apps/ied_simulator/src/MmsEngineeringEvidence.hpp new file mode 100644 index 000000000..905193ca7 --- /dev/null +++ b/apps/ied_simulator/src/MmsEngineeringEvidence.hpp @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/mms/data_value.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +// One typed, fail-closed engineering projection for native Browser q/t values. +// The underlying MMS data remains authoritative; GUI never guesses hex semantics. +namespace arstack::iedsim { +namespace detail { +inline void addEvidenceRow(QVariantList& rows, const QString& label, const QVariant& value) { + rows.push_back(QVariantMap{{QStringLiteral("label"), label}, + {QStringLiteral("value"), value}}); +} +} // namespace detail + +[[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); + 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 diff --git a/apps/ied_simulator/src/MmsLiveTreeModel.cpp b/apps/ied_simulator/src/MmsLiveTreeModel.cpp index 63c4b8575..7b6e92ac7 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.cpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.cpp @@ -344,6 +344,7 @@ QVariantMap MmsLiveTreeModel::nodeMap(const int nodeIndex) const { } } result.insert(QStringLiteral("value"), displayValue); + result.insert(QStringLiteral("engineeringEvidence"), node.engineeringEvidence); result.insert(QStringLiteral("hasChildren"), !node.children.isEmpty()); result.insert(QStringLiteral("childCount"), node.children.size()); result.insert(QStringLiteral("quality"), siblingValue(nodeIndex, QStringLiteral("q"))); @@ -659,13 +660,18 @@ 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& engineeringEvidence) { 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; + if (node.value == displayValue && node.engineeringEvidence == engineeringEvidence) return; node.value = displayValue; + // Evidence is immutable per received sample; a later untyped update must + // clear previous bit/clock details rather than reuse stale measurements. + node.engineeringEvidence = engineeringEvidence; 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..7f433fe20 100644 --- a/apps/ied_simulator/src/MmsLiveTreeModel.hpp +++ b/apps/ied_simulator/src/MmsLiveTreeModel.hpp @@ -157,7 +157,8 @@ 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& engineeringEvidence = {}); signals: void filterTextChanged(); @@ -186,6 +187,7 @@ class MmsLiveTreeModel : public QAbstractListModel { QString typeStatus; QString description; QString value; + QVariantMap engineeringEvidence; bool writable{}; }; 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..16b562f70 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -1,9 +1,13 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsLiveTreeModel.hpp" +#include "MmsEngineeringEvidence.hpp" #include #include +#include +#include + #include #include @@ -239,6 +243,65 @@ int main(int argc, char** argv) { return 20; } + // Decoding is based on actual typed MMS payload, never on displayed hex. + // Quality: validity=invalid, Overflow=true, Source=substituted, + // Test=true, OperatorBlocked=true. The unused bits must be exactly three. + const std::array bits{0x60U, 0x38U}; + const auto qualityEvidence = arstack::iedsim::decodeEngineeringEvidence( + QStringLiteral("LLN19$MX$DO99$q"), + ar::iec61850::mms::MmsDataValue::bit_string(3U, bits)); + const auto qualityRows = qualityEvidence.value(QStringLiteral("rows")).toList(); + const auto malformedQuality = arstack::iedsim::decodeEngineeringEvidence( + QStringLiteral("LLN19$MX$DO99$q"), + ar::iec61850::mms::MmsDataValue::bit_string(2U, bits)); + const auto wrongKind = arstack::iedsim::decodeEngineeringEvidence( + QStringLiteral("LLN19$MX$DO99$q"), + ar::iec61850::mms::MmsDataValue::boolean(true)); + if (qualityEvidence.value(QStringLiteral("kind")).toString() != QStringLiteral("quality") || + qualityRows.size() != 12 || + qualityRows.at(0).toMap().value(QStringLiteral("value")).toString() != QStringLiteral("invalid") || + !qualityRows.at(1).toMap().value(QStringLiteral("value")).toBool() || + qualityRows.at(9).toMap().value(QStringLiteral("value")).toString() != QStringLiteral("substituted") || + !qualityRows.at(10).toMap().value(QStringLiteral("value")).toBool() || + !qualityRows.at(11).toMap().value(QStringLiteral("value")).toBool() || + !malformedQuality.isEmpty() || !wrongKind.isEmpty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL typed_quality_evidence\n"; + return 22; + } + + const ar::iec61850::mms::Iec61850UtcTime utc{ + std::chrono::system_clock::time_point{std::chrono::seconds{1720000000}}, + 0xBFU}; // leap known, clock not synchronized, accuracy unspecified + const auto timeEvidence = arstack::iedsim::decodeEngineeringEvidence( + QStringLiteral("LLN19$MX$DO99$t"), + ar::iec61850::mms::MmsDataValue::utc_time(utc)); + const auto timeRows = timeEvidence.value(QStringLiteral("rows")).toList(); + if (timeEvidence.value(QStringLiteral("kind")).toString() != QStringLiteral("timestamp") || + timeRows.size() != 5 || + !timeRows.at(1).toMap().value(QStringLiteral("value")).toBool() || + timeRows.at(2).toMap().value(QStringLiteral("value")).toBool() || + !timeRows.at(3).toMap().value(QStringLiteral("value")).toBool() || + timeRows.at(4).toMap().value(QStringLiteral("value")).toString() != QStringLiteral("unspecified") || + !timeEvidence.value(QStringLiteral("summary")).toString().endsWith(QStringLiteral("UTC"))) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL typed_timestamp_evidence\n"; + return 23; + } + + // Model evidence is replaced by each new sample and never retained + // after an untyped refresh. Reuse an existing canonical MMS leaf. + model.applyReadValue(liveKey, QStringLiteral("typed"), qualityEvidence); + if (model.nodeForReference(QStringLiteral("LD4/LLN19.DO99.DA4")) + .value(QStringLiteral("engineeringEvidence")).toMap() != qualityEvidence) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL canonical_evidence_path\n"; + return 24; + } + model.applyReadValue(liveKey, QStringLiteral("typed")); + if (!model.nodeForReference(QStringLiteral("LD4/LLN19.DO99.DA4")) + .value(QStringLiteral("engineeringEvidence")).toMap().isEmpty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL stale_evidence\n"; + return 25; + } + 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..a0cc5135c 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -54,6 +54,8 @@ 'root.detail.selectParent()', 'root.detail.truncated', 'onDoubleClicked: function(event)', + 'BrowserEngineeringEvidence {', + 'root.currentNode.engineeringEvidence', ] for label, source, required in [ @@ -74,6 +76,8 @@ 'root.expandedMemberIndex', 'maximumExpandedChildren: 64', 'required property var modelData', + 'root.showSelectedEvidence = true', + 'BrowserEngineeringEvidence {', ] missing_dataset = [term for term in data_set_required if term not in data_set] if missing_dataset: