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
6 changes: 3 additions & 3 deletions apps/arstack_studio/qml/ModernRibbon.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: {
Expand Down
8 changes: 4 additions & 4 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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."
Expand Down Expand Up @@ -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
Expand Down
40 changes: 28 additions & 12 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
30 changes: 18 additions & 12 deletions apps/arstack_studio/src/DeviceIoWorker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include <QElapsedTimer>
#include <QSerialPort>
#include <QSerialPortInfo>
#include <QSettings>
#include <QThread>
#include <QTimer>

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 15 additions & 2 deletions apps/arstack_studio/src/DeviceIoWorker.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Comment on lines +28 to +29

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 Align the controller IDENTIFY policy with the worker

Changing only DeviceIoWorker to 5 attempts/1100 ms leaves DeviceController::identityMaxAttempts() and identityRetryIntervalMs() at 3/650. Consequently, checkFirmwareContract() makes both boundedIdentifyPolicy and workerPolicyAligned false and exits with code 11; the Studio Qt, native-preview, and release workflows all invoke --check-firmware-contract, so this change breaks those required checks on every platform. Update the controller-side contract and its assertions together with these worker values.

AGENTS.md reference: AGENTS.md:L230-L236

Useful? React with 👍 / 👎.


// 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
Expand Down
20 changes: 16 additions & 4 deletions apps/arstack_studio/src/SmartSessionController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +64 to +68

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 Cap automatic rediscovery after terminal timeouts

When a non-ARStack or permanently unresponsive serial device remains visible, every terminal Unidentified result reaches this threshold, resets the counter, probes every visible port again, and then repeats indefinitely on the periodic timer. The five-attempt limit only bounds each individual port visit; it does not bound the reconnect policy, so Studio can continually reopen unrelated COM ports and send IDENTIFY until shutdown. Add a maximum rediscovery count or transition to a terminal state requiring explicit retry.

AGENTS.md reference: AGENTS.md:L80-L84

Useful? React with 👍 / 👎.

static_cast<void>(startDeviceDiscovery());
}
});
Expand Down Expand Up @@ -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;
Expand All @@ -865,6 +876,7 @@ void SmartSessionController::clearBlankBoardContext() {
blankBoardDetected_ = false;
blankBoardPort_.clear();
manualRecoveryArmed_ = false;
unidentifiedDiscoveryTicks_ = 0;
setupError_ = false;
setupErrorStatus_.clear();
}
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions apps/arstack_studio/src/SmartSessionController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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};
Expand Down
8 changes: 5 additions & 3 deletions apps/arstack_studio/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
14 changes: 14 additions & 0 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading