diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index ddfed8eb6..41a31383a 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 @@ -170,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/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/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..eb0bae4c5 --- /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 + // textAt resolves QStringList and QAbstractItemModel textRole. + width: control.width + height: 35 + highlighted: control.highlightedIndex === index + contentItem: Text { + text: control.textAt(item.index) + 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/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/Main.qml b/apps/arstack_studio/qml/Main.qml index 4da38730e..01b538345 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -298,6 +298,52 @@ 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) + 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 + } + function applyAllSignals() { if (!workflowBar.session || !workflowBar.session.liveControlReady) return workflowBar.session.requestSetFrequency(signalFrequency) @@ -472,10 +518,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 +531,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 +762,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/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 diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 80d1de77f..419509eb1 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 @@ -12,6 +13,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 +49,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 +113,50 @@ SurfacePanel { } } Item { Layout.fillWidth: true } + Switch { + id: ptpToggle + text: "PTP" + checked: panel.device.ptpRunning + enabled: panel.controlReady && !panel.startPending + onClicked: { + // 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 + 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 +191,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 +211,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 +220,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 } } } @@ -214,9 +264,17 @@ 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 } - CheckBox { - text: "Advanced settings" + DarkCheckBox { + theme: panel.theme + text: "Protocol settings" checked: panel.advancedVisible onToggled: panel.advancedVisible = checked font.family: panel.uiFont @@ -239,11 +297,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 +336,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 +413,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 +432,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 } 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..27724359c --- /dev/null +++ b/apps/arstack_studio/qml/SignalQuickMenu.qml @@ -0,0 +1,41 @@ +// 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 } + // 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 + 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" } + } + 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") + } + QuickAction { + visible: quick.phaseMode + height: visible ? implicitHeight : 0 + text: "Balance ABC · 120°" + onTriggered: quick.controller.quickSetGroup(quick.groupIndex, quick.rowIndex, "balance_angle") + } +} 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 } } 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..c1936c4ca 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& setpoints, + const double currentCountsPerAmp, + const double voltageCountsPerVolt, + const double frequencyHz) { + if (setpoints.size() != 3 || !finitePositive(currentCountsPerAmp) || + !finitePositive(voltageCountsPerVolt)) return {}; + + const QString first = setpoints[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 = setpoints[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& setpoints, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + const QStringList commands = buildThreePhaseSetBatch( + setpoints, 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..8518403c6 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& 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); @@ -351,6 +354,9 @@ class DeviceController : public QObject { bool beginPtpStopVerification(); void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; + [[nodiscard]] static QStringList buildThreePhaseSetBatch( + const QVariantList& setpoints, 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); diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 8c052266e..e75c0d628 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& setpoints, + const double currentCountsPerAmp, + const double voltageCountsPerVolt) { + return liveControlReady() && device_ != nullptr && + device_->setSignalsBatch(setpoints, 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..6476ef8f3 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& setpoints, + double currentCountsPerAmp, + double voltageCountsPerVolt); Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) { return liveControlReady() && device_ != nullptr && device_->setEnabled(signalId, enabled); } 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"));