diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 151cdb636..32bb8194c 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -5,6 +5,7 @@ on: types: [opened, synchronize, reopened, ready_for_review] paths: - 'apps/arstack_studio/**' + - 'tests/test_m4_studio_qml_safety_contract.py' # Firmware-only timing fixes MUST create a matched ESP32 image and # Windows Inno installer; do not leave users on stale embedded firmware. - 'embedded/esp32p4_smv_injector/**' @@ -45,6 +46,12 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + - name: M4 Studio QML safety contract regression + shell: pwsh + run: | + python tests/test_m4_studio_qml_safety_contract.py + if ($LASTEXITCODE -ne 0) { throw 'M4 QML safety contract failed' } + - name: Install Qt uses: jurplel/install-qt-action@v4 with: @@ -58,8 +65,10 @@ 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 --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 --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_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 4d15af2ca..0bcee1c1a 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 --parallel + --target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness arstack_m4_pages_harness --parallel - name: S8A deterministic session harness on Windows timeout-minutes: 1 @@ -98,6 +98,14 @@ jobs: throw "S8A Windows deterministic harness failed with exit code $exitCode." } + - name: Native M4 per-MU pagination regression + timeout-minutes: 1 + shell: powershell + run: | + $exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_m4_pages_harness.exe' + & $exe + if ($LASTEXITCODE -ne 0) { throw "M4 native stream-page state regression failed: $LASTEXITCODE" } + - name: Native Windows clipboard roundtrip regression timeout-minutes: 1 shell: powershell @@ -226,9 +234,13 @@ jobs: run: | set -o pipefail cmake --build build-arstack-studio \ - --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness --parallel 2 \ + --target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness arstack_m4_pages_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: 4I+4V reference template regression shell: bash run: | diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index ffc5b1923..359e4cee2 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -20,6 +20,8 @@ add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../.. ${CMAKE_BINARY_DIR}/ariec61850- set(ARSTACK_STUDIO_CORE_SOURCES src/SclProfileModel.cpp src/SclProfileModel.hpp + src/M4StreamScenarioModel.cpp + src/M4StreamScenarioModel.hpp src/DeviceIoWorker.cpp src/DeviceIoWorker.hpp src/DeviceController.cpp @@ -210,6 +212,15 @@ target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) +# Portable native GUI-side M4 pagination regression, no COM/ESP32 required. +qt_add_executable(arstack_m4_pages_harness + src/M4StreamScenarioHarness.cpp + src/M4StreamScenarioModel.cpp + src/M4StreamScenarioModel.hpp +) +target_link_libraries(arstack_m4_pages_harness PRIVATE Qt6::Core) +target_compile_features(arstack_m4_pages_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/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 835973c92..967030b99 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -35,9 +35,13 @@ ApplicationWindow { Timer { id: saveScenarioTimer interval: 400 - onTriggered: scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + onTriggered: { + if (!m4Pages.active && !root.restoringScenario) + scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + } } function adoptRunningBoardScenario(snapshot) { + if (m4Pages.active) return // M4 drafts are never V1 board readback. if (!device.deviceVerified || !device.running || device.scenarioApplying || reconnectScenarioRevision < 0 || reconnectScenarioRevision !== scenarioRevision || @@ -150,6 +154,12 @@ ApplicationWindow { } function markScenarioDirty() { if (restoringScenario) return + if (m4Pages.active) { + m4Pages.saveCurrent(startScenarioSnapshot(false)) + pendingApply = true + scenarioRevision += 1 + return // No legacy single-SV serial mutation in M4 draft mode. + } saveScenarioTimer.restart() pendingApply = true scenarioRevision += 1 @@ -285,7 +295,7 @@ ApplicationWindow { // closes immediately after editing. Flush the local draft once, while // ListModels and Settings remain alive; no device command is sent. saveScenarioTimer.stop() - if (!root.restoringScenario) + if (!root.restoringScenario && !m4Pages.active) scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) // C++ owns process termination and bounded worker retirement. Do not // race it with a second QML quit path. @@ -306,7 +316,91 @@ ApplicationWindow { property double lastNotifiedSvStopIncident: 0 SclProfileModel { id: sclProfiles } + M4StreamScenarioModel { id: m4Pages } DeviceController { id: device } + property var m4LegacyDraft: null + + // One set of native editors and preview canvases. The page model stores + // independent per-IED drafts; no parallel QML waveform/phasor engines. + function restoreM4Draft(draft) { + if (!draft || !draft.current || !draft.voltage || + draft.current.length !== 4 || draft.voltage.length !== 4) return false + restoringScenario = true + const groups = [draft.current, draft.voltage] + const models = [currentModel, voltageModel] + for (var g = 0; g < 2; ++g) { + for (var row = 0; row < 4; ++row) { + const ch = groups[g][row] + models[g].setProperty(row, "magnitude", ch[0]) + models[g].setProperty(row, "phase", ch[1]) + models[g].setProperty(row, "enabled", ch[2]) + models[g].setProperty(row, "quality", ch[3]) + } + } + signalFrequency = draft.hz + previousAcFrequency = draft.hz > 0 ? draft.hz : 50 + frequencyField.text = signalFrequency.toFixed(3) + frequencyField.frequencyDirty = false + frequencyField.invalidInput = false + phaseLink = draft.linked === true + if (draft.ct) { + ctSaturationEnabled = draft.ct.enabled + ctDcOffsetPercent = draft.ct.dc + ctHarmonicPercent = draft.ct.harmonic + ctHarmonicOrder = draft.ct.order + ctClipPercent = draft.ct.clip + } + selectSignal(activeGroup, activeRow) + refreshPreview() + restoringScenario = false + return true + } + function m4OpenDraft() { + if (!sclProfiles.m4DraftSupported || m4Pages.active || device.running || + device.scenarioApplying || workflowBar.startQueued) { + showMessage("M4 draft requires a qualified 3-SV SCD and stopped board.", true) + return + } + if (!root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return + const before = startScenarioSnapshot(false) + if (!before || !m4Pages.openThree(sclProfiles.m4RosterRows(), before)) { + showMessage("M4 stream identities invalid: cannot open three pages.", true) + return + } + m4LegacyDraft = before + // Flush the legacy experiment once before the MU editor becomes active. + saveScenarioTimer.stop() + scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) + autoLiveApplyTimer.stop() + autoLiveQueuedRevision = -1 + showMessage("Three MU draft pages ready. Device deploy is not enabled yet.", false) + } + function m4SelectPage(slot) { + if (!m4Pages.active || slot === m4Pages.selectedSlot) return + // A tab click need not blur the focused NumericField; commit first. + if (!root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) return + const next = m4Pages.select(slot, startScenarioSnapshot(false)) + if (!next || !restoreM4Draft(next)) + showMessage("MU switch rejected: incomplete scenario.", true) + } + function m4CloseDraft() { + if (!m4Pages.active) return + // Never let the pending legacy save timer persist the outgoing MU. + saveScenarioTimer.stop() + const previous = m4LegacyDraft + m4Pages.clear() + m4LegacyDraft = null + if (previous) restoreM4Draft(previous) + showMessage("M4 draft pages closed. Legacy 4I+4V editor restored.", false) + } + Connections { + target: sclProfiles + function onSourceChanged() { + if (m4Pages.active) root.m4CloseDraft() + } + } Connections { target: device @@ -665,6 +759,7 @@ ApplicationWindow { } function sendSignal(group, row) { + if (m4Pages.active) return false if (!workflowBar.session || !workflowBar.session.liveControlReady) return true var signal = groupModel(group).get(row) return workflowBar.session.requestSetSignal( @@ -715,6 +810,10 @@ ApplicationWindow { } function applyAllSignals() { + if (m4Pages.active) { + showMessage("M4 pages are offline drafts until three-stream deploy/readback is available.", true) + return false + } if (workflowBar.injectionRunning) return applyLiveScenario() if (!workflowBar.session || !workflowBar.session.liveControlReady) return false var ok = workflowBar.session.requestSetFrequency(signalFrequency) @@ -758,6 +857,7 @@ ApplicationWindow { } function applyLiveScenario() { + if (m4Pages.active) return false if (!workflowBar.injectionRunning || !workflowBar.session || !workflowBar.session.liveControlReady || device.scenarioApplying) return false // Live must transmit committed model values only. A second text cell @@ -826,7 +926,7 @@ ApplicationWindow { ctSaturationEnabled = enabled refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) showMessage(enabled @@ -897,7 +997,7 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() } function zeroAll() { @@ -908,7 +1008,7 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestZero() } @@ -917,7 +1017,7 @@ ApplicationWindow { groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) + if (!m4Pages.active && workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } @@ -1227,11 +1327,63 @@ ApplicationWindow { controller: root device: device profiles: sclProfiles + m4DraftOnly: m4Pages.active uiFont: root.uiFont monoFont: root.monoFont compact: root.compactLayout } + RowLayout { + id: m4PaginationStrip + visible: sclProfiles.m4DraftSupported || m4Pages.active + Layout.fillWidth: true + Layout.preferredHeight: visible ? 31 : 0 + spacing: 5 + Label { + text: "M4 · 3 SV" + color: studioTheme.muted + font.family: root.uiFont + font.pixelSize: 10 + } + Repeater { + model: m4Pages.active ? 3 : 0 + delegate: Rectangle { + required property int index + implicitWidth: 114 + implicitHeight: 26 + radius: 5 + color: m4Pages.selectedSlot === index ? studioTheme.accentSoft : studioTheme.surface2 + border.width: 1 + border.color: m4Pages.selectedSlot === index ? studioTheme.accent : studioTheme.lineSoft + Label { + anchors.centerIn: parent + text: "MU " + (index + 1) + font.family: root.uiFont + font.pixelSize: 10 + color: studioTheme.text + } + MouseArea { + anchors.fill: parent + onClicked: root.m4SelectPage(index) + } + } + } + Item { Layout.fillWidth: true } + Label { + visible: m4Pages.active + text: "DRAFT ONLY · NOT DEPLOYED" + color: studioTheme.amber + font.family: root.uiFont + font.pixelSize: 9 + } + Button { + text: m4Pages.active ? "Close MU pages" : "Open MU pages" + font.family: root.uiFont + font.pixelSize: 10 + onClicked: m4Pages.active ? root.m4CloseDraft() : root.m4OpenDraft() + } + } + SplitView { id: shellSplit Layout.fillWidth: true diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 9cf2334b5..8f923088d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -12,6 +12,7 @@ SurfacePanel { property var controller property var device property var profiles + property bool m4DraftOnly: false property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false @@ -46,6 +47,7 @@ SurfacePanel { startIntentExpiry.stop() } function queueFirstStart() { + if (ribbon.m4DraftOnly) return false // Only a verified, physically connected board may receive deferred // START. Never arm for offline, firmware update or reconnect. if (!device.deviceVerified || !device.connected || @@ -338,6 +340,10 @@ SurfacePanel { } function requestStart() { + if (ribbon.m4DraftOnly) { + controller.showMessage("M4 draft pages are not deployed. START blocked until MULTISV deploy/readback is implemented.", true) + return + } if (ribbon.injectionRunning || ribbon.startQueued) return if (smartSession.firmwareInstallRequired) { ribbon.installPromptDeferred = false diff --git a/apps/arstack_studio/src/M4StreamScenarioHarness.cpp b/apps/arstack_studio/src/M4StreamScenarioHarness.cpp new file mode 100644 index 000000000..e89ee47b3 --- /dev/null +++ b/apps/arstack_studio/src/M4StreamScenarioHarness.cpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4StreamScenarioModel.hpp" +#include +#include + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + QVariantList roster; + for (int i = 1; i <= 3; ++i) roster.push_back(QVariantMap{ + {QStringLiteral("ied"), QStringLiteral("MU01_4I_4V_%1").arg(i)}, + {QStringLiteral("controlBlockReference"), QStringLiteral("MU%1/LLN0.MSVCB01").arg(i)}, + {QStringLiteral("svId"), QStringLiteral("MU01_SV%1").arg(i)}, + {QStringLiteral("destinationMac"), QStringLiteral("01:0C:CD:04:00:0%1").arg(i)}}); + QVariantMap initial{ + {QStringLiteral("hz"), 50.0}, + {QStringLiteral("current"), QVariantList{QVariantMap{ + {QStringLiteral("phase"), 0.0}, {QStringLiteral("quality"), 0U}}}}, + {QStringLiteral("voltage"), QVariantList{QVariantMap{ + {QStringLiteral("phase"), -120.0}, {QStringLiteral("quality"), 0U}}}}, + {QStringLiteral("ct"), QVariantMap{{QStringLiteral("enabled"), false}, + {QStringLiteral("harmonic"), 28.0}}} + }; + M4StreamScenarioModel model; + const auto check = [](bool value, const char* reason) { + if (!value) qCritical() << "FAIL M4 pages" << reason; + return value; + }; + if (!check(model.openThree(roster, initial), "valid SCD roster") || + !check(model.active() && model.roster().size() == 3, "distinct roster identity")) return 1; + auto one = initial, two = initial, three = initial; + two.insert(QStringLiteral("hz"), 60.0); + three.insert(QStringLiteral("hz"), 0.0); + two.insert(QStringLiteral("current"), QVariantList{QVariantMap{ + {QStringLiteral("phase"), 71.0}, {QStringLiteral("quality"), 0x12000000U}}}); + three.insert(QStringLiteral("ct"), QVariantMap{ + {QStringLiteral("enabled"), true}, {QStringLiteral("harmonic"), 58.0}}); + if (!check(model.select(1, one).value(QStringLiteral("hz")).toDouble() == 50.0, "MU2 seeded") || + !check(model.select(2, two).value(QStringLiteral("hz")).toDouble() == 50.0, "MU3 seeded") || + !check(model.select(0, three).value(QStringLiteral("hz")).toDouble() == 50.0, "MU1 retained") || + !check(model.draftFor(1).value(QStringLiteral("hz")).toDouble() == 60.0, "MU2 isolated") || + !check(model.draftFor(2).value(QStringLiteral("hz")).toDouble() == 0.0, "MU3 isolated") || + !check(model.draftFor(1).value(QStringLiteral("current")).toList()[0].toMap().value( + QStringLiteral("quality")).toUInt() == 0x12000000U, "MU2 quality isolated") || + !check(model.draftFor(1).value(QStringLiteral("current")).toList()[0].toMap().value( + QStringLiteral("phase")).toDouble() == 71.0, "MU2 phase isolated") || + !check(model.draftFor(2).value(QStringLiteral("ct")).toMap().value( + QStringLiteral("harmonic")).toDouble() == 58.0, "MU3 waveform isolated")) + return 1; + model.clear(); + QVariantList duplicate = roster; + duplicate[2] = duplicate[1]; + if (!check(!model.openThree(duplicate, initial), "reject duplicate IED") || + !check(!model.active(), "bad roster doesn't activate") || + !check(model.openThree(roster, initial), "restart after rejection") || + !check(model.select(3, initial).isEmpty(), "reject fourth MU") || + !check(model.selectedSlot() == 0, "no invalid selection")) + return 1; + qInfo() << "PASS M4 P0E dedicated MU drafts and IED-qualified page selection"; + return 0; +} diff --git a/apps/arstack_studio/src/M4StreamScenarioModel.cpp b/apps/arstack_studio/src/M4StreamScenarioModel.cpp new file mode 100644 index 000000000..cf1fe5b65 --- /dev/null +++ b/apps/arstack_studio/src/M4StreamScenarioModel.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4StreamScenarioModel.hpp" +#include +#include +#include + +QVariantList M4StreamScenarioModel::roster() const { + QVariantList out; + if (active_) for (const auto& slot : slots_) out.push_back(slot.identity); + return out; +} +bool M4StreamScenarioModel::openThree(const QVariantList& roster, const QVariantMap& initial) { + if (active_ || roster.size() != 3 || initial.isEmpty()) return false; + QSet ieds, references, wireIds; + std::array staged{}; + for (int index = 0; index < 3; ++index) { + const auto identity = roster[index].toMap(); + const auto ied = identity.value(QStringLiteral("ied")).toString().trimmed(); + const auto cb = identity.value(QStringLiteral("controlBlockReference")).toString().trimmed(); + const auto sv = identity.value(QStringLiteral("svId")).toString().trimmed(); + const auto mac = identity.value(QStringLiteral("destinationMac")).toString().trimmed(); + if (ied.isEmpty() || cb.isEmpty() || sv.isEmpty() || mac.isEmpty() || + ieds.contains(ied) || references.contains(cb) || + wireIds.contains(mac + QLatin1Char('/') + sv)) return false; + ieds.insert(ied); references.insert(cb); wireIds.insert(mac + QLatin1Char('/') + sv); + staged[static_cast(index)] = {identity, initial}; + } + slots_ = std::move(staged); + selectedSlot_ = 0; active_ = true; emit changed(); + return true; +} +bool M4StreamScenarioModel::saveCurrent(const QVariantMap& draft) { + if (!active_ || selectedSlot_ < 0 || draft.isEmpty()) return false; + slots_[static_cast(selectedSlot_)].draft = draft; + return true; +} +QVariantMap M4StreamScenarioModel::select(int slot, const QVariantMap& outgoing) { + if (!active_ || slot < 0 || slot >= 3 || outgoing.isEmpty()) return {}; + slots_[static_cast(selectedSlot_)].draft = outgoing; + if (slot != selectedSlot_) { selectedSlot_ = slot; emit changed(); } + return slots_[static_cast(slot)].draft; +} +QVariantMap M4StreamScenarioModel::draftFor(int slot) const { + return active_ && slot >= 0 && slot < 3 + ? slots_[static_cast(slot)].draft : QVariantMap{}; +} +void M4StreamScenarioModel::clear() { + if (!active_) return; + active_ = false; selectedSlot_ = -1; slots_ = {}; emit changed(); +} diff --git a/apps/arstack_studio/src/M4StreamScenarioModel.hpp b/apps/arstack_studio/src/M4StreamScenarioModel.hpp new file mode 100644 index 000000000..82cd932dc --- /dev/null +++ b/apps/arstack_studio/src/M4StreamScenarioModel.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include +#include + +// Presentation-only independent MU drafts, never the firmware source of truth. +// An explicitly opened SCD roster owns 3 IED-qualified immutable identities. +class M4StreamScenarioModel : public QObject { + Q_OBJECT + Q_PROPERTY(bool active READ active NOTIFY changed) + Q_PROPERTY(int selectedSlot READ selectedSlot NOTIFY changed) + Q_PROPERTY(QVariantList roster READ roster NOTIFY changed) +public: + explicit M4StreamScenarioModel(QObject* parent = nullptr) : QObject(parent) {} + [[nodiscard]] bool active() const noexcept { return active_; } + [[nodiscard]] int selectedSlot() const noexcept { return selectedSlot_; } + [[nodiscard]] QVariantList roster() const; + Q_INVOKABLE bool openThree(const QVariantList& roster, const QVariantMap& initial); + Q_INVOKABLE bool saveCurrent(const QVariantMap& draft); + Q_INVOKABLE QVariantMap select(int slot, const QVariantMap& outgoing); + Q_INVOKABLE QVariantMap draftFor(int slot) const; + Q_INVOKABLE void clear(); +signals: + void changed(); +private: + struct Slot { QVariantMap identity; QVariantMap draft; }; + std::array slots_{}; + int selectedSlot_{-1}; + bool active_{false}; +}; diff --git a/apps/arstack_studio/src/SclProfileModel.cpp b/apps/arstack_studio/src/SclProfileModel.cpp index 6bf78ada6..69295059b 100644 --- a/apps/arstack_studio/src/SclProfileModel.cpp +++ b/apps/arstack_studio/src/SclProfileModel.cpp @@ -204,6 +204,35 @@ int SclProfileModel::selectedIndex() const noexcept { return selectedIndex_; } 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; +} + +QVariantList SclProfileModel::m4RosterRows() const { + QVariantList roster; + if (!m4DraftSupported()) return roster; + for (const auto& row : rows_) { + auto item = profileToVariantMap(*row.profile); + item.insert(QStringLiteral("ied"), row.ied); + item.insert(QStringLiteral("controlBlockReference"), row.controlBlockReference); + roster.push_back(item); + } + return roster; +} + QVariantMap SclProfileModel::selectedProfile() const { if (selectedIndex_ < 0 || selectedIndex_ >= rowCount()) return {}; const auto& row = rows_[static_cast(selectedIndex_)]; diff --git a/apps/arstack_studio/src/SclProfileModel.hpp b/apps/arstack_studio/src/SclProfileModel.hpp index d3c78021e..2fa1778d2 100644 --- a/apps/arstack_studio/src/SclProfileModel.hpp +++ b/apps/arstack_studio/src/SclProfileModel.hpp @@ -22,6 +22,7 @@ class SclProfileModel : public QAbstractListModel { Q_PROPERTY(QVariantMap selectedProfile READ selectedProfile NOTIFY selectedProfileChanged) Q_PROPERTY(bool hasProfiles READ hasProfiles NOTIFY sourceChanged) Q_PROPERTY(bool referenceTemplateActive READ referenceTemplateActive NOTIFY sourceChanged) + Q_PROPERTY(bool m4DraftSupported READ m4DraftSupported NOTIFY sourceChanged) public: enum Roles { @@ -55,6 +56,8 @@ class SclProfileModel : public QAbstractListModel { [[nodiscard]] QVariantMap selectedProfile() const; [[nodiscard]] bool hasProfiles() const noexcept; [[nodiscard]] bool referenceTemplateActive() const noexcept; + [[nodiscard]] bool m4DraftSupported() const noexcept; + Q_INVOKABLE QVariantList m4RosterRows() const; Q_INVOKABLE bool loadFile(const QUrl& fileUrl); Q_INVOKABLE bool loadReferenceTemplate(); diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index a17178797..c421dc0f3 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -3,6 +3,7 @@ #include "DeviceIoWorker.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" +#include "M4StreamScenarioModel.hpp" #include "SingleInstanceGuard.hpp" #include "SmartSessionController.hpp" #include "StudioClipboard.hpp" @@ -642,6 +643,7 @@ int main(int argc, char* argv[]) { FirmwareManager firmwareService; qmlRegisterType("ARStack.Studio", 1, 0, "SclProfileModel"); + qmlRegisterType("ARStack.Studio", 1, 0, "M4StreamScenarioModel"); qmlRegisterType("ARStack.Studio", 1, 0, "DeviceController"); qmlRegisterType("ARStack.Studio", 1, 0, "SvCaptureController"); qmlRegisterType("ARStack.Studio", 1, 0, "SmartSessionController"); diff --git a/docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md b/docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md new file mode 100644 index 000000000..3ee269b46 --- /dev/null +++ b/docs/M4_P0E_STUDIO_PAGINATION_CHECKPOINT.md @@ -0,0 +1,22 @@ +# M4 P0E — One Studio window, three independent SCD-backed MU draft pages + +**Scope:** Native Qt/QML GUI milestone. **Draft only — no M4 firmware deployment and no physical output claim.** All existing single-SV and M3.H.3 release artifacts stay unchanged. + +## Implemented + +- SclProfileModel exposes `m4DraftSupported` and `m4RosterRows()` only when **exactly three** individually resolved SCD SV profiles have class A, exactly 4,000 fps, 4,000 counter modulus and 64-byte payload. The operator still chooses/validates the SCL profile family and counter policy; this is never inferred from the SCD file name. +- One `M4StreamScenarioModel` retains three separate IED-qualified identities and three detached deep-copy value drafts, indexed by MU slot; duplicate identities are rejected before activation. +- The **existing** SignalMatrix, SignalPreview Table/phasor/waveform, current/voltage ListModels and theme remain one instance. The operator switches MU 1/2/3; outgoing draft is captured and incoming draft is restored with the existing non-serial `restoringScenario` guard. Frequency, 8 channels, phases, quality, enable states and waveform shaping are stored individually in each draft. +- The M4 editor is explicitly opt-in, only while output is stopped. Header reads **DRAFT ONLY · NOT DEPLOYED**; `WorkflowBar.requestStart` and deferred start are refused. `applyAllSignals` and `applyLiveScenario` reject M4 drafts, preventing accidental single-SV V1 commands. A different SCL document invalidates/ends the M4 draft view; exiting restores the old single-SV editor values. +- Native `arstack_m4_pages_harness` covers independent slots, stable IDs, duplicate roster rejection and out-of-range selection. Linux and Windows Qt CI and native preview CI now build/run the harness. + +## Important incomplete capabilities + +1. **Still not a firmware deployment path.** Compile the full `M4ThreeSvRoster` and nested V2 binary, then upload through the existing single `DeviceIoWorker` queue as `MULTISV BEGIN/CHUNK/VALIDATE/COMMIT/SELECT` with transaction readback. No QML direct serial write. +2. Connect M4 page edit commits to `SVSTREAM CFG` with host `sv_scenario_hash`, accepted generation/hash and per-slot readback, including a no-op on reconnect when state is already verified. +3. Only after the device's actual three-stream capability and readback are verified should the M4 START affordance be enabled. Preserve the legacy V1 START route exactly. +4. P0F needs an exact-head paired installer/firmware and on-wire 3x4000fps physical capture with all 3 MAC/svID/APPID/VLAN and common PTP sample indices; no CI-only performance claim. + +## Design rationale + +One Qt page composition with three isolated **data snapshots** instead of three GUIs or three always-running QML canvases. The embedded single GPTimer/PTP/EMAC publisher remains separately owned; nothing in this UI milestone modifies the 250-us firmware path. \ No newline at end of file diff --git a/tests/test_m4_studio_qml_safety_contract.py b/tests/test_m4_studio_qml_safety_contract.py new file mode 100644 index 000000000..82dafae50 --- /dev/null +++ b/tests/test_m4_studio_qml_safety_contract.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Fail-closed source contract for M4 draft/legacy boundary. + +This complements the native M4 state harness; it does not emulate Qt focus or hardware. +""" +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +QML = (ROOT / "apps/arstack_studio/qml/Main.qml").read_text(encoding="utf-8") + + +def block(start: str, end: str) -> str: + assert start in QML and end in QML, f"Missing contract anchor: {start} / {end}" + return QML.split(start, 1)[1].split(end, 1)[0] + + +class M4QmlSafetyContract(unittest.TestCase): + def test_debounced_legacy_save_is_gated(self): + save = block("id: saveScenarioTimer", "function adoptRunningBoardScenario") + self.assertIn("!m4Pages.active", save) + self.assertIn("!root.restoringScenario", save) + + def test_close_cannot_persist_active_mu_as_legacy(self): + close = block("onClosing: function(close)", "FontLoader {") + self.assertIn("saveScenarioTimer.stop()", close) + self.assertIn("!m4Pages.active", close) + + def test_open_saves_legacy_before_mu_navigation(self): + opened = block("function m4OpenDraft()", "function m4SelectPage(slot)") + self.assertIn("m4LegacyDraft = before", opened) + self.assertIn("saveScenarioTimer.stop()", opened) + self.assertIn("scenarioSettings.lastScenario", opened) + + def test_navigation_commits_frequency_before_snapshot(self): + nav = block("function m4SelectPage(slot)", "function m4CloseDraft()") + self.assertLess(nav.index("commitFrequencyEditor()"), nav.index("m4Pages.select(")) + self.assertLess(nav.index("flushPendingEdits()"), nav.index("m4Pages.select(")) + + def test_restore_clears_old_frequency_editor_state(self): + restore = block("function restoreM4Draft(draft)", "function m4OpenDraft()") + self.assertIn("frequencyField.frequencyDirty = false", restore) + self.assertIn("frequencyField.invalidInput = false", restore) + + +if __name__ == "__main__": + unittest.main()