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 @@ -158,6 +158,8 @@ qt_add_qml_module(arstack_studio
assets/material/file_upload.svg
assets/material/stop.svg
assets/material/play_arrow.svg
assets/material/run-bolt.svg
assets/material/stop-bolt.svg
assets/material/dashboard.svg
assets/material/folder_open.svg
assets/material/search.svg
Expand Down
1 change: 1 addition & 0 deletions apps/arstack_studio/assets/material/run-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/stop-bolt.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 9 additions & 5 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -1065,8 +1065,12 @@ ApplicationWindow {
objectName: "studioInjectionSurface"
SplitView.fillWidth: true
SplitView.fillHeight: true
SplitView.minimumWidth: root.compactLayout ? 500 : 550
SplitView.preferredWidth: root.compactLayout ? 700 : 860
// Form fields must not expand across the whole screen.
// Keep the injector compact; give spare width to plots.
// Users can still resize the split manually.
SplitView.minimumWidth: root.compactLayout ? 480 : 520
SplitView.preferredWidth: root.compactLayout ? 505 : 610
SplitView.maximumWidth: 660
theme: studioTheme

ColumnLayout {
Expand Down Expand Up @@ -1295,9 +1299,9 @@ ApplicationWindow {
SplitView {
visible: (root.phasorDockVisible && !root.phasorDetached) ||
(root.waveformDockVisible && !root.waveformDetached)
SplitView.preferredWidth: root.compactLayout ? 300 : 380
SplitView.minimumWidth: root.compactLayout ? 270 : 300
SplitView.maximumWidth: 680
SplitView.preferredWidth: root.compactLayout ? 420 : 560
SplitView.minimumWidth: root.compactLayout ? 330 : 380
SplitView.maximumWidth: 1200
SplitView.fillHeight: true
orientation: Qt.Vertical
handle: Rectangle {
Expand Down
36 changes: 21 additions & 15 deletions apps/arstack_studio/qml/ModernRibbon.qml
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,13 @@ Rectangle {
}

Rectangle {
Layout.preferredWidth: bar.compact ? 84 : 108
Layout.preferredHeight: 36
Layout.preferredWidth: bar.compact ? 92 : 120
Layout.preferredHeight: 39
Layout.alignment: Qt.AlignVCenter
radius: 15
color: "#0a1016"
radius: 12
// A noninteractive status, not another START/STOP authority.
// Green only after verified board RUNNING; muted red otherwise.
color: bar.device.running ? "#112a23" : "#271a20"
border.width: 1
border.color: Qt.rgba(bar.workflow.smartStateColor.r,
bar.workflow.smartStateColor.g,
Expand All @@ -236,21 +238,25 @@ Rectangle {
anchors.rightMargin: 9
spacing: 6

Rectangle {
width: 6
height: 6
radius: 3
color: bar.workflow.smartStateColor
Image {
source: bar.device.running
? Qt.resolvedUrl("../assets/material/run-bolt.svg")
: Qt.resolvedUrl("../assets/material/stop-bolt.svg")
sourceSize.width: 21
sourceSize.height: 21
Layout.preferredWidth: 21
Layout.preferredHeight: 21
fillMode: Image.PreserveAspectFit
opacity: bar.device.running ? 1.0 : 0.72
}

Label {
Layout.fillWidth: true
text: bar.conciseState()
color: bar.workflow.smartStateColor
text: bar.device.running ? "RUN" : "STOP"
color: bar.device.running ? bar.theme.green : "#b45d67"
font.family: bar.uiFont
font.pixelSize: 9
font.weight: Font.DemiBold
elide: Text.ElideRight
font.pixelSize: 12
font.weight: Font.Bold
font.letterSpacing: 0.7
verticalAlignment: Text.AlignVCenter
}
}
Expand Down
77 changes: 72 additions & 5 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,64 @@ 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)
readonly property bool startQueued: Boolean(
(ribbon.device && ribbon.device.scenarioApplying) || ribbon.startIntentWaiting)
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the stale startQueued contract check

Update the existing assertion in check_operator_workspace.py together with this expression: line 194 still requires the exact old Boolean(ribbon.device && ribbon.device.scenarioApplying) text, so running python3 apps/arstack_studio/tests/check_operator_workspace.py now fails with SV STOP must remain actionable while the acknowledged scenario transaction is pending. Because .github/workflows/arstack-studio-qt.yml runs this script, the Studio CI job fails for every revision containing this commit even though the new contract check added later passes.

AGENTS.md reference: AGENTS.md:L230-L236

Useful? React with 👍 / 👎.

// One deliberate click while 4I+4V is preparing remains armed only for
// this verified board boot. No automatic START after USB reconnect/flash.
property bool startIntentWaiting: false
property string startIntentBootId: ""
function cancelStartIntent() {
startIntentWaiting = false
startIntentBootId = ""
startIntentDeadline.stop()
}
Timer {
id: startIntentDeadline
interval: 8000
repeat: false
onTriggered: {
if (!ribbon.startIntentWaiting) return
ribbon.cancelStartIntent()
controller.showMessage(
"START belum dikirim: persiapan profil 4I+4V melebihi 8 detik. Periksa status board dan coba lagi.", true)
}
}
function deliverReadyStart() {
if (!startIntentWaiting || !ribbon.device || !ribbon.device.deviceVerified) return
if (ribbon.device.bootId !== startIntentBootId ||
smartSession.updatingFirmware || smartSession.updateNeedsBootloaderHelp) {
cancelStartIntent()
return
}
if (ribbon.device.running || ribbon.device.scenarioApplying) {
cancelStartIntent()
return
}
if (!smartSession.startReady) return
cancelStartIntent()
// Snapshot is taken at the actual verified start boundary, not from
// stale startup defaults. No hidden retries after a rejected command.
const snapshot = controller.startScenarioSnapshot()
if (!snapshot) return
if (!smartSession.requestStartWithScenario(snapshot)) {
controller.pendingApply = true
controller.showMessage("START ditolak board. Periksa status koneksi dan diagnostics.", true)
}
}
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()
}
}
function onDeviceIdentityChanged() {
if (ribbon.startIntentWaiting && ribbon.device.bootId !== ribbon.startIntentBootId)
ribbon.cancelStartIntent()
}
}
property bool updatePromptDeferred: false
Expand Down Expand Up @@ -123,6 +173,7 @@ SurfacePanel {
Connections {
target: smartSession
function onReadyForLiveApply() {
if (ribbon.startIntentWaiting) Qt.callLater(ribbon.deliverReadyStart)
// The current board SHOW response does not contain per-channel
// amplitude/phase/quality. Never silently claim readback or
// overwrite a board with a restored/offline local draft.
Expand All @@ -132,6 +183,7 @@ SurfacePanel {
// No auto-transmit on device discovery/reconnection.
}
function onStateChanged() {
if (ribbon.startIntentWaiting) Qt.callLater(ribbon.deliverReadyStart)
if (ribbon.firmwareGateActive)
controller.profileDirty = true

Expand Down Expand Up @@ -271,9 +323,17 @@ 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.
controller.showMessage("START waiting: " + ribbon.startReason(), true)
if (ribbon.device.deviceVerified &&
smartSession.state === "PREPARING 4I+4V" &&
ribbon.device.bootId.length > 0) {
ribbon.startIntentBootId = ribbon.device.bootId
ribbon.startIntentWaiting = true
startIntentDeadline.restart()
controller.showMessage(
"START diterima. Menunggu profil 4I+4V siap; injeksi dimulai setelah board terverifikasi.", false)
} else {
controller.showMessage("START belum siap: " + ribbon.startReason(), true)
}
return
}
// A START always transmits the current 4I+4V table snapshot.
Expand All @@ -291,6 +351,13 @@ SurfacePanel {
}

function requestStop() {
// An operator STOP cancels a queued one-click intent before any
// firmware command can be dispatched.
if (startIntentWaiting) {
cancelStartIntent()
controller.showMessage("START dibatalkan. Board tetap STOP.", 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
28 changes: 27 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,10 @@ 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 'ribbon.device.scenarioApplying) || ribbon.startIntentWaiting' in source("WorkflowBar.qml")
and 'if (startIntentWaiting)' in source("WorkflowBar.qml")
and 'cancelStartIntent()' 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 +540,29 @@ 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")

# Operator desktop layout: bounded injector column, space for plots,
# no extra QML START authority and no abandoned first-click intent.
layout = source("Main.qml")
ribbon = source("ModernRibbon.qml")
workflow = source("WorkflowBar.qml")
require("SplitView.maximumWidth: 660" in layout
and "SplitView.preferredWidth: root.compactLayout ? 505 : 610" in layout
and "SplitView.preferredWidth: root.compactLayout ? 420 : 560" in layout
and "run-bolt.svg" in ribbon and "stop-bolt.svg" in ribbon
and "assets/material/run-bolt.svg" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
and "assets/material/stop-bolt.svg" in (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8")
and 'text: bar.device.running ? "RUN" : "STOP"' in ribbon
and "property bool startIntentWaiting: false" in workflow
and "interval: 8000" in workflow
and "startIntentBootId" in workflow
and "function cancelStartIntent()" in workflow
and "function deliverReadyStart()" in workflow
and "smartSession.state === \"PREPARING 4I+4V\"" in workflow
and "if (startIntentWaiting)" in workflow
and "smartSession.requestStartWithScenario(snapshot)" in workflow,
"Studio must offer proportional engineering panels, verified bolt RUN/STOP "
"and first-click START through a bounded same-boot cancellable intent")

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