diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index b8460bcb1..a5753c9fe 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -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() } } diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index 1d82c503f..bd29ab381 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -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 : "", @@ -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 diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 213c4dead..25e9a7895 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -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 @@ -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) } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bfd9605db..c3fad02f3 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -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()}, @@ -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; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 506fff1ad..76dd9f49f 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -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; @@ -139,6 +152,7 @@ void SmartSessionController::shutdown() { if (shuttingDown_) return; shuttingDown_ = true; started_ = false; + postFlashVerificationPending_ = false; discoveryTimer_.stop(); prepareTimer_.stop(); reconnectTimer_.stop(); @@ -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; @@ -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(); @@ -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; @@ -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; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index aa9678d36..ffd3f955c 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -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) @@ -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; @@ -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}; diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 5ec9f7a3a..39e86e4f1 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -448,7 +448,7 @@ QVariantMap SvCaptureController::decodePcap( QHash previousCounters; QHash previousSync; QHash 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; @@ -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," @@ -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)}, @@ -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")); @@ -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} diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index 40df159c2..9c1e68efc 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -93,9 +93,9 @@ class SvCaptureHarness { le32(global, 65535); le32(global, 1); file.write(global); - const std::array counts{3999, 0, 2}; - for (int i = 0; i < 3; ++i) { - const QByteArray wire = packet(counts[i], i == 2 ? 1 : 0); + const std::array counts{3999, 0, 2, 3}; + for (std::size_t i = 0; i < counts.size(); ++i) { + const QByteArray wire = packet(counts[i], i == 2U ? 1 : 0); QByteArray header; le32(header, 1000); // Recreate observed field PCAP startup timestamp stall. @@ -134,16 +134,17 @@ class SvCaptureHarness { const QByteArray secondsRows = secondsFile.readAll(); const QByteArray eventRows = eventsFile.readAll(); const bool ok = - parsed.value("sv_frames").toDouble() == 3 && + parsed.value("sv_frames").toDouble() == 4 && parsed.value("missing_sample_counts").toDouble() == 1 && parsed.value("duplicate_sample_counts").toDouble() == 0 && - parsed.value("csv_rows").toDouble() == 3 && + parsed.value("csv_rows").toDouble() == 4 && parsed.value("physical_acceptance").toBool() == false && parsed.value("schema").toString() == "arstack.sv.capture.v2" && parsed.value("status").toString() == "CAPTURED_WITH_ANOMALIES" && parsed.value("ptp_l2_frames").toDouble() == 1 && parsed.value("ptp_l2_message_counts").toObject().value("SYNC").toDouble() == 1 && - parsed.value("sync_transition_count").toDouble() == 1 && + parsed.value("sync_transition_count").toDouble() == 2 && + parsed.value("sync_loss_count").toDouble() == 1 && parsed.value("host_timestamp_gaps_over_1ms").toDouble() == 1 && parsed.value("max_host_timestamp_gap_us").toDouble() == 42389 && parsed.value("malformed_frames").toDouble() == 0 && @@ -151,7 +152,7 @@ class SvCaptureHarness { std::abs(parsed.value("wire_channel_measurements").toObject() .value("IA").toObject().value("rms_wire_counts").toDouble() - 1200.0) < 0.01 && parsed.value("wire_channel_measurements").toObject() - .value("IA").toObject().value("nonzero_quality_frame_count").toDouble() == 3 && + .value("IA").toObject().value("nonzero_quality_frame_count").toDouble() == 4 && parsed.value("fundamental_reference_hz_UNVERIFIED").toDouble() == 50.0 && parsed.value("studio_telemetry_series_UNVERIFIED").toArray().size() == 1 && parsed.value("studio_telemetry_series_UNVERIFIED").toArray()[0] @@ -167,15 +168,17 @@ class SvCaptureHarness { eventRows.contains("SAMPLE_GAP") && eventRows.contains("HOST_CAPTURE_INTERVAL") && eventRows.contains("SMPSYNCH_CHANGE") && + eventRows.contains("1_to_0") && eventRows.contains("PTP_SYNC") && eventRows.contains("sequence=4660") && - result.value("status").toString().contains("3 SV / 1 PTP") && + result.value("status").toString().contains("4 SV / 1 PTP") && // The SAME production report powers the beginner-friendly // in-Studio summary: this is on-wire evidence, not a PASS badge. - result.value("summary").toMap().value("sv_frames").toULongLong() == 3 && + result.value("summary").toMap().value("sv_frames").toULongLong() == 4 && result.value("summary").toMap().value("ptp_frames").toULongLong() == 1 && result.value("summary").toMap().value("missing_samples").toULongLong() == 1 && - result.value("summary").toMap().value("sync_transitions").toULongLong() == 1 && + result.value("summary").toMap().value("sync_transitions").toULongLong() == 2 && + result.value("summary").toMap().value("sync_losses").toULongLong() == 1 && !result.value("summary").toMap().value("physical_acceptance").toBool(); if (!ok) qCritical() << "Failed production PCAP fixture:" << result << parsed; return ok; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 4e5e8766f..bb45d6c4b 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -275,7 +275,7 @@ def main() -> None: "every Start must send the visible 4I+4V table, never reuse stale device angles") require("Qt.callLater(installDialog.open)" not in workflow and "Qt.callLater(updateDialog.open)" not in workflow - and "if (!success) Qt.callLater(resultDialog.open)" in workflow + and "if (!success && !smartSession.firmwareVerificationPending)" in workflow and "installDialog.open()" in workflow and "updateDialog.open()" in workflow, "connection must not pop setup/update dialogs; Start may offer firmware repair") @@ -425,6 +425,24 @@ def main() -> None: "QDesktopServices::openUrl(" in capture_cpp and "QString findDumpcap()" in capture_cpp, "Studio capture results must derive from production wire decoder with click-only setup") + # Physical bench findings: flash succeeded but USB identity returned + # later; an unverified Start was hijacked into an offline preview. + smart = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") + smart_h = (STUDIO / "src/SmartSessionController.hpp").read_text(encoding="utf-8") + workflow = source("WorkflowBar.qml") + require("postFlashVerificationPending_" in smart_h and + "if (postFlashVerificationPending_ && !device_->deviceVerified())" in smart and + "postFlashVerificationPending_ = true;" in smart and + "Firmware written; awaiting board identity" in smart and + "if (firmwareIsCurrent())" in smart and + "Exact bundled firmware build verified after delayed USB reconnection" in smart, + "successful flash must recover delayed USB identity WITHOUT reflash or a fake VERIFIED state") + require('"Connecting"' in ribbon and + '"Preview"' in ribbon and + 'bar.session.state === "WAITING FOR DEVICE" && !bar.device.discovering' in ribbon and + "bar.session.firmwareVerificationPending" in ribbon and + "smartSession.firmwareVerificationPending" in workflow, + "Start must not be silently converted to offline preview during real-board connection") print("Studio operator workspace contract: PASS")