Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions apps/arstack_studio/qml/BoardConnectionIndicator.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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.rgba(indicator.stateColor.r, indicator.stateColor.g,
indicator.stateColor.b, 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
}
}
41 changes: 24 additions & 17 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
93 changes: 91 additions & 2 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -1122,10 +1124,96 @@ class DeterministicSessionHarness final {
return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty();
}

static bool boardStopCausePersistsUntilVerifiedRestart() {
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;
if (!f.profileReady) return false;
seedVerified(f, identity(), QStringLiteral("COM7"), true);
auto& d = f.device;
d.processLine(QStringLiteral(
"I ar_smv_ctrl: state=STOPPED generation=3 signal_frequency=50000 mHz"));
if (d.running() || d.svStopReason_ != QStringLiteral("AWAITING_BOARD_CAUSE"))
Expand Down Expand Up @@ -1575,7 +1663,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();
Expand Down
Loading
Loading