Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 14 additions & 3 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@ ApplicationWindow {
function onDeviceMessage(message) {
root.showMessage(message, false)
}
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)
}
}
function onDeviceVerifiedChanged() {
root.pendingApply = true
root.offlinePreviewRunning = false
Expand Down Expand Up @@ -771,11 +779,14 @@ ApplicationWindow {
Label {
text: !device.deviceVerified
? "No ESP32 board detected · Preview mode only"
: device.portName + " · ESP32-P4" + (workflowBar.injectionRunning ? " · Injecting" : " · Ready")
color: device.deviceVerified ? studioTheme.textSoft : studioTheme.muted
: 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: Font.Medium
font.weight: workflowBar.unexpectedSvStop ? Font.DemiBold : Font.Medium
}
Rectangle { width: 1; height: 12; color: studioTheme.lineSoft }
Label {
Expand Down
12 changes: 12 additions & 0 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,18 @@ SurfacePanel {
}
}

// Never display green READY immediately after an unsolicited board STOP.
// 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.startQueued)
readonly property string displayState: {
if (ribbon.unexpectedSvStop) {
if (ribbon.device.svStopReason === "LEASE_EXPIRED") return "SV stopped · USB control lease expired"
if (ribbon.device.svStopReason === "RUNTIME_FAULT") return "SV stopped · board runtime fault"
return "SV stopped · checking board cause"
}
if (smartSession.state === "WAITING FOR DEVICE") return "Connect device"
if (smartSession.state === "DEVICE FOUND" || smartSession.state === "CONNECTING") return "Connecting"
if (smartSession.state === "CHECKING DEVICE") return "Checking device"
Expand All @@ -209,6 +220,7 @@ SurfacePanel {
}

readonly property color smartStateColor: {
if (ribbon.unexpectedSvStop) return theme.red
if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green
if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE" ||
smartSession.state === "FIRMWARE RECOVERY" || smartSession.state === "PREPARING 4I+4V" ||
Expand Down
35 changes: 35 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class DeterministicSessionHarness final {
{"SV STOP remains responsive with full command queue", svPriorityStopBypassesFullQueue()},
{"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()},
{"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 @@ -980,6 +981,40 @@ class DeterministicSessionHarness final {
return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty();
}

static bool boardStopCausePersistsUntilVerifiedRestart() {
Fixture f;
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"))
return false;
d.processLine(QStringLiteral(
"I ar_smv_ctrl: SVLIFE state=STOPPED stop_cause=LEASE_EXPIRED "
"stop_seq=1 stop_uptime_ms=33000 stop_hb_age_ms=2500 "
"hb_age_ms=2600 hb_seen=1 lease_ms=2500 watchdog=1"));
if (d.svStopReason_ != QStringLiteral("LEASE_EXPIRED") ||
d.svLastStopCause_ != QStringLiteral("LEASE_EXPIRED") ||
!d.logText_.contains(QStringLiteral("BOARD_STOP_READBACK")))
return false;
// Repeated polling of the same event must be idempotent.
d.processLine(QStringLiteral(
"SVLIFE state=STOPPED stop_cause=LEASE_EXPIRED stop_seq=1"));
if (d.svStopReason_ != QStringLiteral("LEASE_EXPIRED"))
return false;
// An already restarted board can still return its last-stop history.
d.processLine(QStringLiteral(
"SVLIFE state=RUNNING stop_cause=LEASE_EXPIRED stop_seq=1"));
if (!d.svStopReason_.isEmpty() ||
d.svLastStopCause_ != QStringLiteral("LEASE_EXPIRED")) return false;
// Operator STOP must never be reported as spontaneous lease loss.
d.setRunning(true);
d.operatorStopPending_ = true;
d.processLine(QStringLiteral(
"SVLIFE state=STOPPED stop_cause=OPERATOR_STOP stop_seq=2"));
return d.svStopReason_.isEmpty();
}

static bool forcedFirmwareStopRetainsCauseAndIsNotOperatorAck() {
Fixture f;
auto& d = f.device;
Expand Down
62 changes: 62 additions & 0 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) {
return true;
}
bool DeviceController::stop() {
operatorStopPending_ = true;
appendLog(QStringLiteral("SVTX"), QStringLiteral(
"OPERATOR_STOP_REQUEST session=%1 boot=%2 running=%3 cfg_pending=%4")
.arg(sessionGeneration_).arg(identity_.bootId)
Expand Down Expand Up @@ -1751,6 +1752,12 @@ void DeviceController::failScenarioStart(const QString& message) {

void DeviceController::setRunning(const bool value) {
if (running_ == value) return;
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();
}
Expand Down Expand Up @@ -1918,6 +1925,11 @@ void DeviceController::processLine(const QString& rawLine) {
failScenarioStart(QStringLiteral("Unexpected SV START ACK identity/hash."));
return;
}
operatorStopPending_ = false;
if (!svStopReason_.isEmpty()) {
svStopReason_.clear();
emit svStopReasonChanged();
}
// Publish verified RUNNING before clearing the pending START phase.
// Otherwise QML can briefly see neither RUNNING nor STARTING and
// bounce the button back to START during a successful ACK handoff.
Expand All @@ -1943,6 +1955,11 @@ 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")) {
svStopReason_ = QStringLiteral("RUNTIME_FAULT");
emit svStopReasonChanged();
}
operatorStopPending_ = false;
setRunning(false);
Comment on lines +1962 to 1963

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 Preserve operator-stop intent through the state transition

For a normal operator STOP while running_ is true, clearing operatorStopPending_ before calling setRunning(false) makes that method classify the transition as unsolicited and set svStopReason_ to AWAITING_BOARD_CAUSE. The UI therefore shows a red unexpected-stop warning after an intentional STOP until a later SVLIFE readback clears it; perform the running transition while the operator intent is still set.

AGENTS.md reference: AGENTS.md:L74-L86

Useful? React with 👍 / 👎.

}
if (line.contains(QStringLiteral("Control session lease expired"), Qt::CaseInsensitive) ||
Expand All @@ -1953,6 +1970,44 @@ void DeviceController::processLine(const QString& rawLine) {
appendLog(QStringLiteral("SVTX"), QStringLiteral(
"FIRMWARE_STOP_CAUSE session=%1 boot=%2 detail=%3")
.arg(sessionGeneration_).arg(identity_.bootId).arg(line));
const QString reason = line.contains(QStringLiteral("Control session lease expired"), Qt::CaseInsensitive)
? QStringLiteral("LEASE_EXPIRED") : QStringLiteral("RUNTIME_FAULT");
if (svLastStopCause_ != reason) {
svLastStopCause_ = reason;
emit svStopReasonChanged();
}
if (svStopReason_ != reason) {
svStopReason_ = reason;
emit svStopReasonChanged();
Comment on lines +1979 to +1981

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Mark lease expiry as a stopped state

When the board emits Control session lease expired, firmware has already disabled SV output, but this branch only records the reason and leaves running_ true. Because WorkflowBar.unexpectedSvStop also requires !device.running, Studio continues showing green Injection running until a later SHOW response updates the state; that poll can be delayed or suppressed during other transactions. Transition to stopped when this authoritative lease-expiry event is received.

AGENTS.md reference: AGENTS.md:L74-L86

Useful? React with 👍 / 👎.

}
}

// Board-owned latched event survives the small console log window and
// is returned with SHOW even after the operator has restarted SV.
static const QRegularExpression kSvLife(
QStringLiteral("SVLIFE state=(RUNNING|STOPPED) stop_cause=([A-Z_]+) stop_seq=(\\d+)"));
const auto svLife = kSvLife.match(line);
if (svLife.hasMatch()) {
bool seqOk = false;
const auto seq = svLife.captured(3).toULongLong(&seqOk);
if (seqOk && (seq >= lastSvStopSeq_ || !deviceVerified_)) {
lastSvStopSeq_ = seq;
Comment on lines +1993 to +1994

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 Reset STOP sequence tracking when the boot ID changes

The firmware's stop_seq restarts at zero on every boot, but a same-COM soft reboot leaves lastSvStopSeq_ intact and keeps deviceVerified_ true. If the previous boot reached a higher sequence, this comparison rejects every new boot's STOP cause until its counter catches up, leaving Studio with a stale reason or AWAITING_BOARD_CAUSE; scope the sequence to the boot ID or reset it in the existing new-boot path.

AGENTS.md reference: AGENTS.md:L74-L86

Useful? React with 👍 / 👎.

const QString lastReason = svLife.captured(2);
if (seq > 0U && svLastStopCause_ != lastReason) {
svLastStopCause_ = lastReason;
emit svStopReasonChanged();
}
const bool boardStopped = svLife.captured(1) == QStringLiteral("STOPPED");
const QString effective = boardStopped && lastReason != QStringLiteral("OPERATOR_STOP") &&
lastReason != QStringLiteral("NONE") ? lastReason : QString{};
if (svStopReason_ != effective) {
svStopReason_ = effective;
appendLog(QStringLiteral("SVTX"), QStringLiteral(
"BOARD_STOP_READBACK session=%1 boot=%2 cause=%3 seq=%4")
.arg(sessionGeneration_).arg(identity_.bootId).arg(lastReason).arg(seq));
emit svStopReasonChanged();
}
}
}

auto match = kStateExpression.match(line);
Expand Down Expand Up @@ -2146,6 +2201,13 @@ void DeviceController::processLine(const QString& rawLine) {
}

void DeviceController::resetTelemetry() {
operatorStopPending_ = false;
lastSvStopSeq_ = 0;
svLastStopCause_.clear();
if (!svStopReason_.isEmpty()) {
svStopReason_.clear();
emit svStopReasonChanged();
}
fps_ = QStringLiteral("—");
missed_ = QStringLiteral("—");
txFailures_ = QStringLiteral("—");
Expand Down
9 changes: 9 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ class DeviceController : public QObject {
Q_PROPERTY(QStringList capabilities READ capabilities NOTIFY deviceIdentityChanged)
Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
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 scenarioApplying READ scenarioApplying NOTIFY scenarioStateChanged)
Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Expand Down Expand Up @@ -140,6 +142,8 @@ class DeviceController : public QObject {
[[nodiscard]] DeviceIdentity deviceIdentity() const;
[[nodiscard]] bool connected() const noexcept;
[[nodiscard]] bool running() const noexcept;
[[nodiscard]] QString svStopReason() const { return svStopReason_; }
[[nodiscard]] QString svLastStopCause() const { return svLastStopCause_; }
[[nodiscard]] bool scenarioApplying() const noexcept;
[[nodiscard]] QString portName() const;
[[nodiscard]] QString lastError() const;
Expand Down Expand Up @@ -295,6 +299,7 @@ class DeviceController : public QObject {
void deviceIdentityChanged();
void connectedChanged();
void runningChanged();
void svStopReasonChanged();
void lastErrorChanged();
void logTextChanged();
void syncDiagnosticsChanged();
Expand Down Expand Up @@ -416,6 +421,10 @@ class DeviceController : public QObject {
int identifyAttempts_{0};
int highConfidenceCount_{0};
bool running_{false};
bool operatorStopPending_{false};
QString svStopReason_;
QString svLastStopCause_;
quint64 lastSvStopSeq_{0};
bool connected_{false};
bool discovering_{false};
bool deviceVerified_{false};
Expand Down
3 changes: 3 additions & 0 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -979,6 +979,9 @@ QString SmartSessionController::diagnosticsText() const {
add(QStringLiteral("Profile generation"), device_->profileGeneration());
add(QStringLiteral("Profile armed"), device_->profileArmed() ? QStringLiteral("yes") : QStringLiteral("no"));
add(QStringLiteral("SMV running"), device_->running() ? QStringLiteral("yes") : QStringLiteral("no"));
add(QStringLiteral("Current SV stop condition (board readback)"), device_->svStopReason());
add(QStringLiteral("Last SV stop cause (same board, persists across restart)"),
device_->svLastStopCause());
add(QStringLiteral("SMV FPS reported"), device_->fps());
add(QStringLiteral("SMV missed reported"), device_->missed());
add(QStringLiteral("SMV TX failures reported"), device_->txFailures());
Expand Down
21 changes: 21 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,27 @@ def main() -> None:
'ptpRuntimeRunningSeen_' in device_control_cpp and
'if (!ptpRuntimeRoleSeen_ || !ptpRuntimeRunningSeen_' in device_control_cpp,
"PTP errors must originate on board and two-order runtime proofs must be joined")
# Control-session lease is a safety authority, not a QML watchdog or
# clock qualification. One boot-lifetime periodic timer eliminates the
# old racing one-shot stop/rearm from the ESP console and timer callback.
life_header = (STUDIO / "src/DeviceController.hpp").read_text(encoding="utf-8")
life_firmware = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/live_control.cpp").read_text(encoding="utf-8")
life_qml = source("WorkflowBar.qml")
require("esp_timer_start_periodic(" in life_firmware
and "kControlWatchdogPeriodUs = 250000ULL" in life_firmware
and "void enforce_control_lease() noexcept" in life_firmware
and "g_control_lease_mux" in life_firmware
and "esp_timer_start_once(" not in life_firmware
and "SVLIFE state=%s stop_cause=%s" in life_firmware
and "SvStopCause::lease_expired" in life_firmware
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 "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")

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")
Expand Down
Loading
Loading