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
26 changes: 20 additions & 6 deletions apps/arstack_studio/qml/ModernRibbon.qml
Original file line number Diff line number Diff line change
Expand Up @@ -90,17 +90,31 @@ Rectangle {
RibbonAction {
theme: bar.theme
uiFont: bar.uiFont
text: bar.workflow.startQueued ? "Starting" : "Start"
tone: !bar.device.deviceVerified ? "accent"
: bar.session.startReady ? "success" : "neutral"
// A connecting physical board must NEVER turn START into an
// unrelated offline preview which looks like a lost first click.
text: bar.workflow.startQueued ? "Starting…"
: bar.session.state === "CONNECTING" || bar.session.state === "CHECKING DEVICE"
|| bar.session.firmwareVerificationPending ? "Connecting"
: !bar.device.deviceVerified ? "Preview" : "Start"
tone: bar.session.startReady ? "success"
: bar.device.deviceVerified ? "neutral" : "accent"
iconSource: Qt.resolvedUrl("../assets/material/play_arrow.svg")
// A physical Start is clickable only when READY, or when the
// operator explicitly needs a firmware/profile recovery prompt.
// PREPARING is not an accepted click: no silent "try again".
enabled: !bar.workflow.injectionRunning && !bar.workflow.startQueued && !bar.controller.offlinePreviewRunning
&& !bar.session.updatingFirmware && !bar.session.updateNeedsBootloaderHelp
toolTipText: !enabled && bar.device.deviceVerified ? bar.workflow.startReason() : ""
&& (bar.device.deviceVerified
? (bar.session.startReady || bar.session.firmwareInstallRequired ||
bar.session.firmwareUpdateRequired || bar.session.profileSyncRetryAvailable)
: (bar.session.state === "WAITING FOR DEVICE" && !bar.device.discovering &&
bar.device.ports.length === 0))
toolTipText: !enabled ? bar.workflow.startReason() :
!bar.device.deviceVerified ? "Offline preview only; no Ethernet SV output" : ""
onClicked: {
if (!bar.device.deviceVerified) {
// The footer already identifies preview-only operation.
bar.controller.offlinePreviewRunning = true
if (bar.session.state === "WAITING FOR DEVICE" && !bar.device.discovering)
bar.controller.offlinePreviewRunning = true
} else bar.workflow.requestStart()
}
}
Expand Down
10 changes: 9 additions & 1 deletion apps/arstack_studio/qml/SvCapturePanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ Rectangle {
// Read already-published Studio state passively. This never queues serial
// commands; sample it at 1Hz so PTP/SV transitions can be time-correlated.
function telemetrySnapshot() {
// Keep relevant embedded phase/egress diagnostics with the joint
// PCAP without issuing any extra serial commands. The final 1.5 KB is
// intentionally bounded at 1 Hz, including long endurance captures.
var logTail = panel.device ? String(panel.device.logText).slice(-1536) : ""
return {
"firmware_log_tail_UNVERIFIED": logTail,
"captured_utc": new Date().toISOString(),
"operator_frequency_hz_UNVERIFIED": panel.signalFrequency,
"firmware_version": panel.device ? panel.device.firmwareVersion : "",
Expand Down Expand Up @@ -218,7 +223,10 @@ Rectangle {
}
Label {
Layout.columnSpan: 4
text: "Wire capture saved · Physical synchronization is not yet verified"
text: Number(capture.summary.sync_losses) > 0
? "SYNC UNSTABLE · " + String(capture.summary.sync_losses) +
" loss event(s). Do not release; review phase/MAC evidence."
: "Wire capture saved · Physical synchronization is not yet verified"
color: panel.theme.textSoft
font.family: panel.uiFont
font.pixelSize: 9
Expand Down
9 changes: 8 additions & 1 deletion apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,12 @@ SurfacePanel {
ribbon.firmwareResultTitle = "Firmware installed successfully"
ribbon.firmwareResultMessage = "ESP32-P4 reconnected and verified."
controller.showMessage("Firmware installed and verified successfully.", false)
} else if (smartSession.firmwareVerificationPending) {
// The write completed; Windows USB/CDC identity is pending.
// Nonmodal state keeps automatic IDENTIFY-only recovery active.
ribbon.firmwareResultTitle = "Firmware written · waiting for board"
ribbon.firmwareResultMessage = smartSession.firmwareUpdateResult
controller.showMessage("Firmware written. Press RESET or reconnect USB; Studio will verify automatically.", false)
} else {
ribbon.firmwareResultTitle = "Firmware not verified"
ribbon.firmwareResultMessage = smartSession.firmwareUpdateResult.length > 0
Expand All @@ -188,7 +194,8 @@ SurfacePanel {
// Installation completion is a normal successful transition,
// not another mandatory acknowledgment dialog. Retain full
// diagnostics and a modal only when verification failed.
if (!success) Qt.callLater(resultDialog.open)
if (!success && !smartSession.firmwareVerificationPending)
Qt.callLater(resultDialog.open)
}
}

Expand Down
66 changes: 66 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ class DeterministicSessionHarness final {
{"firmware stage projection -> supervisor-owned", firmwareStageProjectionIsExplicit()},
{"firmware verification failure remains copyable after READY", firmwareFailureRemainsDiagnosable()},
{"firmware build mismatch explains expected and observed IDs", reconnectBuildMismatchPreservesEvidence()},
{"post-flash USB timeout stays pending, delayed exact ID recovers", postFlashDelayedIdentityRecovers()},
{"post-flash delayed wrong build never verifies", postFlashWrongBuildFailsClosed()},
{"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()},
{"verified COM hint falls back without bypassing identity", verifiedComOrderingPreservesFallback()},
{"automatic IDENTIFY timeout never implies missing firmware", automaticIdentityTimeoutOffersFirmwareRecovery()},
Expand Down Expand Up @@ -359,6 +361,70 @@ class DeterministicSessionHarness final {
session.diagnosticsText().contains(evidence);
}

static bool postFlashDelayedIdentityRecovers() {
Fixture fixture;
if (!fixture.profileReady) return false;
auto& session = fixture.session;
auto& device = fixture.device;
quiesce(session, device);
session.started_ = false;
session.updateRequested_ = true;
session.updateWasOptional_ = false;
session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;
session.updateReconnectAttempts_ = 6;
session.portOwner_ = SmartSessionController::PortOwner::none;
int successCount = 0, pendingCount = 0;
QObject::connect(&session, &SmartSessionController::firmwareUpdateFinished,
&session, [&](const bool success) {
if (success) ++successCount;
else ++pendingCount;
});
// Fire the real supervisor's reconnect timeout; no serial, sleeps,
// fake flash success or event-loop race in this native regression.
if (!QMetaObject::invokeMethod(
&session.reconnectTimer_, "timeout", Qt::DirectConnection)) return false;
const bool pending = session.firmwareVerificationPending() &&
!session.updateRequested_ && !session.setupError_ &&
session.state() == QStringLiteral("CONNECTING") &&
!session.startReady() && pendingCount == 1 && successCount == 0 &&
session.firmwareUpdateResult().contains(QStringLiteral("Do not flash again"));
seedVerified(fixture, identity(), QStringLiteral("COM7"), false);
return pending && !session.firmwareVerificationPending() &&
successCount == 1 && pendingCount == 1 &&
session.state() == QStringLiteral("READY") &&
session.firmwareUpdateResult().contains(
QStringLiteral("Exact bundled firmware build verified"));
}

static bool postFlashWrongBuildFailsClosed() {
Fixture fixture;
if (!fixture.profileReady) return false;
auto& session = fixture.session;
auto& device = fixture.device;
quiesce(session, device);
session.started_ = false;
session.updateRequested_ = true;
session.updateStage_ = SmartSessionController::UpdateStage::reconnecting;
session.updateReconnectAttempts_ = 6;
session.portOwner_ = SmartSessionController::PortOwner::none;
int successfulVerifications = 0;
QObject::connect(&session, &SmartSessionController::firmwareUpdateFinished,
&session, [&](const bool success) {
if (success) ++successfulVerifications;
});
if (!QMetaObject::invokeMethod(
&session.reconnectTimer_, "timeout", Qt::DirectConnection)) return false;
auto wrong = identity();
wrong.buildId = QStringLiteral("fedcba9876543210");
seedVerified(fixture, wrong, QStringLiteral("COM7"), false);
return !session.firmwareVerificationPending() &&
successfulVerifications == 0 &&
session.state() == QStringLiteral("READY") &&
session.firmwareUpdateAvailable() &&
session.firmwareUpdateResult().contains(
QStringLiteral("does not match the bundled build"));
}

static bool optionalBootloaderWaitCanCancel() {
Fixture fixture;
if (!fixture.profileReady) return false;
Expand Down
42 changes: 40 additions & 2 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,21 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent
return;
}
if (updateReconnectAttempts_ >= kMaxUpdateReconnectAttempts) {
latchFirmwareFailure(QStringLiteral(
"Firmware was written, but Studio could not reconnect and verify the ESP32-P4. Reconnect USB and retry firmware setup."));
// Flash write and ROM verification already succeeded. A delayed
// Windows USB CDC identity is NOT grounds to claim failed flash.
// Retire the installer timeout, but let semantic-only discovery
// continue. Never retry erase/write automatically.
reconnectTimer_.stop();
updateRequested_ = false;
updateWasOptional_ = false;
updateStage_ = UpdateStage::idle;
postFlashVerificationPending_ = true;
setupError_ = false;
setupErrorStatus_.clear();
lastFirmwareUpdateResult_ = QStringLiteral(
"Firmware written; waiting for post-reset ESP32-P4 identity. "
"Press RESET once or reconnect USB; Studio will check the exact build automatically. "
"Do not flash again.");
emit firmwareUpdateFinished(false);
reconcile();
return;
Expand All @@ -139,6 +152,7 @@ void SmartSessionController::shutdown() {
if (shuttingDown_) return;
shuttingDown_ = true;
started_ = false;
postFlashVerificationPending_ = false;
discoveryTimer_.stop();
prepareTimer_.stop();
reconnectTimer_.stop();
Expand Down Expand Up @@ -514,6 +528,7 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) {
if (device_ == nullptr || firmware_ == nullptr || port.isEmpty() || firmware_->busy()) return false;

updatePort_ = port;
postFlashVerificationPending_ = false;
updateRequested_ = true;
updateReconnectAttempts_ = 0;
pendingReleaseGeneration_ = 0;
Expand Down Expand Up @@ -674,6 +689,20 @@ void SmartSessionController::reconnectDeviceSignals() {
connect(device_, &DeviceController::deviceVerifiedChanged, this, [this] {
if (device_ != nullptr && device_->deviceVerified()) {
if (portOwner_ != PortOwner::firmwareTool) setPortOwner(PortOwner::deviceSession);
if (postFlashVerificationPending_) {
postFlashVerificationPending_ = false;
refreshFirmwareIdentity();
if (firmwareIsCurrent()) {
lastFirmwareUpdateResult_ = QStringLiteral(
"Exact bundled firmware build verified after delayed USB reconnection.");
emit firmwareUpdateFinished(true);
} else {
lastFirmwareUpdateResult_ = QStringLiteral(
"Board reconnected but firmware does not match the bundled build. "
"Review identity before attempting any firmware operation.");
emit firmwareUpdateFinished(false);
}
}
clearBlankBoardContext();
refreshFirmwareIdentity();
profileSyncBootId_.clear();
Expand Down Expand Up @@ -990,6 +1019,7 @@ QString SmartSessionController::diagnosticsText() const {

void SmartSessionController::latchFirmwareFailure(QString message) {
reconnectTimer_.stop();
postFlashVerificationPending_ = false;
const bool optionalOperation = updateWasOptional_;
updateRequested_ = false;
updateWasOptional_ = false;
Expand Down Expand Up @@ -1232,6 +1262,14 @@ void SmartSessionController::reconcile() {
return;
}

if (postFlashVerificationPending_ && !device_->deviceVerified()) {
setPresentation(
QStringLiteral("CONNECTING"),
QStringLiteral("Firmware written; awaiting board identity. "
"Press RESET or reconnect USB. Studio checks identity, never auto-reflashes."),
false, false);
return;
}
if (device_->discovering() || (device_->connected() && !device_->deviceVerified())) {
setPresentation(QStringLiteral("CONNECTING"), device_->discoveryStatus(), false, false);
return;
Expand Down
3 changes: 3 additions & 0 deletions apps/arstack_studio/src/SmartSessionController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class SmartSessionController : public QObject {
Q_PROPERTY(QString state READ state NOTIFY stateChanged)
Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged)
Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged)
Q_PROPERTY(bool firmwareVerificationPending READ firmwareVerificationPending NOTIFY stateChanged)
Q_PROPERTY(bool canDeployProfile READ canDeployProfile NOTIFY stateChanged)
Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged)
Q_PROPERTY(bool engineeringEditable READ engineeringEditable NOTIFY stateChanged)
Expand Down Expand Up @@ -76,6 +77,7 @@ class SmartSessionController : public QObject {
[[nodiscard]] QString state() const;
[[nodiscard]] QString statusText() const;
[[nodiscard]] bool startReady() const noexcept;
[[nodiscard]] bool firmwareVerificationPending() const noexcept { return postFlashVerificationPending_; }
[[nodiscard]] bool canDeployProfile() const noexcept;
[[nodiscard]] bool liveControlReady() const noexcept;
[[nodiscard]] bool engineeringEditable() const noexcept;
Expand Down Expand Up @@ -403,6 +405,7 @@ class SmartSessionController : public QObject {
bool wasReady_{false};
bool needsProfileSync_{true};
bool updateRequested_{false};
bool postFlashVerificationPending_{false};
bool updateWasOptional_{false};
bool blankBoardDetected_{false};
bool manualRecoveryArmed_{false};
Expand Down
12 changes: 10 additions & 2 deletions apps/arstack_studio/src/SvCaptureController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ QVariantMap SvCaptureController::decodePcap(
QHash<QString, quint32> previousCounters;
QHash<QString, quint32> previousSync;
QHash<QString, quint64> previousTimestamps;
quint64 ptpFrames = 0, malformedPtp = 0, syncTransitions = 0;
quint64 ptpFrames = 0, malformedPtp = 0, syncTransitions = 0, syncRevocations = 0;
quint64 sampleJumpEvents = 0, backwardOrResetEvents = 0;
QVariantMap ptpMessageCounts;
QVariantList transitionTimeline;
Expand Down Expand Up @@ -594,6 +594,11 @@ QVariantMap SvCaptureController::decodePcap(
const auto oldSync = previousSync.constFind(streamKey);
if (oldSync != previousSync.cend() && oldSync.value() != record.synch) {
++syncTransitions;
// A demotion of advertised synchronized data to NOT_SYNCED is a
// physical-release blocker even when ALL smpCnt slots are present.
// A promotion from LOCAL(1) to GLOBAL(2) is NOT a loss.
if ((oldSync.value() == 1U || oldSync.value() == 2U) &&
record.synch == 0U) ++syncRevocations;
eventStream << timestamp << ",SMPSYNCH_CHANGE,"
<< record.src << ',' << record.svId
<< ',' << record.count << ',' << record.count << ",0,0,"
Expand Down Expand Up @@ -704,6 +709,7 @@ QVariantMap SvCaptureController::decodePcap(
{QStringLiteral("ptp_malformed_frames"), qulonglong(malformedPtp)},
{QStringLiteral("ptp_l2_message_counts"), ptpMessageCounts},
{QStringLiteral("sync_transition_count"), qulonglong(syncTransitions)},
{QStringLiteral("sync_loss_count"), qulonglong(syncRevocations)},
{QStringLiteral("sync_transitions"), transitionTimeline},
{QStringLiteral("counter_discontinuity_events"), qulonglong(sampleJumpEvents)},
{QStringLiteral("counter_reset_or_reorder_events"), qulonglong(backwardOrResetEvents)},
Expand All @@ -730,7 +736,8 @@ QVariantMap SvCaptureController::decodePcap(
};
const bool usable = parseFailure.isEmpty() && matched > 0;
const bool anomalies = missing > 0 || duplicates > 0 ||
malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0;
malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0 ||
syncRevocations > 0;
report.insert(QStringLiteral("status"), !usable ? QStringLiteral("INVALID_CAPTURE")
: anomalies ? QStringLiteral("CAPTURED_WITH_ANOMALIES")
: QStringLiteral("CAPTURED_NOT_ACCEPTED"));
Expand All @@ -750,6 +757,7 @@ QVariantMap SvCaptureController::decodePcap(
{QStringLiteral("ptp_frames"), qulonglong(ptpFrames)},
{QStringLiteral("missing_samples"), qulonglong(missing)},
{QStringLiteral("sync_transitions"), qulonglong(syncTransitions)},
{QStringLiteral("sync_losses"), qulonglong(syncRevocations)},
{QStringLiteral("observed_fps"), observedFps},
{QStringLiteral("status"), report.value(QStringLiteral("status"))},
{QStringLiteral("physical_acceptance"), false}
Expand Down
Loading
Loading