From dd48d60bfae21a15e24ecb1ed65553ce91403b86 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:26:58 +0700 Subject: [PATCH 1/2] feat(M4-P0E2A): compile Studio SCD to canonical atomic ARM3 V2 envelope Cache exact three profiles and identity hash from SCD-selected family and per-MU VLAN overrides. Reject invalid identities and layouts; exercise round-trip and isolation on Linux/Windows native Qt and matched preview CI. No device writes; preserve M3.H.3. --- .../arstack-studio-native-preview.yml | 4 +- .github/workflows/arstack-studio-qt.yml | 15 +- apps/arstack_studio/CMakeLists.txt | 18 +++ .../src/M4SclCompileHarness.cpp | 129 ++++++++++++++++++ apps/arstack_studio/src/SclProfileModel.cpp | 93 +++++++++++-- apps/arstack_studio/src/SclProfileModel.hpp | 8 ++ 6 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 apps/arstack_studio/src/M4SclCompileHarness.cpp diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 32bb8194c..80abc2310 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -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" diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index 0bcee1c1a..19ef85a7e 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -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 @@ -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 @@ -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: | diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 359e4cee2..91d98d14b 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -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) diff --git a/apps/arstack_studio/src/M4SclCompileHarness.cpp b/apps/arstack_studio/src/M4SclCompileHarness.cpp new file mode 100644 index 000000000..43942b769 --- /dev/null +++ b/apps/arstack_studio/src/M4SclCompileHarness.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +namespace { +using namespace ar::iec61850::sampled_values; + +void require(bool value, const char* message) { + if (!value) throw std::runtime_error{message}; +} + +std::array +decode(const QByteArray& bytes) { + require(!bytes.isEmpty(), "ARM3 candidate missing"); + require(bytes.startsWith("ARM3"), "ARM3 magic missing"); + std::array profiles{}; + const auto source = std::span{ + reinterpret_cast(bytes.constData()), + static_cast(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{ + reinterpret_cast(baseline.constData()), + static_cast(baseline.size())}; + const auto expectedHash = QString::number( + static_cast(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{0x01, 0x0C, 0xCD, 0x04, 0x00, + static_cast(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(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; + } +} diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 69295059b..ffd4cbcaa 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -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" @@ -14,6 +16,8 @@ #include #include #include +#include +#include #include namespace { @@ -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 { @@ -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; @@ -469,6 +471,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 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{4000U} || + p.timing.exact_frame_rate_hz() != std::optional{4000U} || + p.timing.asdus_per_frame != 1U || + p.sample_counter_modulus != std::optional{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::numeric_limits::max())) { + QByteArray bytes(static_cast(*size), '\0'); + auto output = std::span{ + reinterpret_cast(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{output}); + m4EnvelopeHash_ = QString::number( + static_cast(hash), 16).rightJustified( + 16, QLatin1Char('0')).toUpper(); + m4BinaryEnvelope_ = std::move(bytes); + } + } + } + } + selectedIndex_ = rows_.empty() ? -1 : std::clamp(previousSelection, 0, static_cast(rows_.size()) - 1); diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index 2fa1778d2..93600060d 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -5,6 +5,7 @@ #include "ariec61850/scl/model.hpp" #include +#include #include #include @@ -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 { @@ -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(); @@ -106,6 +112,8 @@ class SclProfileModel : public QAbstractListModel { std::vector profileFamilies_; std::vector> vlanOverrides_; std::vector rows_; + QByteArray m4BinaryEnvelope_; + QString m4EnvelopeHash_; int selectedIndex_{-1}; QString fatalError_; bool referenceTemplateActive_{false}; From aa0a16889d3b1a1565da85b4672cb980a31e4857 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 08:27:33 +0700 Subject: [PATCH 2/2] fix(M4-P0E2A): invalidate cached ARM3 deploy image when Studio SCD is cleared --- apps/arstack_studio/src/SclProfileModel.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index ffd4cbcaa..8bdcbc894 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -330,6 +330,8 @@ void SclProfileModel::clear() { profileFamilies_.clear(); vlanOverrides_.clear(); rows_.clear(); + m4BinaryEnvelope_.clear(); + m4EnvelopeHash_.clear(); selectedIndex_ = -1; fatalError_.clear(); referenceTemplateActive_ = false;