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
4 changes: 3 additions & 1 deletion .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,12 @@ jobs:
run: |
cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON
if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' }
cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness arstack_m4_pages_harness --parallel 2
cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness arstack_capture_harness arstack_m4_pages_harness arstack_m4_compile_harness --parallel 2
if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' }
& "$PWD\build-studio-preview\Release\arstack_m4_pages_harness.exe"
if ($LASTEXITCODE -ne 0) { throw 'M4 page state regression failed' }
& "$PWD\build-studio-preview\Release\arstack_m4_compile_harness.exe"
if ($LASTEXITCODE -ne 0) { throw 'M4 SCD ARM3 compiler regression failed' }
& "$PWD\build-studio-preview\Release\arstack_session_harness.exe"
if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' }
& "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe"
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/arstack-studio-qt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
shell: powershell
run: |
cmake --build build-arstack-studio --config Release `
--target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness arstack_m4_pages_harness --parallel
--target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness arstack_m4_pages_harness arstack_m4_compile_harness --parallel

- name: S8A deterministic session harness on Windows
timeout-minutes: 1
Expand All @@ -106,6 +106,13 @@ jobs:
& $exe
if ($LASTEXITCODE -ne 0) { throw "M4 native stream-page state regression failed: $LASTEXITCODE" }

- name: M4 real SCD to ARM3 V2 compiler regression (Windows)
timeout-minutes: 1
shell: powershell
run: |
& "$PWD\\build-arstack-studio\\Release\\arstack_m4_compile_harness.exe"
if ($LASTEXITCODE -ne 0) { throw "M4 SCD ARM3 compiler regression failed: $LASTEXITCODE" }

- name: Native Windows clipboard roundtrip regression
timeout-minutes: 1
shell: powershell
Expand Down Expand Up @@ -234,13 +241,17 @@ jobs:
run: |
set -o pipefail
cmake --build build-arstack-studio \
--target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness arstack_m4_pages_harness --parallel 2 \
--target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness arstack_m4_pages_harness arstack_m4_compile_harness --parallel 2 \
2>&1 | tee /tmp/arstack-studio-build.log

- name: M4 three independent GUI pages regression
shell: bash
run: ./build-arstack-studio/arstack_m4_pages_harness

- name: M4 real SCD to ARM3 V2 compiler regression (Linux)
shell: bash
run: ./build-arstack-studio/arstack_m4_compile_harness

- name: 4I+4V reference template regression
shell: bash
run: |
Expand Down
18 changes: 18 additions & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,24 @@ qt_add_executable(arstack_m4_pages_harness
target_link_libraries(arstack_m4_pages_harness PRIVATE Qt6::Core)
target_compile_features(arstack_m4_pages_harness PRIVATE cxx_std_20)

# Exercise the actual production SclProfileModel, not a parallel XML decoder.
# The canonical ARM3 envelope is compiled once from the three exact SCD rows,
# preserving per-stream VLAN overrides and the firmware identity hash.
qt_add_executable(arstack_m4_compile_harness
src/M4SclCompileHarness.cpp
src/SclProfileModel.cpp
src/SclProfileModel.hpp
)
target_link_libraries(arstack_m4_compile_harness PRIVATE
Qt6::Core
ARIEC61850::core
)
target_compile_definitions(arstack_m4_compile_harness PRIVATE
ARIEC61850_SOURCE_DIR="${CMAKE_CURRENT_LIST_DIR}/../.."
)
target_compile_features(arstack_m4_compile_harness PRIVATE cxx_std_20)


# Opt-in Windows engineering preview identity; OFF for all stable builds.
option(ARSTACK_STUDIO_ENGINEERING_PREVIEW "Mark this Studio build as an isolated preview" OFF)
if(ARSTACK_STUDIO_ENGINEERING_PREVIEW)
Expand Down
129 changes: 129 additions & 0 deletions apps/arstack_studio/src/M4SclCompileHarness.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Production Studio SCD-model integration test: no ESP32, Qt Quick, or COM port.
#include "SclProfileModel.hpp"
#include "ariec61850/sampled_values/m4_three_sv_binary.hpp"
#include "ariec61850/sampled_values/profile_identity.hpp"

#include <QCoreApplication>
#include <QUrl>
#include <array>
#include <cstdint>
#include <filesystem>
#include <span>
#include <stdexcept>
#include <string>

namespace {
using namespace ar::iec61850::sampled_values;

void require(bool value, const char* message) {
if (!value) throw std::runtime_error{message};
}

std::array<CompiledSvDeviceProfile, m4_three_sv_capacity>
decode(const QByteArray& bytes) {
require(!bytes.isEmpty(), "ARM3 candidate missing");
require(bytes.startsWith("ARM3"), "ARM3 magic missing");
std::array<CompiledSvDeviceProfile, m4_three_sv_capacity> profiles{};
const auto source = std::span<const std::uint8_t>{
reinterpret_cast<const std::uint8_t*>(bytes.constData()),
static_cast<std::size_t>(bytes.size())};
const auto result = M4ThreeSvBinaryCodec::decode(source, profiles);
require(result.success() && result.bytes == source.size(),
"ARM3 V2 full decode/CRC rejected");
return profiles;
}

void test_real_scd_compiles_exact_mus() {
const auto fixture = std::filesystem::path{ARIEC61850_SOURCE_DIR} /
"tests/fixtures/scl/m4-three-9-2le-reference.scd";
SclProfileModel model;
require(model.loadFile(QUrl::fromLocalFile(
QString::fromStdString(fixture.string()))), "SCD parse failed");
require(!model.m4DraftSupported() && model.m4ConfigurationBinary().isEmpty(),
"Arbitrary imported SCD must not silently infer 9-2LE");
require(model.confirmCounterModulus(4000), "explicit shared modulus");
for (int i = 0; i < 3; ++i) {
model.selectStream(i);
require(model.selectProfileFamily(QStringLiteral("9-2le")),
"operator-selected IEC 61850 9-2LE profile family");
}
require(model.m4DraftSupported(), "3 exact canonical SCD profiles required");
const auto baseline = model.m4ConfigurationBinary();
const auto hash = model.m4ConfigurationHash();
require(hash.size() == 16, "firmware-compatible 64-bit identity hash");
const auto profiles = decode(baseline);
const auto source = std::span<const std::uint8_t>{
reinterpret_cast<const std::uint8_t*>(baseline.constData()),
static_cast<std::size_t>(baseline.size())};
const auto expectedHash = QString::number(
static_cast<qulonglong>(sv_binary_profile_identity_hash(source)), 16)
.rightJustified(16, QLatin1Char('0')).toUpper();
require(hash == expectedHash, "firmware identity must hash entire envelope");
const auto roster = model.m4RosterRows();
require(roster.size() == 3, "one scenario page per source-backed MU");
for (std::size_t i = 0; i < 3; ++i) {
const auto& profile = profiles[i];
require(profile.app_id == 0x4000U, "SCD repeated APPID changed");
require(profile.destination_mac ==
(std::array<std::uint8_t, 6>{0x01, 0x0C, 0xCD, 0x04, 0x00,
static_cast<std::uint8_t>(i + 1U)}),
"MU multicast destination changed");
require(profile.frame_rate_hz == 4000U &&
profile.sample_counter_modulus == 4000U &&
profile.payload_size_bytes == 64U,
"shared grid/canonical 64B layout changed");
const auto expectedSv = "MU01_SV" + std::to_string(i + 1U);
require(std::string(profile.sv_id.data(), profile.sv_id_length) == expectedSv,
"SCD svID changed");
require(profile.vlan_present && profile.vlan_id == 0U &&
profile.vlan_priority == 4U,
"SCD priority tag must survive compilation");
const auto shown = roster[static_cast<qsizetype>(i)].toMap();
require(shown.value(QStringLiteral("svId")).toString() ==
QString::fromStdString(expectedSv), "UI and binary identity differ");
}

// Changing MU2's wire VLAN recompiles the WHOLE atomic V2 envelope;
// sibling MU1/MU3 profiles remain byte-identical (not reindexed/recreated).
model.selectStream(1);
require(model.configureSelectedVlan(true, 100, 5), "MU2 VLAN override");
require(model.m4DraftSupported(), "valid independent VLAN override");
const auto changed = decode(model.m4ConfigurationBinary());
require(model.m4ConfigurationHash() != hash, "wire change must change hash");
require(changed[0] == profiles[0] && changed[2] == profiles[2],
"MU2 wire override leaked into siblings");
require(changed[1].vlan_present && changed[1].vlan_id == 100U &&
changed[1].vlan_priority == 5U, "MU2 exact VLAN not compiled");

require(model.configureSelectedVlan(true, 0, 4), "restore MU2 SCD tag");
require(model.m4ConfigurationBinary() == baseline &&
model.m4ConfigurationHash() == hash,
"restored SCD identities must encode deterministically");
require(model.selectProfileFamily(QStringLiteral("unspecified")),
"unselect profile family");
require(!model.m4DraftSupported() && model.m4ConfigurationBinary().isEmpty(),
"invalid profile must invalidate cached deployment");
require(model.selectProfileFamily(QStringLiteral("9-2le")),
"reselect valid family");
require(model.m4ConfigurationBinary() == baseline,
"recompiled envelope must be identical");
model.clear();
require(!model.m4DraftSupported() && model.m4ConfigurationBinary().isEmpty() &&
model.m4ConfigurationHash().isEmpty(),
"source clear must retire stale deploy image/readback identity");
}
} // namespace

int main(int argc, char** argv) {
QCoreApplication app(argc, argv);
try {
test_real_scd_compiles_exact_mus();
qInfo("PASS P0E2A Studio: imported 3-MU SCD => ARM3 V2, CRC, exact "
"wire IDs, shared 4000 grid, isolation, invalidation");
return 0;
} catch (const std::exception& e) {
qCritical("FAIL P0E2A Studio: %s", e.what());
return 1;
}
}
95 changes: 81 additions & 14 deletions apps/arstack_studio/src/SclProfileModel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include "SclProfileModel.hpp"

#include "ariec61850/sampled_values/compiled_device_profile.hpp"
#include "ariec61850/sampled_values/m4_three_sv_binary.hpp"
#include "ariec61850/sampled_values/profile_identity.hpp"
#include "ariec61850/sampled_values/esp32p4_profile_support.hpp"
#include "ariec61850/scl/parser.hpp"

Expand All @@ -14,6 +16,8 @@
#include <array>
#include <cstdint>
#include <filesystem>
#include <span>
#include <limits>
#include <string_view>

namespace {
Expand Down Expand Up @@ -205,20 +209,16 @@ bool SclProfileModel::hasProfiles() const noexcept { return !rows_.empty(); }
bool SclProfileModel::referenceTemplateActive() const noexcept { return referenceTemplateActive_; }

bool SclProfileModel::m4DraftSupported() const noexcept {
if (rows_.size() != 3U) return false;
for (const auto& row : rows_) {
if (!row.errors.isEmpty() || !row.profile.has_value() ||
row.compatibilityClass != QStringLiteral("A")) return false;
const auto& p = *row.profile;
if (classify_esp32p4_sv_profile(p) != Esp32P4SvProfileSupport::ready)
return false;
const auto hz = p.timing.exact_frame_rate_hz();
if (!hz.has_value() || *hz != 4000U ||
!p.sample_counter_modulus.has_value() ||
*p.sample_counter_modulus != 4000U ||
p.payload_size_bytes != 64U) return false;
}
return true;
// Recomputed only on engineering-source/profile changes, not GUI rendering.
return !m4BinaryEnvelope_.isEmpty();
}

QByteArray SclProfileModel::m4ConfigurationBinary() const {
return m4BinaryEnvelope_;
}

QString SclProfileModel::m4ConfigurationHash() const {
return m4EnvelopeHash_;
}

QVariantList SclProfileModel::m4RosterRows() const {
Expand Down Expand Up @@ -265,6 +265,8 @@ void SclProfileModel::installDocument(
: SvProfileFamily::unspecified);
vlanOverrides_.assign(document_->sampled_values_streams.size(), std::nullopt);
rows_.clear();
m4BinaryEnvelope_.clear();
m4EnvelopeHash_.clear();
selectedIndex_ = -1;
fatalError_.clear();
referenceTemplateActive_ = referenceTemplate;
Expand Down Expand Up @@ -328,6 +330,8 @@ void SclProfileModel::clear() {
profileFamilies_.clear();
vlanOverrides_.clear();
rows_.clear();
m4BinaryEnvelope_.clear();
m4EnvelopeHash_.clear();
selectedIndex_ = -1;
fatalError_.clear();
referenceTemplateActive_ = false;
Expand Down Expand Up @@ -469,6 +473,69 @@ void SclProfileModel::rebuildRows() {
rows_.push_back(std::move(row));
}

// Compile a bounded ARM3 envelope ONCE per changed SCD/selection.
// Always derive from the exact individual profiles shown in the UI, so
// engineer VLAN overrides and selected profile-family are not discarded.
// No XML reparsing, no separate MU serialization, no GUI-frame allocation.
m4BinaryEnvelope_.clear();
m4EnvelopeHash_.clear();
if (rows_.size() == ar::iec61850::sampled_values::m4_three_sv_capacity) {
using namespace ar::iec61850::sampled_values;
std::array<CompiledSvDeviceProfile, m4_three_sv_capacity> profiles{};
bool valid = true;
for (std::size_t i = 0; i < m4_three_sv_capacity && valid; ++i) {
const auto& row = rows_[i];
if (!row.errors.isEmpty() || !row.profile.has_value() ||
row.ied.isEmpty() || row.controlBlockReference.isEmpty() ||
row.compatibilityClass != QStringLiteral("A")) {
valid = false;
break;
}
// M4.0 is three separately addressed virtual MUs, not three
// controls accidentally collapsed into a single IED identity.
for (std::size_t j = 0; j < i; ++j) {
if (row.ied == rows_[j].ied) {
valid = false;
break;
}
}
const auto& p = *row.profile;
if (!valid || classify_esp32p4_sv_profile(p) != Esp32P4SvProfileSupport::ready ||
p.timing.exact_sample_rate_hz() != std::optional<std::uint32_t>{4000U} ||
p.timing.exact_frame_rate_hz() != std::optional<std::uint32_t>{4000U} ||
p.timing.asdus_per_frame != 1U ||
p.sample_counter_modulus != std::optional<std::uint16_t>{4000U} ||
!p.asdu_options.sample_synchronized ||
p.payload_size_bytes != 64U || p.channels.size() != 16U) {
valid = false;
break;
}
const auto device = compile_esp32p4_device_profile(p);
if (!device.ok()) { valid = false; break; }
profiles[i] = *device.profile;
}
if (valid) {
const auto size = M4ThreeSvBinaryCodec::encoded_size(profiles);
// Firmware MultiSvConfigStore max_record_bytes is under 4096.
if (size.has_value() && *size > M4ThreeSvBinaryCodec::header_bytes &&
*size < 4096U &&
*size <= static_cast<std::size_t>(std::numeric_limits<qsizetype>::max())) {
QByteArray bytes(static_cast<qsizetype>(*size), '\0');
auto output = std::span<std::uint8_t>{
reinterpret_cast<std::uint8_t*>(bytes.data()), *size};
const auto encoded = M4ThreeSvBinaryCodec::encode_into(profiles, output);
if (encoded.success() && encoded.bytes == *size) {
const auto hash = sv_binary_profile_identity_hash(
std::span<const std::uint8_t>{output});
m4EnvelopeHash_ = QString::number(
static_cast<qulonglong>(hash), 16).rightJustified(
16, QLatin1Char('0')).toUpper();
m4BinaryEnvelope_ = std::move(bytes);
}
}
}
}

selectedIndex_ = rows_.empty()
? -1
: std::clamp(previousSelection, 0, static_cast<int>(rows_.size()) - 1);
Expand Down
8 changes: 8 additions & 0 deletions apps/arstack_studio/src/SclProfileModel.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include "ariec61850/scl/model.hpp"

#include <QAbstractListModel>
#include <QByteArray>
#include <QUrl>
#include <QVariantMap>

Expand All @@ -23,6 +24,7 @@ class SclProfileModel : public QAbstractListModel {
Q_PROPERTY(bool hasProfiles READ hasProfiles NOTIFY sourceChanged)
Q_PROPERTY(bool referenceTemplateActive READ referenceTemplateActive NOTIFY sourceChanged)
Q_PROPERTY(bool m4DraftSupported READ m4DraftSupported NOTIFY sourceChanged)
Q_PROPERTY(QString m4ConfigurationHash READ m4ConfigurationHash NOTIFY sourceChanged)

public:
enum Roles {
Expand Down Expand Up @@ -58,6 +60,10 @@ class SclProfileModel : public QAbstractListModel {
[[nodiscard]] bool referenceTemplateActive() const noexcept;
[[nodiscard]] bool m4DraftSupported() const noexcept;
Q_INVOKABLE QVariantList m4RosterRows() const;
// Canonical, immutable after SCD recompilation; never encode per GUI frame.
// Empty on any invalid MU or duplicate wire identity.
Q_INVOKABLE QByteArray m4ConfigurationBinary() const;
[[nodiscard]] QString m4ConfigurationHash() const;

Q_INVOKABLE bool loadFile(const QUrl& fileUrl);
Q_INVOKABLE bool loadReferenceTemplate();
Expand Down Expand Up @@ -106,6 +112,8 @@ class SclProfileModel : public QAbstractListModel {
std::vector<ar::iec61850::sampled_values::SvProfileFamily> profileFamilies_;
std::vector<std::optional<VlanOverride>> vlanOverrides_;
std::vector<Row> rows_;
QByteArray m4BinaryEnvelope_;
QString m4EnvelopeHash_;
int selectedIndex_{-1};
QString fatalError_;
bool referenceTemplateActive_{false};
Expand Down
Loading