diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 83ca7bb7e..5b81b75c6 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -113,6 +113,13 @@ qt_add_qml_module(arstack_studio RESOURCES assets/InterVariable.ttf assets/INTER_LICENSE.txt + assets/material_dark/play_arrow.svg + assets/material_dark/stop.svg + assets/material_dark/folder_open.svg + assets/material_dark/dashboard.svg + assets/material_dark/tune.svg + assets/material_dark/monitor_heart.svg + assets/material_dark/settings.svg assets/MATERIAL_ICONS_LICENSE.txt assets/material/close.svg assets/material/open_in_new.svg diff --git a/apps/arstack_studio/assets/material_dark/dashboard.svg b/apps/arstack_studio/assets/material_dark/dashboard.svg new file mode 100644 index 000000000..d71fc1014 --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material_dark/folder_open.svg b/apps/arstack_studio/assets/material_dark/folder_open.svg new file mode 100644 index 000000000..8c460009e --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/folder_open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material_dark/monitor_heart.svg b/apps/arstack_studio/assets/material_dark/monitor_heart.svg new file mode 100644 index 000000000..d76823214 --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/monitor_heart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material_dark/play_arrow.svg b/apps/arstack_studio/assets/material_dark/play_arrow.svg new file mode 100644 index 000000000..295bece93 --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/play_arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material_dark/settings.svg b/apps/arstack_studio/assets/material_dark/settings.svg new file mode 100644 index 000000000..2fffc5ac9 --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material_dark/stop.svg b/apps/arstack_studio/assets/material_dark/stop.svg new file mode 100644 index 000000000..c3f97a40e --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/stop.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/assets/material_dark/tune.svg b/apps/arstack_studio/assets/material_dark/tune.svg new file mode 100644 index 000000000..944fa0eca --- /dev/null +++ b/apps/arstack_studio/assets/material_dark/tune.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 9c4e3b919..ec67055e2 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -52,7 +52,9 @@ ApplicationWindow { function markScenarioDirty() { if (restoringScenario) return saveScenarioTimer.restart() - if (!workflowBar.session || !workflowBar.session.liveControlReady) + // While STOPPED, a new edit belongs to the next supervised Start batch. + // Only a genuinely RUNNING board receives incremental live SETs. + if (!workflowBar.injectionRunning) pendingApply = true } function restoreLocalScenario() { @@ -132,6 +134,7 @@ ApplicationWindow { property real activePhase: 0.0 property real activeQuality: 0 property string transientMessage: "" + property string statusMessage: "" property bool transientError: false property bool phasorDockVisible: true property bool phasorDetached: false @@ -197,6 +200,7 @@ ApplicationWindow { onTriggered: { root.transientMessage = "" root.transientError = false + root.statusMessage = "" } } @@ -222,7 +226,8 @@ ApplicationWindow { } function showMessage(message, error) { - transientMessage = message + statusMessage = message + transientMessage = error === true ? message : "" transientError = error === true statusHistoryModel.append({ timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"), @@ -425,16 +430,13 @@ ApplicationWindow { : "One or more draft commands could not be queued; retry after checking the board.", !ok) return ok } - function requestApplyDraft() { - if (!workflowBar.session || !workflowBar.session.liveControlReady) { - showMessage("Local draft saved; connect a verified ESP32-P4 to apply it.", false) - return false - } - if (workflowBar.injectionRunning) { - showMessage("Stop the publisher before applying an offline draft.", true) - return false - } - return applyAllSignals() + function startScenarioSnapshot() { + var draft = scenarioSnapshot() + draft.currentScale = currentScale + draft.voltageScale = voltageScale + draft.ct = { enabled: ctSaturationEnabled, dc: ctDcOffsetPercent, + harmonic: ctHarmonicPercent, order: ctHarmonicOrder, clip: ctClipPercent } + return draft } function setFrequencyValue(value) { @@ -565,8 +567,8 @@ ApplicationWindow { workflowBar.session.requestSetQuality(activeSignal, unsignedValue) } - Shortcut { sequence: "Ctrl+B"; onActivated: root.balanced() } - Shortcut { sequence: "Ctrl+0"; onActivated: root.zeroAll() } + Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } + Shortcut { sequence: "Ctrl+0"; enabled: !workflowBar.startQueued; onActivated: root.zeroAll() } Shortcut { sequence: "F5"; enabled: workflowBar.session && workflowBar.session.state !== "RUNNING"; onActivated: workflowBar.requestStart() } Shortcut { sequence: "F6"; enabled: workflowBar.session && workflowBar.session.state === "RUNNING"; onActivated: workflowBar.requestStop() } @@ -630,7 +632,9 @@ ApplicationWindow { spacing: 8 Label { - text: device.deviceVerified ? device.portName + " · ESP32-P4" : "No device" + text: !device.deviceVerified + ? "No ESP32 board detected · Preview mode only" + : device.portName + " · ESP32-P4" + (workflowBar.injectionRunning ? " · Injecting" : " · Ready") color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted font.family: root.uiFont font.pixelSize: 9 @@ -644,6 +648,22 @@ ApplicationWindow { font.pixelSize: 9 } Item { Layout.fillWidth: true } + Label { + visible: root.statusMessage.length > 0 + Layout.maximumWidth: root.compactLayout ? 165 : 300 + text: root.statusMessage + elide: Text.ElideRight + color: root.transientError ? studioTheme.amber : studioTheme.textSoft + font.family: root.uiFont + font.pixelSize: 9 + } + Label { + visible: device.deviceVerified + text: "PTP " + (device.ptpRunning ? device.ptpRole : "OFF") + " · smpSynch " + device.smpSynchValue + color: device.ptpRunning ? studioTheme.green : studioTheme.muted + font.family: root.uiFont + font.pixelSize: 9 + } Label { visible: device.deviceVerified text: "FPS " + device.fps + " MISSED " + device.missed + " TX FAIL " + device.txFailures @@ -795,8 +815,8 @@ ApplicationWindow { id: workflowBar objectName: "studioWorkflowBar" Layout.fillWidth: true - Layout.preferredHeight: 54 - Layout.minimumHeight: 54 + Layout.preferredHeight: 78 + Layout.minimumHeight: 78 theme: studioTheme controller: root device: device @@ -854,7 +874,7 @@ ApplicationWindow { ColumnLayout { anchors.fill: parent anchors.margins: root.compactLayout ? 11 : 13 - spacing: 8 + spacing: root.compactLayout ? 6 : 8 RowLayout { Layout.fillWidth: true @@ -863,22 +883,14 @@ ApplicationWindow { Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: root.compactLayout ? 17 : 18; font.weight: Font.DemiBold } } Item { Layout.fillWidth: true } - CalmButton { - theme: studioTheme - uiFont: root.uiFont - iconSource: Qt.resolvedUrl("../assets/material/file_upload.svg") - text: !device.deviceVerified ? "Local draft" - : root.pendingApply ? "Apply to board" : "SET queued" - implicitWidth: 106 - tone: device.deviceVerified && root.pendingApply ? "accent" : "normal" - toolTipText: "Local setpoints are separate from physical output. Board last values are not read back." - onClicked: root.requestApplyDraft() - } + // START owns the single ordered scenario transaction. + // No redundant Apply button in the operator workflow. } Rectangle { Layout.fillWidth: true height: 48 + enabled: !workflowBar.startQueued radius: 8 color: "#0b1219" border.width: 0 @@ -891,6 +903,7 @@ ApplicationWindow { x: 80 y: 45 title: "Frequency · shared across all channels" + commandRows: 4 Action { text: "DC · 0 Hz"; icon.source: Qt.resolvedUrl("../assets/material/block.svg"); onTriggered: root.setWaveformMode("DC") } Action { text: "50 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(50) } Action { text: "60 Hz · nominal"; icon.source: Qt.resolvedUrl("../assets/material/schedule.svg"); onTriggered: root.setFrequencyValue(60) } @@ -920,7 +933,7 @@ ApplicationWindow { id: frequencyField ToolTip.visible: hovered && !activeFocus ToolTip.delay: 650 - ToolTip.text: "Right-click for DC / 50 Hz / 60 Hz and AC restoration" + ToolTip.text: "Right-click · Shift+F10" MouseArea { anchors.fill: parent acceptedButtons: Qt.RightButton @@ -979,10 +992,12 @@ ApplicationWindow { RowLayout { Layout.fillWidth: true - Layout.fillHeight: true + Layout.preferredHeight: root.compactLayout ? 214 : 224 + Layout.maximumHeight: root.compactLayout ? 214 : 224 spacing: 9 SignalMatrix { id: voltageMatrix + enabled: !workflowBar.startQueued objectName: "studioVoltageMatrix" Layout.fillWidth: true Layout.fillHeight: true @@ -1001,6 +1016,7 @@ ApplicationWindow { } SignalMatrix { id: currentMatrix + enabled: !workflowBar.startQueued objectName: "studioCurrentMatrix" Layout.fillWidth: true Layout.fillHeight: true @@ -1023,8 +1039,8 @@ ApplicationWindow { StudioTimingTray { objectName: "studioTimingTray" Layout.fillWidth: true - Layout.preferredHeight: root.height < 700 ? 112 : 132 - Layout.minimumHeight: root.height < 700 ? 112 : 132 + Layout.preferredHeight: 84 + Layout.minimumHeight: 84 theme: studioTheme controller: root device: device @@ -1050,6 +1066,7 @@ ApplicationWindow { } Rectangle { width: 1; height: 20; color: studioTheme.lineSoft } QualityEditor { + enabled: !workflowBar.startQueued Layout.fillWidth: true theme: studioTheme controller: root @@ -1060,6 +1077,7 @@ ApplicationWindow { compact: root.compactLayout } } + Item { Layout.fillHeight: true } } } @@ -1089,7 +1107,7 @@ ApplicationWindow { SplitView.fillWidth: true SplitView.fillHeight: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 300 + SplitView.preferredHeight: 238 theme: studioTheme titleText: "Phasor" statusText: "" @@ -1128,7 +1146,7 @@ ApplicationWindow { SplitView.fillWidth: true SplitView.fillHeight: true SplitView.minimumHeight: 170 - SplitView.preferredHeight: 300 + SplitView.preferredHeight: 328 theme: studioTheme titleText: "Waveform" statusText: root.signalFrequency.toFixed(3) + " Hz" diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 0ed98d7f5..0c8034d14 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -14,7 +14,7 @@ Rectangle { property string uiFont: "Inter" property bool compact: false - implicitHeight: 52 + implicitHeight: 76 radius: 0 color: "transparent" border.width: 0 @@ -47,7 +47,7 @@ Rectangle { spacing: 6 RowLayout { - Layout.preferredWidth: bar.compact ? 112 : 132 + Layout.preferredWidth: bar.compact ? 105 : 124 Layout.alignment: Qt.AlignVCenter spacing: 8 @@ -80,20 +80,18 @@ Rectangle { } } - Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 42; color: bar.theme.lineSoft } RibbonAction { theme: bar.theme uiFont: bar.uiFont - text: !bar.device.deviceVerified ? "Preview" : "Start" + text: bar.workflow.startQueued ? "Starting" : "Start" tone: !bar.device.deviceVerified ? "accent" : bar.session.startReady ? "success" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/play_arrow.svg") - enabled: !bar.workflow.injectionRunning && !bar.controller.offlinePreviewRunning + enabled: !bar.workflow.injectionRunning && !bar.workflow.startQueued && !bar.controller.offlinePreviewRunning && !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp - toolTipText: !bar.device.deviceVerified - ? "Animate local setpoint preview only; no SV frames are transmitted." - : bar.workflow.startReason() + toolTipText: "Start" onClicked: { if (!bar.device.deviceVerified) { bar.controller.offlinePreviewRunning = true @@ -110,21 +108,21 @@ Rectangle { iconSource: Qt.resolvedUrl("../assets/material/stop.svg") enabled: (bar.workflow.injectionRunning || bar.controller.offlinePreviewRunning) && !bar.session.updatingFirmware - toolTipText: bar.device.deviceVerified ? "Stop Sampled Values output" : "Stop local preview only" + toolTipText: "Stop" onClicked: { if (!bar.device.deviceVerified) bar.controller.offlinePreviewRunning = false else bar.workflow.requestStop() } } - Rectangle { width: 1; height: 24; color: bar.theme.lineSoft } + Rectangle { width: 1; height: 42; color: bar.theme.lineSoft } RibbonAction { theme: bar.theme uiFont: bar.uiFont text: bar.compact ? "SCL" : "Open SCL" iconSource: Qt.resolvedUrl("../assets/material/folder_open.svg") - enabled: bar.session.engineeringEditable + enabled: bar.session.engineeringEditable && !bar.workflow.startQueued toolTipText: "Open IEC 61850 engineering configuration" onClicked: bar.workflow.openEngineeringDialog() } @@ -135,7 +133,7 @@ Rectangle { uiFont: bar.uiFont text: "4I+4V" iconSource: Qt.resolvedUrl("../assets/material/dashboard.svg") - enabled: bar.session.engineeringEditable + enabled: bar.session.engineeringEditable && !bar.workflow.startQueued toolTipText: "Use the built-in 4I + 4V / 4000 samples/s profile" onClicked: bar.workflow.useBuiltInProfile() } @@ -194,19 +192,17 @@ Rectangle { RibbonAction { theme: bar.theme uiFont: bar.uiFont - text: bar.compact ? "" : "Advanced" - iconOnly: bar.compact + text: "Advanced" + iconOnly: false iconSource: Qt.resolvedUrl("../assets/material/settings.svg") tone: "neutral" - toolTipText: bar.session.firmwareUpdateAvailable - ? "Advanced controls · optional firmware update available" - : "Advanced device, firmware, waveform and timing controls" + toolTipText: "Advanced" onClicked: bar.controller.openConfiguration() } Rectangle { - Layout.preferredWidth: bar.compact ? 92 : 112 - Layout.preferredHeight: 30 + Layout.preferredWidth: bar.compact ? 84 : 108 + Layout.preferredHeight: 36 Layout.alignment: Qt.AlignVCenter radius: 15 color: "#0a1016" diff --git a/apps/arstack_studio/qml/RibbonAction.qml b/apps/arstack_studio/qml/RibbonAction.qml index 5826cd4f7..8093a829f 100644 --- a/apps/arstack_studio/qml/RibbonAction.qml +++ b/apps/arstack_studio/qml/RibbonAction.qml @@ -5,87 +5,71 @@ import QtQuick.Layouts Button { id: control - property var theme property string uiFont: "Inter" property string tone: "neutral" property url iconSource - property bool large: false + property bool large: true property bool iconOnly: false property string toolTipText: "" - - implicitWidth: iconOnly ? 32 : large ? Math.max(74, label.implicitWidth + 40) : Math.max(54, label.implicitWidth + (icon.visible ? 32 : 20)) - implicitHeight: large ? 38 : 32 + readonly property string hoverPath: iconSource.toString().replace("/assets/material/", "/assets/material_dark/") + implicitWidth: iconOnly ? 60 : Math.max(67, caption.implicitWidth + 20) + implicitHeight: 65 hoverEnabled: true activeFocusOnTab: true font.family: uiFont - font.pixelSize: large ? 11 : 9 + font.pixelSize: 10 font.weight: Font.DemiBold - readonly property color fillColor: !enabled ? "transparent" - : tone === "success" ? (hovered ? "#194433" : "#14372a") - : tone === "danger" ? (hovered ? "#46252d" : "#351e24") - : tone === "accent" ? (hovered ? "#15324d" : "#11283d") - : tone === "warning" ? (hovered ? "#41351f" : "#312919") - : checked ? "#132b42" - : hovered ? theme.raisedHover : "transparent" + : hovered ? "#e9f2fd" + : tone === "success" ? "#173d30" + : tone === "danger" ? "#3b2027" + : tone === "accent" || checked ? "#163451" + : tone === "warning" ? "#45351b" : "transparent" readonly property color edgeColor: !enabled ? "transparent" - : tone === "success" ? "#2b684e" - : tone === "danger" ? "#713843" - : tone === "accent" ? "#2b567f" - : tone === "warning" ? "#715a2f" - : checked ? "#294f74" - : activeFocus ? theme.accent : "transparent" + : hovered ? "#a6c9f2" + : tone === "success" ? "#367e5e" + : tone === "danger" ? "#8b4b56" + : tone === "accent" || checked ? "#34638a" + : tone === "warning" ? "#806536" : "transparent" readonly property color textColor: !enabled ? theme.muted2 - : tone === "success" ? "#d9f7e9" - : tone === "danger" ? "#ffdce1" - : tone === "accent" ? "#ddebff" - : tone === "warning" ? "#f0d49a" - : checked ? theme.text : theme.textSoft - - contentItem: Item { - implicitWidth: row.implicitWidth - implicitHeight: row.implicitHeight - - RowLayout { - id: row - anchors.centerIn: parent - spacing: control.iconOnly ? 0 : 6 - - Image { - id: icon - visible: control.iconSource.toString().length > 0 - source: control.iconSource - Layout.preferredWidth: control.large ? 17 : 14 - Layout.preferredHeight: Layout.preferredWidth - sourceSize.width: Layout.preferredWidth * 2 - sourceSize.height: Layout.preferredHeight * 2 - opacity: control.enabled ? 0.92 : 0.28 - } - - Text { - id: label - visible: !control.iconOnly - text: control.text - color: control.textColor - font: control.font - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight - } + : hovered ? "#142334" + : tone === "success" ? "#dbfaec" + : tone === "warning" ? "#f5daaa" + : tone === "danger" ? "#ffe3e6" + : tone === "accent" || checked ? "#e1f1ff" : theme.textSoft + contentItem: ColumnLayout { + spacing: 3 + Image { + visible: control.iconSource.toString().length > 0 + source: control.hovered && control.enabled ? control.hoverPath : control.iconSource + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + sourceSize.width: 48 + sourceSize.height: 48 + opacity: control.enabled ? 1 : 0.3 + } + Text { + id: caption + visible: !control.iconOnly + text: control.text + color: control.textColor + font: control.font + Layout.alignment: Qt.AlignHCenter + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight } } - background: Rectangle { - radius: 6 + radius: 9 color: control.fillColor border.width: control.edgeColor === "transparent" ? 0 : 1 border.color: control.edgeColor Behavior on color { ColorAnimation { duration: 90 } } - Behavior on border.color { ColorAnimation { duration: 90 } } } - ToolTip.visible: hovered && toolTipText.length > 0 ToolTip.text: toolTipText - ToolTip.delay: 420 + ToolTip.delay: 600 } diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index 8b49bd621..2e6d9d242 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -140,6 +140,7 @@ Rectangle { theme: matrix.theme uiFont: matrix.uiFont title: "Magnitude · " + signalRow.sid + commandRows: 4 Action { text: "Zero · 0" icon.source: Qt.resolvedUrl("../assets/material/block.svg") @@ -170,6 +171,7 @@ Rectangle { theme: matrix.theme uiFont: matrix.uiFont title: "Angle · " + signalRow.sid + commandRows: 3 Action { text: "Zero angle · 0°" icon.source: Qt.resolvedUrl("../assets/material/block.svg") @@ -238,8 +240,7 @@ Rectangle { Layout.preferredWidth: 25 Layout.alignment: Qt.AlignVCenter checked: signalRow.channelEnabled - ToolTip.visible: hovered - ToolTip.text: checked ? "Channel enabled" : "Channel disabled" + ToolTip.visible: false onToggled: { matrix.sourceModel.setProperty(signalRow.rowIndex, "enabled", checked) matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) @@ -267,9 +268,9 @@ Rectangle { NumericField { id: magnitudeField - ToolTip.visible: hovered && !activeFocus + ToolTip.visible: hovered && !activeFocus && !magnitudeMenu.visible && !phaseMenu.visible ToolTip.delay: 650 - ToolTip.text: "Right-click or Shift+F10: magnitude presets and Equal ABC" + ToolTip.text: "Right-click · Shift+F10" MouseArea { anchors.fill: parent z: 5 @@ -329,9 +330,9 @@ Rectangle { NumericField { id: phaseField - ToolTip.visible: hovered && !activeFocus + ToolTip.visible: hovered && !activeFocus && !magnitudeMenu.visible && !phaseMenu.visible ToolTip.delay: 650 - ToolTip.text: "Right-click or Shift+F10: Equal/Balance ABC angles" + ToolTip.text: "Right-click · Shift+F10" MouseArea { anchors.fill: parent z: 5 diff --git a/apps/arstack_studio/qml/SignalPreview.qml b/apps/arstack_studio/qml/SignalPreview.qml index 342998400..61b85e65c 100644 --- a/apps/arstack_studio/qml/SignalPreview.qml +++ b/apps/arstack_studio/qml/SignalPreview.qml @@ -532,13 +532,6 @@ SurfacePanel { } } - Label { - Layout.fillWidth: true - text: "Generated setpoint · not independent on-wire proof" - horizontalAlignment: Text.AlignRight - color: preview.theme.muted - font.family: preview.uiFont - font.pixelSize: 8 - } + } } diff --git a/apps/arstack_studio/qml/StudioActionMenu.qml b/apps/arstack_studio/qml/StudioActionMenu.qml index 62c4dc0df..a026db163 100644 --- a/apps/arstack_studio/qml/StudioActionMenu.qml +++ b/apps/arstack_studio/qml/StudioActionMenu.qml @@ -1,5 +1,4 @@ // SPDX-License-Identifier: GPL-3.0-or-later -// Compact Material-action popup shared by magnitude, angle and frequency. import QtQuick import QtQuick.Controls import QtQuick.Layouts @@ -8,46 +7,48 @@ Menu { id: actionMenu property var theme property string uiFont: "Inter" + property int commandRows: 4 + property int dividerRows: 1 popupType: Popup.Item - implicitWidth: 232 - topPadding: 5 - bottomPadding: 5 - leftPadding: 5 - rightPadding: 5 + width: 212 + height: commandRows * 29 + dividerRows * 7 + 8 + topPadding: 4 + bottomPadding: 4 + leftPadding: 4 + rightPadding: 4 spacing: 0 font.family: uiFont font.pixelSize: 11 delegate: MenuItem { id: actionItem width: actionMenu.width - actionMenu.leftPadding - actionMenu.rightPadding - implicitHeight: actionMenu.theme.menuItemHeight - leftPadding: 9 - rightPadding: 10 + implicitHeight: 29 + leftPadding: 8 + rightPadding: 8 font.family: actionMenu.uiFont font.pixelSize: 11 contentItem: RowLayout { - spacing: 9 + spacing: 7 Image { visible: actionItem.icon.source.toString().length > 0 source: actionItem.icon.source - sourceSize.width: 28 - sourceSize.height: 28 - Layout.preferredWidth: visible ? 17 : 0 - Layout.preferredHeight: 17 - opacity: actionItem.enabled ? 0.95 : 0.35 + sourceSize.width: 24 + sourceSize.height: 24 + Layout.preferredWidth: visible ? 15 : 0 + Layout.preferredHeight: 15 + opacity: actionItem.enabled ? 0.95 : 0.33 } Text { text: actionItem.text font: actionItem.font - color: !actionItem.enabled ? actionMenu.theme.muted2 - : actionItem.highlighted ? actionMenu.theme.text : actionMenu.theme.textSoft + color: actionItem.enabled ? actionMenu.theme.textSoft : actionMenu.theme.muted2 verticalAlignment: Text.AlignVCenter elide: Text.ElideRight Layout.fillWidth: true } } background: Rectangle { - radius: actionMenu.theme.controlRadius + radius: 4 color: actionItem.highlighted ? actionMenu.theme.menuHover : "transparent" border.width: actionItem.activeFocus ? 1 : 0 border.color: actionMenu.theme.accent @@ -55,7 +56,7 @@ Menu { } background: Rectangle { color: actionMenu.theme.menuSurface - radius: actionMenu.theme.menuRadius + radius: 7 border.width: 1 border.color: actionMenu.theme.line } diff --git a/apps/arstack_studio/qml/StudioMenuDivider.qml b/apps/arstack_studio/qml/StudioMenuDivider.qml index c673ae18c..18a96ea93 100644 --- a/apps/arstack_studio/qml/StudioMenuDivider.qml +++ b/apps/arstack_studio/qml/StudioMenuDivider.qml @@ -5,8 +5,8 @@ import QtQuick.Controls MenuSeparator { id: separator property var theme - topPadding: 3 - bottomPadding: 3 + topPadding: 2 + bottomPadding: 2 leftPadding: 10 rightPadding: 10 contentItem: Rectangle { diff --git a/apps/arstack_studio/qml/StudioTheme.qml b/apps/arstack_studio/qml/StudioTheme.qml index 1cb937802..e673f1f71 100644 --- a/apps/arstack_studio/qml/StudioTheme.qml +++ b/apps/arstack_studio/qml/StudioTheme.qml @@ -47,8 +47,8 @@ QtObject { readonly property int controlRadius: 7 // Shared Material-inspired desktop surface/interaction tokens. readonly property color menuSurface: "#18222e" - readonly property color menuHover: "#253b51" - readonly property int menuRadius: 11 + readonly property color menuHover: "#314a63" + readonly property int menuRadius: 7 readonly property int menuItemHeight: 33 readonly property int spaceXs: 4 readonly property int spaceSm: 8 diff --git a/apps/arstack_studio/qml/StudioTimingTray.qml b/apps/arstack_studio/qml/StudioTimingTray.qml index 0da9537dc..08708d47d 100644 --- a/apps/arstack_studio/qml/StudioTimingTray.qml +++ b/apps/arstack_studio/qml/StudioTimingTray.qml @@ -96,7 +96,7 @@ Rectangle { checked: tray.boardOnline ? Boolean(tray.device.ptpRunning) : offlineSettings.previewPtp enabled: !tray.busy && (!tray.boardOnline || tray.ptpReady) ToolTip.visible: hovered - ToolTip.text: "Start/stop PTP with verified board feedback. Does not establish SV sample-phase lock." + ToolTip.text: "PTP" onClicked: { // Switch flips locally before asynchronous firmware evidence. // Restore binding immediately, including on rejected requests. @@ -136,7 +136,7 @@ Rectangle { }) } ToolTip.visible: hovered - ToolTip.text: "Source transmits PTP; Follower discovers an external GM; Monitor receives without disciplining." + ToolTip.text: "PTP role" } Label { text: "smpSynch" @@ -156,7 +156,7 @@ Rectangle { ? tray.syncIndex(tray.device.smpSynchMode) : offlineSettings.lastSynch enabled: !tray.boardOnline || (tray.session && !tray.session.updatingFirmware) ToolTip.visible: hovered - ToolTip.text: "1/2 change SV metadata only; they do not synchronize the sample clock." + ToolTip.text: "smpSynch mode" onActivated: { const requested = currentIndex offlineSettings.lastSynch = requested @@ -184,26 +184,10 @@ Rectangle { iconSource: Qt.resolvedUrl("../assets/material/settings.svg") objectName: "studioTimingDetails" implicitWidth: 66 - toolTipText: "PTP settings, timing evidence and diagnostics" + toolTipText: "Timing settings" onClicked: if (tray.controller) tray.controller.openTimingSettings() } } - Label { - Layout.fillWidth: true - text: !tray.boardOnline - ? "OFFLINE PLAN ONLY · PTP and smpSynch selections are local; no PTP/SV packets sent. Board state wins at reconnect." - : tray.device.smpSynchSimulated - ? "SV smpSynch=" + tray.device.smpSynchValue + " · SIMULATED metadata — sample clock not proven locked." - : tray.device.smpSynchMeasured - ? "SV smpSynch=" + tray.device.smpSynchValue + " · board-reported evidence; sample phase needs independent verification." - : "SV smpSynch=" + tray.device.smpSynchValue + " · unqualified. PTP traffic alone does not prove SV synchronization." - color: tray.device && tray.device.smpSynchSimulated ? tray.theme.amber : tray.theme.muted - font.family: tray.uiFont - font.pixelSize: tray.compact ? 9 : 10 - elide: Text.ElideRight - ToolTip.visible: evidenceHover.hovered - ToolTip.text: text - HoverHandler { id: evidenceHover } - } + } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 9865562ad..3059987a3 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -15,6 +15,40 @@ SurfacePanel { property string uiFont: "Inter" property string monoFont: "Inter" property bool compact: false + property bool startQueued: false + Timer { + id: startWatchdog + interval: 8000 + repeat: false + onTriggered: { + if (!ribbon.startQueued) return + ribbon.startQueued = false + controller.pendingApply = true + controller.showMessage("START not confirmed by ESP32-P4. Check board status.", true) + } + } + Connections { + target: ribbon.device + function onRunningChanged() { + if (ribbon.device.running) { + ribbon.startQueued = false + startWatchdog.stop() + controller.pendingApply = false + } + } + function onLastErrorChanged() { + if (!ribbon.startQueued) return + ribbon.startQueued = false + startWatchdog.stop() + controller.pendingApply = true + } + function onDeviceVerifiedChanged() { + if (ribbon.device.deviceVerified) return + ribbon.startQueued = false + startWatchdog.stop() + controller.pendingApply = true + } + } property bool updatePromptDeferred: false property bool installPromptDeferred: false property bool firmwareResultSuccess: false @@ -117,7 +151,8 @@ SurfacePanel { // overwrite a board with a restored/offline local draft. controller.profileDirty = false controller.pendingApply = true - controller.showMessage("Board ready. Review your local values, then Apply to board.", false) + // Fast path: the next START uploads the local setpoints first. + // No auto-transmit on device discovery/reconnection. } function onStateChanged() { if (ribbon.firmwareGateActive) @@ -222,7 +257,7 @@ SurfacePanel { } function requestStart() { - if (ribbon.injectionRunning) return + if (ribbon.injectionRunning || ribbon.startQueued) return if (smartSession.firmwareInstallRequired) { ribbon.installPromptDeferred = false installDialog.open() @@ -248,12 +283,18 @@ SurfacePanel { controller.showMessage(ribbon.startReason(), false) return } - if (controller.pendingApply) { - controller.showMessage("Apply the local scenario before starting physical SMV output.", true) - return + const accepted = controller.pendingApply + ? smartSession.requestStartWithScenario(controller.startScenarioSnapshot()) + : smartSession.requestStart() + if (!accepted) { + controller.pendingApply = true + controller.showMessage("Start rejected. Check device status.", true) + } else { + // A successful queue call is NOT the board START ACK. + // Keep the draft dirty until RUNNING evidence arrives. + ribbon.startQueued = true + startWatchdog.restart() } - if (!smartSession.requestStart()) - controller.showMessage(smartSession.statusText, true) } function requestStop() { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 978dd0025..265da7431 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -627,6 +627,79 @@ void DeviceController::handlePortClosed(const QString& portName) { bool DeviceController::sendShow() { return sendCommand(QStringLiteral("SHOW")); } bool DeviceController::start() { return sendCommand(QStringLiteral("START")); } + +bool DeviceController::startWithScenario(const QVariantMap& scenario) { + if (!deviceVerified_ || running_) return false; + bool hzOk = false, currentScaleOk = false, voltageScaleOk = false; + const double hz = scenario.value(QStringLiteral("hz")).toDouble(&hzOk); + const double currentScale = scenario.value(QStringLiteral("currentScale")).toDouble(¤tScaleOk); + const double voltageScale = scenario.value(QStringLiteral("voltageScale")).toDouble(&voltageScaleOk); + if (!hzOk || !std::isfinite(hz) || hz < 0.0 || hz > 1000.0 || + !currentScaleOk || !voltageScaleOk || !finitePositive(currentScale) || + !finitePositive(voltageScale)) { + setError(QStringLiteral("Invalid frequency or engineering scale; START refused.")); + return false; + } + const auto current = scenario.value(QStringLiteral("current")).toList(); + const auto voltage = scenario.value(QStringLiteral("voltage")).toList(); + if (current.size() != 4 || voltage.size() != 4) { + setError(QStringLiteral("START requires exactly four current and four voltage channels.")); + return false; + } + QStringList commands; + commands.reserve(20); + commands << QStringLiteral("FREQ %1").arg(std::llround(hz * 1000.0)); + static const QStringList ids{QStringLiteral("IA"), QStringLiteral("IB"), + QStringLiteral("IC"), QStringLiteral("IN"), QStringLiteral("UA"), + QStringLiteral("UB"), QStringLiteral("UC"), QStringLiteral("UN")}; + for (int i = 0; i < 8; ++i) { + const QVariantList row = (i < 4 ? current.at(i) : voltage.at(i - 4)).toList(); + if (row.size() != 4 || row.at(2).metaType().id() != QMetaType::Bool) { + setError(QStringLiteral("Invalid channel state; START refused.")); + return false; + } + bool magOk = false, phaseOk = false, qualityOk = false; + const double magnitude = row.at(0).toDouble(&magOk); + const double phase = row.at(1).toDouble(&phaseOk); + const qulonglong quality = row.at(3).toULongLong(&qualityOk); + if (!magOk || !phaseOk || !qualityOk || quality > std::numeric_limits::max()) { + setError(QStringLiteral("Invalid channel data; START refused.")); + return false; + } + const QString command = formatSignalCommand(ids.at(i), magnitude, phase, + static_cast(quality), currentScale, voltageScale, hz); + if (command.isEmpty()) { + setError(QStringLiteral("Out-of-range setpoint; START refused.")); + return false; + } + commands << command; + commands << QStringLiteral("ENABLE %1 %2").arg(ids.at(i)).arg(row.at(2).toBool() ? 1 : 0); + } + const QVariantMap shape = scenario.value(QStringLiteral("ct")).toMap(); + bool dcOk = false, harmonicOk = false, clipOk = false, orderOk = false; + const double dc = shape.value(QStringLiteral("dc")).toDouble(&dcOk); + const double harmonic = shape.value(QStringLiteral("harmonic")).toDouble(&harmonicOk); + const double clip = shape.value(QStringLiteral("clip")).toDouble(&clipOk); + const int order = shape.value(QStringLiteral("order")).toInt(&orderOk); + const bool shaping = shape.value(QStringLiteral("enabled")).toBool(); + if (!dcOk || !harmonicOk || !clipOk || !orderOk || + (shaping && hz == 0.0) || !std::isfinite(dc) || dc < -300.0 || dc > 300.0 || + !std::isfinite(harmonic) || harmonic < 0.0 || harmonic > 300.0 || + !std::isfinite(clip) || clip < 1.0 || clip > 1000.0 || order < 2 || order > 63) { + setError(QStringLiteral("Invalid CT shaping; START refused.")); + return false; + } + commands << QStringLiteral("SHAPE CT %1 %2 %3 %4 %5") + .arg(shaping ? 1 : 0).arg(std::llround(dc * 10.0)) + .arg(std::llround(harmonic * 10.0)).arg(order).arg(std::llround(clip * 10.0)); + commands << QStringLiteral("START"); + // Worker admission is all-or-none (bounded queue), and exclusive FIFO + // prevents concurrent UI edits interleaving with this START transaction. + // Acceptance is not firmware ACK; RUNNING still requires board telemetry. + if (!sendCommandBatch(commands, false, true)) return false; + signalFrequencyHz_ = hz; + return true; +} bool DeviceController::stop() { return sendCommand(QStringLiteral("STOP")); } bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } @@ -1282,20 +1355,21 @@ bool DeviceController::sendQuietCommand(const QString& command) { return sendCommandBatch({command}, true); } -bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet) { +bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { if (!connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) { setError(QStringLiteral("Device is not connected.")); return false; } const QStringList copy = commands; const quint64 generation = sessionGeneration_; - QMetaObject::invokeMethod( + const bool queued = QMetaObject::invokeMethod( ioWorker_, - [worker = ioWorker_, copy, quiet, generation] { - worker->enqueueCommands(copy, quiet, generation); + [worker = ioWorker_, copy, quiet, exclusive, generation] { + worker->enqueueCommands(copy, quiet, exclusive, generation); }, Qt::QueuedConnection); - return true; + if (!queued) setError(QStringLiteral("Device queue dispatcher unavailable.")); + return queued; } void DeviceController::setSessionHeartbeatEnabled(const bool enabled) { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index de712e2c8..80bc1377f 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -211,6 +211,8 @@ class DeviceController : public QObject { void disconnectPort(); bool sendShow(); virtual bool start(); + // Single validated, bounded FIFO transaction. START is always last. + bool startWithScenario(const QVariantMap& scenario); bool stop(); virtual bool zero(); virtual bool setFrequency(double hz); @@ -288,7 +290,7 @@ class DeviceController : public QObject { protected: bool sendQuietCommand(const QString& command); - bool sendCommandBatch(const QStringList& commands, bool quiet = false); + bool sendCommandBatch(const QStringList& commands, bool quiet = false, bool exclusive = false); // One canonical SET encoder reused by single edits and the coalesced // multi-channel flush. Empty result = invalid; never send partial batch. static QString formatSignalCommand(const QString& signalId, double magnitude, diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 8c052266e..93d8b747a 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -302,6 +302,11 @@ bool SmartSessionController::requestStart() { return device_->start(); } +bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) { + if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; + return device_->startWithScenario(scenario); +} + bool SmartSessionController::requestStop() { if (device_ == nullptr) return false; if (!device_->running()) return true; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index b31d2a673..bd7efd3f6 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -165,6 +165,7 @@ class SmartSessionController : public QObject { // S7: QML submits operator intent only. The supervisor owns every gate that // can mutate device connection, output, live values, profile state or PTP. Q_INVOKABLE bool requestStart(); + Q_INVOKABLE bool requestStartWithScenario(const QVariantMap& scenario); Q_INVOKABLE bool requestStop(); Q_INVOKABLE bool requestConnect(); Q_INVOKABLE void requestRefreshPorts(); diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 68fe8e9b6..e04a09245 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -54,8 +54,9 @@ def main() -> None: and "tray.session.requestSmpSynch([\"AUTO\", \"0\", \"1\", \"2\"][requested])" in tray and "if (!tray.boardOnline)" in tray, "main PTP/smpSynch must restore device-owned state after user intent") - require("SIMULATED metadata" in tray and "sample phase needs independent verification" in tray, - "SV simulation must never masquerade as independently proven lock") + require("SIMULATED FLAG" in expert and "MEASURED SYNC FLAG" in expert + and "sample-phase evidence independently" in expert, + "Advanced must retain truthful SV timing evidence without noisy Main disclaimers") require(matrix.count("Qt.Key_Menu") == 2 and matrix.count("Qt.Key_F10") == 2 and matrix.count("signalRow.openFieldMenu(") == 4, "both magnitude and angle menus must support mouse and keyboard") @@ -65,8 +66,10 @@ def main() -> None: ribbon = source("ModernRibbon.qml") cmake_icons = (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") require("scenarioSnapshot()" in main_qml and "restoreLocalScenario()" in main_qml - and "requestApplyDraft()" in main_qml and "offlinePreviewRunning" in main_qml, - "offline-first scenario needs persistence, explicit Apply and non-wire preview") + and "startScenarioSnapshot()" in main_qml and "offlinePreviewRunning" in main_qml, + "offline-first scenario must persist and support one-click Start") + require("Apply to board" not in main_qml and "requestApplyDraft()" not in main_qml, + "operator UI must not expose a redundant Apply-to-board action") require('saveScenarioTimer.stop()' in main_qml and 'scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot())' in main_qml, "closing immediately after an edit must flush the local draft before teardown") @@ -75,12 +78,19 @@ def main() -> None: and 'typeof row[1] !== "number"' in main_qml and 'typeof row[3] !== "number"' in main_qml, "saved scenario restoration must reject JSON null/string coercion and nonfinite values") + require('id: voltageMatrix\n enabled: !workflowBar.startQueued' in main_qml + and 'id: currentMatrix\n enabled: !workflowBar.startQueued' in main_qml + and 'enabled: !workflowBar.startQueued; onActivated: root.balanced()' in main_qml + and 'enabled: !workflowBar.startQueued; onActivated: root.zeroAll()' in main_qml, + "scenario editors and shortcuts must be frozen during a pending START snapshot") require("controller.pendingApply = true" in workflow - and "Apply the local scenario before starting" in workflow - and "controller.applyAllSignals()" not in workflow, - "reconnect must not silently transmit an offline draft") - require("offline-ptp-lab" in tray and "no PTP/SV packets sent" in tray, - "PTP/smpSynch offline controls must be plans only") + and "requestStartWithScenario(controller.startScenarioSnapshot())" in workflow + and "controller.applyAllSignals()" not in workflow + and "startWatchdog" in workflow and "onRunningChanged()" in workflow, + "reconnect must not auto-transmit, but one-click Start must stage a bounded scenario") + require("offline-ptp-lab" in tray and "if (!tray.boardOnline)" in tray + and "no network packets or clock discipline" in tray, + "PTP/smpSynch offline controls must be plans only, not device writes") require("assets/material/smv_injector.svg" in cmake_icons and "assets/MATERIAL_ICONS_LICENSE.txt" in cmake_icons and "../assets/material/smv_injector.svg" in ribbon, @@ -89,6 +99,21 @@ def main() -> None: and "Equal magnitudes A/B/C" in matrix and "Balance angles · 120°" in matrix, "context actions must stay compact, descriptive and grouped") + assert "implicitHeight: 65" in source("RibbonAction.qml") + assert 'hovered ? "#142334"' in source("RibbonAction.qml") + assert "width: 212" in menu and "commandRows * 29" in menu + assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview + assert "No ESP32 board detected · Preview mode only" in main_qml + assert "Layout.preferredHeight: 78" in main_qml + device_src = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") + session_src = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") + require('commands << QStringLiteral("START")' in device_src + and "sendCommandBatch(commands, false, true)" in device_src + and "formatSignalCommand(ids.at(i)" in device_src + and "requestStartWithScenario(const QVariantMap& scenario)" in session_src, + "one-click physical Start must validate and exclusively enqueue all SETs before START") + require("assets/material_dark/play_arrow.svg" in cmake, + "light-hover Material icons must have bundled dark variants") require(matrix.count("StudioActionMenu {") == 2 and "id: magnitudeMenu" in matrix and "id: phaseMenu" in matrix and "popupType: Popup.Item" in menu,