Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions .github/workflows/ied-simulator-release-hardening.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Comment on lines +87 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Separate the unrelated NSIS hardening

This evidence-focused commit also changes Chocolatey retry behavior and NSIS compiler discovery in the Windows packaging workflow, which addresses an independent CI failure mode and expands the review and rollback scope without contributing to typed Quality/UTC evidence. Move this workflow hardening into a separate focused change with its own validation.

AGENTS.md reference: AGENTS.md:L238-L240

Useful? React with 👍 / 👎.

$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
Expand Down Expand Up @@ -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" }

Expand Down
1 change: 1 addition & 0 deletions apps/ied_simulator/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/ied_simulator/qml/BrowserDataModelPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,11 @@ Rectangle {
}
}

BrowserEngineeringEvidence {
theme: root.theme
engineeringEvidence: root.currentNode.engineeringEvidence || ({})
}

Label {
visible: !root.currentNode.kind
Layout.fillWidth: true
Expand Down
15 changes: 13 additions & 2 deletions apps/ied_simulator/qml/BrowserDataSetPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -55,6 +56,7 @@ Rectangle {
function onSelectionChanged() {
root.expandedMemberIndex = -1
root.expandedMemberChildren = []
root.showSelectedEvidence = false
}
}
Connections {
Expand Down Expand Up @@ -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
}
}
}
}
Expand All @@ -278,6 +283,12 @@ Rectangle {
}
}
}
BrowserEngineeringEvidence {
theme: root.theme
engineeringEvidence: root.showSelectedEvidence
? (root.modelProvider.treeModel.selectedNode.engineeringEvidence || ({}))
: ({})
Comment on lines +286 to +290

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the evidence inspector inside the selected DataSet page

When a DataSet is selected, this component is the third direct child of the StackLayout, while currentIndex is limited to 0 or 1 for the overview and member list. A StackLayout displays only its current child, so the new inspector remains hidden even when hasEvidence is true; place it within the member-page layout or outside the stack.

Useful? React with 👍 / 👎.

Comment on lines +288 to +290

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enable evidence when selecting a direct DataSet attribute

When a DataSet member resolves directly to a q or t DataAttribute, the member-row click selects it but never changes showSelectedEvidence; that flag is set only by clicks on children of an expanded DataObject. Consequently this conditional supplies an empty map for direct Quality/Timestamp FCDA members, including those present in the repository fixtures. Set the flag for the direct-readable-member path too, or derive visibility from the selected node.

Useful? React with 👍 / 👎.

}
}
}
}
82 changes: 82 additions & 0 deletions apps/ied_simulator/qml/BrowserEngineeringEvidence.qml
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
16 changes: 11 additions & 5 deletions apps/ied_simulator/src/MmsClientController.cpp
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -34,6 +35,7 @@ std::span<const std::uint8_t> responsePayload(const mms::MmsConfirmedExchangeRes
struct ReadValue final {
QString key;
QString display;
QVariantMap engineeringEvidence;
};

QVector<ReadValue> readTargets(
Expand Down Expand Up @@ -67,7 +69,9 @@ QVector<ReadValue> 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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
86 changes: 86 additions & 0 deletions apps/ied_simulator/src/MmsEngineeringEvidence.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once

#include "ariec61850/mms/data_value.hpp"

#include <QDateTime>
#include <QString>
#include <QStringList>
#include <QVariant>
#include <QVariantList>
#include <QVariantMap>

#include <chrono>
#include <cstdint>
#include <variant>

// 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<Iec61850UtcTime>(value.value());
const auto milliseconds = std::chrono::duration_cast<std::chrono::milliseconds>(
utc.value.time_since_epoch()).count();
const QString timestamp = QDateTime::fromMSecsSinceEpoch(
static_cast<qint64>(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
10 changes: 8 additions & 2 deletions apps/ied_simulator/src/MmsLiveTreeModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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")));
Expand Down Expand Up @@ -659,13 +660,18 @@ QVector<MmsLiveTreeModel::ReadTarget> 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);
Expand Down
4 changes: 3 additions & 1 deletion apps/ied_simulator/src/MmsLiveTreeModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ class MmsLiveTreeModel : public QAbstractListModel {
[[nodiscard]] QVector<ReadTarget> 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();
Expand Down Expand Up @@ -186,6 +187,7 @@ class MmsLiveTreeModel : public QAbstractListModel {
QString typeStatus;
QString description;
QString value;
QVariantMap engineeringEvidence;
bool writable{};
};

Expand Down
Loading
Loading