Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f7224fc
feat(M4-P0E): add typed independent MU draft state and regression har…
masarray Oct 26, 2026
d50c9d7
feat(M4-P0E): add typed independent MU draft state and regression har…
masarray Oct 26, 2026
5b03aa9
feat(M4-P0E): add typed independent MU draft state and regression har…
masarray Oct 26, 2026
d8a10f3
feat(M4-P0E): expose three validated SCD roster rows to typed Studio …
masarray Oct 26, 2026
f6c3a88
feat(M4-P0E): expose three validated SCD roster rows to typed Studio …
masarray Oct 26, 2026
e1ee21b
feat(M4-P0E): expose three validated SCD roster rows to typed Studio …
masarray Oct 26, 2026
970e9f7
feat(M4-P0E): expose three validated SCD roster rows to typed Studio …
masarray Oct 26, 2026
8162808
feat(M4-P0E): use one editor and preview surface with isolated three-…
masarray Oct 26, 2026
e848dff
feat(M4-P0E): use one editor and preview surface with isolated three-…
masarray Oct 26, 2026
e14e86b
ci(M4-P0E): build and execute three-stream native pagination regression
masarray Oct 26, 2026
ec6c936
ci(M4-P0E): build and execute three-stream native pagination regression
masarray Oct 26, 2026
bde59a4
docs(M4-P0E): checkpoint draft-only safety and next deploy/readback b…
masarray Oct 26, 2026
c058436
fix(M4-P0E): permit Qt QML type wrapper to derive from registered QOb…
masarray Oct 26, 2026
1ee98a3
fix(M4-P0E): block remaining direct V1 per-channel/CT/quality/zero se…
masarray Oct 26, 2026
8f63b13
test(M4-P0E): qualify actual ESP32 profile and regress phase/quality/…
masarray Oct 26, 2026
419fc43
test(M4-P0E): qualify actual ESP32 profile and regress phase/quality/…
masarray Oct 26, 2026
479ed05
fix(M4-P0E1): isolate legacy persistence and commit MU frequency befo…
masarray Oct 26, 2026
c1c0ce0
test(M4-P0E1): guard GUI legacy persistence and MU frequency navigation
masarray Oct 26, 2026
bf7f965
ci(M4-P0E1): gate native preview on QML persistence/navigation safety…
masarray Oct 26, 2026
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
11 changes: 10 additions & 1 deletion .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**'
Expand Down Expand Up @@ -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:
Expand All @@ -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"
Expand Down
16 changes: 14 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 --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
Expand All @@ -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
Expand Down Expand Up @@ -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: |
Expand Down
11 changes: 11 additions & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
164 changes: 158 additions & 6 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Comment thread
masarray marked this conversation as resolved.
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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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()
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ||
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading