From 9a2313852460fe960f17299afff21da6d815a114 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:48:16 +0700 Subject: [PATCH 01/13] feat(studio): fast ABC context actions, readable native controls, streamlined Advanced layout --- apps/arstack_studio/CMakeLists.txt | 3 + apps/arstack_studio/qml/DarkCheckBox.qml | 38 ++++++++++ apps/arstack_studio/qml/DarkComboBox.qml | 77 +++++++++++++++++++++ apps/arstack_studio/qml/Main.qml | 65 +++++++++++------ apps/arstack_studio/qml/SignalMatrix.qml | 28 ++++++++ apps/arstack_studio/qml/SignalQuickMenu.qml | 39 +++++++++++ 6 files changed, 228 insertions(+), 22 deletions(-) create mode 100644 apps/arstack_studio/qml/DarkCheckBox.qml create mode 100644 apps/arstack_studio/qml/DarkComboBox.qml create mode 100644 apps/arstack_studio/qml/SignalQuickMenu.qml diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index ddfed8eb6..70fe635ea 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -93,6 +93,9 @@ qt_add_qml_module(arstack_studio qml/RibbonAction.qml qml/CalmButton.qml qml/NumericField.qml + qml/DarkComboBox.qml + qml/DarkCheckBox.qml + qml/SignalQuickMenu.qml qml/StateBadge.qml qml/ProfileInspector.qml qml/ConfigurationHub.qml diff --git a/apps/arstack_studio/qml/DarkCheckBox.qml b/apps/arstack_studio/qml/DarkCheckBox.qml new file mode 100644 index 000000000..fc9e5ef9b --- /dev/null +++ b/apps/arstack_studio/qml/DarkCheckBox.qml @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +CheckBox { + id: control + property var theme + property string uiFont: "Inter" + font.family: uiFont + font.pixelSize: 11 + implicitHeight: 29 + spacing: 8 + indicator: Rectangle { + implicitWidth: 17 + implicitHeight: 17 + x: 1 + y: (control.height - height) / 2 + radius: 4 + color: control.checked ? control.theme.accent : control.theme.surface2 + border.width: 1 + border.color: control.checked ? control.theme.accent : control.theme.line + Text { + anchors.centerIn: parent + text: "✓" + visible: control.checked + color: control.theme.bg + font.pixelSize: 12 + font.weight: Font.Bold + } + } + contentItem: Text { + text: control.text + font: control.font + color: control.enabled ? control.theme.textSoft : control.theme.muted + verticalAlignment: Text.AlignVCenter + leftPadding: control.indicator.width + control.spacing + } +} diff --git a/apps/arstack_studio/qml/DarkComboBox.qml b/apps/arstack_studio/qml/DarkComboBox.qml new file mode 100644 index 000000000..69f70549a --- /dev/null +++ b/apps/arstack_studio/qml/DarkComboBox.qml @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +ComboBox { + id: control + property var theme + property string uiFont: "Inter" + implicitWidth: 180 + implicitHeight: 35 + font.family: uiFont + font.pixelSize: 11 + hoverEnabled: true + contentItem: Text { + text: control.displayText + color: control.enabled ? control.theme.text : control.theme.muted + font: control.font + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 11 + rightPadding: 30 + } + indicator: Text { + text: "⌄" + color: control.theme.textSoft + font.pixelSize: 18 + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: 12 + } + background: Rectangle { + radius: 7 + color: control.hovered ? control.theme.raisedHover : control.theme.surface2 + border.width: 1 + border.color: control.activeFocus ? control.theme.accent : control.theme.line + } + delegate: ItemDelegate { + id: item + required property int index + required property var modelData + width: control.width + height: 35 + highlighted: control.highlightedIndex === index + contentItem: Text { + text: String(item.modelData) + font: control.font + color: control.theme.text + leftPadding: 9 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + background: Rectangle { + radius: 5 + color: item.highlighted ? control.theme.accentSoft + : item.hovered ? control.theme.raisedHover : "transparent" + } + } + popup: Popup { + y: control.height + 3 + width: control.width + implicitHeight: Math.min(contentItem.implicitHeight + 10, 220) + padding: 5 + contentItem: ListView { + implicitHeight: contentHeight + clip: true + model: control.popup.visible ? control.delegateModel : null + currentIndex: control.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + background: Rectangle { + radius: 8 + color: control.theme.raised + border.width: 1 + border.color: control.theme.line + } + } +} diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 4da38730e..f5b860d0c 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -298,6 +298,36 @@ ApplicationWindow { return true } + // Three-phase lab shortcuts; neutral is deliberately untouched. + // Existing live setpoint session owns the serial writer and ordering. + function quickSetGroup(group, row, action) { + if (row < 0 || row > 2) return false + var model = groupModel(group) + var anchor = model.get(row) + var i + if (action === "equal_value") { + if (!validMagnitude(group, anchor.magnitude)) return false + for (i = 0; i < 3; ++i) model.setProperty(i, "magnitude", anchor.magnitude) + } else if (action === "equal_angle") { + if (signalFrequency <= 0 || !validPhase(anchor.phase)) return false + for (i = 0; i < 3; ++i) model.setProperty(i, "phase", anchor.phase) + } else if (action === "balance_angle") { + if (signalFrequency <= 0 || !validPhase(anchor.phase)) return false + var a = anchor.phase + (row === 1 ? 120 : row === 2 ? -120 : 0) + var angles = [normalizedAngle(a), normalizedAngle(a - 120), normalizedAngle(a + 120)] + for (i = 0; i < 3; ++i) model.setProperty(i, "phase", angles[i]) + } else return false + selectSignal(group, row) + if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { + showMessage("ABC changed locally; one or more live setpoints were rejected. Copy diagnostics.", true) + return false + } + showMessage((group === 0 ? "Current" : "Voltage") + " ABC: " + + (action === "equal_value" ? "Equal Value" : action === "equal_angle" ? "Equal Angle" : "Balance 120°") + + " · neutral unchanged", false) + return true + } + function applyAllSignals() { if (!workflowBar.session || !workflowBar.session.liveControlReady) return workflowBar.session.requestSetFrequency(signalFrequency) @@ -472,10 +502,10 @@ ApplicationWindow { Window { id: configurationWindow - width: 760 - height: 780 - minimumWidth: 640 - minimumHeight: 660 + width: 860 + height: 750 + minimumWidth: 700 + minimumHeight: 620 visible: false title: "ARStack Studio · Advanced" color: studioTheme.bg @@ -485,27 +515,17 @@ ApplicationWindow { configurationWindow.hide() } - DockFrame { + ConfigurationHub { + id: profilePanel anchors.fill: parent - anchors.margins: 10 + anchors.margins: 18 theme: studioTheme - titleText: "Advanced" - statusText: device.deviceVerified ? "DEVICE CONNECTED" : "OFFLINE" + controller: root + device: device + profiles: sclProfiles + session: workflowBar.session uiFont: root.uiFont monoFont: root.monoFont - closable: false - - ConfigurationHub { - id: profilePanel - anchors.fill: parent - theme: studioTheme - controller: root - device: device - profiles: sclProfiles - session: workflowBar.session - uiFont: root.uiFont - monoFont: root.monoFont - } } } @@ -726,7 +746,8 @@ ApplicationWindow { 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 } - CheckBox { + DarkCheckBox { + theme: studioTheme enabled: root.signalFrequency > 0 checked: root.phaseLink text: "3-phase link" diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index f00843460..5d23c3b76 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -204,6 +204,20 @@ Rectangle { suffixText: matrix.groupIndex === 0 ? "A" : "V" text: signalRow.mag.toFixed(3) invalidInput: false + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: { + if (signalRow.rowIndex < 3) magnitudeMenu.popup() + } + } + SignalQuickMenu { + id: magnitudeMenu + theme: matrix.theme + controller: matrix.controller + groupIndex: matrix.groupIndex + rowIndex: signalRow.rowIndex + phaseMode: false + } onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) @@ -254,6 +268,20 @@ Rectangle { suffixText: "°" text: signalRow.angle.toFixed(2) invalidInput: false + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: { + if (signalRow.rowIndex < 3) phaseMenu.popup() + } + } + SignalQuickMenu { + id: phaseMenu + theme: matrix.theme + controller: matrix.controller + groupIndex: matrix.groupIndex + rowIndex: signalRow.rowIndex + phaseMode: true + } onActiveFocusChanged: { if (activeFocus) { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) diff --git a/apps/arstack_studio/qml/SignalQuickMenu.qml b/apps/arstack_studio/qml/SignalQuickMenu.qml new file mode 100644 index 000000000..286a36bc3 --- /dev/null +++ b/apps/arstack_studio/qml/SignalQuickMenu.qml @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls + +Menu { + id: quick + property var theme + property var controller + property int groupIndex: 0 + property int rowIndex: 0 + property bool phaseMode: false + width: 238 + padding: 5 + background: Rectangle { radius: 8; color: quick.theme.raised; border.width: 1; border.color: quick.theme.line } + delegate: MenuItem { + id: action + implicitWidth: quick.width - 10 + implicitHeight: 34 + contentItem: Text { + text: action.text + color: quick.theme.text + font.pixelSize: 11 + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + background: Rectangle { radius: 6; color: action.highlighted ? quick.theme.accentSoft : "transparent" } + } + MenuItem { + text: quick.phaseMode ? "Equal Angle · A B C" : "Equal Value · A B C" + onTriggered: quick.controller.quickSetGroup(quick.groupIndex, quick.rowIndex, + quick.phaseMode ? "equal_angle" : "equal_value") + } + MenuItem { + visible: quick.phaseMode + height: visible ? implicitHeight : 0 + text: "Balance ABC · 120°" + onTriggered: quick.controller.quickSetGroup(quick.groupIndex, quick.rowIndex, "balance_angle") + } +} From ce28978dd8ee20885a10ac18342cb4e87fc69ec5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:51:12 +0700 Subject: [PATCH 02/13] feat(timing): simple PTP switch and roles, one-click SV lab sync metadata --- apps/arstack_studio/qml/PtpExpertPanel.qml | 158 ++++++++++++++------- 1 file changed, 110 insertions(+), 48 deletions(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 80d1de77f..ee6d653ec 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -12,6 +12,9 @@ SurfacePanel { property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] property bool advancedVisible: false + // Keep PTP role changes in the existing verified serial transaction. + readonly property bool controlReady: panel.session && panel.device.ptpAvailable && + !panel.session.updatingFirmware readonly property bool startPending: panel.device && panel.device.ptpStarting function roleIndex(role) { @@ -45,7 +48,7 @@ SurfacePanel { } function startSelectedMode() { - if (!panel.session || !panel.device.ptpAvailable) return + if (!panel.controlReady || panel.startPending || panel.device.ptpRunning) return var role = panel.roleValues[modeCombo.currentIndex] if (!panel.session.requestStartPtpConfigured(role, panel.ptpProfile())) { if (panel.controller) panel.controller.showMessage("PTP start blocked. See the Timing status for details.", true) @@ -109,6 +112,44 @@ SurfacePanel { } } Item { Layout.fillWidth: true } + Switch { + id: ptpToggle + text: "PTP" + checked: panel.device.ptpRunning + enabled: panel.controlReady && !panel.startPending + onClicked: { + if (checked) panel.startSelectedMode() + else panel.session.requestStopPtp() + } + font.family: panel.uiFont + font.pixelSize: 11 + indicator: Rectangle { + implicitWidth: 42 + implicitHeight: 24 + x: 0 + y: (ptpToggle.height - height) / 2 + radius: 12 + color: ptpToggle.checked ? panel.theme.greenSoft : panel.theme.raised + border.width: 1 + border.color: ptpToggle.checked ? panel.theme.green : panel.theme.line + Rectangle { + width: 16 + height: 16 + radius: 8 + x: ptpToggle.checked ? 22 : 3 + anchors.verticalCenter: parent.verticalCenter + color: ptpToggle.checked ? panel.theme.green : panel.theme.textSoft + Behavior on x { NumberAnimation { duration: 110 } } + } + } + contentItem: Text { + text: "PTP" + font: ptpToggle.font + color: panel.theme.textSoft + leftPadding: ptpToggle.indicator.width + 9 + verticalAlignment: Text.AlignVCenter + } + } StateBadge { theme: panel.theme monoFont: panel.uiFont @@ -143,10 +184,12 @@ SurfacePanel { font.pixelSize: 9 font.weight: Font.DemiBold } - ComboBox { + DarkComboBox { + theme: panel.theme + uiFont: panel.uiFont id: modeCombo Layout.fillWidth: true - model: ["Provide PTP timing", "Follow external PTP", "Monitor PTP only"] + model: ["Source · provide lab time", "Follower · auto-discover GM", "Monitor · listen only"] currentIndex: panel.roleIndex(panel.activeRole) enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware font.family: panel.uiFont @@ -161,7 +204,7 @@ SurfacePanel { text: modeCombo.currentIndex === 0 ? "Board sends Announce + hardware-timestamped two-step Sync/Follow_Up." : modeCombo.currentIndex === 1 - ? "Board follows an external PTP source and reports measured clock lock." + ? "Automatically discovers a grandmaster; a server IP is not required for L2 PTP." : "Board listens to PTP without transmitting or disciplining its clock." wrapMode: Text.WordWrap color: panel.theme.textSoft @@ -170,14 +213,14 @@ SurfacePanel { } Label { text: modeCombo.currentIndex === 0 - ? "PTP TX does not by itself change SV smpSynch." + ? "PTP transmit does not automatically phase-lock SMV." : modeCombo.currentIndex === 1 ? "AUTO smpSynch follows measured receiver evidence only." : "Useful for observing timing traffic without changing the network." wrapMode: Text.WordWrap color: panel.theme.muted font.family: panel.uiFont - font.pixelSize: 8 + font.pixelSize: 10 } } } @@ -215,8 +258,9 @@ SurfacePanel { onClicked: panel.session.requestPtpRefresh() } Item { Layout.fillWidth: true } - CheckBox { - text: "Advanced settings" + DarkCheckBox { + theme: panel.theme + text: "Protocol settings" checked: panel.advancedVisible onToggled: panel.advancedVisible = checked font.family: panel.uiFont @@ -239,11 +283,11 @@ SurfacePanel { columnSpacing: 14 rowSpacing: 6 - Label { text: panel.sourceRole ? "Announce TX" : "Announce RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "Sync TX" : "Sync RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "Follow_Up TX" : "Follow_Up RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "Pdelay TX" : "Pdelay RX / Req"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.sourceRole ? "TX failures" : "Accepted / rejected"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: panel.sourceRole ? "Announce TX" : "Announce RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + Label { text: panel.sourceRole ? "Sync TX" : "Sync RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + Label { text: panel.sourceRole ? "Follow_Up TX" : "Follow_Up RX"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + Label { text: panel.sourceRole ? "Pdelay TX" : "Pdelay RX / Req"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } + Label { text: panel.sourceRole ? "TX failures" : "Accepted / rejected"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } Label { text: panel.sourceRole ? panel.device.ptpAnnounceSent : panel.device.ptpRxAnnounce; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } Label { text: panel.sourceRole ? panel.device.ptpSyncSent : panel.device.ptpRxSync; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 15; font.weight: Font.DemiBold } @@ -278,17 +322,62 @@ SurfacePanel { anchors.fill: parent anchors.margins: 10 columns: 4 - Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } Label { text: panel.device.ptpDiscipline === "LOCKED" ? "LOCKED (board)" : panel.device.ptpDiscipline; color: panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } - Label { text: "Selected source"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "Selected source"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9 } - Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } Label { text: panel.device.ptpOffsetNs === "NA" ? "—" : panel.device.ptpOffsetNs + " ns"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } - Label { text: "Path delay"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } + Label { text: "Path delay"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 10 } Label { text: panel.device.ptpPathDelayNs === "NA" ? "—" : panel.device.ptpPathDelayNs + " ns"; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 10 } } } + Rectangle { + Layout.fillWidth: true + implicitHeight: 134 + radius: 8 + color: panel.theme.panelAlt + border.width: 1 + border.color: panel.theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 6 +Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Label { text: "SV output flag · lab simulation"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } + RowLayout { + Layout.fillWidth: true + DarkComboBox { + theme: panel.theme + uiFont: panel.uiFont + Layout.fillWidth: true + model: ["AUTO · measured only", "0 · Unsynchronized", "1 · Local (simulated)", "2 · Global (simulated)"] + currentIndex: panel.syncIndex(panel.device.smpSynchMode) + enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware + font.family: panel.uiFont + font.pixelSize: 9 + onActivated: panel.session.requestSmpSynch(["AUTO", "0", "1", "2"][currentIndex]) + } + StateBadge { + theme: panel.theme + monoFont: panel.uiFont + state: "smpSynch=" + panel.device.smpSynchValue + + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") + stateColor: panel.device.smpSynchSimulated ? panel.theme.amber : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted + } + } + Label { + Layout.fillWidth: true + text: "PTP observed does not imply the SV sample clock is phase-locked. Lab override sets advertised metadata only." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 10 + } + } + } + ColumnLayout { visible: panel.advancedVisible Layout.fillWidth: true @@ -310,11 +399,13 @@ SurfacePanel { NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } - CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } + DarkCheckBox { + theme: panel.theme id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } - CheckBox { id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 9 } + DarkCheckBox { + theme: panel.theme id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 9 } CalmButton { theme: panel.theme uiFont: panel.uiFont @@ -327,35 +418,6 @@ SurfacePanel { } } - Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } - Label { text: "SV synchronization flag · lab / interoperability"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } - RowLayout { - Layout.fillWidth: true - ComboBox { - Layout.fillWidth: true - model: ["AUTO — measured PTP only", "0 — Not synchronized", "1 — Local synchronized (simulation)", "2 — Global synchronized (simulation)"] - currentIndex: panel.syncIndex(panel.device.smpSynchMode) - enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware - font.family: panel.uiFont - font.pixelSize: 9 - onActivated: panel.session.requestSmpSynch(["AUTO", "0", "1", "2"][currentIndex]) - } - StateBadge { - theme: panel.theme - monoFont: panel.uiFont - state: "smpSynch=" + panel.device.smpSynchValue - + (panel.device.smpSynchSimulated ? " SIMULATED" : panel.device.smpSynchMeasured ? " MEASURED" : " SAFE") - stateColor: panel.device.smpSynchSimulated ? panel.theme.amber : panel.device.smpSynchMeasured ? panel.theme.green : panel.theme.muted - } - } - Label { - Layout.fillWidth: true - text: "PTP Source transmission is not proof that the SV clock is externally synchronized. AUTO stays fail-closed unless receiver discipline is measured." - wrapMode: Text.WordWrap - color: panel.theme.textSoft - font.family: panel.uiFont - font.pixelSize: 8 - } } Item { Layout.minimumHeight: 8 } From e830d5d7a8183630765bec564ca5e90c8c0db81e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:53:16 +0700 Subject: [PATCH 03/13] style(studio): reorganize Advanced tabs and theme model-backed dropdowns --- apps/arstack_studio/qml/ConfigurationHub.qml | 8 ++++---- apps/arstack_studio/qml/DeviceExpertPanel.qml | 4 +++- apps/arstack_studio/qml/ProfileInspector.qml | 8 ++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index 417f86d58..af6566a0c 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -75,7 +75,7 @@ Item { font.weight: Font.DemiBold } Label { - text: "Firmware recovery, engineering profile, waveform and timing tools" + text: "SIMULATION LAB / 4I+4V · PTP · Firmware · Diagnostics" color: hub.theme.muted font.family: hub.uiFont font.pixelSize: 11 @@ -121,10 +121,10 @@ Item { border.width: 1 border.color: hub.theme.lineSoft } - ExpertTab { text: "Injection" } + ExpertTab { text: "Signal profile" } ExpertTab { text: "Firmware" } - ExpertTab { text: "Waveform" } - ExpertTab { text: "Timing" } + ExpertTab { text: "Wave shaping" } + ExpertTab { text: "PTP / Sync" } ExpertTab { text: "Device" } } diff --git a/apps/arstack_studio/qml/DeviceExpertPanel.qml b/apps/arstack_studio/qml/DeviceExpertPanel.qml index 0470ddf30..3963d0e32 100644 --- a/apps/arstack_studio/qml/DeviceExpertPanel.qml +++ b/apps/arstack_studio/qml/DeviceExpertPanel.qml @@ -53,7 +53,9 @@ SurfacePanel { RowLayout { Layout.fillWidth: true - ComboBox { + DarkComboBox { + theme: panel.theme + uiFont: panel.uiFont id: recoveryPort Layout.preferredWidth: 170 model: panel.device.ports diff --git a/apps/arstack_studio/qml/ProfileInspector.qml b/apps/arstack_studio/qml/ProfileInspector.qml index cff19bbce..273d40e0d 100644 --- a/apps/arstack_studio/qml/ProfileInspector.qml +++ b/apps/arstack_studio/qml/ProfileInspector.qml @@ -117,7 +117,9 @@ SurfacePanel { font.letterSpacing: 1.0 } - ComboBox { + DarkComboBox { + theme: panel.theme + uiFont: panel.uiFont Layout.fillWidth: true model: panel.profiles textRole: "control" @@ -308,7 +310,9 @@ SurfacePanel { wrapMode: Text.WordWrap } - ComboBox { + DarkComboBox { + theme: panel.theme + uiFont: panel.uiFont id: profileFamilySelector Layout.fillWidth: true enabled: panel.session && panel.session.engineeringEditable From ef389ebef4516f77192557c9524c25a9356206e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:53:48 +0700 Subject: [PATCH 04/13] style(studio): unify simulation checkboxes and ComboBox textRole presentation --- apps/arstack_studio/qml/DarkComboBox.qml | 4 ++-- apps/arstack_studio/qml/WaveformExpertPanel.qml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/DarkComboBox.qml b/apps/arstack_studio/qml/DarkComboBox.qml index 69f70549a..eb0bae4c5 100644 --- a/apps/arstack_studio/qml/DarkComboBox.qml +++ b/apps/arstack_studio/qml/DarkComboBox.qml @@ -37,12 +37,12 @@ ComboBox { delegate: ItemDelegate { id: item required property int index - required property var modelData + // textAt resolves QStringList and QAbstractItemModel textRole. width: control.width height: 35 highlighted: control.highlightedIndex === index contentItem: Text { - text: String(item.modelData) + text: control.textAt(item.index) font: control.font color: control.theme.text leftPadding: 9 diff --git a/apps/arstack_studio/qml/WaveformExpertPanel.qml b/apps/arstack_studio/qml/WaveformExpertPanel.qml index fdc888d96..cb9805cda 100644 --- a/apps/arstack_studio/qml/WaveformExpertPanel.qml +++ b/apps/arstack_studio/qml/WaveformExpertPanel.qml @@ -35,7 +35,7 @@ SurfacePanel { RowLayout { anchors.fill: parent anchors.margins: 12 - CheckBox { id: enabledCheck; checked: panel.controller.ctSaturationEnabled; text: "Enable CT saturation stress"; font.family: panel.uiFont; font.pixelSize: 10 } + DarkCheckBox { id: enabledCheck; theme: panel.theme; checked: panel.controller.ctSaturationEnabled; text: "Enable CT saturation stress"; font.family: panel.uiFont; font.pixelSize: 10 } Item { Layout.fillWidth: true } Label { text: panel.controller.ctSaturationEnabled ? "ACTIVE" : "CLEAN SINE"; color: panel.controller.ctSaturationEnabled ? panel.theme.amber : panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8; font.weight: Font.Bold } } From c9d5748df20f9139c7da9983e707afe87570ee04 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:54:35 +0700 Subject: [PATCH 05/13] feat(studio): dedicated Studio icon in Qt, Windows EXE, and Inno Setup --- apps/arstack_studio/CMakeLists.txt | 7 +++++++ apps/arstack_studio/packaging/ARStackStudio.iss | 2 ++ apps/arstack_studio/src/StudioIcon.rc | 2 ++ apps/arstack_studio/src/main.cpp | 2 ++ 4 files changed, 13 insertions(+) create mode 100644 apps/arstack_studio/src/StudioIcon.rc diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 70fe635ea..41a31383a 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -173,6 +173,13 @@ target_compile_definitions(arstack_session_harness PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) +# Dedicated ARStack Studio icon shared by Qt window, Windows PE, and Inno. +qt_add_resources(arstack_studio "studio_branding" + PREFIX "/studio" + BASE "${CMAKE_CURRENT_LIST_DIR}/../../assets/studio_icon" + FILES "${CMAKE_CURRENT_LIST_DIR}/../../assets/studio_icon/favicon.ico" +) if(WIN32) + target_sources(arstack_studio PRIVATE src/StudioIcon.rc) set_target_properties(arstack_studio PROPERTIES WIN32_EXECUTABLE TRUE) endif() \ No newline at end of file diff --git a/apps/arstack_studio/packaging/ARStackStudio.iss b/apps/arstack_studio/packaging/ARStackStudio.iss index d0013daf6..4d97486e0 100644 --- a/apps/arstack_studio/packaging/ARStackStudio.iss +++ b/apps/arstack_studio/packaging/ARStackStudio.iss @@ -36,6 +36,8 @@ UninstallDisplayName={#ProductName} DefaultDirName={localappdata}\Programs\{#ProductFolder} DefaultGroupName={#ProductName} OutputBaseFilename={#ProductOutput} +SetupIconFile=..\..\..\assets\studio_icon\favicon.ico +UninstallDisplayIcon={app}\ARStackStudio.exe PrivilegesRequired=lowest ArchitecturesAllowed=x64compatible CloseApplications=yes diff --git a/apps/arstack_studio/src/StudioIcon.rc b/apps/arstack_studio/src/StudioIcon.rc new file mode 100644 index 000000000..545c46c57 --- /dev/null +++ b/apps/arstack_studio/src/StudioIcon.rc @@ -0,0 +1,2 @@ +// Use the dedicated Studio icon. Workbench/favicon resources stay untouched. +1 ICON "../../../assets/studio_icon/favicon.ico" diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index f208cd705..6f7ee1513 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -438,6 +439,7 @@ int main(int argc, char* argv[]) { QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(true); + app.setWindowIcon(QIcon(QStringLiteral(":/studio/favicon.ico"))); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); #ifdef ARSTACK_STUDIO_ENGINEERING_PREVIEW QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio Preview")); From 8cc7ed7b3d0fb2b4546358f5de876637910230e6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:56:04 +0700 Subject: [PATCH 06/13] fix(qml): separate inline check property declarations in Timing panel --- apps/arstack_studio/qml/PtpExpertPanel.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index ee6d653ec..9f199814f 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -344,7 +344,7 @@ SurfacePanel { anchors.fill: parent anchors.margins: 12 spacing: 6 -Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } + Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } Label { text: "SV output flag · lab simulation"; color: panel.theme.text; font.family: panel.uiFont; font.pixelSize: 12; font.weight: Font.DemiBold } RowLayout { Layout.fillWidth: true @@ -400,12 +400,12 @@ Rectangle { Layout.fillWidth: true; height: 1; color: panel.theme.lineSoft } Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } DarkCheckBox { - theme: panel.theme id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } + theme: panel.theme; id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: pcpField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "4"; validator: IntValidator { bottom: 0; top: 7 } } DarkCheckBox { - theme: panel.theme id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 9 } + theme: panel.theme; id: pdelayCheck; text: "Peer-delay response"; checked: true; font.family: panel.uiFont; font.pixelSize: 9 } CalmButton { theme: panel.theme uiFont: panel.uiFont From 074c88c648f601a3f1f62d8a6b00062b3378d501 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:58:25 +0700 Subject: [PATCH 07/13] feat(timing): copy full native firmware and PTP evidence from the operating panel --- apps/arstack_studio/qml/PtpExpertPanel.qml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 9f199814f..a49a0111f 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.Studio 1.0 SurfacePanel { id: panel @@ -257,6 +258,13 @@ SurfacePanel { enabled: panel.session && panel.device.deviceVerified && !panel.session.updatingFirmware onClicked: panel.session.requestPtpRefresh() } + CalmButton { + theme: panel.theme + uiFont: panel.uiFont + text: "Copy diagnostics" + enabled: panel.session + onClicked: StudioClipboard.copyText(panel.session.diagnosticsText()) + } Item { Layout.fillWidth: true } DarkCheckBox { theme: panel.theme From b7a3f1e0bd57948c19ab76b28d29b576cb07fb53 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 14:59:33 +0700 Subject: [PATCH 08/13] style(abc): ensure inline context actions retain dark high-contrast styling --- apps/arstack_studio/qml/SignalQuickMenu.qml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/SignalQuickMenu.qml b/apps/arstack_studio/qml/SignalQuickMenu.qml index 286a36bc3..27724359c 100644 --- a/apps/arstack_studio/qml/SignalQuickMenu.qml +++ b/apps/arstack_studio/qml/SignalQuickMenu.qml @@ -12,7 +12,9 @@ Menu { width: 238 padding: 5 background: Rectangle { radius: 8; color: quick.theme.raised; border.width: 1; border.color: quick.theme.line } - delegate: MenuItem { + // Inline menu items share the same explicit dark presentation, including + // declarative MenuItem children (Menu.delegate only styles Actions). + component QuickAction: MenuItem { id: action implicitWidth: quick.width - 10 implicitHeight: 34 @@ -25,12 +27,12 @@ Menu { } background: Rectangle { radius: 6; color: action.highlighted ? quick.theme.accentSoft : "transparent" } } - MenuItem { + QuickAction { text: quick.phaseMode ? "Equal Angle · A B C" : "Equal Value · A B C" onTriggered: quick.controller.quickSetGroup(quick.groupIndex, quick.rowIndex, quick.phaseMode ? "equal_angle" : "equal_value") } - MenuItem { + QuickAction { visible: quick.phaseMode height: visible ? implicitHeight : 0 text: "Balance ABC · 120°" From f01d2eae14be99a7a67eae093f86288194aeeb9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 15:03:22 +0700 Subject: [PATCH 09/13] fix(ptp-ux): bind toggle display to verified board state after rejected START --- apps/arstack_studio/qml/PtpExpertPanel.qml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index a49a0111f..419509eb1 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -119,7 +119,13 @@ SurfacePanel { checked: panel.device.ptpRunning enabled: panel.controlReady && !panel.startPending onClicked: { - if (checked) panel.startSelectedMode() + // AbstractButton toggles its local checked property before + // the async firmware ACK. Restore the only authoritative + // device-state binding immediately: rejected START must + // never leave this switch falsely showing ON. + var requestedOn = checked + checked = Qt.binding(function() { return panel.device.ptpRunning }) + if (requestedOn) panel.startSelectedMode() else panel.session.requestStopPtp() } font.family: panel.uiFont From 12a78011966749144c1d92403c5a5d7c3a09ddef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 15:04:23 +0700 Subject: [PATCH 10/13] fix(abc): validate 3-phase signals and enqueue one indivisible bounded worker batch --- .../src/DeterministicSessionHarness.cpp | 32 +++++++++ apps/arstack_studio/src/DeviceController.cpp | 67 +++++++++++++++++++ apps/arstack_studio/src/DeviceController.hpp | 6 ++ 3 files changed, 105 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 55e1e90da..d3de5981e 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -42,6 +42,7 @@ class DeterministicSessionHarness final { {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"ABC preparation rejects partial or out-of-range batches", abcSetBatchIsAllOrNothing()}, {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, @@ -343,6 +344,37 @@ class DeterministicSessionHarness final { session.diagnosticsText().contains(evidence); } + static bool abcSetBatchIsAllOrNothing() { + const auto sample = [](const QString& id, const double magnitude, + const double phase, const qulonglong quality) { + return QVariantMap{ + {QStringLiteral("signalId"), id}, + {QStringLiteral("magnitude"), magnitude}, + {QStringLiteral("phase"), phase}, + {QStringLiteral("quality"), quality}}; + }; + QVariantList values{ + sample(QStringLiteral("Ia"), 1.0, 0.0, 0), + sample(QStringLiteral("Ib"), 1.0, -120.0, 0), + sample(QStringLiteral("Ic"), 1.0, 120.0, 0)}; + const QStringList commands = + DeviceController::buildThreePhaseSetBatch(values, 1000, 100, 50.0); + if (commands.size() != 3 || + commands[0] != QStringLiteral("SET IA 1000 0 0") || + commands[1] != QStringLiteral("SET IB 1000 -120000 0") || + commands[2] != QStringLiteral("SET IC 1000 120000 0")) return false; + + values[1] = sample(QStringLiteral("Ia"), 1.0, -120.0, 0); + if (!DeviceController::buildThreePhaseSetBatch(values, 1000, 100, 50.0).isEmpty()) + return false; + values[1] = sample(QStringLiteral("Ib"), 1.0e12, -120.0, 0); + if (!DeviceController::buildThreePhaseSetBatch(values, 1000, 100, 50.0).isEmpty()) + return false; + values[1] = sample(QStringLiteral("Ib"), 1.0, -120.0, 0); + values.removeLast(); + return DeviceController::buildThreePhaseSetBatch(values, 1000, 100, 50.0).isEmpty(); + } + static bool optionalBootloaderWaitCanCancel() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index bbb1bb5e7..8bbda92d5 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -679,6 +679,73 @@ bool DeviceController::setSignal( .arg(static_cast(quality))); } +QStringList DeviceController::buildThreePhaseSetBatch( + const QVariantList& signals, + const double currentCountsPerAmp, + const double voltageCountsPerVolt, + const double frequencyHz) { + if (signals.size() != 3 || !finitePositive(currentCountsPerAmp) || + !finitePositive(voltageCountsPerVolt)) return {}; + + const QString first = signals[0].toMap().value(QStringLiteral("signalId")) + .toString().trimmed().toUpper(); + const bool currents = first == QStringLiteral("IA"); + if (!currents && first != QStringLiteral("UA")) return {}; + const QStringList expected = currents + ? QStringList{QStringLiteral("IA"), QStringLiteral("IB"), QStringLiteral("IC")} + : QStringList{QStringLiteral("UA"), QStringLiteral("UB"), QStringLiteral("UC")}; + const double scale = currents ? currentCountsPerAmp : voltageCountsPerVolt; + + QStringList commands; + commands.reserve(3); + for (int i = 0; i < 3; ++i) { + const QVariantMap item = signals[i].toMap(); + if (item.value(QStringLiteral("signalId")).toString().trimmed().toUpper() != expected[i]) + return {}; + bool magnitudeValid = false; + bool phaseValid = false; + bool qualityValid = false; + const double magnitude = item.value(QStringLiteral("magnitude")).toDouble(&magnitudeValid); + const double phase = item.value(QStringLiteral("phase")).toDouble(&phaseValid); + const quint64 quality = item.value(QStringLiteral("quality")).toULongLong(&qualityValid); + if (!magnitudeValid || !phaseValid || !qualityValid || + quality > std::numeric_limits::max() || + !std::isfinite(magnitude) || !std::isfinite(phase) || + (frequencyHz > 0.0 && magnitude < 0.0)) return {}; + + const double scaled = magnitude * scale; + const double angleMilli = phase * 1000.0; + if (!std::isfinite(scaled) || !std::isfinite(angleMilli) || + scaled < std::numeric_limits::min() || + scaled > std::numeric_limits::max() || + angleMilli < std::numeric_limits::min() || + angleMilli > std::numeric_limits::max()) return {}; + const auto counts = std::llround(scaled); + const auto phaseMdeg = std::llround(angleMilli); + commands.append(QStringLiteral("SET %1 %2 %3 %4") + .arg(expected[i]) + .arg(counts) + .arg(phaseMdeg) + .arg(quality)); + } + return commands; +} + +bool DeviceController::setSignalsBatch( + const QVariantList& signals, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + const QStringList commands = buildThreePhaseSetBatch( + signals, currentCountsPerAmp, voltageCountsPerVolt, signalFrequencyHz_); + if (commands.size() != 3) { + setError(QStringLiteral("ABC batch is invalid; no phase was queued.")); + return false; + } + // Exactly one queued worker call. DeviceIoWorker checks ALL THREE slots + // before enqueueing ANY; a full queue cannot accept an ABC prefix. + return sendCommandBatch(commands, false); +} + bool DeviceController::setEnabled(const QString& signalId, const bool enabled) { return sendCommand(QStringLiteral("ENABLE %1 %2") .arg(signalId.trimmed().toUpper()) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index f77ebf2c3..24b93178f 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -7,6 +7,7 @@ #include #include #include +#include class DeterministicSessionHarness; class DeviceIoWorker; @@ -221,6 +222,8 @@ class DeviceController : public QObject { quint32 quality, double currentCountsPerAmp, double voltageCountsPerVolt); + // One all-or-none worker queue submission for fast ABC operator actions. + bool setSignalsBatch(const QVariantList& signals, double currentCountsPerAmp, double voltageCountsPerVolt); bool setEnabled(const QString& signalId, bool enabled); bool setQuality(const QString& signalId, quint32 quality); bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); @@ -351,6 +354,9 @@ class DeviceController : public QObject { bool beginPtpStopVerification(); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; + [[nodiscard]] static QStringList buildThreePhaseSetBatch( + const QVariantList& signals, double currentCountsPerAmp, + double voltageCountsPerVolt, double frequencyHz); [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); static QString cleanLine(const QString& rawLine); static QString utf8Hex(const QString& text); From 859516b185cc1bea0353a1b1f119e72d8bfe1a97 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 15:05:42 +0700 Subject: [PATCH 11/13] fix(abc): send Equal/Balance as one validated bounded three-phase batch --- apps/arstack_studio/qml/Main.qml | 22 ++++++++++++++++--- .../src/SmartSessionController.cpp | 8 +++++++ .../src/SmartSessionController.hpp | 5 +++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index f5b860d0c..01b538345 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -318,12 +318,28 @@ ApplicationWindow { for (i = 0; i < 3; ++i) model.setProperty(i, "phase", angles[i]) } else return false selectSignal(group, row) - if (workflowBar.session && workflowBar.session.liveControlReady && !sendLinkedGroup(group)) { - showMessage("ABC changed locally; one or more live setpoints were rejected. Copy diagnostics.", true) - return false + var submitted = false + if (workflowBar.session && workflowBar.session.liveControlReady) { + var batch = [] + for (i = 0; i < 3; ++i) { + var channel = model.get(i) + batch.push({ + signalId: channel.signalId, + magnitude: channel.magnitude, + phase: channel.phase, + quality: Number(channel.quality) >>> 0 + }) + } + submitted = workflowBar.session.requestSetSignalsBatch( + batch, currentScale, voltageScale) + if (!submitted) { + showMessage("ABC changed locally; complete live batch rejected. Copy diagnostics.", true) + return false + } } showMessage((group === 0 ? "Current" : "Voltage") + " ABC: " + (action === "equal_value" ? "Equal Value" : action === "equal_angle" ? "Equal Angle" : "Balance 120°") + + (submitted ? " · 3 commands submitted together" : " · offline preview") + " · neutral unchanged", false) return true } diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 8c052266e..eebd1a443 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -385,6 +385,14 @@ bool SmartSessionController::requestSetSignal( voltageCountsPerVolt); } +bool SmartSessionController::requestSetSignalsBatch( + const QVariantList& signals, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + return liveControlReady() && device_ != nullptr && + device_->setSignalsBatch(signals, currentCountsPerAmp, voltageCountsPerVolt); +} + bool SmartSessionController::requestSetQuality(const QString& signalId, const quint32 quality) { return liveControlReady() && device_ != nullptr && device_->setQuality(signalId, quality); } diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index b31d2a673..afbffb1ea 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -179,6 +179,11 @@ class SmartSessionController : public QObject { quint32 quality, double currentCountsPerAmp, double voltageCountsPerVolt); + // One all-or-none worker FIFO admission for fast ABC operator actions. + Q_INVOKABLE bool requestSetSignalsBatch( + const QVariantList& signals, + double currentCountsPerAmp, + double voltageCountsPerVolt); Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) { return liveControlReady() && device_ != nullptr && device_->setEnabled(signalId, enabled); } From fff19233922327e4136a41f6281f0ec6eeb0f929 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 15:09:59 +0700 Subject: [PATCH 12/13] fix(qt): avoid Qt signals macro in native ABC batch parameter names --- apps/arstack_studio/src/DeviceController.cpp | 12 ++++++------ apps/arstack_studio/src/DeviceController.hpp | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 8bbda92d5..c1936c4ca 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -680,14 +680,14 @@ bool DeviceController::setSignal( } QStringList DeviceController::buildThreePhaseSetBatch( - const QVariantList& signals, + const QVariantList& setpoints, const double currentCountsPerAmp, const double voltageCountsPerVolt, const double frequencyHz) { - if (signals.size() != 3 || !finitePositive(currentCountsPerAmp) || + if (setpoints.size() != 3 || !finitePositive(currentCountsPerAmp) || !finitePositive(voltageCountsPerVolt)) return {}; - const QString first = signals[0].toMap().value(QStringLiteral("signalId")) + const QString first = setpoints[0].toMap().value(QStringLiteral("signalId")) .toString().trimmed().toUpper(); const bool currents = first == QStringLiteral("IA"); if (!currents && first != QStringLiteral("UA")) return {}; @@ -699,7 +699,7 @@ QStringList DeviceController::buildThreePhaseSetBatch( QStringList commands; commands.reserve(3); for (int i = 0; i < 3; ++i) { - const QVariantMap item = signals[i].toMap(); + const QVariantMap item = setpoints[i].toMap(); if (item.value(QStringLiteral("signalId")).toString().trimmed().toUpper() != expected[i]) return {}; bool magnitudeValid = false; @@ -732,11 +732,11 @@ QStringList DeviceController::buildThreePhaseSetBatch( } bool DeviceController::setSignalsBatch( - const QVariantList& signals, + const QVariantList& setpoints, const double currentCountsPerAmp, const double voltageCountsPerVolt) { const QStringList commands = buildThreePhaseSetBatch( - signals, currentCountsPerAmp, voltageCountsPerVolt, signalFrequencyHz_); + setpoints, currentCountsPerAmp, voltageCountsPerVolt, signalFrequencyHz_); if (commands.size() != 3) { setError(QStringLiteral("ABC batch is invalid; no phase was queued.")); return false; diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 24b93178f..8518403c6 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -223,7 +223,7 @@ class DeviceController : public QObject { double currentCountsPerAmp, double voltageCountsPerVolt); // One all-or-none worker queue submission for fast ABC operator actions. - bool setSignalsBatch(const QVariantList& signals, double currentCountsPerAmp, double voltageCountsPerVolt); + bool setSignalsBatch(const QVariantList& setpoints, double currentCountsPerAmp, double voltageCountsPerVolt); bool setEnabled(const QString& signalId, bool enabled); bool setQuality(const QString& signalId, quint32 quality); bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); @@ -355,7 +355,7 @@ class DeviceController : public QObject { void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QStringList buildThreePhaseSetBatch( - const QVariantList& signals, double currentCountsPerAmp, + const QVariantList& setpoints, double currentCountsPerAmp, double voltageCountsPerVolt, double frequencyHz); [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); static QString cleanLine(const QString& rawLine); From 624940822e1284ce67339ce6187978fec8ddb0ae Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 15:10:34 +0700 Subject: [PATCH 13/13] fix(qt): use setpoints argument in Q_INVOKABLE ABC transaction --- apps/arstack_studio/src/SmartSessionController.cpp | 4 ++-- apps/arstack_studio/src/SmartSessionController.hpp | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index eebd1a443..e75c0d628 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -386,11 +386,11 @@ bool SmartSessionController::requestSetSignal( } bool SmartSessionController::requestSetSignalsBatch( - const QVariantList& signals, + const QVariantList& setpoints, const double currentCountsPerAmp, const double voltageCountsPerVolt) { return liveControlReady() && device_ != nullptr && - device_->setSignalsBatch(signals, currentCountsPerAmp, voltageCountsPerVolt); + device_->setSignalsBatch(setpoints, currentCountsPerAmp, voltageCountsPerVolt); } bool SmartSessionController::requestSetQuality(const QString& signalId, const quint32 quality) { diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index afbffb1ea..6476ef8f3 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -181,7 +181,7 @@ class SmartSessionController : public QObject { double voltageCountsPerVolt); // One all-or-none worker FIFO admission for fast ABC operator actions. Q_INVOKABLE bool requestSetSignalsBatch( - const QVariantList& signals, + const QVariantList& setpoints, double currentCountsPerAmp, double voltageCountsPerVolt); Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) {