diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c3d738d23..3ee451d17 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -156,8 +156,14 @@ 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 string lastPtpPhyLink: "UNKNOWN" + property string lastSessionPresentationState: "" property bool phasorDockVisible: true property bool phasorDetached: false property bool waveformDockVisible: true @@ -196,6 +202,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 +215,28 @@ 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 = "" + 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 !== "" && @@ -226,7 +254,24 @@ ApplicationWindow { Connections { target: workflowBar.session + function onStateChanged() { + const currentState = workflowBar.session.state + // 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) + 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 +295,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 +336,7 @@ ApplicationWindow { interval: 2600 onTriggered: { root.transientMessage = "" + root.transientDetail = "" root.transientError = false root.statusMessage = "" } @@ -310,18 +363,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|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 + // 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 +985,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 +1496,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 } } diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml index da68a7a7c..5c2fe3626 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 ? 124 : 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 + } + } } } diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 4422674b8..e24f21960 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.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, + "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")