diff --git a/apps/arstack_studio/qml/ConfigurationHub.qml b/apps/arstack_studio/qml/ConfigurationHub.qml index cc41ad14d..33887362f 100644 --- a/apps/arstack_studio/qml/ConfigurationHub.qml +++ b/apps/arstack_studio/qml/ConfigurationHub.qml @@ -93,12 +93,14 @@ Item { } Rectangle { - implicitWidth: 112 + implicitWidth: hub.session && hub.session.updatingFirmware ? 144 : 112 implicitHeight: 30 radius: 15 - color: hub.device.deviceVerified ? "#10251d" : "#261f13" + color: hub.session && hub.session.updatingFirmware + ? "#261f13" : hub.device.deviceVerified ? "#10251d" : "#261f13" border.width: 1 - border.color: hub.device.deviceVerified ? "#347a59" : "#705827" + border.color: hub.session && hub.session.updatingFirmware + ? "#705827" : hub.device.deviceVerified ? "#347a59" : "#705827" RowLayout { anchors.centerIn: parent @@ -107,11 +109,14 @@ Item { width: 7 height: 7 radius: 4 - color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber + color: hub.session && hub.session.updatingFirmware + ? hub.theme.amber : hub.device.deviceVerified ? hub.theme.green : hub.theme.amber } Label { - text: hub.device.deviceVerified ? "Device ready" : "Setup mode" - color: hub.device.deviceVerified ? hub.theme.green : hub.theme.amber + text: hub.session && hub.session.updatingFirmware + ? "Updating firmware" : hub.device.deviceVerified ? "Device ready" : "Setup mode" + color: hub.session && hub.session.updatingFirmware + ? hub.theme.amber : hub.device.deviceVerified ? hub.theme.green : hub.theme.amber font.family: hub.uiFont font.pixelSize: 10 font.weight: Font.DemiBold diff --git a/apps/arstack_studio/qml/FirmwarePanel.qml b/apps/arstack_studio/qml/FirmwarePanel.qml index bd25ac991..538b1c67c 100644 --- a/apps/arstack_studio/qml/FirmwarePanel.qml +++ b/apps/arstack_studio/qml/FirmwarePanel.qml @@ -13,9 +13,10 @@ SurfacePanel { property string monoFont: "Inter" property bool technicalDetailsVisible: false readonly property string firmwareDisplayStatus: - panel.session && panel.session.firmwareUpdateResult.length > 0 && - !panel.session.updatingFirmware - ? panel.session.firmwareUpdateResult : panel.firmware.status + panel.session && panel.session.updatingFirmware + ? panel.session.updateStatus + : panel.session && panel.session.firmwareUpdateResult.length > 0 + ? panel.session.firmwareUpdateResult : panel.firmware.status readonly property bool verificationFailed: panel.session && panel.session.firmwareUpdateResult.length > 0 && !panel.session.firmwareUpdateResult.startsWith("Exact bundled firmware build verified") @@ -179,14 +180,16 @@ SurfacePanel { id: firmwarePort Layout.preferredWidth: 205 model: panel.device.ports - enabled: panel.session && !panel.firmware.busy + enabled: panel.session && !panel.firmware.busy && + !panel.session.updatingFirmware onPressedChanged: if (pressed && panel.session) panel.session.requestRefreshPorts() } CalmButton { theme: panel.theme uiFont: panel.uiFont text: "Refresh" - enabled: panel.session && !panel.firmware.busy + enabled: panel.session && !panel.firmware.busy && + !panel.session.updatingFirmware onClicked: panel.session.requestRefreshPorts() } Item { Layout.fillWidth: true } @@ -219,7 +222,8 @@ SurfacePanel { Layout.fillWidth: true spacing: 5 Label { - text: panel.firmware.busy ? "Installing firmware" : "Firmware action" + text: panel.session && panel.session.updatingFirmware + ? "Preparing / installing firmware" : "Firmware action" color: panel.theme.text font.family: panel.uiFont font.pixelSize: 12 @@ -227,8 +231,8 @@ SurfacePanel { } Label { Layout.fillWidth: true - text: panel.firmware.busy - ? "Keep USB connected until verification completes." + text: panel.session && panel.session.updatingFirmware + ? "Keep USB connected. Studio is verifying the board and firmware." : (panel.session && panel.session.firmwareUpdateAvailable ? "Current firmware works; update is optional." : "Installation and recovery are supervised automatically.") @@ -250,15 +254,10 @@ SurfacePanel { visible: panel.session && (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable || panel.session.firmwareReinstallAvailable) enabled: visible && !panel.firmware.busy && - !panel.device.running && !panel.device.ptpRunning && !panel.device.ptpStarting - toolTipText: enabled ? "Install the matched firmware" : "Stop active SV/PTP in the main firmware update dialog first" + !panel.session.updatingFirmware + toolTipText: "Studio will stop SV/PTP and verify board readback before firmware installation" tone: panel.session && panel.session.firmwareUpdateRequired ? "accent" : "normal" - onClicked: { - if (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) - panel.session.beginFirmwareUpdate() - else - panel.session.beginFirmwareReinstall() - } + onClicked: firmwareConfirmDialog.open() } @@ -362,4 +361,50 @@ SurfacePanel { Layout.fillHeight: true } } + + Dialog { + id: firmwareConfirmDialog + modal: true + anchors.centerIn: Overlay.overlay + width: Math.min(400, panel.width - 32) + padding: 16 + closePolicy: Popup.CloseOnEscape + title: "Update board firmware" + background: Rectangle { + color: panel.theme.surface + radius: 9 + border.width: 1 + border.color: panel.theme.line + } + contentItem: ColumnLayout { + spacing: 12 + Label { + Layout.fillWidth: true + text: "Studio will stop SV and PTP, verify both are stopped on the board, then install the matching firmware. If STOP cannot be verified, nothing will be flashed. Output will not restart automatically." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 11 + } + RowLayout { + Layout.fillWidth: true + Item { Layout.fillWidth: true } + CalmButton { + theme: panel.theme; uiFont: panel.uiFont; text: "Cancel" + onClicked: firmwareConfirmDialog.close() + } + CalmButton { + theme: panel.theme; uiFont: panel.uiFont + text: "Stop services & update"; tone: "accent"; implicitWidth: 172 + onClicked: { + const ok = (panel.session.firmwareUpdateRequired || panel.session.firmwareUpdateAvailable) + ? panel.session.beginFirmwareUpdate() + : panel.session.beginFirmwareReinstall() + // Never leave an inoperable modal covering the error. + firmwareConfirmDialog.close() + } + } + } + } + } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index f039e96b1..b3c659652 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -43,6 +43,7 @@ class DeterministicSessionHarness final { const CaseResult cases[] = { {"dual-revision catalog selects only qualified silicon", revisionCatalogFailClosed()}, {"firmware timeout retains port until flasher exits", firmwarePortReleaseWaitsForWorker()}, + {"firmware update waits for fresh SV and PTP STOP readback", firmwareStopProofPreventsEarlyFlash()}, {"real timed-out firmware subprocess retires before COM release", firmwareWorkerTimeoutAwaitsProcessExit()}, {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, @@ -214,6 +215,55 @@ class DeterministicSessionHarness final { return restartedCleanly && !unexpectedTerminal && terminalCount == 2; } + + static bool firmwareStopProofPreventsEarlyFlash() { + Fixture f; + if (!f.profileReady) return false; + auto stale = identity(); + stale.buildId = QStringLiteral("fedcba9876543210"); + seedVerified(f, stale, QStringLiteral("COM7"), false); + auto& d = f.device; + auto& s = f.session; + // Reproduce field condition: not RUNNING, but a stale SV START + // transaction and an unverified PTP STOP block the GUI path. + d.ioWorkerReady_ = true; + d.ptpAvailable_ = true; + d.ptpRunning_ = true; + d.ptpStartStage_ = DeviceController::PtpStartStage::stop_unconfirmed; + d.scenarioStage_ = DeviceController::ScenarioStage::awaiting_commit; + const bool started = s.beginFirmwareUpdate(); + const bool safeStage = started && s.updateRequested_ && + s.updateStage_ == SmartSessionController::UpdateStage::stopping && + s.portOwner_ == SmartSessionController::PortOwner::deviceSession && + d.scenarioStage_ == DeviceController::ScenarioStage::idle && + !d.firmwareQuiescenceVerified() && !f.firmware.busy_; + if (!safeStage) { + s.updateRequested_ = false; + d.endFirmwareQuiescence(); + return false; + } + + d.processLine(QStringLiteral("STOP accepted: SV transmission suppressed")); + // SV STOP accepted is insufficient without a fresh board SHOW. + const bool ackOnlyBlocked = d.firmwareSvStopAck_ && + !d.firmwareQuiescenceVerified(); + d.processLine(QStringLiteral( + "SVLIFE state=STOPPED stop_cause=OPERATOR_STOP stop_seq=1")); + const bool stillWaitingForPtp = d.firmwareSvStoppedReadback_ && + !d.firmwareQuiescenceVerified(); + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(QStringLiteral( + "PTP status=STOPPED Announce=0 Sync=0 FollowUp=0 PdelayFrames=0 TXfail=0")); + const bool proof = d.firmwareQuiescenceVerified(); + d.ptpRunning_ = true; + const bool rejectsReactivation = !d.firmwareQuiescenceVerified(); + s.updateRequested_ = false; + s.firmwareTotalTimer_.stop(); + d.endFirmwareQuiescence(); + return ackOnlyBlocked && stillWaitingForPtp && proof && + rejectsReactivation && !d.firmwareQuiescenceVerified(); + } + static bool revisionCatalogFailClosed() { FirmwareManager firmware; firmware.revisionCatalogAvailable_ = true; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index a1c050660..0bf13a391 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -468,6 +468,9 @@ void DeviceController::connectWorkerSignals() { !m4ScenarioRetiring_) return; m4ScenarioRetiring_ = false; emit m4DeploymentChanged(); + // If an update was waiting for the last old CFG write to retire, + // fresh STOP evidence may now complete its verified handoff. + if (firmwareQuiescenceActive_) emit firmwareQuiescenceChanged(); }); connect(ioWorker_, &DeviceIoWorker::commandRejected, this, @@ -1794,6 +1797,54 @@ bool DeviceController::startPtp() { } return sendCommand(QStringLiteral("PTP START")); } +bool DeviceController::beginFirmwareQuiescence() { + if (!connected_ || !deviceVerified_ || !ioWorkerReady_ || sessionGeneration_ == 0 || + identity_.bootId.isEmpty()) { + setError(QStringLiteral("Firmware update requires a connected, verified board.")); + return false; + } + + // An unfinished PROFILE/CFG transaction can keep the PTP STOP readback + // deferred until its deadline. Retire that local work before STOP, even + // when RUNNING is false (START may still be pending). + endFirmwareQuiescence(); + if (profileDeploying_) abandonProfileDeployment(); + firmwareQuiescenceActive_ = true; + firmwareQuiescenceSession_ = sessionGeneration_; + firmwareQuiescenceBoot_ = identity_.bootId; + + // Explicit operator-authorized STOP for BOTH services, independent of + // cached telemetry. STOP preempts pending SV START in the serial worker. + const bool svAccepted = stop(); + const bool ptpAccepted = svAccepted && stopPtp(); + if (!svAccepted || !ptpAccepted) { + endFirmwareQuiescence(); + setError(QStringLiteral( + "Could not request verified SV/PTP STOP. Firmware write remains blocked.")); + return false; + } + return true; +} + +void DeviceController::endFirmwareQuiescence() noexcept { + firmwareQuiescenceActive_ = false; + firmwareSvStopAck_ = false; + firmwareSvStoppedReadback_ = false; + firmwarePtpStoppedReadback_ = false; + firmwareQuiescenceSession_ = 0; + firmwareQuiescenceBoot_.clear(); +} + +bool DeviceController::firmwareQuiescenceVerified() const noexcept { + return firmwareQuiescenceActive_ && connected_ && deviceVerified_ && + firmwareQuiescenceSession_ == sessionGeneration_ && + firmwareQuiescenceBoot_ == identity_.bootId && + firmwareSvStopAck_ && firmwareSvStoppedReadback_ && + firmwarePtpStoppedReadback_ && !running_ && !ptpRunning_ && !ptpStarting() && + scenarioStage_ == ScenarioStage::idle && !profileDeploying_ && + !m4Deploying_ && !m4ScenarioApplying_ && !m4ScenarioRetiring_; +} + bool DeviceController::stopPtp() { if (!connected_ || !ioWorkerReady_ || !deviceVerified_) { setError(QStringLiteral("Connect the verified ESP32-P4 before stopping PTP.")); @@ -2010,6 +2061,12 @@ void DeviceController::advancePtpStartup(const QString& line) { resetPtpStartup(); ptpRunning_ = false; ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); + if (firmwareQuiescenceActive_ && + firmwareQuiescenceSession_ == sessionGeneration_ && + firmwareQuiescenceBoot_ == identity_.bootId) { + firmwarePtpStoppedReadback_ = true; + emit firmwareQuiescenceChanged(); + } emit ptpStateChanged(); } else if (!ptpStopPollTimer_.isActive()) { // Firmware stop is asynchronous. Poll on Qt timers without @@ -2196,6 +2253,18 @@ bool DeviceController::sendQuietCommand(const QString& command) { bool DeviceController::sendCommandBatch(const QStringList& commands, const bool quiet, const bool exclusive) { const bool single = !exclusive && commands.size() == 1; const QString onlyCommand = single ? commands.constFirst() : QString{}; + // Firmware STOP proof owns the transition to ROM mode. Never allow + // a queued UI START/CONFIG transaction to rearm the board in this window. + if (firmwareQuiescenceActive_ && + (!single || (onlyCommand != QStringLiteral("STOP") && + onlyCommand != QStringLiteral("PTP STOP") && + onlyCommand != QStringLiteral("SHOW") && + onlyCommand != QStringLiteral("PTP SHOW") && + onlyCommand != QStringLiteral("HEARTBEAT") && + !onlyCommand.startsWith(QStringLiteral("PROFILE BINABORT "))))) { + setError(QStringLiteral("Firmware update is safely stopping board services.")); + return false; + } // PTP STOP has higher safety priority than non-mutating MU inspection; // never let the reader postpone STOP readback beyond its own deadline. if (m4BoardInspecting_ && @@ -2865,6 +2934,14 @@ void DeviceController::processLine(const QString& rawLine) { // local RUNNING -> STOPPED incident may raise a live alarm. setRunning(false); // preserve operatorStopPending_ until edge classification operatorStopPending_ = false; + if (firmwareQuiescenceActive_ && !forced && + firmwareQuiescenceSession_ == sessionGeneration_ && + firmwareQuiescenceBoot_ == identity_.bootId) { + firmwareSvStopAck_ = true; + // This SHOW is ordered AFTER STOP on the same serial worker; + // a plain STOP log or cached running=false is not stop proof. + static_cast(sendQuietCommand(QStringLiteral("SHOW"))); + } if (forced && svUnexpectedStopObserved_ && svStopReason_ != QStringLiteral("RUNTIME_FAULT")) { svStopReason_ = QStringLiteral("RUNTIME_FAULT"); @@ -2901,6 +2978,13 @@ void DeviceController::processLine(const QString& rawLine) { QStringLiteral("SVLIFE state=(RUNNING|STOPPED) stop_cause=([A-Z_]+) stop_seq=(\\d+)")); const auto svLife = kSvLife.match(line); if (svLife.hasMatch()) { + if (firmwareQuiescenceActive_ && firmwareSvStopAck_ && + firmwareQuiescenceSession_ == sessionGeneration_ && + firmwareQuiescenceBoot_ == identity_.bootId) { + firmwareSvStoppedReadback_ = + svLife.captured(1) == QStringLiteral("STOPPED"); + emit firmwareQuiescenceChanged(); + } bool seqOk = false; const auto seq = svLife.captured(3).toULongLong(&seqOk); if (seqOk && (seq >= lastSvStopSeq_ || !deviceVerified_)) { diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 630a23957..0ad3b8008 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -313,6 +313,11 @@ class DeviceController : public QObject { bool startPtp(); bool startPtpConfigured(const QString& role, const QVariantMap& profile); bool stopPtp(); + // One supervised firmware-update safety gate. This is not a second + // serial owner: the existing STOP/PTP STOP/readback paths remain authoritative. + bool beginFirmwareQuiescence(); + void endFirmwareQuiescence() noexcept; + [[nodiscard]] bool firmwareQuiescenceVerified() const noexcept; bool configurePtp(const QVariantMap& profile); bool setPtpRole(const QString& requestedRole) { @@ -373,6 +378,7 @@ class DeviceController : public QObject { void m4BoardChanged(); void m4ReconnectModeChanged(); void ptpStateChanged(); + void firmwareQuiescenceChanged(); void deviceMessage(const QString& message); void scenarioStateChanged(); void scenarioApplicationFinished(); @@ -513,6 +519,12 @@ class DeviceController : public QObject { int highConfidenceCount_{0}; bool running_{false}; bool operatorStopPending_{false}; + bool firmwareQuiescenceActive_{false}; + bool firmwareSvStopAck_{false}; + bool firmwareSvStoppedReadback_{false}; + bool firmwarePtpStoppedReadback_{false}; + quint64 firmwareQuiescenceSession_{0}; + QString firmwareQuiescenceBoot_; QString svStopReason_; QString svLastStopCause_; // Only an observed unexpected RUNNING -> STOPPED edge in this Studio diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 950fda184..ab5d70c48 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -638,21 +638,27 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { setupErrorStatus_.clear(); lastFirmwareUpdateResult_.clear(); - if (device_->running() || device_->ptpRunning() || device_->ptpStarting()) { + if (device_->deviceVerified()) { + if (!device_->connected() || portOwner_ != PortOwner::deviceSession) { + latchFirmwareFailure(QStringLiteral( + "Verified board serial ownership was lost. Firmware write is blocked.")); + emit firmwareUpdateFinished(false); + reconcile(); + return false; + } + // Always issue a fresh STOP + readback for BOTH board services. + // PREPARING/CFG and STOP_UNVERIFIED are not proof that output is idle. updateStage_ = UpdateStage::stopping; - const bool stopSv = device_->running(); - stoppingPtpForFirmware_ = device_->ptpRunning() || device_->ptpStarting(); + stoppingPtpForFirmware_ = true; setPresentation( QStringLiteral("UPDATING FIRMWARE"), - QStringLiteral("Stopping active SV/PTP test before firmware installation…"), + QStringLiteral("Stopping and verifying SV/PTP on the board…"), false, false); - // Both STOP paths are ACK/readback verified by the existing device owner. - // An asynchronous STOP acceptance is not evidence of a stopped board. - const bool svAccepted = !stopSv || device_->stop(); - const bool ptpAccepted = !stoppingPtpForFirmware_ || (svAccepted && device_->stopPtp()); - if (!svAccepted || !ptpAccepted) { - latchFirmwareFailure(QStringLiteral("Could not safely stop active SV/PTP output. Firmware access was blocked.")); + if (!device_->beginFirmwareQuiescence()) { + latchFirmwareFailure(QStringLiteral( + "The board did not accept the safe SV/PTP stop preflight. " + "Nothing was flashed; inspect diagnostics and retry.")); emit firmwareUpdateFinished(false); reconcile(); return false; @@ -660,6 +666,8 @@ bool SmartSessionController::beginFirmwareOperation(const QString& portName) { return true; } + // Only an explicitly authorized blank-board recovery may probe without + // application STOP proof; it still needs ROM identity and isolation gates. continueFirmwareUpdate(); return true; } @@ -786,10 +794,12 @@ bool SmartSessionController::startFirmwareProbe() { void SmartSessionController::continueFirmwareUpdate() { if (!updateRequested_ || device_ == nullptr || firmware_ == nullptr) return; - if (device_->running() || device_->ptpRunning() || device_->ptpStarting()) { - updateStage_ = UpdateStage::stopping; - return; + if (updateStage_ == UpdateStage::stopping) { + if (!device_->firmwareQuiescenceVerified()) return; } + if (device_->connected() && device_->deviceVerified() && + (device_->running() || device_->ptpRunning() || device_->ptpStarting() || + !device_->firmwareQuiescenceVerified())) return; if (portOwner_ == PortOwner::firmwareTool) { static_cast(startFirmwareProbe()); @@ -907,19 +917,15 @@ void SmartSessionController::reconnectDeviceSignals() { reconcile(); }); }); - connect(device_, &DeviceController::runningChanged, this, [this] { - if (updateRequested_ && updateStage_ == UpdateStage::stopping && - device_ != nullptr && !device_->running() && - !device_->ptpRunning() && !device_->ptpStarting()) { - QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); - } - reconcile(); - }); - connect(device_, &DeviceController::ptpStateChanged, this, [this] { + connect(device_, &DeviceController::runningChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::ptpStateChanged, this, &SmartSessionController::reconcile); + connect(device_, &DeviceController::firmwareQuiescenceChanged, this, [this] { if (updateRequested_ && updateStage_ == UpdateStage::stopping && - device_ != nullptr && !device_->running() && - !device_->ptpRunning() && !device_->ptpStarting()) { - QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); + device_ != nullptr && device_->firmwareQuiescenceVerified()) { + QTimer::singleShot(0, this, [this] { + if (updateRequested_ && updateStage_ == UpdateStage::stopping) + continueFirmwareUpdate(); + }); } reconcile(); }); @@ -931,6 +937,11 @@ void SmartSessionController::reconnectDeviceSignals() { return; } pendingReleaseGeneration_ = 0; + // The verified STOP lease ends only AFTER QSerialPort has closed. + // Release DeviceController's local mutation guard before the next + // firmware boot; retaining it would block START after a successful + // update, even though the new board identity is verified. + if (device_ != nullptr) device_->endFirmwareQuiescence(); setPortOwner(PortOwner::none); QTimer::singleShot(0, this, [this] { continueFirmwareUpdate(); }); }); @@ -1238,6 +1249,7 @@ QString SmartSessionController::diagnosticsText() const { void SmartSessionController::latchFirmwareFailure(QString message) { reconnectTimer_.stop(); firmwareTotalTimer_.stop(); + if (device_ != nullptr) device_->endFirmwareQuiescence(); stoppingPtpForFirmware_ = false; const bool optionalOperation = updateWasOptional_; updateRequested_ = false; @@ -1560,6 +1572,9 @@ void SmartSessionController::reconcile() { } firmwareTotalTimer_.stop(); stoppingPtpForFirmware_ = false; + // A rebooted, verified board must never inherit a pre-flash + // maintenance lock from the old device session. + device_->endFirmwareQuiescence(); updateRequested_ = false; updateWasOptional_ = false; updateStage_ = UpdateStage::idle;