From 3852e31d42aec92cddb64d241b4a2f7e78361145 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:33 +0700 Subject: [PATCH 01/14] fix(studio): extend semantic IDENTIFY retry and order verified COM hints --- apps/arstack_studio/src/DeviceIoWorker.hpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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 From 96cb8877c21e966f2ee4987edbe001aad65abc4b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:36 +0700 Subject: [PATCH 02/14] fix(studio): discover preferred COM and probe fallbacks without blind trust --- apps/arstack_studio/src/DeviceIoWorker.cpp | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) 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( From 90910e0ba99e5cc6cd87e9667938f8adc5e2da66 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:40 +0700 Subject: [PATCH 03/14] fix(studio): retry unverified USB and never auto-diagnose firmware absent --- .../src/SmartSessionController.cpp | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 93d8b747a..d4123b151 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_ || recoveryPending_) 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); From 738ad52ca62c2e8335ecb3cb9ae765b6e09f1fb9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:42 +0700 Subject: [PATCH 04/14] fix(studio): label unverified manual-port workflow as recovery --- apps/arstack_studio/qml/ModernRibbon.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 22f18779c..4aaa2e232 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -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: { From 8d28b319f38e0a37372fcb4c38b9dc6f88e81db9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:45 +0700 Subject: [PATCH 05/14] fix(studio): communicate firmware unknown versus incompatible --- apps/arstack_studio/qml/WorkflowBar.qml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 From e5ce4529011c189369ef709847c5fabb54e7fde3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:49 +0700 Subject: [PATCH 06/14] test(studio): assert automatic timeouts never require firmware install --- .../src/DeterministicSessionHarness.cpp | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index da467e2b9..1fffa6d7b 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -36,10 +36,10 @@ 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()}, + {"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()}, @@ -413,10 +413,9 @@ class DeterministicSessionHarness final { 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 +443,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 +481,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(); From 11c333f7d1264690cdaf0f2c9bc68b9f4301f165 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:26:52 +0700 Subject: [PATCH 07/14] test(studio): enforce reliable identify-retry and honest firmware state --- .../tests/check_operator_workspace.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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") From ae235dd0e72b727e8af5a14668bb25babe3b0ec1 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:27:15 +0700 Subject: [PATCH 08/14] fix(studio): bound the automatic unidentified-board retry cooldown --- apps/arstack_studio/src/SmartSessionController.hpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index bd7efd3f6..c6f1f011e 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -402,6 +402,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}; From a6b304c49440186c45744e0b5a48873696786903 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:28:57 +0700 Subject: [PATCH 09/14] fix(studio): retain device-ID-pinned recovery across transient IDENTIFY timeouts --- apps/arstack_studio/src/SmartSessionController.hpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index c6f1f011e..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}; From 07749bd36261a4e4b619c7b14d5f609c9278f421 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:29:01 +0700 Subject: [PATCH 10/14] fix(studio): allow bounded retry while previously verified board recovers --- apps/arstack_studio/src/SmartSessionController.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index d4123b151..d33833a70 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -60,7 +60,7 @@ SmartSessionController::SmartSessionController(QObject* parent) : QObject(parent // 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_ || recoveryPending_) return; + if (manualRecoveryArmed_) return; ++unidentifiedDiscoveryTicks_; if (unidentifiedDiscoveryTicks_ < 2) return; } From 3b6a33d6c9032445e1ee4257838696e8a254e99d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:29:04 +0700 Subject: [PATCH 11/14] test(studio): cover stale cached COM fallback and semantic verification interval --- .../src/DeterministicSessionHarness.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 1fffa6d7b..30fc39c63 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -36,6 +36,7 @@ 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()}, + {"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()}, @@ -410,6 +411,23 @@ 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; From 24ea4055741ceb0a79dc0037c88c33392a1e85f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:31:38 +0700 Subject: [PATCH 12/14] fix(studio): align controller identification timeout with serial owner --- apps/arstack_studio/src/DeviceController.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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(); } From a6890b26b31787d1861d895a5e3ae1ae48792ea3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:31:41 +0700 Subject: [PATCH 13/14] test(studio): assert bounded five-attempt 1.1s semantic handshake --- apps/arstack_studio/src/main.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 = From f3e445616e9e7d8ba10e787ba4248a1a0396e557 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 19 Oct 2026 06:31:44 +0700 Subject: [PATCH 14/14] fix(studio): show firmware unknown rather than required on manual recovery --- apps/arstack_studio/qml/ModernRibbon.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 4aaa2e232..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"