From 61310e2bc55eb4a480441d61eec58c0d2c3b4660 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 14:49:28 +0700 Subject: [PATCH 1/2] feat(M4-P0E2C1): integrate ACK-gated three-MU roster deploy with Studio serial owner Bind existing M4MultiSvUploadTransaction to DeviceController/DeviceIoWorker with transmit-confirmed ACK gating, 5s step + 120s absolute deadline, session/boot/STOP/transport-failure cancellation, firmware capability preflight and full three-MU SELECT/readback proof. Supervisor suppresses legacy V1 auto-sync/START after explicit M4 intent; QML offers bounded Deploy 3 MU without enabling unverified START. Add deterministic session isolation regressions and engineer-facing protocol guide. No firmware hot path changes, no new CI jobs. --- apps/arstack_studio/qml/Main.qml | 28 +++- .../src/DeterministicSessionHarness.cpp | 53 +++++++ apps/arstack_studio/src/DeviceController.cpp | 149 +++++++++++++++++- apps/arstack_studio/src/DeviceController.hpp | 25 +++ .../src/SmartSessionController.cpp | 48 +++++- .../src/SmartSessionController.hpp | 4 + docs/M4_P0E2C1_STUDIO_SERIAL_DEPLOYMENT.md | 22 +++ 7 files changed, 322 insertions(+), 7 deletions(-) create mode 100644 docs/M4_P0E2C1_STUDIO_SERIAL_DEPLOYMENT.md diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 967030b99..cb26abe9c 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -374,7 +374,7 @@ ApplicationWindow { scenarioSettings.lastScenario = JSON.stringify(root.scenarioSnapshot()) autoLiveApplyTimer.stop() autoLiveQueuedRevision = -1 - showMessage("Three MU draft pages ready. Device deploy is not enabled yet.", false) + showMessage("Three MU pages ready. Use Deploy 3 MU for verified firmware roster upload; START remains gated.", false) } function m4SelectPage(slot) { if (!m4Pages.active || slot === m4Pages.selectedSlot) return @@ -1371,11 +1371,33 @@ ApplicationWindow { Item { Layout.fillWidth: true } Label { visible: m4Pages.active - text: "DRAFT ONLY · NOT DEPLOYED" - color: studioTheme.amber + text: device.m4Deploying ? "UPLOADING · VERIFIED ACK REQUIRED" + : device.m4Ready && device.m4DeployedHash === sclProfiles.m4ConfigurationHash + ? "3 MU ROSTER VERIFIED · START GATED" : "DRAFT · NOT DEPLOYED" + color: device.m4Ready && device.m4DeployedHash === sclProfiles.m4ConfigurationHash + ? studioTheme.green : studioTheme.amber font.family: root.uiFont font.pixelSize: 9 } + Button { + visible: m4Pages.active + enabled: m4Pages.active && workflowBar.session && workflowBar.session.canDeployMultiSv && + !device.m4Deploying && !workflowBar.startQueued + text: device.m4Deploying ? "Uploading…" : "Deploy 3 MU" + font.family: root.uiFont + font.pixelSize: 10 + onClicked: { + if (!root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || + !voltageMatrix.flushPendingEdits()) return + if (m4Pages.active) + m4Pages.saveCurrent(root.startScenarioSnapshot(false)) + const accepted = workflowBar.session && workflowBar.session.requestDeployMultiSv() + root.showMessage(accepted + ? "Uploading ARM3. Board hash, all 3 MAC/svID/VLAN identities and SELECT ACK will be checked." + : "M4 deploy blocked. Check stopped board, firmware capability, or diagnostics.", !accepted) + } + } Button { text: m4Pages.active ? "Close MU pages" : "Open MU pages" font.family: root.uiFont diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index ad0de6680..377caf02e 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -57,6 +57,8 @@ class DeterministicSessionHarness final { {"PTP LINK_DOWN is classified early; MAC INVALID_STATE keeps STOP verification", ptpPhysicalEthernetPreflightAndMacFailure()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"M4 roster never authorizes V1 START or automatic profile sync", m4RosterIsolationAndV1StartGuard()}, + {"M4 upload STOP revokes transaction proof before serial STOP", m4StopCancelsUpload()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, @@ -2021,6 +2023,57 @@ class DeterministicSessionHarness final { report.contains(QStringLiteral("Firmware protocol compatible: UNKNOWN")); } + static bool m4RosterIsolationAndV1StartGuard() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& device = fixture.device; + auto& session = fixture.session; + + // Explicit V1/M4 mode isolation: no implicit PROFILE transaction, + // START or live edit can issue legacy commands after M4 selection. + session.m4Mode_ = true; + device.m4Ready_ = true; + device.m4DeployedHash_ = QStringLiteral("0011223344556677"); + session.needsProfileSync_ = true; + session.reconcile(); + const bool isolated = !session.startReady() && + !session.canDeployProfile() && + !session.liveControlReady() && + session.state() == QStringLiteral("M4 ROSTER VERIFIED") && + !session.requestStartWithScenario({}) && + !session.requestApplyLiveScenario({}) && + !device.startWithScenario({}) && + !device.deployProfile({}) && + session.needsProfileSync_ && !device.profileDeploying(); + return isolated; + } + + static bool m4StopCancelsUpload() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& device = fixture.device; + auto& session = fixture.session; + session.m4Mode_ = true; + device.m4Ready_ = false; + device.m4Deploying_ = true; + device.m4SessionGeneration_ = device.sessionGeneration_; + device.m4InFlightCommand_ = QStringLiteral("MULTISV CHUNK 42 0 0102"); + device.m4AckTimer_.start(5000); + // An explicit STOP is a reserved priority command. The device + // transaction is invalidated synchronously before any worker write. + static_cast(device.stop()); + const bool cancelled = !device.m4Deploying_ && !device.m4Ready_ && + !device.m4AckTimer_.isActive() && + device.m4InFlightCommand_.isEmpty() && + device.m4DeploymentStatus().contains(QStringLiteral("STOP")); + // The profile synchronizer must NOT revive the old V1 profile. + session.reconcile(); + return cancelled && !session.startReady() && + session.state() == QStringLiteral("M4 DEPLOY NOT VERIFIED"); + } + static bool workerTeardownCompletes() { bool profileReady = false; { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 883704577..897b80dbe 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -140,6 +140,14 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { logUpdateTimer_.setInterval(100); connect(&logUpdateTimer_, &QTimer::timeout, this, &DeviceController::logTextChanged); + // M4 upload is deliberately single-flight and timeout-bounded. No + // periodic polling, retries or extra serial thread. + m4AckTimer_.setSingleShot(true); + m4AckTimer_.setInterval(5000); + connect(&m4AckTimer_, &QTimer::timeout, this, [this] { + if (m4Deploying_) + cancelM4(QStringLiteral("M4 MULTISV ACK/readback timed out; configuration not verified.")); + }); scenarioTimer_.setSingleShot(true); scenarioTimer_.setInterval(8000); connect(&scenarioTimer_, &QTimer::timeout, this, [this] { @@ -174,6 +182,7 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { QTimer::singleShot(0, this, [this] { flushDeferredReadbacks(); }); }; connect(this, &DeviceController::profileStateChanged, this, drainDeferred); + connect(this, &DeviceController::m4DeploymentChanged, this, drainDeferred); connect(this, &DeviceController::scenarioStateChanged, this, drainDeferred); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); @@ -195,6 +204,7 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); + cancelM4(QStringLiteral("Studio closing; M4 transaction no longer verified.")); // USB/Studio process lifetime is not the ESP32-P4's publisher lifetime. // Closing the GUI does not imply SV STOP or PTP STOP: those are explicit // operator commands. Disconnect/reconnect only refreshes live readback. @@ -411,11 +421,20 @@ void DeviceController::connectWorkerSignals() { if (command == QStringLiteral("HEARTBEAT")) appendLog(QStringLiteral("HB-TX"), QStringLiteral("HEARTBEAT host drained serial bytes")); else if (!quiet) appendLog(QStringLiteral("→"), command); + if (m4Deploying_ && generation == m4SessionGeneration_ && + command == m4InFlightCommand_) { + // Worker has actually written the UART bytes; only now are + // incoming responses eligible to advance this transaction. + m4InFlightCommand_.clear(); + m4AckTimer_.start(); + } }); connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; + if (m4Deploying_) + cancelM4(QStringLiteral("M4 serial command rejected: ") + message); if (scenarioStage_ != ScenarioStage::idle) failScenarioStart(QStringLiteral("SV scenario serial command rejected: ") + message); if (profileDeploying_) { @@ -443,6 +462,8 @@ void DeviceController::connectWorkerSignals() { [this](const quint64 generation, const QString& message, const bool fatal) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; Q_UNUSED(fatal) + if (m4Deploying_) + cancelM4(QStringLiteral("M4 transport failure: ") + message); if (scenarioStage_ != ScenarioStage::idle) failScenarioStart(QStringLiteral("SV transaction transport error: ") + message); setError(message); @@ -523,6 +544,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; + cancelM4(QStringLiteral("Device session generation changed; previous M4 proof revoked.")); resetPtpStartup(); resetScenarioStart(); deferredShow_ = false; @@ -715,6 +737,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; + cancelM4(QStringLiteral("Serial session reopened; previous M4 proof retired.")); deferredShow_ = false; deferredProfileShow_ = false; deferredPtpShow_ = false; @@ -746,6 +769,7 @@ void DeviceController::handlePortClosed(const QString& portName) { profileDeploying_ = false; profileArmed_ = false; profileWasReused_ = false; + cancelM4(QStringLiteral("Serial session closed; M4 readback no longer valid.")); deferredShow_ = false; deferredProfileShow_ = false; deferredPtpShow_ = false; @@ -779,6 +803,10 @@ bool DeviceController::start() { } bool DeviceController::startWithScenario(const QVariantMap& scenario) { + if (m4Ready_ || m4Deploying_) { + setError(QStringLiteral("Legacy V1 START blocked while an M4 roster is selected. Configure all three MU scenarios first.")); + return false; + } return submitScenario(scenario, true); } @@ -900,6 +928,7 @@ bool DeviceController::submitScenario(const QVariantMap& scenario, const bool st return true; } bool DeviceController::stop() { + if (m4Deploying_) cancelM4(QStringLiteral("M4 upload cancelled by explicit operator STOP.")); operatorStopPending_ = true; appendLog(QStringLiteral("SVTX"), QStringLiteral( "OPERATOR_STOP_REQUEST session=%1 boot=%2 running=%3 cfg_pending=%4") @@ -991,6 +1020,10 @@ bool DeviceController::setQuality(const QString& signalId, const quint32 quality } bool DeviceController::deployProfile(const QVariantMap& profile) { + if (m4Deploying_ || m4Ready_) { + setError(QStringLiteral("M4 roster is selected; legacy PROFILE deploy requires an explicit firmware mode switch.")); + return false; + } if (!deviceVerified_) { setError(QStringLiteral("Connect the ESP32-P4 before deployment.")); return false; @@ -1062,6 +1095,103 @@ bool DeviceController::deployProfile(const QVariantMap& profile) { return true; } + +bool DeviceController::deployMultiSv(const QByteArray& arm3) { + // Config is operator-initiated and independent from V1 profile auto-sync. + // A staging-only board is not a TX-capable board: require BOTH tags. + if (!deviceVerified_ || !connected_ || !ioWorkerReady_ || + sessionGeneration_ == 0 || identity_.bootId.size() != 16 || + running_ || profileDeploying_ || m4Deploying_ || + scenarioStage_ != ScenarioStage::idle || ptpStarting() || + !identity_.capabilities.contains(QStringLiteral("MULTISV-CONFIG-STAGING-V2"), + Qt::CaseInsensitive) || + !identity_.capabilities.contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), + Qt::CaseInsensitive)) { + setError(QStringLiteral("M4 deployment blocked: verified stopped board, " + "exclusive idle serial session and firmware MULTISV staging+TX capabilities required.")); + return false; + } + if (!m4Upload_.start(arm3, identity_.bootId)) { + setError(QStringLiteral("M4 ARM3 configuration failed canonical CRC/layout validation.")); + return false; + } + m4Ready_ = false; + m4DeployedHash_.clear(); + m4Deploying_ = true; + m4Status_ = QStringLiteral("Uploading three SCD profiles (readback required)"); + m4SessionGeneration_ = sessionGeneration_; + m4TotalClock_.start(); + emit m4DeploymentChanged(); + return dispatchNextM4(); +} + +void DeviceController::cancelM4(const QString& reason, const bool clearReady) { + if (!m4Deploying_ && (!clearReady || !m4Ready_)) return; + m4AckTimer_.stop(); + if (m4Upload_.active()) m4Upload_.cancel(reason); + m4InFlightCommand_.clear(); + m4Deploying_ = false; + if (clearReady) { + m4Ready_ = false; + m4DeployedHash_.clear(); + } + m4Status_ = reason; + emit m4DeploymentChanged(); +} + +bool DeviceController::dispatchNextM4() { + if (!m4Deploying_) return false; + if (!connected_ || !deviceVerified_ || sessionGeneration_ != m4SessionGeneration_ || + running_ || m4TotalClock_.elapsed() > 120000) { + cancelM4(QStringLiteral("M4 upload session/STOPPED/deadline guard failed.")); + return false; + } + const QString command = m4Upload_.takeCommand(); + if (command.isEmpty()) { + // MULTISV SHOW is multi-line: wait for the remaining stream records. + m4AckTimer_.start(); + return true; + } + m4InFlightCommand_ = command; + m4AckTimer_.start(); // bounded even if the QSerialPort writer never drains + if (!sendCommand(command)) { + cancelM4(QStringLiteral("M4 serial dispatcher refused the next command.")); + return false; + } + return true; +} + +bool DeviceController::processM4Line(const QString& line) { + if (!m4Deploying_) return false; + if (sessionGeneration_ != m4SessionGeneration_ || !connected_ || + !deviceVerified_ || m4TotalClock_.elapsed() > 120000) { + cancelM4(QStringLiteral("M4 session changed or transaction hard deadline exceeded.")); + return true; + } + if (!line.contains(QStringLiteral("MULTISV "))) return false; + // Replies that precede the worker's commandTransmitted signal are stale; + // never accept a previous transaction's delayed response as current ACK. + if (!m4InFlightCommand_.isEmpty()) return true; + if (!m4Upload_.receive(line, identity_.bootId)) return false; + m4AckTimer_.stop(); + if (m4Upload_.stage() == M4MultiSvUploadTransaction::Stage::failed) { + cancelM4(m4Upload_.error()); + return true; + } + if (m4Upload_.verified()) { + m4Deploying_ = false; + m4Ready_ = true; + m4DeployedHash_ = m4Upload_.identityHash(); + m4Status_ = QStringLiteral("3 MU identities read back; selected roster generation %1, hash %2. START awaits per-MU scenario gate.") + .arg(m4Upload_.generation()).arg(m4Upload_.identityHash()); + m4InFlightCommand_.clear(); + emit m4DeploymentChanged(); + return true; + } + static_cast(dispatchNextM4()); + return true; +} + void DeviceController::abandonProfileDeployment() { if (!profileDeploying_) return; const auto stage = binaryProfileStage_; @@ -1827,6 +1957,20 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } return true; }; + if (m4Deploying_) { + const bool one = !exclusive && commands.size() == 1; + const QString command = one ? commands.constFirst() : QString{}; + const bool fromUpload = one && !m4InFlightCommand_.isEmpty() && + command == m4InFlightCommand_; + const bool safety = command == QStringLiteral("STOP") || + command == QStringLiteral("PTP STOP") || + command == QStringLiteral("HEARTBEAT"); + if (!one || (!fromUpload && !safety)) { + if (readback) return deferReadback(); + setError(QStringLiteral("M4 serial transaction owns the device command queue.")); + return false; + } + } if (profileDeploying_) { const bool exactlyOne = !exclusive && commands.size() == 1; const QString command = exactlyOne ? commands.constFirst() : QString{}; @@ -1874,7 +2018,7 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } void DeviceController::flushDeferredReadbacks() { - if (profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + if (m4Deploying_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || scenarioStage_ != ScenarioStage::idle || !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; @@ -2088,6 +2232,7 @@ void DeviceController::processLine(const QString& rawLine) { const QString line = cleanLine(rawLine); if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); + if (processM4Line(line)) return; // Read-only 4I+4V sync after reconnect: a complete eight-row message // must match the verified boot and one bank generation, never just a @@ -2477,6 +2622,7 @@ void DeviceController::processLine(const QString& rawLine) { const bool unexpectedDevice = deviceVerified_ && !identity_.deviceId.isEmpty() && !sameDevice; if (unexpectedDevice) { + cancelM4(QStringLiteral("Different board identity: M4 roster proof revoked.")); resetScenarioStart(); resetBinaryProfileTransfer(); profileArmed_ = false; @@ -2499,6 +2645,7 @@ void DeviceController::processLine(const QString& rawLine) { const bool newBoot = sameDevice && !identity_.bootId.isEmpty() && identity_.bootId != parsedIdentity.bootId; if (newBoot) { + cancelM4(QStringLiteral("Firmware reboot: M4 transaction/readback revoked.")); resetScenarioStart(); resetBinaryProfileTransfer(); deferredShow_ = false; diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index f1e68af0a..0e9e6cef3 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -1,7 +1,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once +#include "M4MultiSvUploadTransaction.hpp" #include +#include #include #include #include @@ -72,6 +74,10 @@ class DeviceController : public QObject { Q_PROPERTY(bool profileArmed READ profileArmed NOTIFY profileStateChanged) Q_PROPERTY(bool profileWasReused READ profileWasReused NOTIFY profileStateChanged) Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) + Q_PROPERTY(bool m4Deploying READ m4Deploying NOTIFY m4DeploymentChanged) + Q_PROPERTY(bool m4Ready READ m4Ready NOTIFY m4DeploymentChanged) + Q_PROPERTY(QString m4DeploymentStatus READ m4DeploymentStatus NOTIFY m4DeploymentChanged) + Q_PROPERTY(QString m4DeployedHash READ m4DeployedHash NOTIFY m4DeploymentChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpStarting READ ptpStarting NOTIFY ptpStateChanged) @@ -177,6 +183,10 @@ class DeviceController : public QObject { [[nodiscard]] bool profileArmed() const noexcept; [[nodiscard]] bool profileWasReused() const noexcept { return profileWasReused_; } [[nodiscard]] bool profileDeploying() const noexcept; + [[nodiscard]] bool m4Deploying() const noexcept { return m4Deploying_; } + [[nodiscard]] bool m4Ready() const noexcept { return m4Ready_; } + [[nodiscard]] QString m4DeploymentStatus() const { return m4Status_; } + [[nodiscard]] QString m4DeployedHash() const { return m4DeployedHash_; } [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; @@ -274,6 +284,8 @@ class DeviceController : public QObject { bool setQuality(const QString& signalId, quint32 quality); bool setCtSaturation(bool enabled, double dcOffsetPercent, double harmonicPercent, int harmonicOrder, double clipPercent); virtual bool deployProfile(const QVariantMap& profile); + // Exclusive SCD-based, operator-initiated M4 transaction, not V1 auto sync. + bool deployMultiSv(const QByteArray& arm3); void abandonProfileDeployment(); @@ -338,6 +350,7 @@ class DeviceController : public QObject { void boardScenarioChanged(); void profileStateChanged(); void profileDeploymentProgress(); + void m4DeploymentChanged(); void ptpStateChanged(); void deviceMessage(const QString& message); void scenarioStateChanged(); @@ -418,6 +431,9 @@ class DeviceController : public QObject { bool sendNextBinaryProfileChunk(); void resetBinaryProfileTransfer(); void failBinaryProfileDeployment(const QString& message); + bool dispatchNextM4(); + bool processM4Line(const QString& line); + void cancelM4(const QString& reason, bool clearReady = true); void failScenarioStart(const QString& message); bool submitScenario(const QVariantMap& scenario, bool starting); void resetScenarioStart() noexcept; @@ -483,6 +499,15 @@ class DeviceController : public QObject { bool profileArmed_{false}; bool profileWasReused_{false}; bool profileDeploying_{false}; + M4MultiSvUploadTransaction m4Upload_; + QTimer m4AckTimer_; + QElapsedTimer m4TotalClock_; + QString m4InFlightCommand_; + QString m4Status_{QStringLiteral("M4 not deployed")}; + QString m4DeployedHash_; + quint64 m4SessionGeneration_{0}; + bool m4Deploying_{false}; + bool m4Ready_{false}; // One coalesced readback of each kind; no second serial writer or retry loop. bool deferredShow_{false}; bool deferredProfileShow_{false}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 295829388..2821a41ac 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -165,6 +165,7 @@ QString SmartSessionController::state() const { return state_; } QString SmartSessionController::statusText() const { return statusText_; } bool SmartSessionController::startReady() const noexcept { return startReady_; } bool SmartSessionController::canDeployProfile() const noexcept { + if (m4Mode_) return false; if (!deviceControlAvailable() || profiles_ == nullptr || device_->running() || device_->profileDeploying()) { return false; } @@ -172,8 +173,19 @@ bool SmartSessionController::canDeployProfile() const noexcept { return profile.value(QStringLiteral("compatibilityClass")).toString() == QStringLiteral("A") && profile.value(QStringLiteral("deviceSupport")).toString() == QStringLiteral("ready"); } +bool SmartSessionController::canDeployMultiSv() const noexcept { + return deviceControlAvailable() && profiles_ != nullptr && + profiles_->m4DraftSupported() && !device_->running() && + !device_->profileDeploying() && !device_->m4Deploying() && + !device_->scenarioApplying() && !device_->ptpStarting() && + profileSyncStage_ != ProfileSyncStage::deploying && + device_->capabilities().contains(QStringLiteral("MULTISV-CONFIG-STAGING-V2"), + Qt::CaseInsensitive) && + device_->capabilities().contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), + Qt::CaseInsensitive); +} bool SmartSessionController::liveControlReady() const noexcept { - return deviceControlAvailable() && !device_->profileDeploying() && + return !m4Mode_ && deviceControlAvailable() && !device_->profileDeploying() && (startReady_ || device_->running()); } bool SmartSessionController::engineeringEditable() const noexcept { @@ -337,11 +349,12 @@ bool SmartSessionController::requestStart() { } bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenario) { - if (!startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; + if (m4Mode_ || !startReady_ || !deviceControlAvailable() || device_ == nullptr || device_->running()) return false; return device_->startWithScenario(scenario); } bool SmartSessionController::requestApplyLiveScenario(const QVariantMap& scenario) { + if (m4Mode_) return false; // Never silently replay a locally restored draft after reconnect. This // path is entered only by the explicit operator Apply Live intent. if (!liveControlReady() || device_ == nullptr || !device_->running() || @@ -353,7 +366,7 @@ bool SmartSessionController::requestStop() { if (device_ == nullptr) return false; // STOP is a cancellation authority even before SV RUNNING telemetry: an // ACKed staging transaction may still be advancing toward START. - if (!device_->running() && !device_->scenarioApplying()) return true; + if (!device_->running() && !device_->scenarioApplying() && !device_->m4Deploying()) return true; if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; return device_->stop(); } @@ -406,6 +419,17 @@ bool SmartSessionController::requestDisconnect() { return true; } +bool SmartSessionController::requestDeployMultiSv() { + if (!canDeployMultiSv()) return false; + // Atomic supervisor admission: stop V1 retries before granting the + // single DeviceIoWorker's queue to the M4 transaction state machine. + resetProfileSync(false); + m4Mode_ = true; + const bool started = device_->deployMultiSv(profiles_->m4ConfigurationBinary()); + reconcile(); + return started; +} + bool SmartSessionController::requestProfileSync() { if (!canDeployProfile()) return false; resetProfileSync(true); @@ -810,6 +834,9 @@ void SmartSessionController::reconnectDeviceSignals() { handleProfileStateChanged(); reconcile(); }); + connect(device_, &DeviceController::m4DeploymentChanged, this, [this] { + reconcile(); + }); connect(device_, &DeviceController::profileDeploymentProgress, this, [this] { if (profileSyncStage_ == ProfileSyncStage::deploying) { profileSyncTimer_.start(); @@ -1400,6 +1427,21 @@ void SmartSessionController::reconcile() { return; } + // M4 operator intent MUST suppress the legacy automatic V1 PROFILE + // sync/start path. A matched ARM3 upload is a roster proof, not proof + // that three per-MU live waveform scenarios have been applied. + if (m4Mode_) { + const QString phase = device_->m4Deploying() + ? QStringLiteral("M4 DEPLOYING") + : device_->m4Ready() ? QStringLiteral("M4 ROSTER VERIFIED") + : QStringLiteral("M4 DEPLOY NOT VERIFIED"); + const QString message = device_->m4Ready() + ? QStringLiteral("Three firmware SV identities verified. START remains blocked until per-MU scenario/readback is integrated.") + : device_->m4DeploymentStatus(); + setPresentation(phase, message, false, false); + return; + } + if (device_->running()) { setPresentation( QStringLiteral("RUNNING"), diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index bf183b572..c8c6d40aa 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -22,6 +22,7 @@ class SmartSessionController : public QObject { Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) Q_PROPERTY(bool startReady READ startReady NOTIFY stateChanged) Q_PROPERTY(bool canDeployProfile READ canDeployProfile NOTIFY stateChanged) + Q_PROPERTY(bool canDeployMultiSv READ canDeployMultiSv NOTIFY stateChanged) Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged) Q_PROPERTY(bool engineeringEditable READ engineeringEditable NOTIFY stateChanged) Q_PROPERTY(bool firmwareUpdateRequired READ firmwareUpdateRequired NOTIFY stateChanged) @@ -77,6 +78,7 @@ class SmartSessionController : public QObject { [[nodiscard]] QString statusText() const; [[nodiscard]] bool startReady() const noexcept; [[nodiscard]] bool canDeployProfile() const noexcept; + [[nodiscard]] bool canDeployMultiSv() const noexcept; [[nodiscard]] bool liveControlReady() const noexcept; [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; @@ -188,6 +190,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestConnectPort(const QString& portName); Q_INVOKABLE bool requestDisconnect(); Q_INVOKABLE bool requestProfileSync(); + Q_INVOKABLE bool requestDeployMultiSv(); Q_INVOKABLE bool requestSetFrequency(double hz); Q_INVOKABLE bool requestSetSignal( const QString& signalId, @@ -417,6 +420,7 @@ class SmartSessionController : public QObject { bool started_{false}; bool wasReady_{false}; bool needsProfileSync_{true}; + bool m4Mode_{false}; // suppress V1 automatic profile sync after M4 operator intent bool updateRequested_{false}; bool updateWasOptional_{false}; bool blankBoardDetected_{false}; diff --git a/docs/M4_P0E2C1_STUDIO_SERIAL_DEPLOYMENT.md b/docs/M4_P0E2C1_STUDIO_SERIAL_DEPLOYMENT.md new file mode 100644 index 000000000..37f1b86e4 --- /dev/null +++ b/docs/M4_P0E2C1_STUDIO_SERIAL_DEPLOYMENT.md @@ -0,0 +1,22 @@ +# M4-P0E2C1 — Single-owner Studio MULTISV serial deployment + +**Scope:** operator-initiated **configuration deployment and roster readback**; **not** three-stream live scenario application or START. Maintains M3.H.3 Official Stable firmware and V1 single-SV path unchanged. + +## Operational sequence + +1. Open a three-MU SCD in Studio; explicitly choose 9-2LE and the verified 4000 counter policy. `SclProfileModel` caches a single canonical ARM3 V2 envelope. Edit MU draft pages separately. +2. Connect a **verified, stopped** ESP32-P4 with both `MULTISV-CONFIG-STAGING-V2` and `MULTISV-TX-LAB-PREVIEW` capabilities. Staging-only boards cannot pass this gate. +3. Select **Deploy 3 MU**. Supervisor suppresses automatic legacy V1 profile synchronization and grants the existing `DeviceIoWorker` single-owner queue to the M4 transaction. The GUI's legacy START remains disabled. +4. For each control-plane step, `M4MultiSvUploadTransaction` emits at most one command. The controller accepts its ACK only **after the worker confirms transmission**. It enforces monotonic transaction ID, contiguous <=48-byte chunks, exact firmware ACK counters, validation before COMMIT, whole-envelope hash+size+generation, then all three exact `MULTISV SHOW` stream rows before `MULTISV SELECT` confirmation. +5. A per-step 5-second watchdog bounds stalled writes or ACK/readback, and a 120-second overall budget prevents a never-ending sequence of individually valid slow ACKs. No auto retry, speculative send, extra Qt worker, duplicated clock, MCU XML parser or TX hot-path allocation. +6. Explicit operator STOP interrupts upload immediately, then queues the worker's priority STOP. Session generation/boot changes, USB disconnect, error/rejection and GUI shutdown revoke the local proof. The firmware may retain an already committed roster; a later session must re-read or redeploy instead of assuming success. +7. A fully matched SELECT ACK marks **roster verified** and records its content hash. This is **NOT** evidence of per-MU signal state being applied or Ethernet 12kfps. Live three-MU START is deliberately blocked until P0E3/P0F acceptance. + +## CI cost and regression + +This change adds dedicated regression cases to the **existing** headless deterministic session harness; it does not create any extra workflow, CI job, runner matrix, packaging task or firmware build variant. The pull request receives only one consolidated commit after offline source/contract checks. Existing Qt/Windows native preview checks compile the production controller and unchanged worker. No physical hardware claim follows from host CI. + +## Next gates + +- **P0E2C2 / P0E3:** exact three independently staged `SVSTREAM CFG` transactions, SET/FREQ/SHAPE readback, host reconnect adoption and explicit safe group START. +- **P0F:** capture independently via NIC/Wireshark 12,000 canonical frames/s aggregate and validate three MAC/svID identities, common smpCnt/PTP phase, measured egress skew/overruns and autonomy across unplug/reconnect. From 782b02fa40135d3a1244924252013bde4a71ec33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 14:55:06 +0700 Subject: [PATCH 2/2] fix(M4-P0E2C1): expose emergency STOP during stopped-board MULTISV upload Allow Ribbon Stop, F6 and WorkflowBar Stop to synchronously cancel a staging transaction before it reaches COMMIT/SELECT, even when SV RUNNING is false. Keep priority STOP in single serial worker. Addresses Codex PR #346 P2 review; no CI workflow changes. --- apps/arstack_studio/qml/Main.qml | 2 +- apps/arstack_studio/qml/ModernRibbon.qml | 4 ++-- apps/arstack_studio/qml/WorkflowBar.qml | 7 +++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index cb26abe9c..2b4424304 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -1024,7 +1024,7 @@ ApplicationWindow { Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } Shortcut { sequence: "Ctrl+0"; enabled: !workflowBar.startQueued; onActivated: root.zeroAll() } Shortcut { sequence: "F5"; enabled: workflowBar.session && !workflowBar.injectionRunning && !workflowBar.startQueued; onActivated: workflowBar.requestStart() } - Shortcut { sequence: "F6"; enabled: workflowBar.session && (workflowBar.injectionRunning || workflowBar.startQueued); onActivated: workflowBar.requestStop() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && (workflowBar.injectionRunning || workflowBar.startQueued || device.m4Deploying); onActivated: workflowBar.requestStop() } Dialog { id: diagnosticsDialog diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index bce90ffec..0c1b66e6a 100644 --- a/apps/arstack_studio/qml/ModernRibbon.qml +++ b/apps/arstack_studio/qml/ModernRibbon.qml @@ -113,9 +113,9 @@ Rectangle { theme: bar.theme uiFont: bar.uiFont text: "Stop" - tone: bar.workflow.injectionRunning ? "danger" : "neutral" + tone: (bar.workflow.injectionRunning || bar.device.m4Deploying) ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/stop.svg") - enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning || bar.device.m4Deploying) && !bar.session.updatingFirmware toolTipText: "" onClicked: { diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 8f923088d..4da932c6d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -392,6 +392,13 @@ SurfacePanel { } function requestStop() { + // STOP is the operator's emergency cancellation even while an M4 + // roster upload is staged with the publisher still STOPPED. + if (device.m4Deploying) { + if (!smartSession.requestStop()) + controller.showMessage("Unable to stop the M4 device transaction.", true) + return + } if (ribbon.startIntentPending) { ribbon.cancelStartIntent() controller.showMessage("Pending START cancelled.", false)