From 67e3a856574a6bf72c3cc5d9f221330c7b4933af Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:23:46 +0700 Subject: [PATCH 01/25] fix(studio): track bounded coalesced readbacks behind exclusive device transactions --- apps/arstack_studio/src/DeviceController.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 0402b896a..c0244f32b 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -394,6 +394,7 @@ class DeviceController : public QObject { void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); bool beginPtpStopVerification(); + void flushDeferredReadbacks(); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); @@ -435,6 +436,10 @@ class DeviceController : public QObject { bool profileArmed_{false}; bool profileWasReused_{false}; bool profileDeploying_{false}; + // One coalesced readback of each kind; no second serial writer or retry loop. + bool deferredShow_{false}; + bool deferredProfileShow_{false}; + bool deferredPtpShow_{false}; BinaryProfileStage binaryProfileStage_{BinaryProfileStage::idle}; BinaryProfileTransfer binaryProfile_{}; bool ptpAvailable_{false}; From 1658560b13b6eecd7c1b3f3d6c61885c56dfb448 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:24:45 +0700 Subject: [PATCH 02/25] fix(studio): defer coalesced readbacks across CFG and binary transactions --- apps/arstack_studio/src/DeviceController.cpp | 68 +++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 926083fa0..dd169b2ff 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -144,9 +144,17 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] { if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback && !sendPtpShow()) { - markPtpStopUnconfirmed(QStringLiteral("Could not verify board PTP STOP status.")); + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP could not be verified from the board. Check USB connection / Copy diagnostics.")); } }); + // Retry a coalesced readback once after transport authority is released. + const auto drainDeferred = [this] { + if (deferredShow_ || deferredProfileShow_ || deferredPtpShow_) + QTimer::singleShot(0, this, [this] { flushDeferredReadbacks(); }); + }; + connect(this, &DeviceController::profileStateChanged, this, drainDeferred); + connect(this, &DeviceController::scenarioStateChanged, this, drainDeferred); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); @@ -659,6 +667,9 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; resetBinaryProfileTransfer(); resetScenarioStart(); resetPtpState(); @@ -687,6 +698,9 @@ void DeviceController::handlePortClosed(const QString& portName) { profileDeploying_ = false; profileArmed_ = false; profileWasReused_ = false; + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; resetBinaryProfileTransfer(); deviceVerified_ = false; clearIdentity(); @@ -1370,7 +1384,9 @@ bool DeviceController::beginPtpStopVerification() { markPtpStopUnconfirmed(QStringLiteral("Could not queue safety PTP STOP.")); return false; } - ptpStartTimer_.start(5000); + // Absolute STOP readback deadline; STOP itself uses the worker safety lane. + ptpStartTimer_.start(profileDeploying_ || scenarioStage_ != ScenarioStage::idle + ? 12000 : 5000); return true; } @@ -1493,10 +1509,12 @@ void DeviceController::advancePtpStartup(const QString& line) { ptpStatus_ = QStringLiteral("Checking that board PTP is stopped..."); emit ptpStateChanged(); if (!sendPtpShow()) { - markPtpStopUnconfirmed(QStringLiteral("Could not request PTP STOP readback.")); + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP readback could not be queued. Check USB connection / Copy diagnostics.")); return; } - ptpStartTimer_.start(5000); + // Preserve the absolute deadline from STOP dispatch; do not + // restart it on ACK while another transaction owns the console. } return; } @@ -1684,6 +1702,21 @@ bool DeviceController::sendQuietCommand(const QString& command) { } bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { + const bool single = !exclusive && commands.size() == 1; + const QString onlyCommand = single ? commands.constFirst() : QString{}; + const bool readback = onlyCommand == QStringLiteral("SHOW") || + onlyCommand == QStringLiteral("PROFILE SHOW") || + onlyCommand == QStringLiteral("PTP SHOW"); + const auto deferReadback = [this, &onlyCommand] { + bool* pending = onlyCommand == QStringLiteral("SHOW") ? &deferredShow_ : + onlyCommand == QStringLiteral("PROFILE SHOW") ? &deferredProfileShow_ : &deferredPtpShow_; + if (!*pending) { + *pending = true; + appendLog(QStringLiteral("·"), + QStringLiteral("Coalesced %1 until exclusive serial authority is released.").arg(onlyCommand)); + } + return true; + }; if (profileDeploying_) { const bool exactlyOne = !exclusive && commands.size() == 1; const QString command = exactlyOne ? commands.constFirst() : QString{}; @@ -1694,9 +1727,8 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool const bool safety = command == QStringLiteral("STOP") || command == QStringLiteral("PTP STOP"); if (!exactlyOne || (!protocolReply && !safety)) { - setError(QStringLiteral( - "Canonical binary profile transaction owns the device console; " - "concurrent polling and live mutations were deferred/rejected.")); + if (readback) return deferReadback(); + setError(QStringLiteral("Device profile is being prepared; wait before changing live setpoints.")); return false; } } @@ -1707,11 +1739,11 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool (commands.constFirst() == QStringLiteral("STOP") || commands.constFirst() == QStringLiteral("PTP STOP") || commands.constFirst() == QStringLiteral("HEARTBEAT") || - commands.constFirst() == QStringLiteral("SHOW") || commands.constFirst().startsWith(QStringLiteral("CFG SHOW ")) || commands.constFirst().startsWith(QStringLiteral("START "))); if (!stagingBatch && !safeCommand) { - setError(QStringLiteral("SV transaction pending: concurrent configuration rejected.")); + if (readback) return deferReadback(); + setError(QStringLiteral("SV configuration is being verified; wait before changing live setpoints.")); return false; } } @@ -1731,6 +1763,24 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool return queued; } +void DeviceController::flushDeferredReadbacks() { + if (profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + scenarioStage_ != ScenarioStage::idle || + !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) + return; + // At most one of each telemetry/readback type. No competing serial writer. + if (std::exchange(deferredPtpShow_, false)) { + if (!sendQuietCommand(QStringLiteral("PTP SHOW")) && + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP readback could not be sent. Check USB connection / Copy diagnostics.")); + } + if (std::exchange(deferredShow_, false)) + static_cast(sendQuietCommand(QStringLiteral("SHOW"))); + if (std::exchange(deferredProfileShow_, false)) + static_cast(sendQuietCommand(QStringLiteral("PROFILE SHOW"))); +} + void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { if (!ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; const quint64 generation = sessionGeneration_; From d4d89557556ae5709d64ba329bd64ea2a1f09218 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:26:00 +0700 Subject: [PATCH 03/25] fix(studio): commit setpoint drafts once on Enter, Tab or focus loss --- apps/arstack_studio/qml/SignalMatrix.qml | 135 +++++++++-------------- 1 file changed, 55 insertions(+), 80 deletions(-) diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 20477ffd8..b7bc89a41 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -27,9 +27,8 @@ Rectangle { return [theme.phaseA, theme.phaseB, theme.phaseC, theme.phaseN][index] } - // Commit any last keystroke synchronously before constructing an ordered - // START scenario. The 90ms edit debounce must never make the board - // receive old angles while the table already displays new ones. + // Flush final local drafts before START. Editing itself never writes to + // the board or mutates the scenario until Enter/Tab/focus-out. function flushPendingEdits() { for (var i = 0; i < rowRepeater.count; ++i) { const row = rowRepeater.itemAt(i) @@ -130,25 +129,44 @@ Rectangle { property bool selected: matrix.controller.activeSignal === signalRow.sid property alias magnitudeEditor: magnitudeField property alias phaseEditor: phaseField - property real pendingMagnitude: mag - property real pendingPhase: angle + property bool magnitudeDirty: false + property bool phaseDirty: false + + // Idempotent commit: onEditingFinished and focus loss may fire + // for the same gesture, but only one model/board mutation occurs. + function commitRowField(column) { + const magnitude = column === 0 + const field = magnitude ? magnitudeField : phaseField + if (!(magnitude ? magnitudeDirty : phaseDirty)) + return !field.invalidInput + const value = matrix.controller.parseOperatorNumber(field.text) + const valid = magnitude + ? matrix.controller.validMagnitude(matrix.groupIndex, value) + : matrix.controller.validPhase(value) + if (!valid) { + field.invalidInput = true + return false // keep the draft visible for correction + } + if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, + magnitude ? "magnitude" : "phase", value)) { + field.invalidInput = true + return false + } + if (magnitude) magnitudeDirty = false + else phaseDirty = false + field.invalidInput = false + field.text = value.toFixed(magnitude ? 3 : 2) + return true + } function flushPendingEdits() { - if (magnitudeApplyTimer.running || magnitudeField.activeFocus) { - const magValue = matrix.controller.parseOperatorNumber(magnitudeField.text) - if (!matrix.controller.validMagnitude(matrix.groupIndex, magValue)) - return false - magnitudeApplyTimer.stop() - if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "magnitude", magValue)) - return false + if (!commitRowField(0)) { + matrix.focusCell(rowIndex, 0) + return false } - if (phaseApplyTimer.running || phaseField.activeFocus) { - const phaseValue = matrix.controller.parseOperatorNumber(phaseField.text) - if (!matrix.controller.validPhase(phaseValue)) - return false - phaseApplyTimer.stop() - if (!matrix.controller.editSignal(matrix.groupIndex, rowIndex, "phase", phaseValue)) - return false + if (!commitRowField(1)) { + matrix.focusCell(rowIndex, 1) + return false } return true } @@ -237,21 +255,8 @@ Rectangle { HoverHandler { id: rowHover } - Timer { - id: magnitudeApplyTimer - interval: 90 - repeat: false - onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", signalRow.pendingMagnitude) - } - Timer { - id: phaseApplyTimer - interval: 90 - repeat: false - onTriggered: matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", signalRow.pendingPhase) - } - - onMagChanged: if (!magnitudeField.activeFocus) magnitudeField.text = mag.toFixed(3) - onAngleChanged: if (!phaseField.activeFocus) phaseField.text = angle.toFixed(2) + onMagChanged: if (!magnitudeField.activeFocus && !magnitudeDirty) magnitudeField.text = mag.toFixed(3) + onAngleChanged: if (!phaseField.activeFocus && !phaseDirty) phaseField.text = angle.toFixed(2) Rectangle { visible: signalRow.selected @@ -305,32 +310,16 @@ Rectangle { onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - text = signalRow.mag.toFixed(3) + if (!signalRow.magnitudeDirty) text = signalRow.mag.toFixed(3) selectAll() - } + } else signalRow.commitRowField(0) } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validMagnitude(matrix.groupIndex, value)) { - invalidInput = false - signalRow.pendingMagnitude = value - magnitudeApplyTimer.restart() - } else invalidInput = true - } - onEditingFinished: { - var value = matrix.controller.parseOperatorNumber(text) - if (!matrix.controller.validMagnitude(matrix.groupIndex, value)) { - magnitudeApplyTimer.stop() - text = signalRow.mag.toFixed(3) - invalidInput = false - matrix.controller.showMessage(signalRow.sid + " value is outside the supported range.", true) - } else { - signalRow.pendingMagnitude = value - magnitudeApplyTimer.stop() - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "magnitude", value) - text = value.toFixed(3) - } + signalRow.magnitudeDirty = true + invalidInput = !matrix.controller.validMagnitude( + matrix.groupIndex, matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitRowField(0) Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -340,7 +329,8 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 0, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) + if (signalRow.commitRowField(0)) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 0) event.accepted = true } } @@ -368,32 +358,16 @@ Rectangle { onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) - text = signalRow.angle.toFixed(2) + if (!signalRow.phaseDirty) text = signalRow.angle.toFixed(2) selectAll() - } + } else signalRow.commitRowField(1) } onTextEdited: { - var value = matrix.controller.parseOperatorNumber(text) - if (matrix.controller.validPhase(value)) { - invalidInput = false - signalRow.pendingPhase = value - phaseApplyTimer.restart() - } else invalidInput = true - } - onEditingFinished: { - var value = matrix.controller.parseOperatorNumber(text) - if (!matrix.controller.validPhase(value)) { - phaseApplyTimer.stop() - text = signalRow.angle.toFixed(2) - invalidInput = false - matrix.controller.showMessage(signalRow.sid + " phase is outside the supported range.", true) - } else { - signalRow.pendingPhase = value - phaseApplyTimer.stop() - matrix.controller.editSignal(matrix.groupIndex, signalRow.rowIndex, "phase", value) - text = value.toFixed(2) - } + signalRow.phaseDirty = true + invalidInput = !matrix.controller.validPhase( + matrix.controller.parseOperatorNumber(text)) } + onEditingFinished: signalRow.commitRowField(1) Keys.onPressed: function(event) { if (event.key === Qt.Key_Menu || (event.key === Qt.Key_F10 && (event.modifiers & Qt.ShiftModifier))) { @@ -403,7 +377,8 @@ Rectangle { matrix.controller.navigate(matrix.groupIndex, signalRow.rowIndex, 1, event.key) event.accepted = true } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) + if (signalRow.commitRowField(1)) + matrix.controller.focusCell(matrix.groupIndex, signalRow.rowIndex + 1, 1) event.accepted = true } } From a9635a4d747689958a328a0b956048a9a8226b43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:27:02 +0700 Subject: [PATCH 04/25] fix(studio): compact 1024 workbench, equal phasor waveform, safe numeric parsing --- apps/arstack_studio/qml/Main.qml | 51 ++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c8a46b6e8..439ffdc4d 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -7,10 +7,10 @@ import ARStack.Studio 1.0 ApplicationWindow { id: root - width: 1150 - height: 790 - minimumWidth: 960 - minimumHeight: 620 + width: 1024 + height: 748 + minimumWidth: 940 + minimumHeight: 640 visible: true title: "ARStack Studio · SMV + PTP · v" + Qt.application.version color: studioTheme.bg @@ -432,15 +432,16 @@ ApplicationWindow { function parseOperatorNumber(text) { var normalized = String(text).trim() - .replace(/\s/g, "") .replace(/degrees?/ig, "") .replace(/deg/ig, "") .replace(/°/g, "") .replace(/Hz/ig, "") - .replace(/[AV]$/i, "") + .replace(/[AV]$/i, "").trim() + // Decimal comma and dot are equally valid. Never interpret mixed + // punctuation or thousands-grouping as a silently altered number. if (normalized.indexOf(",") >= 0 && normalized.indexOf(".") < 0) normalized = normalized.replace(",", ".") - if (!normalized.length || normalized === "+" || normalized === "-" || normalized === ".") + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(normalized)) return NaN return Number(normalized) } @@ -575,8 +576,8 @@ ApplicationWindow { return ok } function startScenarioSnapshot() { - // The table editors debounce keyboard edits for live preview. Flush - // them synchronously before creating the ordered board START payload. + // Flush every local draft once before serializing the canonical + // 4I+4V scenario. An invalid draft cannot be sent to the board. if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) { showMessage("Invalid signal input: correct highlighted field before Start.", true) return null @@ -1065,12 +1066,21 @@ ApplicationWindow { objectName: "studioInjectionSurface" SplitView.fillWidth: true SplitView.fillHeight: true - SplitView.minimumWidth: root.compactLayout ? 500 : 540 - SplitView.preferredWidth: root.compactLayout ? 600 : 690 - SplitView.maximumWidth: 750 + SplitView.minimumWidth: 370 + SplitView.preferredWidth: 425 + SplitView.maximumWidth: 465 + SplitView.fillWidth: false theme: studioTheme + // Clicking unoccupied workspace commits the focused cell. + // Controls above this background retain their own taps. + MouseArea { + anchors.fill: parent + onClicked: root.contentItem.forceActiveFocus() + } + ColumnLayout { + z: 1 anchors.fill: parent anchors.margins: root.compactLayout ? 11 : 13 spacing: root.compactLayout ? 6 : 8 @@ -1168,10 +1178,6 @@ ApplicationWindow { } } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "0"; implicitWidth: 44; toolTipText: "DC"; onClicked: root.setFrequencyValue(0) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "50"; implicitWidth: 44; onClicked: root.setFrequencyValue(50) } - CalmButton { theme: studioTheme; uiFont: root.uiFont; text: "60"; implicitWidth: 44; onClicked: root.setFrequencyValue(60) } - Rectangle { width: 1; height: 24; color: studioTheme.lineSoft } StudioLinkSwitch { theme: studioTheme uiFont: root.uiFont @@ -1296,9 +1302,9 @@ ApplicationWindow { SplitView { visible: (root.phasorDockVisible && !root.phasorDetached) || (root.waveformDockVisible && !root.waveformDetached) - SplitView.preferredWidth: root.compactLayout ? 450 : 470 - SplitView.minimumWidth: root.compactLayout ? 300 : 340 - SplitView.maximumWidth: 680 + SplitView.preferredWidth: 560 + SplitView.minimumWidth: 390 + SplitView.fillWidth: true SplitView.fillHeight: true orientation: Qt.Vertical handle: Rectangle { @@ -1318,9 +1324,8 @@ ApplicationWindow { visible: root.phasorDockVisible && !root.phasorDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 225 - SplitView.maximumHeight: 265 - SplitView.fillHeight: false + SplitView.preferredHeight: 285 + SplitView.fillHeight: true theme: studioTheme titleText: "Phasor" statusText: "" @@ -1358,7 +1363,7 @@ ApplicationWindow { visible: root.waveformDockVisible && !root.waveformDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 328 + SplitView.preferredHeight: 285 SplitView.fillHeight: true theme: studioTheme titleText: "Waveform" From c6a09a1e967fefd199b17041795a994ec45d0636 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:27:06 +0700 Subject: [PATCH 05/25] fix(studio): make PTP details a compact accessible icon control --- apps/arstack_studio/qml/StudioTimingTray.qml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index 906dbd87d..1cb08c6c7 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -169,10 +169,15 @@ Rectangle { CalmButton { theme: tray.theme uiFont: tray.uiFont - text: "Details" + text: "" iconSource: Qt.resolvedUrl("../assets/material/settings.svg") + iconSize: 16 objectName: "studioTimingDetails" - implicitWidth: 66 + implicitWidth: 34 + implicitHeight: 32 + toolTipText: "Time sync & SV lock details" + Accessible.name: "Time sync & SV lock details" + Accessible.description: "Open PTP and SV synchronization settings" onClicked: if (tray.controller) tray.controller.openTimingSettings() } From dd0d31a091c305585d63242cfe0b365179e7365f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:28:00 +0700 Subject: [PATCH 06/25] fix(studio): enforce single width fill and 50/50 resizable chart geometry --- apps/arstack_studio/qml/Main.qml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 439ffdc4d..b0633d3f9 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1064,7 +1064,6 @@ ApplicationWindow { SurfacePanel { objectName: "studioInjectionSurface" - SplitView.fillWidth: true SplitView.fillHeight: true SplitView.minimumWidth: 370 SplitView.preferredWidth: 425 @@ -1324,8 +1323,8 @@ ApplicationWindow { visible: root.phasorDockVisible && !root.phasorDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 285 - SplitView.fillHeight: true + SplitView.preferredHeight: Math.max(170, (workspaceSplit.height - 9) / 2) + SplitView.fillHeight: false theme: studioTheme titleText: "Phasor" statusText: "" @@ -1363,7 +1362,7 @@ ApplicationWindow { visible: root.waveformDockVisible && !root.waveformDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 285 + SplitView.preferredHeight: Math.max(170, (workspaceSplit.height - 9) / 2) SplitView.fillHeight: true theme: studioTheme titleText: "Waveform" From a732b7dd1245b2a26dea4faba1175bbc9262d140 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:29:29 +0700 Subject: [PATCH 07/25] test(studio): verify coalesced busy polling and STOP unconfirmed after deferred readback --- .../src/DeterministicSessionHarness.cpp | 51 ++++++++++++++++--- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 2663d7d3a..fd97a0aa6 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -80,7 +80,8 @@ class DeterministicSessionHarness final { {"same COM firmware reboot invalidates old START and profile authority", samePortFirmwareRebootResetsAuthority()}, {"corrupt console PROFILE command reports cause and blocks START", malformedProfileLineFailsClosed()}, {"PTP polling suspended while binary PROFILE stage owns serial", ptpRefreshBlockedDuringProfileSync()}, - {"binary PROFILE excludes PTP/SHOW/SET and buffers live setpoints", binaryProfileReservesSerialConsole()}, + {"binary PROFILE coalesces PTP/SHOW without internal toast", binaryProfileReservesSerialConsole()}, + {"PTP STOP during exclusive CFG/BIN defers proof, never invents OFF", ptpStopReadbackDeferredDuringExclusive()}, {"UART paced but STOP remains reserved queue preemption", serialPacingDoesNotRemoveSafetySlot()}, {"binary progress -> per-step deadline rearmed", binaryProfileProgressRearmsDeadline()}, {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, @@ -1543,15 +1544,53 @@ class DeterministicSessionHarness final { d.profileDeploying_ = true; d.profileArmed_ = false; d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; - const bool ptpBlocked = !d.sendPtpShow(); - const bool showBlocked = !d.sendShow(); + const QString initialError = d.lastError_; + const bool ptpDeferred = d.sendPtpShow(); + const bool showDeferred = d.sendShow(); + const bool coalesced = d.sendPtpShow() && d.sendShow() && + d.deferredPtpShow_ && d.deferredShow_ && + d.lastError_ == initialError; const bool setBlocked = !d.setEnabled(QStringLiteral("IA"), true); d.frequencyPending_ = true; d.pendingFrequencyHz_ = 60.0; - const bool deferred = d.flushLiveCommands() && d.frequencyPending_; + const bool deferredMutation = d.flushLiveCommands() && d.frequencyPending_; d.liveFlushTimer_.stop(); - return ptpBlocked && showBlocked && setBlocked && deferred && - d.lastError_.contains(QStringLiteral("owns the device console")); + // Busy telemetry is normal, but an unsafe mutation remains rejected. + return ptpDeferred && showDeferred && coalesced && setBlocked && + deferredMutation && d.lastError_.contains(QStringLiteral("being prepared")) && + !d.lastError_.contains(QStringLiteral("owns the device console")); + } + + static bool ptpStopReadbackDeferredDuringExclusive() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.ptpAvailable_ = true; + d.ptpRunning_ = true; + d.ioWorkerReady_ = true; + d.profileDeploying_ = true; + d.binaryProfileStage_ = DeviceController::BinaryProfileStage::waiting_chunk; + const QString previousError = d.lastError_; + if (!d.stopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) + return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback || + !d.deferredPtpShow_ || !d.ptpRunning_ || d.lastError_ != previousError) + return false; + // When the lock is released, exactly one readback is submitted to + // the existing serial owner; no readback is accepted as STOP proof. + d.profileDeploying_ = false; + d.resetBinaryProfileTransfer(); + d.flushDeferredReadbacks(); + if (d.deferredPtpShow_ || !d.ptpRunning_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; + d.markPtpStopUnconfirmed(QStringLiteral("Injected STOP timeout: Copy diagnostics.")); + return d.ptpRunning_ && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + d.lastError_.contains(QStringLiteral("Copy diagnostics.")); } static bool serialPacingDoesNotRemoveSafetySlot() { From 45bfecf3657df6b861c33d246d2d79ca0765c1f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:30:16 +0700 Subject: [PATCH 08/25] test(studio): update compact UX contracts and reject early commits/hidden busy faults --- .../tests/check_operator_workspace.py | 50 +++++++++++++++---- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index aa459e8ae..f327e7b64 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -196,12 +196,15 @@ def main() -> None: and 'startWatchdog' not in source("WorkflowBar.qml"), "SV STOP must remain actionable while the acknowledged scenario transaction is pending") require("function flushPendingEdits()" in matrix - and "magnitudeApplyTimer.stop()" in matrix - and "phaseApplyTimer.stop()" in matrix + and "function commitRowField(column)" in matrix + and "magnitudeApplyTimer" not in matrix + and "phaseApplyTimer" not in matrix + and "onEditingFinished: signalRow.commitRowField(0)" in matrix + and "onEditingFinished: signalRow.commitRowField(1)" in matrix and "currentMatrix.flushPendingEdits()" in main_qml and "voltageMatrix.flushPendingEdits()" in main_qml and "if (!scenario) return" in source("WorkflowBar.qml"), - "START must commit last visible I/V angle before ordered scenario serialization") + "START must flush each operator draft once, never after a 90ms typing timer") require("Layout.fillHeight: true" in main_qml and "Layout.maximumHeight: root.compactLayout ? 352 : 384" not in main_qml and main_qml.index("id: voltageMatrix") < main_qml.index("id: currentMatrix"), @@ -377,9 +380,9 @@ def main() -> None: assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview assert "No ESP32 board detected · Preview mode only" in main_qml require("Layout.preferredHeight: 57" in main_qml - and "SplitView.maximumHeight: 265" in main_qml - and "SplitView.fillHeight: false" in main_qml, - "compact timing strip and bounded phasor panel must avoid dead space") + and "SplitView.maximumHeight: 265" not in main_qml + and 'Math.max(170, (workspaceSplit.height - 9) / 2)' in main_qml, + "compact timing strip must share height evenly between phasor and waveform") require("TIME SYNC / SV SIMULATION" not in tray and "StateBadge {" not in tray, "device/PTP status must not be duplicated in the control tray") @@ -543,9 +546,15 @@ def main() -> None: responsive_main = source("Main.qml") ribbon_layout = source("ModernRibbon.qml") start_flow = source("WorkflowBar.qml") - require("width: 1150" in responsive_main - and "SplitView.maximumWidth: 750" in responsive_main - and "SplitView.preferredWidth: root.compactLayout ? 450 : 470" in responsive_main + require("width: 1024" in responsive_main + and "height: 748" in responsive_main + and "SplitView.maximumWidth: 465" in responsive_main + and "SplitView.preferredWidth: 425" in responsive_main + and "SplitView.fillWidth: false" in responsive_main + and "SplitView.minimumWidth: 390" in responsive_main + and 'text: "0"; implicitWidth: 44' not in responsive_main + and 'text: "50"; implicitWidth: 44' not in responsive_main + and 'text: "60"; implicitWidth: 44' not in responsive_main and "electric_bolt_run.svg" in ribbon_layout and "electric_bolt_stop.svg" in ribbon_layout and "assets/material/electric_bolt_run.svg" in @@ -601,6 +610,29 @@ def main() -> None: "PTP SOURCE physical RUNNING must survive unqualified clock freshness; " "canonical timing authority must remain independently fail-closed") + # The GUI's normal transient conditions are never shown as operator faults. + # Validate both intended (positive) paths and removed (negative) paths. + require("Coalesced %1 until exclusive serial authority is released." in device_cpp + and "flushDeferredReadbacks()" in device_cpp + and "std::exchange(deferredPtpShow_, false)" in device_cpp + and "if (readback) return deferReadback();" in device_cpp + and "Canonical binary profile transaction owns" not in device_cpp + and "Could not request PTP STOP readback." not in device_cpp + and "ptpStartTimer_.start(profileDeploying_" in device_cpp, + "busy device polling must defer quietly; verified STOP has an absolute deadline") + require('toolTipText: "Time sync & SV lock details"' in timing_ui + and 'Accessible.name: "Time sync & SV lock details"' in timing_ui + and 'objectName: "studioTimingDetails"' in timing_ui + and 'text: "Details"' not in timing_ui, + "PTP Details must be an accessible icon-only button") + require("signalRow.magnitudeDirty = true" in matrix + and "signalRow.phaseDirty = true" in matrix + and "matrix.controller.editSignal(" in matrix + and "field.invalidInput = true" in matrix + and "return false // keep the draft visible" in matrix + and "!/^[+-]?" in main_qml, + "numeric editors must validate local draft and commit only once at edit completion") + print("Studio operator workspace contract: PASS") From 2be8b4a9980e562a00cf87c7caea55fa2fbf6188 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:33:50 +0700 Subject: [PATCH 09/25] test(studio): expose real chart bounds for Qt viewport regression --- apps/arstack_studio/qml/Main.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index b0633d3f9..3cbe4a8fa 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1320,6 +1320,7 @@ ApplicationWindow { } DockFrame { + objectName: "studioPhasorDock" visible: root.phasorDockVisible && !root.phasorDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 @@ -1359,6 +1360,7 @@ ApplicationWindow { } DockFrame { + objectName: "studioWaveformDock" visible: root.waveformDockVisible && !root.waveformDetached SplitView.fillWidth: true SplitView.minimumHeight: 170 From 4c04f4f618d9d43244e53f046074d6a2ed83d281 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:33:56 +0700 Subject: [PATCH 10/25] test(studio): enforce real five-width Qt geometry and best-effort larger viewport screenshots --- apps/arstack_studio/src/main.cpp | 38 ++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index a8320ac1a..04a239358 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -69,6 +69,8 @@ struct UiBounds { const auto current = findUiBounds(window, "studioCurrentMatrix"); const auto tray = findUiBounds(window, "studioTimingTray"); const auto quality = findUiBounds(window, "studioQualityRow"); + const auto phasor = findUiBounds(window, "studioPhasorDock"); + const auto waveform = findUiBounds(window, "studioWaveformDock"); const std::array, 6> areas{{ {"toolbar", bar}, {"injection workspace", surface}, {"voltage matrix", voltage}, {"current matrix", current}, @@ -84,6 +86,14 @@ struct UiBounds { return false; } } + if (surface.area.width() > 470 || + (phasor.visible && waveform.visible && + (phasor.area.height() < 170 || waveform.area.height() < 170 || + std::abs(phasor.area.height() - waveform.area.height()) > 24))) { + qCritical().noquote() << "Studio split width/chart balance violation" << size + << surface.area << phasor.area << waveform.area; + return false; + } // Operator layout: voltage above current. Validate the new actual // vertical hierarchy, not the obsolete side-by-side geometry. if (!(voltage.area.bottom() <= current.area.top() + tolerance @@ -100,7 +110,8 @@ struct UiBounds { qreal previousRight = tray.area.left(); for (const char* name : controls) { const auto control = findUiBounds(window, name); - if (!control.visible || control.area.width() < 35 || + const qreal minimumWidth = std::string_view{name} == "studioTimingDetails" ? 30.0 : 35.0; + if (!control.visible || control.area.width() < minimumWidth || control.area.left() + tolerance < previousRight || !tray.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) .contains(control.area.adjusted(tolerance, tolerance, @@ -631,16 +642,15 @@ int main(int argc, char* argv[]) { } // Exercise desktop, supported minimum, and extended desktop layout on // the real rendering engine. This branch never touches a serial port. - constexpr std::array sizes{{QSize{1240, 760}, QSize{960, 620}, - QSize{1440, 900}}}; - // Virtual Qt platforms can resize to arbitrary logical viewports, - // but cannot support grabWindow. One native 960x620 pass captures - // actual Windows pixels separately without requesting an oversized - // desktop window. + constexpr std::array sizes{{QSize{960, 680}, QSize{1024, 748}, + QSize{1152, 760}, QSize{1366, 800}, QSize{1920, 1000}}}; + // All five logical viewport geometries are exercised by Qt Quick. + // One smaller native viewport captures real Windows pixels; optional + // software screenshots of larger viewports carry no native claim. const bool nativeCapture = !qEnvironmentVariableIsEmpty("ARSTACK_STUDIO_QA_NATIVE_ONLY"); const int caseCount = nativeCapture ? 1 : static_cast(sizes.size()); const auto caseSize = [sizes, nativeCapture](const int step) { - return nativeCapture ? sizes[1] : sizes.at(static_cast(step)); + return nativeCapture ? sizes[0] : sizes.at(static_cast(step)); }; const QString screenshots = qEnvironmentVariable("ARSTACK_STUDIO_QA_DIR"); if (!screenshots.isEmpty() && !QDir{}.mkpath(screenshots)) { @@ -680,10 +690,14 @@ int main(int argc, char* argv[]) { QStringLiteral("studio-%1x%2.png").arg(target.width()).arg(target.height())); const QImage frame = mainWindow->grabWindow(); if (frame.isNull() || !frame.save(path)) { - qCritical().noquote() << "GUI evidence capture failed:" << path; - uiTimer->stop(); - QCoreApplication::exit(22); - return; + if (nativeCapture) { + qCritical().noquote() << "Native GUI evidence capture failed:" << path; + uiTimer->stop(); + QCoreApplication::exit(22); + return; + } + qWarning().noquote() << "Offscreen screenshot unavailable:" << path + << "(viewport bounds still checked)"; } } ++*step; From 62fcec47b539a3721036be82fedf44a68ac8ab96 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:34:01 +0700 Subject: [PATCH 11/25] ci(studio): inspect five widths and multiple DPI; upload available 1024 and 1152 captures --- .github/workflows/arstack-studio-windows-lifecycle.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 7a6e86daa..1c50bceb2 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -109,6 +109,9 @@ jobs: $suffix = 'dpi-' + [string][int]([double]::Parse($scale, [System.Globalization.CultureInfo]::InvariantCulture) * 100) $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix New-Item -ItemType Directory -Path $logdir -Force | Out-Null + # Software viewport shots, when grabWindow supports the CI backend. + # Native Windows fidelity is verified separately below. + $env:ARSTACK_STUDIO_QA_DIR = $logdir $stdout = Join-Path $logdir 'geometry-stdout.txt' $stderr = Join-Path $logdir 'geometry-stderr.txt' $probe = Start-Process -FilePath $exe -ArgumentList '--check-operator-ui' -Wait -PassThru ` @@ -136,11 +139,11 @@ jobs: if ($probe.ExitCode -ne 0) { throw "Native Windows screenshot acceptance failed, exit=$($probe.ExitCode)" } - $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x620.png' + $png = Join-Path $env:ARSTACK_STUDIO_QA_DIR 'studio-960x680.png' if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { - throw 'Native Windows 960x620 screenshot was missing or empty' + throw 'Native Windows 960x680 screenshot was missing or empty' } - Write-Host 'Windows Qt deterministic 3-viewports x 3-DPI geometry + native 960x620 screenshot: PASS' + Write-Host 'Windows Qt deterministic 5-viewports x 3-DPI geometry + native 960x680 screenshot: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4 From f5d35485bb3bc39d1eace2872947823dc2c355f0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:35:27 +0700 Subject: [PATCH 12/25] build(studio): include cmath for explicit viewport proportion check --- apps/arstack_studio/src/main.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 04a239358..b5df77cb5 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -32,6 +32,7 @@ #include #include +#include #include #include From 58fe1a483bb0dddb04c3c966c5064478e94a6aa5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:39:42 +0700 Subject: [PATCH 13/25] fix(studio): preserve PTP board authority while STOP readback is deferred --- apps/arstack_studio/src/DeviceController.cpp | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index dd169b2ff..ad51f50c5 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1520,7 +1520,9 @@ void DeviceController::advancePtpStartup(const QString& line) { } if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { const auto readback = kPtpStatusExpression.match(line); - if (!readback.hasMatch()) return; + // A queued pre-STOP SHOW can arrive while post-STOP readback is + // still deferred. It cannot verify the requested STOP. + if (!readback.hasMatch() || deferredPtpShow_) return; if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { resetPtpStartup(); ptpRunning_ = false; @@ -2195,13 +2197,22 @@ void DeviceController::processLine(const QString& rawLine) { match = kPtpStatusExpression.match(line); if (match.hasMatch()) { ptpAvailable_ = true; - ptpRunning_ = match.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + const bool verifyingStop = ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback; + // A pending STOP is not STOPPED. Ignore unsolicited/stale SHOW state + // while waiting for the fresh post-STOP verification request. The + // stop state machine below is the only writer of verified STOPPED. + if (!verifyingStop) { + ptpRunning_ = match.captured(1).compare( + QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0; + ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : + QStringLiteral("Lab TX stopped"); + } ptpAnnounceSent_ = match.captured(2); ptpSyncSent_ = match.captured(3); ptpFollowUpSent_ = match.captured(4); ptpPdelayFrames_ = match.captured(5); ptpTxFailures_ = match.captured(6); - ptpStatus_ = ptpRunning_ ? QStringLiteral("Lab TX active") : QStringLiteral("Lab TX stopped"); emit ptpStateChanged(); } From d0216e8b80e438963ccd64779c6e37e29e18cf82 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:39:48 +0700 Subject: [PATCH 14/25] test(studio): stale PTP OFF telemetry cannot confirm pending STOP --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index fd97a0aa6..34b897e4f 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -1140,8 +1140,10 @@ class DeterministicSessionHarness final { if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; auto& d = f.device; if (!d.stopPtp()) return false; + d.ptpRunning_ = true; // preserve RUN until the new STOP has proof d.processLine(ptpStoppedStatus()); // stale SHOW reply, before STOP ACK - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpRunning_) return false; d.processLine(QStringLiteral("PTP stop requested")); d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=0 PdelayFrames=0 TXfail=0")); @@ -1579,6 +1581,10 @@ class DeterministicSessionHarness final { if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback || !d.deferredPtpShow_ || !d.ptpRunning_ || d.lastError_ != previousError) return false; + d.processLine(ptpStoppedStatus()); // stale status before fresh readback + if (!d.ptpRunning_ || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) + return false; // When the lock is released, exactly one readback is submitted to // the existing serial owner; no readback is accepted as STOP proof. d.profileDeploying_ = false; From 655446144da7f13cb4601c735cf19ea79c1615c3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:41:49 +0700 Subject: [PATCH 15/25] fix(studio): discard coalesced telemetry across session generation and board reboot --- apps/arstack_studio/src/DeviceController.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index ad51f50c5..94dbf721a 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -490,6 +490,9 @@ void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; resetPtpStartup(); resetScenarioStart(); + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -2162,6 +2165,9 @@ void DeviceController::processLine(const QString& rawLine) { if (newBoot) { resetScenarioStart(); resetBinaryProfileTransfer(); + deferredShow_ = false; + deferredProfileShow_ = false; + deferredPtpShow_ = false; profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; From 857e309156de1fb711e0862488241be92a061f0a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:44:51 +0700 Subject: [PATCH 16/25] fix(studio): suspend health probe misses during ordered CFG transaction --- apps/arstack_studio/src/StudioDeviceController.hpp | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index 01db9a8ce..a10be566c 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -252,10 +252,11 @@ class StudioDeviceController : public DeviceController { return; } - // Profile deployment is an exclusive transport transaction. Health - // observation is deferred instead of interleaving another command or - // treating intentional queue exclusivity as a missed response. - if (profileDeploying()) return; + // Canonical BIN and CFG scenario transactions both own the serial + // console. Suppress only probe accounting during known exclusivity, + // never the worker's independent safety heartbeat/STOP. Resume + // measured liveness normally as soon as the transaction completes. + if (profileDeploying() || scenarioApplying()) return; if (healthProbeOutstanding_) { ++missedHealthReplies_; From ce195c1a10c8e21ce71acea95b686278f234523a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 21:44:56 +0700 Subject: [PATCH 17/25] test(studio): health misses deferred only while CFG owns serial; real misses remain fatal --- .../src/DeterministicSessionHarness.cpp | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 34b897e4f..71056ebff 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -87,6 +87,7 @@ class DeterministicSessionHarness final { {"READY unplug/replug COM renumber -> READY stopped", readyReplugRecoversWithoutStart()}, {"RUNNING unplug/replug -> never auto-restarts", runningReplugNeverAutoStarts()}, {"wrong device during recovery -> SETUP ERROR", wrongDeviceRecoveryFailsClosed()}, + {"CFG transport exclusivity cannot trigger false health loss", healthProbeDefersDuringScenario()}, {"control health misses -> bounded failure", healthTimeoutIsBounded()}, {"worker teardown -> joins cleanly", workerTeardownCompletes()}, }; @@ -1678,6 +1679,26 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + static bool healthProbeDefersDuringScenario() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.controlResponsive_ = true; + d.healthProbeOutstanding_ = true; + d.missedHealthReplies_ = 0; + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + d.serviceControlHealth(); + d.serviceControlHealth(); + // Known exclusive queue occupancy cannot mimic two missing COM replies. + const bool quietBusy = d.controlResponsive_ && d.healthProbeOutstanding_ && + d.missedHealthReplies_ == 0 && d.connected_; + d.scenarioStage_ = DeviceController::ScenarioStage::idle; + d.serviceControlHealth(); + return quietBusy && !d.controlResponsive_ && + d.missedHealthReplies_ == 1; // real timeout remains detectable + } + static bool healthTimeoutIsBounded() { Fixture fixture; if (!fixture.profileReady) return false; From 3db28f0f11c52b3e7e0a54947ef704b12989141a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 04:55:20 +0700 Subject: [PATCH 18/25] test(studio): fail native screenshots when phasor or waveform is missing --- apps/arstack_studio/src/main.cpp | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index b5df77cb5..3ff4ceb5c 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -87,12 +87,18 @@ struct UiBounds { return false; } } + // Real Windows evidence must contain both actual plots. Checking only + // the left table let a 100%-empty right workspace pass every DPI gate. + // Report visible state + bounds to distinguish hidden from zero-size docks. if (surface.area.width() > 470 || - (phasor.visible && waveform.visible && - (phasor.area.height() < 170 || waveform.area.height() < 170 || - std::abs(phasor.area.height() - waveform.area.height()) > 24))) { + !phasor.visible || !waveform.visible || + phasor.area.width() < 180 || waveform.area.width() < 180 || + phasor.area.height() < 170 || waveform.area.height() < 170 || + std::abs(phasor.area.height() - waveform.area.height()) > 24) { qCritical().noquote() << "Studio split width/chart balance violation" << size - << surface.area << phasor.area << waveform.area; + << "surface=" << surface.area + << "phasor-visible=" << phasor.visible << "phasor=" << phasor.area + << "wave-visible=" << waveform.visible << "wave=" << waveform.area; return false; } // Operator layout: voltage above current. Validate the new actual From 8d0a558ca4c6e921c1fd86981a696359013eb78d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 04:57:52 +0700 Subject: [PATCH 19/25] fix(studio): restore visible phasor and waveform on first run and migrate hidden legacy dock defaults --- apps/arstack_studio/qml/WorkflowBar.qml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 3295cbe9d..91eb99333 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -129,8 +129,12 @@ SurfacePanel { category: "operator-ui" property string lastEngineeringUrl: "" property int lastSelectedIndex: 0 - property bool phasorVisible: false - property bool waveformVisible: false + property bool phasorVisible: true + property bool waveformVisible: true + // Earlier builds silently started with both charts hidden. Bump the + // dock preference schema once so existing installations also regain + // the operator's essential phasor and waveform workspaces. + property int dockLayoutVersion: 0 property bool telemetryVisible: true property bool phasorDetached: false property bool waveformDetached: false @@ -145,6 +149,13 @@ SurfacePanel { } Component.onCompleted: { + if (operatorSettings.dockLayoutVersion < 2) { + operatorSettings.phasorVisible = true + operatorSettings.waveformVisible = true + operatorSettings.phasorDetached = false + operatorSettings.waveformDetached = false + operatorSettings.dockLayoutVersion = 2 + } controller.phasorDockVisible = operatorSettings.phasorVisible controller.waveformDockVisible = operatorSettings.waveformVisible controller.telemetryDockVisible = operatorSettings.telemetryVisible @@ -414,7 +425,7 @@ SurfacePanel { controller.phasorDetached = false controller.waveformDetached = false controller.phasorDockVisible = true - controller.waveformDockVisible = false + controller.waveformDockVisible = true controller.telemetryDockVisible = true controller.telemetryExpanded = false controller.showMessage("Dock layout reset.", false) From b3cc1bf13164b98e4ea7f3a84752a67eb5183849 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 04:58:23 +0700 Subject: [PATCH 20/25] test(studio): lock both chart docks visible on first run and legacy preferences migration --- .../tests/check_operator_workspace.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index f327e7b64..e54123a1a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -195,6 +195,19 @@ def main() -> None: and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml") and 'startWatchdog' not in source("WorkflowBar.qml"), "SV STOP must remain actionable while the acknowledged scenario transaction is pending") + # A prior operator-ui QSettings default hid both plot docks on every new + # installation. This passed the geometry test because absent plots were + # optional. Both must now be visible on fresh installs and migrated once. + dock_flow = source("WorkflowBar.qml") + require('property bool phasorVisible: true' in dock_flow + and 'property bool waveformVisible: true' in dock_flow + and 'property int dockLayoutVersion: 0' in dock_flow + and 'operatorSettings.dockLayoutVersion < 2' in dock_flow + and 'operatorSettings.dockLayoutVersion = 2' in dock_flow + and 'controller.waveformDockVisible = true' in dock_flow + and 'objectName: "studioPhasorDock"' in main_qml + and 'objectName: "studioWaveformDock"' in main_qml, + "both chart docks must display on clean startup and migrate once from legacy hidden defaults") require("function flushPendingEdits()" in matrix and "function commitRowField(column)" in matrix and "magnitudeApplyTimer" not in matrix From f423fa6f3a5ce5dd8f17ef9f619894cfc3de0756 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 05:10:43 +0700 Subject: [PATCH 21/25] fix(studio): size injection controls by narrow panel, not desktop; remove redundant selected readout --- apps/arstack_studio/qml/Main.qml | 34 +++++++++++++++----------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 3cbe4a8fa..d48467244 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1065,7 +1065,7 @@ ApplicationWindow { SurfacePanel { objectName: "studioInjectionSurface" SplitView.fillHeight: true - SplitView.minimumWidth: 370 + SplitView.minimumWidth: 405 SplitView.preferredWidth: 425 SplitView.maximumWidth: 465 SplitView.fillWidth: false @@ -1081,14 +1081,17 @@ ApplicationWindow { ColumnLayout { z: 1 anchors.fill: parent - anchors.margins: root.compactLayout ? 11 : 13 - spacing: root.compactLayout ? 6 : 8 + // Injection width stays 405..465px at every desktop size. + // Never expand its children just because the whole window + // crosses the 1220px toolbar breakpoint. + anchors.margins: 10 + spacing: 6 RowLayout { Layout.fillWidth: true ColumnLayout { spacing: 1 - Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } + Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } // START owns the single ordered scenario transaction. @@ -1149,7 +1152,7 @@ ApplicationWindow { } theme: studioTheme monoFont: root.monoFont - compact: root.compactLayout + compact: true implicitWidth: 90 text: "50.000" suffixText: "Hz" @@ -1226,7 +1229,7 @@ ApplicationWindow { titleText: "Voltage" symbolText: "U" unitText: root.signalFrequency === 0 ? "V DC" : "V RMS" - compact: root.compactLayout + compact: true } SignalMatrix { id: currentMatrix @@ -1247,7 +1250,7 @@ ApplicationWindow { titleText: "Current" symbolText: "I" unitText: root.signalFrequency === 0 ? "A DC" : "A RMS" - compact: root.compactLayout + compact: true } } @@ -1263,23 +1266,18 @@ ApplicationWindow { session: workflowBar.session uiFont: root.uiFont monoFont: root.monoFont - compact: root.compactLayout + compact: true } Rectangle { Layout.fillWidth: true; height: 1; color: studioTheme.lineSoft } RowLayout { objectName: "studioQualityRow" Layout.fillWidth: true - spacing: 8 + spacing: 6 Rectangle { width: 6; height: 6; radius: 3; color: studioTheme.accent } Label { text: root.activeSignal; color: studioTheme.textSoft; font.family: root.monoFont; font.pixelSize: studioTheme.labelSize; font.weight: Font.Bold } - Label { - text: root.signalFrequency === 0 - ? root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " DC" - : root.activeMagnitude.toFixed(3) + " " + root.activeUnit + " RMS · " + root.activePhase.toFixed(2) + "°" - color: studioTheme.muted - font.family: root.uiFont - font.pixelSize: 9 - } + // The same magnitude/phase is already visible in + // the active matrix row and plots. Removing this + // duplicate avoids squeezing the quality controls. Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } QualityEditor { enabled: !workflowBar.startQueued @@ -1290,7 +1288,7 @@ ApplicationWindow { uiFont: root.uiFont monoFont: root.monoFont qualityValue: root.activeQuality - compact: root.compactLayout + compact: true } } // No trailing filler: vertically stacked matrices From 9159db604f300c6114557e38a969ff7797411659 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 05:10:46 +0700 Subject: [PATCH 22/25] fix(studio): fit PTP role, smpSynch and details within 405px injection width --- apps/arstack_studio/qml/StudioTimingTray.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index 1cb08c6c7..2178635cf 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -72,7 +72,7 @@ Rectangle { // This is an action strip, not a second diagnostic/status card. RowLayout { Layout.fillWidth: true - spacing: tray.compact ? 7 : 10 + spacing: tray.compact ? 5 : 10 StudioPtpSwitch { id: powerSwitch objectName: "studioPtpSwitch" @@ -112,7 +112,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true - Layout.minimumWidth: 118 + Layout.minimumWidth: tray.compact ? 102 : 118 model: ["Source", "Follower", "Monitor"] currentIndex: tray.device && tray.device.ptpRunning ? tray.roleIndex(tray.device.ptpRole) : tray.selectedRoleIndex @@ -140,7 +140,7 @@ Rectangle { theme: tray.theme uiFont: tray.uiFont Layout.fillWidth: true - Layout.minimumWidth: 100 + Layout.minimumWidth: tray.compact ? 84 : 100 model: ["AUTO", "0 · No sync", "1 · Local (sim)", "2 · Global (sim)"] currentIndex: tray.boardOnline ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch From 67f5491698766e9985b97f9ddb7f6f751948d100 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 05:10:49 +0700 Subject: [PATCH 23/25] fix(studio): compact IEC quality controls without truncating 32-bit mask --- apps/arstack_studio/qml/QualityEditor.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/QualityEditor.qml b/apps/arstack_studio/qml/QualityEditor.qml index 73aef4c91..031f7455f 100644 --- a/apps/arstack_studio/qml/QualityEditor.qml +++ b/apps/arstack_studio/qml/QualityEditor.qml @@ -103,7 +103,7 @@ RowLayout { theme: editor.theme uiFont: editor.uiFont id: presetBox - implicitWidth: editor.compact ? 98 : 108 + implicitWidth: editor.compact ? 90 : 108 model: ["Good", "Invalid", "Questionable", "Test"] currentIndex: editor.presetIndex(editor.qualityValue) font.family: editor.uiFont @@ -116,7 +116,7 @@ RowLayout { theme: editor.theme monoFont: editor.monoFont compact: true - implicitWidth: editor.compact ? 100 : 112 + implicitWidth: editor.compact ? 98 : 112 Component.onCompleted: text = editor.hexText(editor.qualityValue) onEditingFinished: { var trimmed = text.trim() From e7ee461090d133cb3c76e2a5fc8acca13291925a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 05:10:54 +0700 Subject: [PATCH 24/25] test(studio): reject injection child overflow even if it remains inside viewport --- apps/arstack_studio/src/main.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 3ff4ceb5c..d893e1937 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -101,6 +101,19 @@ struct UiBounds { << "wave-visible=" << waveform.visible << "wave=" << waveform.area; return false; } + // A child can pass the viewport test while overflowing its *parent* + // (small controls were clipped by the neighbouring plot at 1366px). + for (const auto& [label, item] : std::array, 4>{{ + {"voltage", voltage}, {"current", current}, + {"timing", tray}, {"quality", quality} + }}) { + if (!surface.area.adjusted(-tolerance, -tolerance, tolerance, tolerance) + .contains(item.area.adjusted(tolerance, tolerance, -tolerance, -tolerance))) { + qCritical().noquote() << "Studio injection child overflows parent" << size + << label << item.area << surface.area; + return false; + } + } // Operator layout: voltage above current. Validate the new actual // vertical hierarchy, not the obsolete side-by-side geometry. if (!(voltage.area.bottom() <= current.area.top() + tolerance From 6d81ad3abb6ebdc2abf0522661202c8d9b1af8c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 22 Oct 2026 05:10:57 +0700 Subject: [PATCH 25/25] ci(studio): test 125% DPI alongside 100, 150 and 175% --- .github/workflows/arstack-studio-windows-lifecycle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-windows-lifecycle.yml b/.github/workflows/arstack-studio-windows-lifecycle.yml index 1c50bceb2..224dde7bd 100644 --- a/.github/workflows/arstack-studio-windows-lifecycle.yml +++ b/.github/workflows/arstack-studio-windows-lifecycle.yml @@ -104,7 +104,7 @@ jobs: New-Item -ItemType Directory -Path 'studio-ui-evidence' -Force | Out-Null Remove-Item Env:ARSTACK_STUDIO_QA_DIR -ErrorAction SilentlyContinue Remove-Item Env:ARSTACK_STUDIO_QA_NATIVE_ONLY -ErrorAction SilentlyContinue - foreach ($scale in @('1', '1.5', '1.75')) { + foreach ($scale in @('1', '1.25', '1.5', '1.75')) { $env:QT_SCALE_FACTOR = $scale $suffix = 'dpi-' + [string][int]([double]::Parse($scale, [System.Globalization.CultureInfo]::InvariantCulture) * 100) $logdir = Join-Path (Join-Path $PWD 'studio-ui-evidence') $suffix @@ -143,7 +143,7 @@ jobs: if (-not (Test-Path $png) -or (Get-Item $png).Length -lt 10000) { throw 'Native Windows 960x680 screenshot was missing or empty' } - Write-Host 'Windows Qt deterministic 5-viewports x 3-DPI geometry + native 960x680 screenshot: PASS' + Write-Host 'Windows Qt deterministic 5-viewports x 4-DPI geometry + native 960x680 screenshot: PASS' - name: Upload desktop layout evidence if: always() uses: actions/upload-artifact@v4