From b52d981544abaa316f5c1bf0e95a5c71c8d3f52e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:31:51 +0700 Subject: [PATCH 1/7] feat(ui): replace small footer toast with calm center-screen actionable notice --- apps/arstack_studio/qml/StatusToast.qml | 80 +++++++++++++++++++------ 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml index da68a7a7c..a90941e26 100644 --- a/apps/arstack_studio/qml/StatusToast.qml +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -1,38 +1,80 @@ // SPDX-License-Identifier: GPL-3.0-or-later +// Center-screen operator notice. Presentation ONLY: dismissal never changes +// board state, acknowledges a fault, or retries PTP/SV behind the operator. import QtQuick import QtQuick.Controls +import QtQuick.Layouts Rectangle { id: toast + objectName: "centralOperatorToast" property var theme property string uiFont: "Inter" property string message: "" + property string detail: "" property bool error: false - implicitWidth: Math.min(620, messageLabel.implicitWidth + 30) - implicitHeight: 36 - radius: 6 - color: "#18212b" + width: Math.min(490, parent ? Math.max(260, parent.width - 36) : 490) + implicitHeight: message.length > 0 ? (detail.length > 0 ? 104 : 72) : 0 + radius: 12 + z: 900 + color: "#1b2632" border.width: 1 - border.color: error ? "#70404a" : "#36536f" + border.color: error ? "#b65b62" : "#557287" opacity: message.length > 0 ? 1 : 0 - scale: message.length > 0 ? 1 : 0.985 + scale: message.length > 0 ? 1 : 0.97 visible: opacity > 0 - Behavior on opacity { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } - Behavior on scale { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } + Behavior on opacity { NumberAnimation { duration: 170; easing.type: Easing.OutCubic } } + Behavior on scale { NumberAnimation { duration: 170; easing.type: Easing.OutCubic } } - Label { - id: messageLabel + RowLayout { anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 - text: toast.message - color: toast.error ? "#ffb3bb" : toast.theme.textSoft - font.family: toast.uiFont - font.pixelSize: 9 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - elide: Text.ElideRight + anchors.margins: 16 + spacing: 12 + + Rectangle { + Layout.alignment: Qt.AlignTop + Layout.preferredWidth: 30 + Layout.preferredHeight: 30 + radius: 15 + color: toast.error ? "#503038" : "#274255" + Label { + anchors.centerIn: parent + text: toast.error ? "!" : "i" + color: toast.error ? "#ffb8bf" : "#b9daec" + font.family: toast.uiFont + font.pixelSize: 16 + font.weight: Font.Bold + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 5 + Label { + Layout.fillWidth: true + text: toast.message + color: "#f2f7fb" + font.family: toast.uiFont + font.pixelSize: 13 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + maximumLineCount: 2 + elide: Text.ElideRight + } + Label { + Layout.fillWidth: true + visible: toast.detail.length > 0 + text: toast.detail + color: "#b7c5d4" + font.family: toast.uiFont + font.pixelSize: 11 + wrapMode: Text.WordWrap + maximumLineCount: 3 + elide: Text.ElideRight + } + } } } From ed6ce6e99bbc3c06301782646e6919d8a209dee8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:33:18 +0700 Subject: [PATCH 2/7] feat(studio): central actionable error toast across Main/Advanced, preserve history, dedupe and suppress telemetry noise --- apps/arstack_studio/qml/Main.qml | 120 ++++++++++++++++++++++++++++--- 1 file changed, 111 insertions(+), 9 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c3d738d23..749ce16d7 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -156,8 +156,12 @@ ApplicationWindow { property real activePhase: 0.0 property real activeQuality: 0 property string transientMessage: "" + property string transientDetail: "" property string statusMessage: "" property bool transientError: false + property string lastOperatorNoticeKey: "" + property double lastOperatorNoticeMs: 0 + property bool lastPtpStartPending: false property bool phasorDockVisible: true property bool phasorDetached: false property bool waveformDockVisible: true @@ -196,6 +200,7 @@ ApplicationWindow { 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 + readonly property string toastDetail: transientDetail readonly property bool toastError: transientError SclProfileModel { id: sclProfiles } @@ -208,7 +213,18 @@ ApplicationWindow { root.profileDirty = false } function onDeviceMessage(message) { - root.showMessage(message, false) + // DeviceController::setError() emits deviceMessage after updating + // lastError. Previously all real firmware faults arrived as INFO + // and were silently relegated to the 9px footer. + root.showMessage(message, message === device.lastError) + } + function onPtpStateChanged() { + const pending = device.ptpStarting + // New explicit START = new operator action; do not suppress a + // repeated physical fault merely because its code is the same. + if (pending && !root.lastPtpStartPending) + root.lastOperatorNoticeKey = "" + root.lastPtpStartPending = pending } function onSvStopReasonChanged() { if (!device.running && device.svStopReason !== "" && @@ -227,6 +243,11 @@ ApplicationWindow { Connections { target: workflowBar.session function onFirmwareUpdateFinished(success) { + if (success) + root.showMessage("Firmware updated and verified on the board.", false) + else + root.showMessage("Firmware update failed: " + + workflowBar.session.firmwareUpdateResult, true) // Reset during firmware install stops the board's PTP SOURCE. // If the operator explicitly enabled it in this live Studio // session, restore once after the verified board is ready. @@ -250,6 +271,13 @@ ApplicationWindow { if (!session || !device.deviceVerified || !session.startReady || session.updatingFirmware || device.ptpStarting) return root.ptpDefaultStartAttempted = true + if (device.ptpEthernetLink === "DOWN" || + device.ptpEthernetDriver === "STOPPED") { + // This default is a convenience, not an excuse to send a + // doomed START without a board-proven physical Ethernet link. + root.showMessage("ETHERNET_LINK_DOWN: PTP auto-start held", true) + return + } if (!device.ptpRunning && !root.startPtpRole("SOURCE")) root.showMessage("PTP could not start automatically; use PTP On to retry.", true) } @@ -284,6 +312,7 @@ ApplicationWindow { interval: 2600 onTriggered: { root.transientMessage = "" + root.transientDetail = "" root.transientError = false root.statusMessage = "" } @@ -310,18 +339,80 @@ ApplicationWindow { ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready."; isError: false } } + // Single operator notification policy for Main + Advanced. The original + // error remains in the bounded event history and Copy diagnostics; only + // the short, actionable explanation is shown over the controls. + function operatorNotice(raw, failure) { + const m = String(raw || "").trim() + const linkDown = /ETHERNET_LINK_DOWN|Ethernet PHY link is DOWN/i.test(m) || + (/PTP/i.test(m) && device.ptpEthernetLink === "DOWN") + if (linkDown) return { + key: "ptp-link-down", + title: "PTP belum bisa dimulai — Ethernet terputus", + detail: "Hubungkan port Ethernet ESP32-P4 ke laptop atau switch yang aktif. Tunggu LINK menyala, lalu tekan Start PTP lagi.", + error: true + } + if (/ETHERNET_DRIVER_NOT_STARTED|ETHERNET_DRIVER_UNAVAILABLE|ETHERNET_DRIVER_NOT_READY/i.test(m)) + return { key: "ptp-driver", title: "Driver Ethernet board belum siap", + detail: "Periksa status Driver di Time Sync. Jika tetap STOPPED, restart board dan coba lagi.", error: true } + if (/ETHERNET_TX_INVALID_STATE|ESP_ERR_INVALID_STATE/i.test(m)) + return { key: "ptp-mac-tx", title: "PTP gagal mengirim lewat Ethernet", + detail: "EMAC menolak TX. Periksa Driver dan PHY di Time Sync, lalu Copy diagnostics.", error: true } + if (/LEASE_EXPIRED|control.session.lease.expired/i.test(m)) + return { key: "sv-heartbeat", title: "Injeksi SV berhenti — komunikasi terputus", + detail: "Periksa USB/COM dan koneksi Studio–board sebelum menekan Start lagi.", error: true } + if (/RUNTIME_FAULT|FIRMWARE_STOP_CAUSE/i.test(m)) + return { key: "sv-fault", title: "Injeksi SV dihentikan oleh board", + detail: "Buka Copy diagnostics untuk melihat fault. Pastikan board siap sebelum Start ulang.", error: true } + if (/PTP .*failed|PTP .*rejected|PTP .*timed out|PTP could not start|PTP did not restart|PTP source did not|PTP start did not/i.test(m)) + return { key: "ptp-failed", title: "PTP belum berhasil aktif", + detail: "Periksa Driver/PHY pada Time Sync. Detail penyebab tersedia di Copy diagnostics.", error: true } + if (/firmware.*(fail|reject|error|timeout)|firmware update failed/i.test(m)) + return { key: "firmware-failed", title: "Pembaruan firmware belum berhasil", + detail: "Periksa koneksi USB dan tahap update di Advanced → Firmware. Lihat diagnostics sebelum mencoba lagi.", error: true } + if (/profile.*(fail|reject|error|blocked)|configuration.*(fail|reject)/i.test(m)) + return { key: "profile-failed", title: "Konfigurasi belum diterapkan", + detail: "Periksa profil dan koneksi board. Gunakan Copy diagnostics untuk rincian.", error: true } + if (/capture.*(fail|reject|error|unavailable)/i.test(m)) + return { key: "capture-failed", title: "Capture belum berhasil", + detail: "Periksa adapter Ethernet yang dipilih dan akses capture. Lihat detail diagnostik.", error: true } + if (failure) + return { key: "error:" + m.slice(0, 100), title: "Perintah belum berhasil", + detail: m.length > 190 ? m.slice(0, 187) + "…" : m, error: true } + if (/copied|saved|verified|firmware updated|successfully|completed/i.test(m)) + return { key: "info:" + m.slice(0, 90), title: "Selesai", + detail: m.length > 180 ? m.slice(0, 177) + "…" : m, error: false } + // High-frequency telemetry and routine informational chatter never + // steal focus or overwrite a currently displayed fault. + return null + } + function showMessage(message, error) { - statusMessage = message - transientMessage = error === true ? message : "" - transientError = error === true + const raw = String(message || "").trim() + if (raw.length === 0) return + statusMessage = raw statusHistoryModel.append({ timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"), - messageText: message, + messageText: raw, isError: error === true }) while (statusHistoryModel.count > 100) statusHistoryModel.remove(0) - messageTimer.restart() + + const notice = operatorNotice(raw, error === true) + if (!notice) return + const now = Date.now() + if (notice.key === lastOperatorNoticeKey && now - lastOperatorNoticeMs < 8000) + return + if (messageTimer.running && transientError && !notice.error) + return // a success/info notice must not bury an active failure + lastOperatorNoticeKey = notice.key + lastOperatorNoticeMs = now + transientMessage = notice.title + transientDetail = notice.detail + transientError = notice.error + messageTimer.interval = notice.error ? 4600 : 2800 + messageTimer.restart() // toast clears; board fault remains unchanged } function detachPhasor() { @@ -870,6 +961,17 @@ ApplicationWindow { monoFont: root.monoFont } } + + // The active Advanced window is its own native Window; an overlay + // attached only to Main.qml is invisible behind this window. + StatusToast { + anchors.centerIn: parent + theme: studioTheme + uiFont: root.uiFont + message: root.toastMessage + detail: root.toastDetail + error: root.toastError + } } Window { @@ -1370,12 +1472,12 @@ ApplicationWindow { } StatusToast { - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: 38 + anchors.centerIn: parent + visible: root.toastMessage.length > 0 && !configurationWindow.visible theme: studioTheme uiFont: root.uiFont message: root.toastMessage + detail: root.toastDetail error: root.toastError } } From 92c554ff4ce20b438bd6673b0a2a0307b81bccc0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:34:53 +0700 Subject: [PATCH 3/7] feat(studio): surface terminal session faults and verified PHY recovery; retried PTP failures still notify --- apps/arstack_studio/qml/Main.qml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 749ce16d7..fe7acfa5a 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -162,6 +162,7 @@ ApplicationWindow { property string lastOperatorNoticeKey: "" property double lastOperatorNoticeMs: 0 property bool lastPtpStartPending: false + property string lastPtpPhyLink: "UNKNOWN" property bool phasorDockVisible: true property bool phasorDetached: false property bool waveformDockVisible: true @@ -224,7 +225,17 @@ ApplicationWindow { // repeated physical fault merely because its code is the same. if (pending && !root.lastPtpStartPending) root.lastOperatorNoticeKey = "" + if (!pending && root.lastPtpStartPending && !device.ptpRunning && + device.ptpLastStartFailure.length > 0) + root.showMessage(device.ptpLastStartFailure, true) root.lastPtpStartPending = pending + + // Report a real PHY recovery once, never an inferred USB link. + const phy = device.ptpEthernetLink + if (root.lastPtpPhyLink === "DOWN" && phy === "UP" && + device.ptpEthernetDriver === "STARTED") + root.showMessage("Ethernet ESP32-P4 connected and verified.", false) + root.lastPtpPhyLink = phy } function onSvStopReasonChanged() { if (!device.running && device.svStopReason !== "" && @@ -242,6 +253,11 @@ ApplicationWindow { Connections { target: workflowBar.session + function onStateChanged() { + const currentState = workflowBar.session.state + if (/ERROR|FAILED|BLOCKED/i.test(currentState)) + root.showMessage(workflowBar.session.statusText, true) + } function onFirmwareUpdateFinished(success) { if (success) root.showMessage("Firmware updated and verified on the board.", false) @@ -379,7 +395,7 @@ ApplicationWindow { if (failure) return { key: "error:" + m.slice(0, 100), title: "Perintah belum berhasil", detail: m.length > 190 ? m.slice(0, 187) + "…" : m, error: true } - if (/copied|saved|verified|firmware updated|successfully|completed/i.test(m)) + if (/copied|saved|verified|firmware updated|successfully|completed|Ethernet ESP32-P4 connected/i.test(m)) return { key: "info:" + m.slice(0, 90), title: "Selesai", detail: m.length > 180 ? m.slice(0, 177) + "…" : m, error: false } // High-frequency telemetry and routine informational chatter never From 3788f1a82ac12fd9492fe8d177643794dfae0a91 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:35:25 +0700 Subject: [PATCH 4/7] test(ui): guarantee center-screen actionable toast on Main/Advanced, errors, dedupe and event history --- .../tests/check_operator_workspace.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 4422674b8..8981cf314 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -101,6 +101,30 @@ def main() -> None: and "Firmware recovery" in ribbon_qml, "never infer missing firmware from USB timeout; retry identification and prioritize semantic-verified ports") main_qml = source("Main.qml") + toast_qml = source("StatusToast.qml") + require(main_qml.count("StatusToast {") == 2 + and main_qml.count("anchors.centerIn: parent") >= 2 + and "anchors.bottom: parent.bottom" not in main_qml[main_qml.rfind("StatusToast {"):] + and "property string transientDetail" in main_qml + and "function operatorNotice(raw, failure)" in main_qml + and "function showMessage(message, error)" in main_qml + and "root.showMessage(message, message === device.lastError)" in main_qml + and "onStateChanged()" in main_qml + and "root.showMessage(workflowBar.session.statusText, true)" in main_qml + and "root.showMessage(device.ptpLastStartFailure, true)" in main_qml + and "ETHERNET_LINK_DOWN" in main_qml + and "Hubungkan port Ethernet ESP32-P4" in main_qml + and "ptpEthernetLink === \"DOWN\"" in main_qml + and "lastOperatorNoticeMs" in main_qml + and "now - lastOperatorNoticeMs < 8000" in main_qml + and "messageTimer.interval = notice.error ? 4600 : 2800" in main_qml + and "statusHistoryModel.append({" in main_qml + and "anchors.centerIn: parent" not in toast_qml + and 'objectName: "centralOperatorToast"' in toast_qml + and "wrapMode: Text.WordWrap" in toast_qml + and "property string detail" in toast_qml, + "PTP/firmware/session faults and attention must display briefly in active-window " + "center with actionable PHY advice, bounded dedupe and retained history") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml") expert = source("PtpExpertPanel.qml") From 7d6c7e7b9ac35e3851ca0404b958fd60af9f44c5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:37:52 +0700 Subject: [PATCH 5/7] test(ui): allow centered icon inside toast while forbidding footer placement --- apps/arstack_studio/tests/check_operator_workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 8981cf314..e24f21960 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -119,7 +119,7 @@ def main() -> None: and "now - lastOperatorNoticeMs < 8000" in main_qml and "messageTimer.interval = notice.error ? 4600 : 2800" in main_qml and "statusHistoryModel.append({" in main_qml - and "anchors.centerIn: parent" not in toast_qml + and "anchors.bottom: parent.bottom" not in toast_qml and 'objectName: "centralOperatorToast"' in toast_qml and "wrapMode: Text.WordWrap" in toast_qml and "property string detail" in toast_qml, From 8dc4562e548292bf20907631ecfe347cc3021dd4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:38:44 +0700 Subject: [PATCH 6/7] fix(ui): emit terminal session failure toast once per state transition, not per telemetry update --- apps/arstack_studio/qml/Main.qml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index fe7acfa5a..3ee451d17 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -163,6 +163,7 @@ ApplicationWindow { property double lastOperatorNoticeMs: 0 property bool lastPtpStartPending: false property string lastPtpPhyLink: "UNKNOWN" + property string lastSessionPresentationState: "" property bool phasorDockVisible: true property bool phasorDetached: false property bool waveformDockVisible: true @@ -255,8 +256,15 @@ ApplicationWindow { target: workflowBar.session function onStateChanged() { const currentState = workflowBar.session.state - if (/ERROR|FAILED|BLOCKED/i.test(currentState)) + // A stable terminal fault is one operator event, not a toast + // every time the session emits changed for unrelated telemetry. + if (currentState !== root.lastSessionPresentationState && + /ERROR|FAILED|BLOCKED/i.test(currentState)) root.showMessage(workflowBar.session.statusText, true) + if (currentState !== root.lastSessionPresentationState && + !/ERROR|FAILED|BLOCKED/i.test(currentState)) + root.lastOperatorNoticeKey = "" + root.lastSessionPresentationState = currentState } function onFirmwareUpdateFinished(success) { if (success) From a03da5e5a1de13daa9aa30bf23ed08419b4178d4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 12:38:57 +0700 Subject: [PATCH 7/7] fix(ui): reserve enough space for three-line operator remedy at compact window widths --- apps/arstack_studio/qml/StatusToast.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml index a90941e26..5c2fe3626 100644 --- a/apps/arstack_studio/qml/StatusToast.qml +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -15,7 +15,7 @@ Rectangle { property bool error: false width: Math.min(490, parent ? Math.max(260, parent.width - 36) : 490) - implicitHeight: message.length > 0 ? (detail.length > 0 ? 104 : 72) : 0 + implicitHeight: message.length > 0 ? (detail.length > 0 ? 124 : 72) : 0 radius: 12 z: 900 color: "#1b2632"