Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ qt_add_qml_module(arstack_studio
assets/material/device_hub.svg
assets/material/help_outline.svg
assets/material/smv_injector.svg
assets/material/electric_bolt_run.svg
assets/material/electric_bolt_stop.svg
)

qt_add_resources(arstack_studio "reference_templates"
Expand Down
1 change: 1 addition & 0 deletions apps/arstack_studio/assets/material/electric_bolt.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/arstack_studio/assets/material/electric_bolt_run.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/arstack_studio/assets/material/electric_bolt_stop.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 8 additions & 7 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import ARStack.Studio 1.0

ApplicationWindow {
id: root
width: 1240
height: 760
width: 1150
height: 790
minimumWidth: 960
minimumHeight: 620
visible: true
Expand Down Expand Up @@ -192,7 +192,7 @@ ApplicationWindow {
source: Qt.resolvedUrl("../assets/InterVariable.ttf")
}

readonly property bool compactLayout: width < 1120
readonly property bool compactLayout: width < 1220
readonly property bool canDeploy: workflowBar.session ? workflowBar.session.canDeployProfile : false
readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false
readonly property string toastMessage: transientMessage
Expand Down Expand Up @@ -1065,8 +1065,9 @@ ApplicationWindow {
objectName: "studioInjectionSurface"
SplitView.fillWidth: true
SplitView.fillHeight: true
SplitView.minimumWidth: root.compactLayout ? 500 : 550
SplitView.preferredWidth: root.compactLayout ? 700 : 860
SplitView.minimumWidth: root.compactLayout ? 500 : 540
SplitView.preferredWidth: root.compactLayout ? 600 : 690
SplitView.maximumWidth: 750
theme: studioTheme

ColumnLayout {
Expand Down Expand Up @@ -1295,8 +1296,8 @@ ApplicationWindow {
SplitView {
visible: (root.phasorDockVisible && !root.phasorDetached) ||
(root.waveformDockVisible && !root.waveformDetached)
SplitView.preferredWidth: root.compactLayout ? 300 : 380
SplitView.minimumWidth: root.compactLayout ? 270 : 300
SplitView.preferredWidth: root.compactLayout ? 450 : 470
SplitView.minimumWidth: root.compactLayout ? 300 : 340
SplitView.maximumWidth: 680
SplitView.fillHeight: true
orientation: Qt.Vertical
Expand Down
42 changes: 24 additions & 18 deletions apps/arstack_studio/qml/ModernRibbon.qml
Original file line number Diff line number Diff line change
Expand Up @@ -220,36 +220,42 @@ Rectangle {
}

Rectangle {
Layout.preferredWidth: bar.compact ? 84 : 108
Layout.preferredHeight: 36
Layout.preferredWidth: bar.compact ? 84 : 102
Layout.preferredHeight: 40
Layout.alignment: Qt.AlignVCenter
radius: 15
color: "#0a1016"
border.width: 1
border.color: Qt.rgba(bar.workflow.smartStateColor.r,
bar.workflow.smartStateColor.g,
bar.workflow.smartStateColor.b, 0.48)
border.color: bar.device.deviceVerified && bar.device.running
? bar.theme.green : "#663942"

readonly property bool physicalRunning: bar.device.deviceVerified &&
bar.device.running
readonly property color runColor: physicalRunning
? bar.theme.green : "#b66069"
RowLayout {
anchors.fill: parent
anchors.leftMargin: 9
anchors.leftMargin: 10
anchors.rightMargin: 9
spacing: 6

Rectangle {
width: 6
height: 6
radius: 3
color: bar.workflow.smartStateColor
}

Image {
Layout.preferredWidth: 19
Layout.preferredHeight: 19
source: Qt.resolvedUrl(parent.parent.physicalRunning
? "../assets/material/electric_bolt_run.svg"
: "../assets/material/electric_bolt_stop.svg")
sourceSize.width: 24
sourceSize.height: 24
opacity: parent.parent.physicalRunning ? 1.0 : 0.48
}
Label {
Layout.fillWidth: true
text: bar.conciseState()
color: bar.workflow.smartStateColor
text: bar.device.deviceVerified
? (bar.device.running ? "RUN" : "STOP") : "OFF"
color: parent.parent.runColor
font.family: bar.uiFont
font.pixelSize: 9
font.weight: Font.DemiBold
font.pixelSize: 11
font.weight: Font.Bold
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
Expand Down
82 changes: 78 additions & 4 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,78 @@ SurfacePanel {
property bool compact: false
// Firmware transaction is the single source of truth. UI timers and
// unrelated PTP/serial errors must not bounce the injection START button.
readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)
// Operator intent may arrive before first profile synchronization ends.
// One bounded, boot-bound pending click is consumed as soon as the SAME
// verified board enters READY. Never persist/replay across reconnect.
property bool startIntentPending: false
property string startIntentBoot: ""
property double startIntentDeadline: 0
readonly property bool startQueued: Boolean(
startIntentPending || (ribbon.device && ribbon.device.scenarioApplying))
Timer {
id: startIntentExpiry
interval: 6500
repeat: false
onTriggered: {
if (!ribbon.startIntentPending) return
ribbon.cancelStartIntent()
controller.showMessage(
"START not sent: device preparation timed out. Check board/profile state and retry.", true)
}
}
function cancelStartIntent() {
startIntentPending = false
startIntentBoot = ""
startIntentDeadline = 0
startIntentExpiry.stop()
}
function queueFirstStart() {
// Only a verified, physically connected board may receive deferred
// START. Never arm for offline, firmware update or reconnect.
if (!device.deviceVerified || !device.connected ||
smartSession.updatingFirmware || smartSession.firmwareUpdateRequired ||
smartSession.firmwareInstallRequired) return false
const boot = String(device.bootId)
if (!boot || boot === "undefined") return false
startIntentBoot = boot
startIntentPending = true
startIntentDeadline = Date.now() + 6500
startIntentExpiry.restart()
controller.showMessage("START queued — waiting for 4I+4V profile readiness on this board.", false)
return true
}
function tryConsumeFirstStart() {
if (!startIntentPending || !smartSession.startReady) return
const boot = String(device.bootId)
if (!device.deviceVerified || !device.connected || boot !== startIntentBoot ||
Date.now() > startIntentDeadline) {
cancelStartIntent()
return
}
const requestedBoot = startIntentBoot
cancelStartIntent()
Qt.callLater(function() {
// Prevent a reconnect between readiness notification and dispatch.
if (device.deviceVerified && device.connected &&
String(device.bootId) === requestedBoot && smartSession.startReady &&
!device.running && !device.scenarioApplying)
ribbon.requestStart()
})
}
Connections {
target: smartSession
function onStateChanged() { ribbon.tryConsumeFirstStart() }
}
Connections {
target: ribbon.device
function onRunningChanged() {
if (ribbon.device.running) controller.pendingApply = false
}
function onDeviceVerifiedChanged() {
if (!ribbon.device.deviceVerified) controller.pendingApply = true
if (!ribbon.device.deviceVerified) {
controller.pendingApply = true
ribbon.cancelStartIntent()
}
}
}
property bool updatePromptDeferred: false
Expand Down Expand Up @@ -271,8 +335,13 @@ SurfacePanel {
return
}
if (!smartSession.startReady) {
// One click must explain why a physical START is not queued.
// Never make the operator click twice to discover readiness.
// A first click during same-device profile preparation is retained
// briefly and dispatched once READY. Other errors are explained.
if (smartSession.state === "PREPARING 4I+4V" ||
smartSession.state === "RECOVERING PROFILE" ||
smartSession.state === "DEVICE FOUND") {
if (ribbon.queueFirstStart()) return
}
controller.showMessage("START waiting: " + ribbon.startReason(), true)
return
}
Expand All @@ -291,6 +360,11 @@ SurfacePanel {
}

function requestStop() {
if (ribbon.startIntentPending) {
ribbon.cancelStartIntent()
controller.showMessage("Pending START cancelled.", false)
return
}
// A pending CFG transaction can progress to START: Stop must remain
// actionable before RUNNING telemetry, not only after it.
if (!ribbon.injectionRunning && !ribbon.startQueued) return
Expand Down
30 changes: 29 additions & 1 deletion apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ def main() -> None:
require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning'
in ribbon_qml
and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml")
and 'readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml")
and 'readonly property bool startQueued: Boolean(' in source("WorkflowBar.qml")
and 'startIntentPending || (ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml")
and 'startWatchdog' not in source("WorkflowBar.qml"),
"SV STOP must remain actionable while the acknowledged scenario transaction is pending")
require("function flushPendingEdits()" in matrix
Expand Down Expand Up @@ -537,6 +538,33 @@ def main() -> None:
"SV output must fail-closed on measured heartbeat loss, preserve board STOP cause, "
"and never show misleading green READY after an unrequested stop")

# Default workbench should never stretch the 4I+4V setpoint matrix
# beyond a useful editing width while starving Phasor/Waveform charts.
responsive_main = source("Main.qml")
ribbon_layout = source("ModernRibbon.qml")
start_flow = source("WorkflowBar.qml")
require("width: 1150" in responsive_main
and "SplitView.maximumWidth: 750" in responsive_main
and "SplitView.preferredWidth: root.compactLayout ? 450 : 470" in responsive_main
and "electric_bolt_run.svg" in ribbon_layout
and "electric_bolt_stop.svg" in ribbon_layout
and "assets/material/electric_bolt_run.svg" in
(STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
and "assets/material/electric_bolt_stop.svg" in
(STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
and "bar.device.running ? \"RUN\" : \"STOP\"" in ribbon_layout,
"Studio default workbench must balance channels and charts; physical RUN/STOP uses supplied bolt")
require("property bool startIntentPending: false" in start_flow
and "String(device.bootId)" in start_flow
and "startIntentDeadline = Date.now() + 6500" in start_flow
and "onStateChanged() { ribbon.tryConsumeFirstStart() }" in start_flow
and "function onDeviceVerifiedChanged()" in start_flow
and "ribbon.cancelStartIntent()" in start_flow
and "if (ribbon.startIntentPending)" in start_flow
and "String(device.bootId) === requestedBoot" in start_flow
and "smartSession.requestStartWithScenario(scenario)" in start_flow,
"First START click must be bounded, single-device, cancelable, and use verified scenario START")

toast_layout = source("Main.qml")
toast_view = source("StatusToast.qml")
require("function explainFailure(message)" in toast_layout
Expand Down
Loading