diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 22f18779c..80e8f8d39 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -32,7 +32,7 @@ Rectangle { if (session.state === "READY") return "Ready" if (session.state === "WAITING FOR DEVICE") return "Offline" if (session.state === "DEVICE FOUND" || session.state === "CONNECTING" || session.state === "CHECKING DEVICE") return "Connecting" - if (session.state === "FIRMWARE REQUIRED") return "Firmware required" + if (session.state === "FIRMWARE RECOVERY") return "Firmware status unknown" if (session.state === "UPDATING FIRMWARE") return "Updating" if (session.state === "UPDATE NEEDS BOOT") return "Bootloader" if (session.state === "PREPARING 4I+4V") return "Preparing" @@ -145,10 +145,10 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont tone: "warning" - text: bar.session.firmwareInstallRequired ? "Install firmware" + text: bar.session.firmwareInstallRequired ? "Firmware recovery" : bar.session.firmwareUpdateRequired ? "Firmware required" : "Retry profile" - toolTipText: bar.session.firmwareInstallRequired ? "Install the bundled ARStack firmware" + toolTipText: bar.session.firmwareInstallRequired ? "Firmware not verified ยท retry identification before optional ROM-verified recovery" : bar.session.firmwareUpdateRequired ? "Firmware update is required before injection" : "Retry the 4I + 4V profile synchronization" onClicked: { diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 943258148..2cccd2a2c 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -216,7 +216,7 @@ SurfacePanel { 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" - if (smartSession.state === "FIRMWARE REQUIRED") return "Firmware required" + if (smartSession.state === "FIRMWARE RECOVERY") return "Firmware status unknown" if (smartSession.state === "FIRMWARE UPDATE") return "Firmware update available" if (smartSession.state === "UPDATING FIRMWARE") return "Installing firmware" if (smartSession.state === "UPDATE NEEDS BOOT") return "Download mode required" @@ -232,7 +232,7 @@ SurfacePanel { readonly property color smartStateColor: { if (smartSession.state === "RUNNING" || smartSession.state === "READY") return theme.green if (smartSession.state === "CONNECTING" || smartSession.state === "CHECKING DEVICE" || - smartSession.state === "FIRMWARE REQUIRED" || smartSession.state === "PREPARING 4I+4V" || + smartSession.state === "FIRMWARE RECOVERY" || smartSession.state === "PREPARING 4I+4V" || smartSession.state === "FIRMWARE UPDATE" || smartSession.state === "UPDATING FIRMWARE" || smartSession.state === "UPDATE NEEDS BOOT") return theme.amber if (smartSession.state === "DEVICE FOUND") return theme.accent @@ -243,7 +243,7 @@ SurfacePanel { function startReason() { if (ribbon.injectionRunning) return "SMV output is already running." - if (smartSession.firmwareInstallRequired) return "Install ARStack firmware before starting injection." + if (smartSession.firmwareInstallRequired) return "ARStack identity not verified. Retry connection or use optional firmware recovery." if (smartSession.firmwareUpdateRequired) return "Update firmware before starting injection." if (smartSession.updatingFirmware) return "Firmware installation is in progress." if (smartSession.updateNeedsBootloaderHelp) return "Complete Download mode recovery before starting injection." @@ -391,7 +391,7 @@ SurfacePanel { spacing: 12 Label { Layout.fillWidth: true - text: "ESP32-P4 detected. Install ARStack firmware to inject SV." + text: "No ARStack identity was verified on the manually selected port. This does NOT prove firmware is missing. Retry connection first; optional installation requires a ROM chip check." color: ribbon.theme.textSoft font.family: ribbon.uiFont font.pixelSize: 11 diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index da467e2b9..30fc39c63 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -36,10 +36,11 @@ class DeterministicSessionHarness final { {"firmware verification failure remains copyable after READY", firmwareFailureRemainsDiagnosable()}, {"firmware build mismatch explains expected and observed IDs", reconnectBuildMismatchPreservesEvidence()}, {"fragmented espflash progress -> monotonic", fragmentedEspflashProgressIsMonotonic()}, - {"trusted ESP32-P4 identity timeout -> firmware required", automaticIdentityTimeoutOffersFirmwareRecovery()}, - {"single visible COM without metadata -> firmware required", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, + {"verified COM hint falls back without bypassing identity", verifiedComOrderingPreservesFallback()}, + {"automatic IDENTIFY timeout never implies missing firmware", automaticIdentityTimeoutOffersFirmwareRecovery()}, + {"single COM semantic timeout stays unverified", singleVisibleTimeoutWithoutRecommendationOffersFirmwareRecovery()}, {"ambiguous identity timeout -> UNIDENTIFIED", ambiguousIdentityTimeoutStaysUnidentified()}, - {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, + {"manual recovery intent stays optional and visible", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, @@ -410,13 +411,29 @@ class DeterministicSessionHarness final { FirmwareManager::parseFlashProgress(QStringLiteral("21/0 segment 0x0")) == -1; } + static bool verifiedComOrderingPreservesFallback() { + const QStringList visible{QStringLiteral("COM3"), QStringLiteral("COM15"), + QStringLiteral("COM7")}; + const QStringList expected{QStringLiteral("COM15"), QStringLiteral("COM7"), + QStringLiteral("COM3")}; + const QStringList staleExpected{QStringLiteral("COM7"), QStringLiteral("COM3"), + QStringLiteral("COM15")}; + return DeviceIoWorker::orderedDiscoveryPorts( + visible, QStringLiteral("COM15"), QStringLiteral("COM7")) == expected && + DeviceIoWorker::orderedDiscoveryPorts( + visible, QStringLiteral("COM99"), QStringLiteral("COM7")) == staleExpected && + DeviceIoWorker::orderedDiscoveryPorts( + visible, QString{}, QString{}) == visible && + DeviceIoWorker::identityMaxAttempts() >= 5 && + DeviceIoWorker::identityRetryIntervalMs() >= 1000; + } + static bool automaticIdentityTimeoutOffersFirmwareRecovery() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, false)) return false; - return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && - fixture.session.firmwareInstallVisible() && - fixture.session.blankBoardDetected_ && - fixture.session.blankBoardPort_ == QStringLiteral("COM7") && + return fixture.session.state() == QStringLiteral("UNIDENTIFIED") && + !fixture.session.firmwareInstallVisible() && + !fixture.session.blankBoardDetected_ && !fixture.session.startReady(); } @@ -444,10 +461,9 @@ class DeterministicSessionHarness final { emit device.identificationStateChanged(); session.reconcile(); - return session.state() == QStringLiteral("FIRMWARE REQUIRED") && - session.firmwareInstallVisible() && - session.blankBoardDetected_ && - session.blankBoardPort_ == QStringLiteral("COM7") && + return session.state() == QStringLiteral("UNIDENTIFIED") && + !session.firmwareInstallVisible() && + !session.blankBoardDetected_ && !session.startReady(); } @@ -483,7 +499,7 @@ class DeterministicSessionHarness final { static bool manualRecoveryIntentArmsFirmwareOffer() { Fixture fixture; if (!fixture.profileReady || !seedIdentityTimeout(fixture, true)) return false; - return fixture.session.state() == QStringLiteral("FIRMWARE REQUIRED") && + return fixture.session.state() == QStringLiteral("FIRMWARE RECOVERY") && fixture.session.firmwareInstallVisible() && fixture.session.blankBoardPort_ == QStringLiteral("COM7") && !fixture.session.startReady(); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index b2a1dc5ef..4b0aa7116 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -193,8 +193,8 @@ class DeviceController : public QObject { const QString& expectedFirmwareVersion, const QString& expectedBuildId = {}); - [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } - [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 5; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 1100; } [[nodiscard]] static constexpr bool identityRetryAllowed(const int attemptsSent) noexcept { return attemptsSent >= 0 && attemptsSent < identityMaxAttempts(); } diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 218939a4b..2d08f8c18 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -199,18 +200,17 @@ void DeviceIoWorker::autoDetectAndConnect(const quint64 generation) { probeQueue_.clear(); genericProbeActive_ = false; - if (!recommendedPort_.isEmpty()) { - if (!openPortInternal(recommendedPort_, true)) emit automaticProbeExhausted(activeGeneration_); - return; - } - if (ports_.isEmpty()) { - emit automaticProbeExhausted(activeGeneration_); - return; - } - - probeQueue_ = ports_; - genericProbeActive_ = true; - tryNextProbe(); + // Prefer the port which previously returned a genuine ARStack identity, + // but never treat its COM number or USB metadata as proof of identity. + // A stale hint MUST fall through to other currently visible candidates. + const QSettings settings; + const QString verifiedPort = settings.value( + QStringLiteral("device-discovery/lastVerifiedPort")).toString().trimmed(); + probeQueue_ = orderedDiscoveryPorts( + ports_, verifiedPort, recommendedPort_); + genericProbeActive_ = !probeQueue_.isEmpty(); + if (genericProbeActive_) tryNextProbe(); + else emit automaticProbeExhausted(activeGeneration_); } void DeviceIoWorker::connectPort(const QString& portName, const quint64 generation) { @@ -346,6 +346,12 @@ void DeviceIoWorker::confirmIdentity(const quint64 generation) { genericProbeActive_ = false; probeQueue_.clear(); if (verificationTimer_ != nullptr) verificationTimer_->stop(); + // Remember a port only AFTER its ARStack identity has been parsed. + // It is just a priority hint for the next Studio start, never a bypass. + QSettings settings; + settings.setValue( + QStringLiteral("device-discovery/lastVerifiedPort"), + serial_->portName()); } void DeviceIoWorker::enqueueCommands( diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 56c8be1cd..f817de0c7 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -25,8 +25,21 @@ class DeviceIoWorker final : public QObject { [[nodiscard]] static constexpr int commandQueueCapacity() noexcept { return 64; } [[nodiscard]] static constexpr int presencePollIntervalMs() noexcept { return 750; } - [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 3; } - [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 650; } + [[nodiscard]] static constexpr int identityMaxAttempts() noexcept { return 5; } + [[nodiscard]] static constexpr int identityRetryIntervalMs() noexcept { return 1100; } + + // USB candidate ordering never certifies firmware; only semantic + // IDENTIFY verifies the device. A prior verified port is only a hint. + [[nodiscard]] static QStringList orderedDiscoveryPorts( + QStringList visible, const QString& verifiedPort, const QString& recommended) { + const auto prioritize = [&visible](const QString& candidate) { + const int index = visible.indexOf(candidate.trimmed()); + if (index > 0) visible.move(index, 0); + }; + prioritize(recommended); + prioritize(verifiedPort); + return visible; + } [[nodiscard]] static constexpr int heartbeatIntervalMs() noexcept { return 700; } // Fast discovery policy: USB bridge metadata may prioritize a port, but it diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 93d8b747a..d33833a70 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -56,8 +56,16 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent connect(&discoveryTimer_, &QTimer::timeout, this, [this] { if (!started_ || device_ == nullptr || updateRequested_ || blankBoardDetected_ || setupError_) return; if (firmware_ != nullptr && firmware_->busy()) return; - if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) return; + // A slow serial reply is NOT evidence that firmware is absent. + // Retry semantic discovery after a bounded 5s cool-down; do not + // endlessly hold the single COM port or auto-flash a live board. + if (device_->identificationState() == DeviceController::IdentificationState::Unidentified) { + if (manualRecoveryArmed_) return; + ++unidentifiedDiscoveryTicks_; + if (unidentifiedDiscoveryTicks_ < 2) return; + } if (!device_->deviceVerified() && !device_->discovering() && !device_->connected()) { + unidentifiedDiscoveryTicks_ = 0; static_cast(startDeviceDiscovery()); } }); @@ -841,7 +849,10 @@ void SmartSessionController::refreshRecoveryOfferFromIdentity() { return; } - if (recoveryPending_) { + // Semantic timeout on an auto-detected port proves *nothing* about + // flash contents. Present firmware recovery only after the operator + // explicitly selected this port. ROM chip checks still precede flash. + if (recoveryPending_ || !manualRecoveryArmed_) { blankBoardDetected_ = false; blankBoardPort_.clear(); return; @@ -865,6 +876,7 @@ void SmartSessionController::clearBlankBoardContext() { blankBoardDetected_ = false; blankBoardPort_.clear(); manualRecoveryArmed_ = false; + unidentifiedDiscoveryTicks_ = 0; setupError_ = false; setupErrorStatus_.clear(); } @@ -1177,8 +1189,8 @@ void SmartSessionController::reconcile() { if (blankBoardDetected_) { setPresentation( - QStringLiteral("FIRMWARE REQUIRED"), - QStringLiteral("ESP32-P4 candidate %1 did not answer the current semantic identity after bounded attempts. Studio can install the bundled firmware after ROM chip/revision verification.") + QStringLiteral("FIRMWARE RECOVERY"), + QStringLiteral("Manually selected port %1 did not answer ARStack IDENTIFY. Firmware status is UNKNOWN. Retry connection first; optional recovery requires ROM chip/revision verification.") .arg(blankBoardPort_), false, false); diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index bd7efd3f6..495f99f01 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -331,10 +331,11 @@ class SmartSessionController : public QObject { void handleIdentificationChange() { if (owner_ == nullptr || device_ == nullptr || !owner_->recoveryPending_ || device_->identificationState() != DeviceController::IdentificationState::Unidentified) return; + // A delayed reply from the previously verified injector is not + // evidence of absent firmware. Keep identity pinning armed and + // let the bounded discovery loop try the port again. owner_->blankBoardDetected_ = false; owner_->blankBoardPort_.clear(); - owner_->setupError_ = true; - owner_->setupErrorStatus_ = QStringLiteral("The previously verified injector did not answer semantic identity after bounded retries. Firmware absence was not inferred; retry identification after USB settles."); owner_->reconcile(); } SmartSessionController* owner_{nullptr}; @@ -402,6 +403,7 @@ class SmartSessionController : public QObject { bool setupError_{false}; bool recoveryPending_{false}; bool shuttingDown_{false}; + int unidentifiedDiscoveryTicks_{0}; int updateReconnectAttempts_{0}; int profileSyncAttempts_{0}; PortOwner portOwner_{PortOwner::none}; diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 1d2c0b492..4ed6ed249 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -330,12 +330,14 @@ int checkP0ControllerPolicy(int argc, char* argv[]) { rejectedIdentity); const bool boundedIdentifyPolicy = - DeviceController::identityMaxAttempts() == 3 && - DeviceController::identityRetryIntervalMs() == 650 && + DeviceController::identityMaxAttempts() == 5 && + DeviceController::identityRetryIntervalMs() == 1100 && DeviceController::identityRetryAllowed(0) && DeviceController::identityRetryAllowed(1) && DeviceController::identityRetryAllowed(2) && - !DeviceController::identityRetryAllowed(3) && + DeviceController::identityRetryAllowed(3) && + DeviceController::identityRetryAllowed(4) && + !DeviceController::identityRetryAllowed(5) && !DeviceController::identityRetryAllowed(-1); const bool boundedProfileSyncPolicy = diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 39fd0d4ba..a605626a8 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -79,6 +79,20 @@ def main() -> None: and "preflight_telemetry" in firmware_source and "SvAutoPreflightPolicy" in phase_header, "AUTO SOURCE preflight must discipline the sample clock before START without transmitting SV") + # Field regression: no firmware-reinstall mandate after a slow serial + # response, and previously verified COM gets priority without bypassing + # semantic identification. + io_worker = (STUDIO / "src/DeviceIoWorker.cpp").read_text(encoding="utf-8") + io_header = (STUDIO / "src/DeviceIoWorker.hpp").read_text(encoding="utf-8") + session_cpp = (STUDIO / "src/SmartSessionController.cpp").read_text(encoding="utf-8") + ribbon_qml = source("ModernRibbon.qml") + require('device-discovery/lastVerifiedPort' in io_worker + and "orderedDiscoveryPorts(" in io_worker + and "identityMaxAttempts() noexcept { return 5; }" in io_header + and "manualRecoveryArmed_" in session_cpp + and "FIRMWARE RECOVERY" in session_cpp + and "Firmware recovery" in ribbon_qml, + "never infer missing firmware from USB timeout; retry identification and prioritize semantic-verified ports") main_qml = source("Main.qml") preview = source("SignalPreview.qml") tray = source("StudioTimingTray.qml")