Skip to content
144 changes: 135 additions & 9 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -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 !== "" &&
Expand All @@ -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.
Expand All @@ -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)
}
Expand Down Expand Up @@ -284,6 +336,7 @@ ApplicationWindow {
interval: 2600
onTriggered: {
root.transientMessage = ""
root.transientDetail = ""
root.transientError = false
root.statusMessage = ""
}
Expand All @@ -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")
Comment on lines +371 to +372

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Limit link-down mapping to actual PTP failures

When the board reports PHY DOWN, this condition classifies every message containing PTP as a start failure regardless of the failure argument or message semantics. For example, the existing successful Copy diagnostics path sends "PTP and firmware diagnostics copied." from PtpExpertPanel.qml, but in the common link-down troubleshooting state it now displays the Ethernet-disconnected error instead of confirming the copy; restrict this fallback to start/failure messages rather than all PTP text.

Useful? React with 👍 / 👎.

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
Comment on lines +426 to +427

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep routine footer messages transient

For routine messages that operatorNotice() intentionally ignores, this return occurs after assigning statusMessage but before starting messageTimer. Calls such as the existing AC/DC mode notifications therefore remain in the footer indefinitely, whereas previously every showMessage() call restarted the timer; clear these messages with a separate footer timer or preserve the prior timer behavior even when no toast is created.

Useful? React with 👍 / 👎.

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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
80 changes: 61 additions & 19 deletions apps/arstack_studio/qml/StatusToast.qml
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
24 changes: 24 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading