Skip to content
Merged
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
17 changes: 11 additions & 6 deletions apps/arstack_studio/qml/ConfigurationHub.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
77 changes: 61 additions & 16 deletions apps/arstack_studio/qml/FirmwarePanel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -219,16 +222,17 @@ 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
font.weight: Font.DemiBold
}
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.")
Expand All @@ -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()
}


Expand Down Expand Up @@ -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()
}
}
}
}
}
}
50 changes: 50 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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_ &&
Expand Down Expand Up @@ -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<void>(sendQuietCommand(QStringLiteral("SHOW")));
}
if (forced && svUnexpectedStopObserved_ &&
svStopReason_ != QStringLiteral("RUNTIME_FAULT")) {
svStopReason_ = QStringLiteral("RUNTIME_FAULT");
Expand Down Expand Up @@ -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_)) {
Expand Down
12 changes: 12 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading