From d4d01805c60844b3df498c900629157c871f49af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 19:23:19 +0700 Subject: [PATCH 1/2] feat(workbench): typed IEC Quality and UTC TimeQuality evidence in canonical Browser Decode 13-bit IEC Quality and UTC TimeQuality exclusively from successfully read MMS typed values, keep decoded metadata on the same canonical DA nodes, and expose a reusable dense inspector in Data Model and DataSets. Unknown or malformed evidence remains unknown, untyped updates clear stale evidence, all network reads stay explicit, and large-model regression covers bit mapping and accuracy. No protocol core/firmware changes. --- apps/ied_simulator/CMakeLists.txt | 1 + .../qml/BrowserDataModelPane.qml | 5 ++ apps/ied_simulator/qml/BrowserDataSetPane.qml | 15 +++- .../qml/BrowserEngineeringEvidence.qml | 82 ++++++++++++++++++ .../ied_simulator/src/MmsClientController.cpp | 16 ++-- .../src/MmsEngineeringEvidence.hpp | 86 +++++++++++++++++++ apps/ied_simulator/src/MmsLiveTreeModel.cpp | 10 ++- apps/ied_simulator/src/MmsLiveTreeModel.hpp | 4 +- .../src/ied_browser_large_model_qa.cpp | 63 ++++++++++++++ .../ied_simulator/test_browser_convergence.py | 4 + 10 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 apps/ied_simulator/qml/BrowserEngineeringEvidence.qml create mode 100644 apps/ied_simulator/src/MmsEngineeringEvidence.hpp 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: From 51badab31391cedd56e356a74a79b66745eba340 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 14 Oct 2026 20:11:56 +0700 Subject: [PATCH 2/2] ci(workbench): preflight NSIS availability before Windows compilation Chocolatey feed 504 left NSIS unavailable but workflow continued through full MSVC build. Retry bounded twice, verify executable and version, propagate resolved path to packaging, and fail early before expensive release compile when package feed remains unavailable. --- .../ied-simulator-release-hardening.yml | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) 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" }