diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index c3d738d23..e0ee2e98c 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -210,6 +210,10 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } + function onLastErrorChanged() { + if (device.lastError && device.lastError.length > 0) + root.showMessage(device.lastError, true) + } function onSvStopReasonChanged() { if (!device.running && device.svStopReason !== "" && device.svStopReason !== "AWAITING_BOARD_CAUSE") { @@ -310,10 +314,44 @@ ApplicationWindow { ListElement { timeText: "--:--:--"; messageText: "ARStack Studio ready."; isError: false } } + property string lastToastKey: "" + property double lastToastAt: 0 + function explainFailure(message) { + const m = String(message || "") + if (m.indexOf("ETHERNET_LINK_DOWN") >= 0 || + m.indexOf("Ethernet PHY link is DOWN") >= 0 || + m.indexOf("source did not emit") >= 0 || + m.indexOf("no verified Announce") >= 0) + return "PTP belum bisa aktif. Sambungkan kabel LAN ESP32-P4 ke switch/port Ethernet aktif, tunggu PHY UP, lalu START PTP." + if (m.indexOf("ETHERNET_DRIVER_NOT_STARTED") >= 0 || + m.indexOf("ETHERNET_DRIVER_UNAVAILABLE") >= 0) + return "Driver Ethernet ESP32-P4 belum siap. Periksa koneksi board, lalu restart perangkat bila driver tetap STOPPED." + if (m.indexOf("ETHERNET_TX_INVALID_STATE") >= 0 || + m.indexOf("ESP_ERR_INVALID_STATE") >= 0) + return "PTP TX ditolak EMAC. Periksa Driver/PHY di Advanced → Time Sync; salin diagnostics jika PHY sudah UP." + if (m.indexOf("LEASE_EXPIRED") >= 0) + return "Injeksi SV berhenti: heartbeat kontrol USB terputus. Periksa kabel USB/COM lalu START kembali." + if (m.indexOf("RUNTIME_FAULT") >= 0) + return "Board menghentikan injeksi SV karena gangguan runtime. Buka Copy diagnostics sebelum START ulang." + return m + } function showMessage(message, error) { + if (!message || String(message).trim().length === 0) return statusMessage = message - transientMessage = error === true ? message : "" - transientError = error === true + const critical = error === true + const userMessage = critical ? explainFailure(message) : String(message) + const key = (critical ? "E:" : "I:") + userMessage + const now = Date.now() + const duplicate = key === lastToastKey && now - lastToastAt < 4000 + const meaningful = critical || /accepted|ready|connected|installed|updated|stopped|started|saved|completed|success/i.test(userMessage) + if (meaningful && !duplicate) { + lastToastKey = key + lastToastAt = now + transientMessage = userMessage + transientError = critical + messageTimer.interval = critical ? 4200 : 2300 + messageTimer.restart() + } statusHistoryModel.append({ timeText: new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss"), messageText: message, @@ -321,7 +359,6 @@ ApplicationWindow { }) while (statusHistoryModel.count > 100) statusHistoryModel.remove(0) - messageTimer.restart() } function detachPhasor() { @@ -847,6 +884,15 @@ ApplicationWindow { configurationWindow.hide() } + StatusToast { + anchors.centerIn: parent + z: 1500 + theme: studioTheme + uiFont: root.uiFont + message: root.toastMessage + error: root.toastError + } + DockFrame { anchors.fill: parent anchors.margins: 10 @@ -1370,9 +1416,8 @@ ApplicationWindow { } StatusToast { - anchors.horizontalCenter: parent.horizontalCenter - anchors.bottom: parent.bottom - anchors.bottomMargin: 38 + anchors.centerIn: parent + z: 1500 theme: studioTheme uiFont: root.uiFont message: root.toastMessage diff --git a/apps/arstack_studio/qml/StatusToast.qml b/apps/arstack_studio/qml/StatusToast.qml index da68a7a7c..dce3df27d 100644 --- a/apps/arstack_studio/qml/StatusToast.qml +++ b/apps/arstack_studio/qml/StatusToast.qml @@ -9,12 +9,12 @@ Rectangle { property string message: "" property bool error: false - implicitWidth: Math.min(620, messageLabel.implicitWidth + 30) - implicitHeight: 36 - radius: 6 + implicitWidth: Math.min(520, Math.max(290, messageLabel.implicitWidth + 52)) + implicitHeight: Math.max(60, messageLabel.implicitHeight + 30) + radius: 12 color: "#18212b" border.width: 1 - border.color: error ? "#70404a" : "#36536f" + border.color: error ? "#98505a" : "#36536f" opacity: message.length > 0 ? 1 : 0 scale: message.length > 0 ? 1 : 0.985 visible: opacity > 0 @@ -25,14 +25,19 @@ Rectangle { Label { id: messageLabel anchors.fill: parent - anchors.leftMargin: 14 - anchors.rightMargin: 14 + anchors.leftMargin: 24 + anchors.rightMargin: 24 + anchors.topMargin: 12 + anchors.bottomMargin: 12 text: toast.message color: toast.error ? "#ffb3bb" : toast.theme.textSoft font.family: toast.uiFont - font.pixelSize: 9 + font.pixelSize: 13 + font.weight: Font.DemiBold horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + maximumLineCount: 4 elide: Text.ElideRight } } diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 39a130b75..926083fa0 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2130,9 +2130,16 @@ void DeviceController::processLine(const QString& rawLine) { if (match.hasMatch()) { // Only the board's ETH_EVENT stream may establish this state; // NEVER infer it from COM, profile selection or PTP SOURCE intent. + const QString previousLink = ptpEthernetLink_; ptpEthernetDriver_ = match.captured(1).toUpper(); ptpEthernetLink_ = match.captured(2).toUpper(); emit ptpStateChanged(); + if (previousLink == QStringLiteral("DOWN") && + ptpEthernetLink_ == QStringLiteral("UP") && + ptpEthernetDriver_ == QStringLiteral("STARTED")) { + emit deviceMessage(QStringLiteral( + "Ethernet connected — PHY UP. PTP dapat di-START kembali.")); + } } match = kPtpStatusExpression.match(line); diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 4422674b8..fe344ee67 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -537,6 +537,21 @@ 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") + toast_layout = source("Main.qml") + toast_view = source("StatusToast.qml") + require("function explainFailure(message)" in toast_layout + and "ETHERNET_LINK_DOWN" in toast_layout + and "ETHERNET_TX_INVALID_STATE" in toast_layout + and "function onLastErrorChanged()" in toast_layout + and toast_layout.count("anchors.centerIn: parent") >= 2 + and "lastToastAt" in toast_layout + and "messageTimer.interval = critical ? 4200 : 2300" in toast_layout + and "wrapMode: Text.WordWrap" in toast_view + and "Ethernet connected" in + (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8"), + "PTP physical-link and board failures must generate bounded actionable center-screen toasts " + "in both the main window and Advanced") + clipboard_header = (STUDIO / "src/StudioClipboard.hpp").read_text(encoding="utf-8") clipboard_cpp = (STUDIO / "src/StudioClipboard.cpp").read_text(encoding="utf-8") main_cpp = (STUDIO / "src/main.cpp").read_text(encoding="utf-8")