From 6222fd6d15331fdf149e3b49cbe51c82d01c4c73 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:01:01 +0700 Subject: [PATCH 01/14] fix(ui): distinguish live unexpected SV STOP from board-latched stop history --- apps/arstack_studio/src/DeviceController.hpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 7a95095df..232da8d8e 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -53,6 +53,8 @@ class DeviceController : public QObject { Q_PROPERTY(bool running READ running NOTIFY runningChanged) Q_PROPERTY(QString svStopReason READ svStopReason NOTIFY svStopReasonChanged) Q_PROPERTY(QString svLastStopCause READ svLastStopCause NOTIFY svStopReasonChanged) + Q_PROPERTY(bool svUnexpectedStopObserved READ svUnexpectedStopObserved NOTIFY svStopReasonChanged) + Q_PROPERTY(quint64 svUnexpectedStopIncident READ svUnexpectedStopIncident NOTIFY svStopReasonChanged) Q_PROPERTY(QString svTxFaultDetail READ svTxFaultDetail NOTIFY svStopReasonChanged) Q_PROPERTY(bool scenarioApplying READ scenarioApplying NOTIFY scenarioStateChanged) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) @@ -152,8 +154,10 @@ class DeviceController : public QObject { [[nodiscard]] bool running() const noexcept; [[nodiscard]] QString svStopReason() const { return svStopReason_; } [[nodiscard]] QString svLastStopCause() const { return svLastStopCause_; } + [[nodiscard]] bool svUnexpectedStopObserved() const noexcept { return svUnexpectedStopObserved_; } + [[nodiscard]] quint64 svUnexpectedStopIncident() const noexcept { return svUnexpectedStopIncident_; } [[nodiscard]] QString svTxFaultDetail() const { - return svTxFaultSeq_ != 0U && svTxFaultSeq_ == lastSvStopSeq_ + return svUnexpectedStopObserved_ && svTxFaultSeq_ != 0U && svTxFaultSeq_ == lastSvStopSeq_ ? svTxFaultDetail_ : QString{}; } [[nodiscard]] bool scenarioApplying() const noexcept; @@ -456,6 +460,10 @@ class DeviceController : public QObject { bool operatorStopPending_{false}; QString svStopReason_; QString svLastStopCause_; + // Only an observed unexpected RUNNING -> STOPPED edge in this Studio + // connection may turn persisted board stop history into an active alarm. + bool svUnexpectedStopObserved_{false}; + quint64 svUnexpectedStopIncident_{0}; // monotonically increasing within this UI instance quint64 lastSvStopSeq_{0}; quint64 svTxFaultSeq_{0}; QString svTxFaultDetail_; From 1933e2d1249deedaf8c2d679769b399e7c7cfd29 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:01:51 +0700 Subject: [PATCH 02/14] fix(ui): classify SV faults by observed session transition, not persisted SVLIFE history --- apps/arstack_studio/src/DeviceController.cpp | 51 +++++++++++++++----- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 1a0db9ed4..4a422669d 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1941,6 +1941,24 @@ void DeviceController::failScenarioStart(const QString& message) { void DeviceController::setRunning(const bool value) { if (running_ == value) return; + const bool unexpectedEdge = running_ && !value && !operatorStopPending_ && + scenarioStage_ == ScenarioStage::idle; + bool presentationChanged = false; + if (value) { + // A new verified RUNNING state ends the old active incident, without + // erasing the board's sticky stop history in diagnostics. + if (svUnexpectedStopObserved_ || !svStopReason_.isEmpty()) { + svUnexpectedStopObserved_ = false; + svStopReason_.clear(); + presentationChanged = true; + } + } else if (unexpectedEdge) { + svUnexpectedStopObserved_ = true; + ++svUnexpectedStopIncident_; + if (svStopReason_.isEmpty()) + svStopReason_ = QStringLiteral("AWAITING_BOARD_CAUSE"); + presentationChanged = true; + } appendLog(QStringLiteral("SVTX"), QStringLiteral( "RUNNING_EDGE previous=%1 next=%2 operator_stop=%3 session=%4 boot=%5 stop_reason=%6 last_cause=%7") .arg(running_ ? 1 : 0).arg(value ? 1 : 0) @@ -1959,14 +1977,10 @@ void DeviceController::setRunning(const bool value) { static_cast(sendQuietCommand(QStringLiteral("SHOW"))); }); } - if (!value && running_ && !operatorStopPending_ && svStopReason_.isEmpty()) { - // Do not silently turn a physical STOP into a green READY state. - // SHOW/SVLIFE will replace this interim state with the board cause. - svStopReason_ = QStringLiteral("AWAITING_BOARD_CAUSE"); - emit svStopReasonChanged(); - } running_ = value; emit runningChanged(); + // Emit after running_ changes so QML observes an atomic incident state. + if (presentationChanged) emit svStopReasonChanged(); } void DeviceController::setIdentificationState(const IdentificationState state) { @@ -2206,12 +2220,15 @@ void DeviceController::processLine(const QString& rawLine) { .arg(forced ? QStringLiteral("RUNTIME_FAIL_SAFE_STOP_OBSERVED") : QStringLiteral("STOP_ACK_OBSERVED")) .arg(sessionGeneration_).arg(identity_.bootId).arg(running_ ? 1 : 0)); - if (forced && svStopReason_ != QStringLiteral("RUNTIME_FAULT")) { + // A log line by itself can be a boot/USB backlog. Only an observed + // local RUNNING -> STOPPED incident may raise a live alarm. + setRunning(false); // preserve operatorStopPending_ until edge classification + operatorStopPending_ = false; + if (forced && svUnexpectedStopObserved_ && + svStopReason_ != QStringLiteral("RUNTIME_FAULT")) { svStopReason_ = QStringLiteral("RUNTIME_FAULT"); emit svStopReasonChanged(); } - operatorStopPending_ = false; - setRunning(false); } if (line.contains(QStringLiteral("Control session lease expired"), Qt::CaseInsensitive) || line.contains(QStringLiteral("Control session heartbeat could not rearm"), Qt::CaseInsensitive) || @@ -2227,7 +2244,11 @@ void DeviceController::processLine(const QString& rawLine) { svLastStopCause_ = reason; emit svStopReasonChanged(); } - if (svStopReason_ != reason) { + // Firmware diagnostic text does not prove the STOP happened in this + // connection. Preserve it in svLastStopCause, never awaken a stale + // red toast while the board is simply READY at startup. + if (!operatorStopPending_ && (running_ || svUnexpectedStopObserved_) && + svStopReason_ != reason) { svStopReason_ = reason; emit svStopReasonChanged(); } @@ -2249,7 +2270,9 @@ void DeviceController::processLine(const QString& rawLine) { emit svStopReasonChanged(); } const bool boardStopped = svLife.captured(1) == QStringLiteral("STOPPED"); - const QString effective = boardStopped && lastReason != QStringLiteral("OPERATOR_STOP") && + const QString effective = + boardStopped && svUnexpectedStopObserved_ && + lastReason != QStringLiteral("OPERATOR_STOP") && lastReason != QStringLiteral("NONE") ? lastReason : QString{}; if (svStopReason_ != effective) { svStopReason_ = effective; @@ -2545,7 +2568,11 @@ void DeviceController::resetTelemetry() { operatorStopPending_ = false; lastSvStopSeq_ = 0; const bool hadStopDetails = !svLastStopCause_.isEmpty() || - !svTxFaultDetail_.isEmpty() || !svStopReason_.isEmpty(); + !svTxFaultDetail_.isEmpty() || !svStopReason_.isEmpty() || + svUnexpectedStopObserved_; + svUnexpectedStopObserved_ = false; + // Incident sequence intentionally monotonic across COM reconnects, so + // the same QML window never de-duplicates a new real fault as an old one. svLastStopCause_.clear(); svStopReason_.clear(); svTxFaultDetail_.clear(); From 20f73292837d71f8f5408069c86ef1602ef28bb6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:02:38 +0700 Subject: [PATCH 03/14] feat(ui): compact multi-color hardware COM connection status indicator --- .../qml/BoardConnectionIndicator.qml | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 apps/arstack_studio/qml/BoardConnectionIndicator.qml diff --git a/apps/arstack_studio/qml/BoardConnectionIndicator.qml b/apps/arstack_studio/qml/BoardConnectionIndicator.qml new file mode 100644 index 000000000..1a224966e --- /dev/null +++ b/apps/arstack_studio/qml/BoardConnectionIndicator.qml @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import ARStack.Studio 1.0 + +// Read-only projection of the real serial connection and session supervisor. +// Do not infer a hardware fault from latched firmware STOP history. +Item { + id: indicator + + required property var device + required property var session + required property var theme + property string uiFont: "Inter" + property bool compact: false + + readonly property string supervisorState: session ? String(session.state) : "" + readonly property bool portOpen: Boolean(device && device.connected) + readonly property bool verified: Boolean(device && device.deviceVerified) + readonly property string stateKey: { + if (!portOpen) + return device && device.discovering ? "connecting" : "disconnected" + if (!verified) + return (supervisorState === "SETUP ERROR" || + (device.identificationState === DeviceController.Unidentified && + !device.discovering)) ? "error" : "connecting" + if (!device.running && device.svUnexpectedStopObserved) + return "error" + if (supervisorState === "SETUP ERROR" || + supervisorState === "PROFILE SYNC ERROR" || + supervisorState === "PROFILE BLOCKED") + return "error" + if (device.running) return "running" + if (supervisorState === "READY") return "ready" + return "preparing" + } + readonly property color stateColor: { + switch (stateKey) { + case "connecting": return theme.accent + case "preparing": return theme.amber + case "ready": + case "running": return theme.green + case "error": return theme.red + default: return theme.muted2 + } + } + readonly property string stateCaption: { + switch (stateKey) { + case "connecting": return "CONNECTING" + case "preparing": return "PREPARING" + case "ready": return "READY" + case "running": return "RUNNING" + case "error": return "ERROR" + default: return "DISCONNECTED" + } + } + readonly property string portCaption: + portOpen && device.portName.length ? device.portName : "BOARD" + readonly property string detail: { + if (stateKey === "error") { + if (device.svUnexpectedStopObserved) + return "SV stopped in this Studio session: " + + (device.svTxFaultDetail || device.svStopReason) + + ". See diagnostics." + return "Board connected, but verification or setup needs attention: " + + supervisorState + } + if (stateKey === "ready") return "ESP32-P4 verified; ready for an operator START" + if (stateKey === "running") return "SV transmitter running (board reported; wire still requires independent capture)" + if (stateKey === "preparing") return "ESP32-P4 connected and verified; preparing injection profile" + if (stateKey === "connecting") return "Searching for or verifying the serial connection" + return "No verified ESP32-P4 connection; offline edit is available" + } + + implicitHeight: 20 + implicitWidth: compact ? 180 : 210 + + RowLayout { + anchors.fill: parent + spacing: 6 + Rectangle { + Layout.preferredWidth: 14 + Layout.preferredHeight: 14 + radius: 7 + color: "transparent" + border.width: 1 + border.color: Qt.alpha(indicator.stateColor, 0.44) + Rectangle { + anchors.centerIn: parent + width: 8 + height: 8 + radius: 4 + color: indicator.stateColor + Behavior on color { ColorAnimation { duration: 150 } } + } + } + Label { + text: indicator.portCaption + color: indicator.verified ? indicator.theme.textSoft : indicator.theme.muted + font.family: indicator.uiFont + font.pixelSize: 10 + font.weight: Font.Medium + Layout.maximumWidth: indicator.compact ? 56 : 75 + elide: Text.ElideRight + } + Label { + text: indicator.stateCaption + color: indicator.stateColor + font.family: indicator.uiFont + font.pixelSize: 9 + font.weight: Font.DemiBold + Layout.fillWidth: true + elide: Text.ElideRight + } + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + ToolTip.visible: containsMouse + ToolTip.delay: 750 + ToolTip.text: indicator.detail + } +} From 5d1b4656e2bbdccfdb9cce856461066c6f05b721 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:02:58 +0700 Subject: [PATCH 04/14] fix(ui): alert on observed unexpected SV stop only; add colored COM board status --- apps/arstack_studio/qml/Main.qml | 41 +++++++++++++++++++------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 0259539f5..0fe3dd671 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -229,6 +229,9 @@ ApplicationWindow { readonly property bool canStart: workflowBar.session ? workflowBar.session.startReady : false readonly property string toastMessage: transientMessage readonly property bool toastError: transientError + // One toast per real unsolicited stop edge, not per historical SVLIFE + // readback or additional TX-fault detail arriving during SHOW. + property double lastNotifiedSvStopIncident: 0 SclProfileModel { id: sclProfiles } DeviceController { id: device } @@ -247,12 +250,18 @@ ApplicationWindow { root.showMessage(device.lastError, true) } function onSvStopReasonChanged() { - if (!device.running && device.svStopReason !== "" && - device.svStopReason !== "AWAITING_BOARD_CAUSE") { - root.showMessage( - "ESP32-P4 stopped SV: " + device.svStopReason + - ". PTP state is independent. Open Copy diagnostics.", true) - } + // SVLIFE reports the previous stop even when the board is healthy + // and idle after reconnect. Toast only a new in-session fault. + if (!device.deviceVerified || device.running || + !device.svUnexpectedStopObserved || + device.svStopReason === "" || + device.svStopReason === "AWAITING_BOARD_CAUSE" || + device.svUnexpectedStopIncident <= root.lastNotifiedSvStopIncident) + return + root.lastNotifiedSvStopIncident = device.svUnexpectedStopIncident + root.showMessage( + "SV stopped unexpectedly (" + device.svStopReason + + "). Open diagnostics for the current-board TX error.", true) } function onDeviceVerifiedChanged() { root.pendingApply = true @@ -934,17 +943,15 @@ ApplicationWindow { anchors.rightMargin: 12 spacing: 8 - Label { - text: !device.deviceVerified - ? "No ESP32 board detected · Preview mode only" - : device.portName + " · ESP32-P4 · " + workflowBar.displayState - color: workflowBar.unexpectedSvStop ? studioTheme.red - : device.deviceVerified ? studioTheme.textSoft : studioTheme.muted - Layout.maximumWidth: root.compactLayout ? 255 : 410 - elide: Text.ElideRight - font.family: root.uiFont - font.pixelSize: 9 - font.weight: workflowBar.unexpectedSvStop ? Font.DemiBold : Font.Medium + BoardConnectionIndicator { + id: boardConnection + device: device + session: workflowBar.session + theme: studioTheme + uiFont: root.uiFont + compact: root.compactLayout + Layout.preferredWidth: implicitWidth + Layout.preferredHeight: implicitHeight } Rectangle { width: 1; height: 12; color: studioTheme.lineSoft } Label { From c45004a278d5d27c66666253e770e3243cef2c87 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:03:11 +0700 Subject: [PATCH 05/14] fix(ui): red workflow state requires current-session SV stop incident --- apps/arstack_studio/qml/WorkflowBar.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 9788555f8..ff52d3e3e 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -275,7 +275,8 @@ SurfacePanel { // The board-reported reason is distinct from the physical PTP role. readonly property bool unexpectedSvStop: Boolean( ribbon.device && ribbon.device.deviceVerified && - !ribbon.device.running && ribbon.device.svStopReason.length > 0 && + !ribbon.device.running && ribbon.device.svUnexpectedStopObserved && + ribbon.device.svStopReason.length > 0 && !ribbon.startQueued) readonly property string displayState: { if (ribbon.injectionRunning && smartSession.state !== "RUNNING") From 623ebfcf957c15bbe6784c75bb82e598988db2fd Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:03:19 +0700 Subject: [PATCH 06/14] build(ui): register compact board connection indicator in Qt QML module --- apps/arstack_studio/CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 79db977ea..ffc5b1923 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -111,6 +111,7 @@ qt_add_qml_module(arstack_studio qml/DarkToolButton.qml qml/TelemetryDock.qml qml/WorkflowBar.qml + qml/BoardConnectionIndicator.qml qml/ModernRibbon.qml qml/RibbonGroup.qml qml/RibbonAction.qml From 617b5cfc970fac5cb679287a9f8ecd57ac8d4c54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:04:18 +0700 Subject: [PATCH 07/14] test(ui): prove old SV stop history is quiet on startup and real stops alert once --- .../src/DeterministicSessionHarness.cpp | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5725c1577..2ba4da994 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -69,6 +69,8 @@ class DeterministicSessionHarness final { {"control lease heartbeat coalesces outside saturated exclusive queue", heartbeatKeepsBoundedSafetySlotUnderExclusiveLoad()}, {"board fail-safe STOP is not mislabelled as operator STOP ACK", forcedFirmwareStopRetainsCauseAndIsNotOperatorAck()}, {"latched board STOP cause overrides READY and survives console truncation", boardStopCausePersistsUntilVerifiedRestart()}, + {"old board SV fault at startup is diagnostic history, not an active alarm", historicSvStopDoesNotTriggerStartupAlarm()}, + {"live SV fault has one active incident and manual STOP stays quiet", liveSvStopIsNewIncidentOnly()}, {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, @@ -1122,6 +1124,91 @@ class DeterministicSessionHarness final { return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty(); } + static bool historicSvStopDoesNotTriggerStartupAlarm() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM15"), false); + auto& d = f.device; + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=RUNTIME_FAULT stop_seq=7 " + "stop_uptime_ms=1308494 hb_seen=1")); + if (d.svLastStopCause() != QStringLiteral("RUNTIME_FAULT") || + !d.svStopReason().isEmpty() || d.svUnexpectedStopObserved() || + d.svUnexpectedStopIncident() != 0U) return false; + + // First SHOW after reconnect may contain the last TX error and/or + // trailing serial log lines from before the current Studio session. + d.processLine(QStringLiteral( + "SVTXFAULT state=STOPPED svlife_stop_seq=7 burst_now=0 " + "last_stop_seq=3 last_stop_first_error=263 last_stop_last_error=263 " + "last_stop_link_up=1")); + d.processLine(QStringLiteral("SV transport fault: 400 consecutive TX failures")); + d.processLine(QStringLiteral( + "STOP accepted: SV transmission suppressed by fail-closed runtime")); + d.processLine(QStringLiteral( + "state=STOPPED generation=5 signal_frequency=50000 mHz")); + return !d.running() && !d.svUnexpectedStopObserved() && + d.svUnexpectedStopIncident() == 0U && + d.svStopReason().isEmpty() && + d.svTxFaultDetail().isEmpty() && + d.svLastStopCause() == QStringLiteral("RUNTIME_FAULT") && + f.session.state() == QStringLiteral("READY"); + } + + static bool liveSvStopIsNewIncidentOnly() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM15"), false); + auto& d = f.device; + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=RUNTIME_FAULT stop_seq=3")); + if (d.svUnexpectedStopObserved()) return false; + + // This RUNNING state is seen during the current verified session, + // then the board stops without an operator STOP request. + d.setRunning(true); + d.processLine(QStringLiteral( + "state=STOPPED generation=8 signal_frequency=50000 mHz")); + if (d.running() || !d.svUnexpectedStopObserved() || + d.svStopReason() != QStringLiteral("AWAITING_BOARD_CAUSE") || + d.svUnexpectedStopIncident() != 1U) return false; + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=RUNTIME_FAULT stop_seq=4")); + d.processLine(QStringLiteral( + "SVTXFAULT state=STOPPED svlife_stop_seq=4 burst_now=0 " + "last_stop_seq=1 last_stop_first_error=263 last_stop_last_error=263 " + "last_stop_link_up=1")); + if (d.svStopReason() != QStringLiteral("RUNTIME_FAULT") || + !d.svTxFaultDetail().contains(QStringLiteral("TIMEOUT")) || + d.svUnexpectedStopIncident() != 1U) return false; + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=RUNTIME_FAULT stop_seq=4")); + if (d.svUnexpectedStopIncident() != 1U) return false; + + // An actual subsequent successful START clears the presentation, not + // the board's sticky last-stop cause. Operator STOP is not an incident. + d.setRunning(true); + if (d.svUnexpectedStopObserved() || !d.svStopReason().isEmpty() || + !d.svTxFaultDetail().isEmpty() || + d.svLastStopCause() != QStringLiteral("RUNTIME_FAULT")) return false; + d.operatorStopPending_ = true; + d.setRunning(false); + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=OPERATOR_STOP stop_seq=5")); + if (d.svUnexpectedStopObserved() || !d.svStopReason().isEmpty() || + d.svUnexpectedStopIncident() != 1U) return false; + + // Even if a new COM session sees the same old fault record, it must + // not create a new alarm. The next genuine stop increments the ID. + d.resetTelemetry(); + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=RUNTIME_FAULT stop_seq=5")); + if (d.svUnexpectedStopObserved() || !d.svStopReason().isEmpty()) return false; + d.setRunning(true); + d.setRunning(false); + return d.svUnexpectedStopObserved() && d.svUnexpectedStopIncident() == 2U; + } + static bool boardStopCausePersistsUntilVerifiedRestart() { Fixture f; auto& d = f.device; From c582ea11a7e9944de01f060278e1f981ef6b20a8 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:04:50 +0700 Subject: [PATCH 08/14] test(ui): assert truthful colored COM indicator and session-gated SV alarms --- .../tests/check_operator_workspace.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index a817101ec..704d99c6a 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -608,11 +608,31 @@ def main() -> None: and "SvStopCause::runtime_fault" in life_firmware and "Q_PROPERTY(QString svStopReason READ svStopReason NOTIFY svStopReasonChanged)" in life_header and "ribbon.unexpectedSvStop" in life_qml - and "workflowBar.displayState" in source("Main.qml") + and "BoardConnectionIndicator {" in source("Main.qml") + and "svUnexpectedStopObserved" in life_qml + and "svUnexpectedStopObserved" in device_control_cpp and "function onSvStopReasonChanged()" in source("Main.qml"), "SV output must fail-closed on measured heartbeat loss, preserve board STOP cause, " "and never show misleading green READY after an unrequested stop") + # The bottom-left board indicator must reflect physical serial/identity + # readiness, not a persisted SVLIFE fault from the previous session. + indicator = source("BoardConnectionIndicator.qml") + require('qml/BoardConnectionIndicator.qml' in + (STUDIO / "CMakeLists.txt").read_text(encoding="utf-8") + and "device.connected" in indicator + and "device.deviceVerified" in indicator + and "device.svUnexpectedStopObserved" in indicator + and "supervisorState === \"READY\"" in indicator + and all(value in indicator for value in + ('"disconnected"', '"connecting"', '"preparing"', + '"ready"', '"running"', '"error"')) + and "indicator.stateColor" in indicator + and "lastNotifiedSvStopIncident" in source("Main.qml") + and "device.svUnexpectedStopIncident" in source("Main.qml") + and "if (seqOk && (seq >= lastSvStopSeq_" in device_control_cpp, + "Footer status must use truthful board/COM states and ignore historical SV faults") + # Default workbench should never stretch the 4I+4V setpoint matrix # beyond a useful editing width while starving Phasor/Waveform charts. responsive_main = source("Main.qml") From aa0fb02c5fe04449c787fdd508a5a582062b01ba Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:05:12 +0700 Subject: [PATCH 09/14] fix(ui): use portable Qt.rgba color projection for board LED ring --- apps/arstack_studio/qml/BoardConnectionIndicator.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/BoardConnectionIndicator.qml b/apps/arstack_studio/qml/BoardConnectionIndicator.qml index 1a224966e..7d4612bfa 100644 --- a/apps/arstack_studio/qml/BoardConnectionIndicator.qml +++ b/apps/arstack_studio/qml/BoardConnectionIndicator.qml @@ -85,7 +85,8 @@ Item { radius: 7 color: "transparent" border.width: 1 - border.color: Qt.alpha(indicator.stateColor, 0.44) + border.color: Qt.rgba(indicator.stateColor.r, indicator.stateColor.g, + indicator.stateColor.b, 0.44) Rectangle { anchors.centerIn: parent width: 8 From 08bbdf9c884c402270c94dc3184768f099d7dccf Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:06:39 +0700 Subject: [PATCH 10/14] fix(ui): treat in-place firmware reboot and swapped board as connection events, not SV faults --- apps/arstack_studio/src/DeviceController.cpp | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 4a422669d..c64ff9950 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -2379,7 +2379,13 @@ void DeviceController::processLine(const QString& rawLine) { profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; - setRunning(false); + // A foreign board is a connection/identity fault, not an SV TX + // fault on the previously verified boot. Reset telemetry without + // fabricating a RUNNING -> STOPPED incident. + const bool wasRunning = running_; + running_ = false; + resetTelemetry(); + if (wasRunning) emit runningChanged(); emit profileStateChanged(); setError(QStringLiteral( "Different ESP32-P4 identity appeared on the active serial session; " @@ -2398,7 +2404,13 @@ void DeviceController::processLine(const QString& rawLine) { profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; - setRunning(false); + // A newly detected boot is a connection lifecycle transition, + // not a canonical transmit fault. The previous board RAM stop + // sequence is no longer valid for this boot epoch. + const bool wasRunning = running_; + running_ = false; + resetTelemetry(); + if (wasRunning) emit runningChanged(); resetPtpState(); appendLog(QStringLiteral("!"), QStringLiteral( "Firmware reboot detected on the same COM port; old profile and START revoked.")); From 4cbfc75074d88ab3b1de9451a845a37de9fde4ab Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:06:50 +0700 Subject: [PATCH 11/14] test(ui): same-COM firmware reboot never fabricates SV runtime error --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 2ba4da994..219ab4097 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -1662,7 +1662,8 @@ class DeterministicSessionHarness final { device.processLine(replacement); return device.bootId() == QStringLiteral("FEDCBA9876543210") && device.deviceVerified() && - !device.running() && !device.profileArmed() && + !device.running() && !device.svUnexpectedStopObserved() && + device.svStopReason().isEmpty() && !device.profileArmed() && !device.profileWasReused() && session.profileSyncBootId_ != oldIdentity.bootId && !session.startReady(); From aae0e09e3b5209a12373a67b90800e5e1cf460e4 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:08:41 +0700 Subject: [PATCH 12/14] test(ui): replace obsolete offline-footer literal with new board connection contract --- apps/arstack_studio/tests/check_operator_workspace.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 704d99c6a..cb0659aba 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -433,7 +433,8 @@ def main() -> None: and "signalRow.mapFromItem(field, field.width, field.height)" in matrix, "context menu must align to the editor with compact deterministic geometry") assert "text: \"Generated setpoint · not independent on-wire proof\"" not in preview - assert "No ESP32 board detected · Preview mode only" in main_qml + assert "BoardConnectionIndicator {" in main_qml + assert '"DISCONNECTED"' in source("BoardConnectionIndicator.qml") require("Layout.preferredHeight: 57" in main_qml and "SplitView.maximumHeight: 265" not in main_qml and 'Math.max(170, (workspaceSplit.height - 9) / 2)' in main_qml, From d0aac1a53bf353bdc40fa06147e61f08c9109ce6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:09:23 +0700 Subject: [PATCH 13/14] fix(ui): require verified same-session hardware before raising unexpected SV stop incident --- apps/arstack_studio/src/DeviceController.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index c64ff9950..f38990d26 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1942,7 +1942,7 @@ void DeviceController::failScenarioStart(const QString& message) { void DeviceController::setRunning(const bool value) { if (running_ == value) return; const bool unexpectedEdge = running_ && !value && !operatorStopPending_ && - scenarioStage_ == ScenarioStage::idle; + connected_ && deviceVerified_ && scenarioStage_ == ScenarioStage::idle; bool presentationChanged = false; if (value) { // A new verified RUNNING state ends the old active incident, without From fb81c247fb24cd6b5ac6aad6b3fb27486d620b96 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Sun, 25 Oct 2026 07:09:32 +0700 Subject: [PATCH 14/14] test(ui): model verified board authority in SV incident lifecycle regression --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 219ab4097..39c3cbc55 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -1211,8 +1211,9 @@ class DeterministicSessionHarness final { static bool boardStopCausePersistsUntilVerifiedRestart() { Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), true); auto& d = f.device; - d.setRunning(true); d.processLine(QStringLiteral( "I ar_smv_ctrl: state=STOPPED generation=3 signal_frequency=50000 mHz")); if (d.running() || d.svStopReason_ != QStringLiteral("AWAITING_BOARD_CAUSE"))