From ed4a6a7f0c555ec3fc713f703a87d67ac388c825 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 16:56:22 +0700 Subject: [PATCH 1/4] feat(M4-P0E3A): staged independent MU scenario apply with firmware COMMIT/readback Prevalidate/freeze all three typed 4I4V scenario drafts and canonical per-MU hashes before touching UART. Apply fixed 12-command per-slot SVSTREAM CFG batches via the sole DeviceIoWorker, with mandatory firmware token/hash/generation COMMIT+READBACK before moving to next MU. Enforce 7s stage/60s total bounds, STOP/boot/session cancellation and explicit partial-commit diagnostics. Integrate Apply 3 MU in single Studio GUI, keep global START gated. Extend existing M4 and S8 harnesses; no new CI jobs, no MCU realtime-path change. --- apps/arstack_studio/CMakeLists.txt | 4 + apps/arstack_studio/qml/Main.qml | 29 ++- apps/arstack_studio/qml/ModernRibbon.qml | 4 +- apps/arstack_studio/qml/WorkflowBar.qml | 2 +- .../src/DeterministicSessionHarness.cpp | 23 +++ apps/arstack_studio/src/DeviceController.cpp | 129 +++++++++++- apps/arstack_studio/src/DeviceController.hpp | 19 ++ .../src/M4MultiSvUploadHarness.cpp | 91 +++++++- .../src/M4ThreeMuScenarioApply.cpp | 195 ++++++++++++++++++ .../src/M4ThreeMuScenarioApply.hpp | 48 +++++ .../src/SmartSessionController.cpp | 35 +++- .../src/SmartSessionController.hpp | 3 + docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md | 23 +++ 13 files changed, 590 insertions(+), 15 deletions(-) create mode 100644 apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp create mode 100644 apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp create mode 100644 docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index c2f6aec02..85de8c34b 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -24,6 +24,8 @@ set(ARSTACK_STUDIO_CORE_SOURCES src/M4StreamScenarioModel.hpp src/M4MultiSvUploadTransaction.cpp src/M4MultiSvUploadTransaction.hpp + src/M4ThreeMuScenarioApply.cpp + src/M4ThreeMuScenarioApply.hpp src/DeviceIoWorker.cpp src/DeviceIoWorker.hpp src/DeviceController.cpp @@ -246,6 +248,8 @@ qt_add_executable(arstack_m4_upload_harness src/M4MultiSvUploadHarness.cpp src/M4MultiSvUploadTransaction.cpp src/M4MultiSvUploadTransaction.hpp + src/M4ThreeMuScenarioApply.cpp + src/M4ThreeMuScenarioApply.hpp src/SclProfileModel.cpp src/SclProfileModel.hpp ) diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 2b4424304..c20267884 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 || device.m4Deploying); onActivated: workflowBar.requestStop() } + Shortcut { sequence: "F6"; enabled: workflowBar.session && (workflowBar.injectionRunning || workflowBar.startQueued || device.m4Deploying || device.m4ScenarioApplying); onActivated: workflowBar.requestStop() } Dialog { id: diagnosticsDialog @@ -1371,7 +1371,9 @@ ApplicationWindow { Item { Layout.fillWidth: true } Label { visible: m4Pages.active - text: device.m4Deploying ? "UPLOADING · VERIFIED ACK REQUIRED" + text: device.m4ScenarioApplying ? "APPLYING · MU BANK READBACK REQUIRED" + : device.m4ScenariosVerified && device.m4DeployedHash === sclProfiles.m4ConfigurationHash ? "3/3 MU BANKS VERIFIED · START GATED" + : 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 @@ -1382,7 +1384,7 @@ ApplicationWindow { Button { visible: m4Pages.active enabled: m4Pages.active && workflowBar.session && workflowBar.session.canDeployMultiSv && - !device.m4Deploying && !workflowBar.startQueued + !device.m4Deploying && !device.m4ScenarioApplying && !workflowBar.startQueued text: device.m4Deploying ? "Uploading…" : "Deploy 3 MU" font.family: root.uiFont font.pixelSize: 10 @@ -1399,6 +1401,27 @@ ApplicationWindow { } } Button { + visible: m4Pages.active + enabled: m4Pages.active && workflowBar.session && + workflowBar.session.canApplyMultiSvScenarios && + !workflowBar.startQueued + text: device.m4ScenarioApplying ? "Applying…" : "Apply 3 MU" + font.family: root.uiFont + font.pixelSize: 10 + onClicked: { + if (!root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || + !voltageMatrix.flushPendingEdits()) return + if (!m4Pages.saveCurrent(root.startScenarioSnapshot(false))) return + const drafts = [m4Pages.draftFor(0), m4Pages.draftFor(1), m4Pages.draftFor(2)] + const accepted = workflowBar.session.requestApplyMultiSvScenarios(drafts) + root.showMessage(accepted + ? "Applying 3 isolated MU signal banks with firmware COMMIT and READBACK." + : "Apply rejected: verify current SCD roster and all three scenario drafts.", !accepted) + } + } + Button { + enabled: !device.m4Deploying && !device.m4ScenarioApplying text: m4Pages.active ? "Close MU pages" : "Open MU pages" font.family: root.uiFont font.pixelSize: 10 diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 0c1b66e6a..2775a6f09 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 || bar.device.m4Deploying) ? "danger" : "neutral" + tone: (bar.workflow.injectionRunning || bar.device.m4Deploying || bar.device.m4ScenarioApplying) ? "danger" : "neutral" iconSource: Qt.resolvedUrl("../assets/material/stop.svg") - enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning || bar.device.m4Deploying) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning || bar.device.m4Deploying || bar.device.m4ScenarioApplying) && !bar.session.updatingFirmware toolTipText: "" onClicked: { diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 4da932c6d..6f41d798d 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -394,7 +394,7 @@ 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 (device.m4Deploying || device.m4ScenarioApplying) { if (!smartSession.requestStop()) controller.showMessage("Unable to stop the M4 device transaction.", true) return diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 377caf02e..a0040860b 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -59,6 +59,7 @@ class DeterministicSessionHarness final { {"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()}, + {"M4 per-MU CFG STOP aborts serial queue without V1 START", m4PerMuScenarioStopGuard()}, {"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()}, @@ -2074,6 +2075,28 @@ class DeterministicSessionHarness final { session.state() == QStringLiteral("M4 DEPLOY NOT VERIFIED"); } + static bool m4PerMuScenarioStopGuard() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + auto& session = fixture.session; + session.m4Mode_ = true; + d.m4Ready_ = true; + d.m4ScenarioApplying_ = true; + d.m4ScenarioSession_ = d.sessionGeneration_; + d.m4ScenarioTimer_.start(5000); + session.reconcile(); + if (session.startReady() || session.liveControlReady() || + session.state() != QStringLiteral("M4 APPLYING MU BANKS")) return false; + static_cast(session.requestStop()); + return !d.m4ScenarioApplying_ && !d.m4ScenariosVerified_ && + !d.m4ScenarioTimer_.isActive() && + d.m4ScenarioStatus().contains(QStringLiteral("STOP")) && + !session.requestStartWithScenario({}) && + session.state() == QStringLiteral("M4 ROSTER VERIFIED"); + } + static bool workerTeardownCompletes() { bool profileReady = false; { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 897b80dbe..4109e8103 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -148,6 +148,14 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { if (m4Deploying_) cancelM4(QStringLiteral("M4 MULTISV ACK/readback timed out; configuration not verified.")); }); + // Per-MU CFG transactions are bounded on the UI control thread only; + // the 4kHz firmware sample loop never waits on these timers. + m4ScenarioTimer_.setSingleShot(true); + m4ScenarioTimer_.setInterval(7000); + connect(&m4ScenarioTimer_, &QTimer::timeout, this, [this] { + if (m4ScenarioApplying_) + cancelM4Scenarios(QStringLiteral("Per-MU CFG COMMIT/READBACK timeout; partial board state possible.")); + }); scenarioTimer_.setSingleShot(true); scenarioTimer_.setInterval(8000); connect(&scenarioTimer_, &QTimer::timeout, this, [this] { @@ -204,6 +212,7 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); + cancelM4Scenarios(QStringLiteral("Studio closing; per-MU board state must be re-read.")); 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 @@ -435,6 +444,8 @@ void DeviceController::connectWorkerSignals() { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; if (m4Deploying_) cancelM4(QStringLiteral("M4 serial command rejected: ") + message); + if (m4ScenarioApplying_) + cancelM4Scenarios(QStringLiteral("Per-MU serial command rejected: ") + message); if (scenarioStage_ != ScenarioStage::idle) failScenarioStart(QStringLiteral("SV scenario serial command rejected: ") + message); if (profileDeploying_) { @@ -464,6 +475,8 @@ void DeviceController::connectWorkerSignals() { Q_UNUSED(fatal) if (m4Deploying_) cancelM4(QStringLiteral("M4 transport failure: ") + message); + if (m4ScenarioApplying_) + cancelM4Scenarios(QStringLiteral("Per-MU transport failure: ") + message); if (scenarioStage_ != ScenarioStage::idle) failScenarioStart(QStringLiteral("SV transaction transport error: ") + message); setError(message); @@ -544,6 +557,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; + cancelM4Scenarios(QStringLiteral("Device session changed; per-MU proof revoked.")); cancelM4(QStringLiteral("Device session generation changed; previous M4 proof revoked.")); resetPtpStartup(); resetScenarioStart(); @@ -737,6 +751,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileArmed_ = false; profileWasReused_ = false; profileDeploying_ = false; + cancelM4Scenarios(QStringLiteral("Port reopened; per-MU proof revoked.")); cancelM4(QStringLiteral("Serial session reopened; previous M4 proof retired.")); deferredShow_ = false; deferredProfileShow_ = false; @@ -769,6 +784,7 @@ void DeviceController::handlePortClosed(const QString& portName) { profileDeploying_ = false; profileArmed_ = false; profileWasReused_ = false; + cancelM4Scenarios(QStringLiteral("Port closed; per-MU readback invalid.")); cancelM4(QStringLiteral("Serial session closed; M4 readback no longer valid.")); deferredShow_ = false; deferredProfileShow_ = false; @@ -803,7 +819,7 @@ bool DeviceController::start() { } bool DeviceController::startWithScenario(const QVariantMap& scenario) { - if (m4Ready_ || m4Deploying_) { + if (m4Ready_ || m4Deploying_ || m4ScenarioApplying_) { setError(QStringLiteral("Legacy V1 START blocked while an M4 roster is selected. Configure all three MU scenarios first.")); return false; } @@ -811,6 +827,7 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { } bool DeviceController::applyLiveScenario(const QVariantMap& scenario) { + if (m4Ready_ || m4Deploying_ || m4ScenarioApplying_) return false; return submitScenario(scenario, false); } @@ -928,6 +945,7 @@ bool DeviceController::submitScenario(const QVariantMap& scenario, const bool st return true; } bool DeviceController::stop() { + if (m4ScenarioApplying_) cancelM4Scenarios(QStringLiteral("Explicit STOP cancelled per-MU CFG transaction.")); if (m4Deploying_) cancelM4(QStringLiteral("M4 upload cancelled by explicit operator STOP.")); operatorStopPending_ = true; appendLog(QStringLiteral("SVTX"), QStringLiteral( @@ -1020,7 +1038,7 @@ bool DeviceController::setQuality(const QString& signalId, const quint32 quality } bool DeviceController::deployProfile(const QVariantMap& profile) { - if (m4Deploying_ || m4Ready_) { + if (m4Deploying_ || m4Ready_ || m4ScenarioApplying_) { setError(QStringLiteral("M4 roster is selected; legacy PROFILE deploy requires an explicit firmware mode switch.")); return false; } @@ -1101,7 +1119,7 @@ bool DeviceController::deployMultiSv(const QByteArray& arm3) { // 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_ || + running_ || profileDeploying_ || m4Deploying_ || m4ScenarioApplying_ || scenarioStage_ != ScenarioStage::idle || ptpStarting() || !identity_.capabilities.contains(QStringLiteral("MULTISV-CONFIG-STAGING-V2"), Qt::CaseInsensitive) || @@ -1115,6 +1133,7 @@ bool DeviceController::deployMultiSv(const QByteArray& arm3) { setError(QStringLiteral("M4 ARM3 configuration failed canonical CRC/layout validation.")); return false; } + cancelM4Scenarios(QStringLiteral("New ARM3 roster invalidates previous per-MU banks.")); m4Ready_ = false; m4DeployedHash_.clear(); m4Deploying_ = true; @@ -1126,6 +1145,7 @@ bool DeviceController::deployMultiSv(const QByteArray& arm3) { } void DeviceController::cancelM4(const QString& reason, const bool clearReady) { + if (clearReady) cancelM4Scenarios(reason); if (!m4Deploying_ && (!clearReady || !m4Ready_)) return; m4AckTimer_.stop(); if (m4Upload_.active()) m4Upload_.cancel(reason); @@ -1192,6 +1212,93 @@ bool DeviceController::processM4Line(const QString& line) { return true; } + +bool DeviceController::applyMultiSvScenarios(const QVariantList& drafts) { + if (!connected_ || !deviceVerified_ || !ioWorkerReady_ || !m4Ready_ || + m4Deploying_ || m4ScenarioApplying_ || running_ || ptpStarting() || + profileDeploying_ || scenarioStage_ != ScenarioStage::idle || + identity_.bootId.size() != 16 || sessionGeneration_ == 0) { + setError(QStringLiteral("Three-MU CFG requires verified selected ARM3 roster, " + "STOPPED board and exclusive idle device session.")); + return false; + } + // Validate all three and freeze the snapshots before first UART mutation. + if (!m4Scenarios_.start(drafts, identity_.bootId)) { + setError(m4Scenarios_.error().isEmpty() + ? QStringLiteral("Invalid three-MU scenario draft set.") + : m4Scenarios_.error()); + return false; + } + m4ScenariosVerified_ = false; + m4ScenarioApplying_ = true; + m4ScenarioSession_ = sessionGeneration_; + m4ScenarioStatus_ = QStringLiteral("Applying three independent MU scenarios; START blocked."); + m4ScenarioClock_.start(); + emit m4DeploymentChanged(); + return dispatchM4ScenarioStep(); +} + +void DeviceController::cancelM4Scenarios(const QString& reason) { + if (!m4ScenarioApplying_ && !m4ScenariosVerified_) return; + m4ScenarioTimer_.stop(); + if (m4Scenarios_.active()) m4Scenarios_.cancel(reason); + const int verified = m4Scenarios_.verifiedSlots(); + m4ScenarioApplying_ = false; + m4ScenariosVerified_ = false; + m4ScenarioDispatching_ = false; + m4ScenarioStatus_ = QStringLiteral("%1 Board may hold %2/3 committed MU banks; " + "START is NOT authorized.").arg(reason).arg(verified); + emit m4DeploymentChanged(); +} + +bool DeviceController::dispatchM4ScenarioStep() { + if (!m4ScenarioApplying_ || !m4Ready_ || !connected_ || + !deviceVerified_ || running_ || sessionGeneration_ != m4ScenarioSession_ || + m4ScenarioClock_.elapsed() > 60000) { + cancelM4Scenarios(QStringLiteral("Per-MU session, STOPPED or overall deadline guard failed.")); + return false; + } + const auto commands = m4Scenarios_.takeCommands(); + if (commands.isEmpty()) { + cancelM4Scenarios(QStringLiteral("Per-MU state machine emitted no next command.")); + return false; + } + // Only this narrow callsite may enqueue the in-progress exact transaction; + // STOP/PTP STOP/HEARTBEAT retain priority in the existing single worker. + m4ScenarioDispatching_ = true; + const bool ok = sendCommandBatch(commands, false, commands.size() > 1); + m4ScenarioDispatching_ = false; + if (!ok) { + cancelM4Scenarios(QStringLiteral("Per-MU serial dispatcher refused transaction step.")); + return false; + } + m4ScenarioTimer_.start(); + return true; +} + +bool DeviceController::processM4ScenarioLine(const QString& line) { + if (!m4ScenarioApplying_ || !line.contains(QStringLiteral("SVSTREAM "))) return false; + if (!connected_ || !deviceVerified_ || running_ || + sessionGeneration_ != m4ScenarioSession_ || m4ScenarioClock_.elapsed() > 60000) { + cancelM4Scenarios(QStringLiteral("Per-MU session changed during scenario confirmation.")); + return true; + } + if (!m4Scenarios_.receive(line, identity_.bootId)) return false; + m4ScenarioTimer_.stop(); + if (m4Scenarios_.stage() == M4ThreeMuScenarioApply::Stage::failed) { + cancelM4Scenarios(m4Scenarios_.error()); + } else if (m4Scenarios_.verified()) { + m4ScenarioApplying_ = false; + m4ScenariosVerified_ = true; + m4ScenarioStatus_ = QStringLiteral("3/3 MU CFG COMMIT+READBACK verified on this boot; " + "group START still gated pending M4 start/readback acceptance."); + emit m4DeploymentChanged(); + } else { + static_cast(dispatchM4ScenarioStep()); + } + return true; +} + void DeviceController::abandonProfileDeployment() { if (!profileDeploying_) return; const auto stage = binaryProfileStage_; @@ -1957,6 +2064,17 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } return true; }; + if (m4ScenarioApplying_) { + const bool one = !exclusive && commands.size() == 1; + const QString command = one ? commands.constFirst() : QString{}; + const bool safety = one && (command == QStringLiteral("STOP") || + command == QStringLiteral("PTP STOP") || command == QStringLiteral("HEARTBEAT")); + if (!m4ScenarioDispatching_ && !safety) { + if (readback) return deferReadback(); + setError(QStringLiteral("Three-MU CFG owns the serial queue until independent readback.")); + return false; + } + } if (m4Deploying_) { const bool one = !exclusive && commands.size() == 1; const QString command = one ? commands.constFirst() : QString{}; @@ -2018,7 +2136,7 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } void DeviceController::flushDeferredReadbacks() { - if (m4Deploying_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + if (m4Deploying_ || m4ScenarioApplying_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || scenarioStage_ != ScenarioStage::idle || !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; @@ -2233,6 +2351,7 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); if (processM4Line(line)) return; + if (processM4ScenarioLine(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 @@ -2622,6 +2741,7 @@ void DeviceController::processLine(const QString& rawLine) { const bool unexpectedDevice = deviceVerified_ && !identity_.deviceId.isEmpty() && !sameDevice; if (unexpectedDevice) { + cancelM4Scenarios(QStringLiteral("Different board identity: MU banks unverified.")); cancelM4(QStringLiteral("Different board identity: M4 roster proof revoked.")); resetScenarioStart(); resetBinaryProfileTransfer(); @@ -2645,6 +2765,7 @@ void DeviceController::processLine(const QString& rawLine) { const bool newBoot = sameDevice && !identity_.bootId.isEmpty() && identity_.bootId != parsedIdentity.bootId; if (newBoot) { + cancelM4Scenarios(QStringLiteral("Firmware reboot: MU banks invalidated.")); cancelM4(QStringLiteral("Firmware reboot: M4 transaction/readback revoked.")); resetScenarioStart(); resetBinaryProfileTransfer(); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 0e9e6cef3..22c5f3e05 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -2,6 +2,7 @@ #pragma once #include "M4MultiSvUploadTransaction.hpp" +#include "M4ThreeMuScenarioApply.hpp" #include #include #include @@ -78,6 +79,9 @@ class DeviceController : public QObject { 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 m4ScenarioApplying READ m4ScenarioApplying NOTIFY m4DeploymentChanged) + Q_PROPERTY(bool m4ScenariosVerified READ m4ScenariosVerified NOTIFY m4DeploymentChanged) + Q_PROPERTY(QString m4ScenarioStatus READ m4ScenarioStatus 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) @@ -187,6 +191,9 @@ class DeviceController : public QObject { [[nodiscard]] bool m4Ready() const noexcept { return m4Ready_; } [[nodiscard]] QString m4DeploymentStatus() const { return m4Status_; } [[nodiscard]] QString m4DeployedHash() const { return m4DeployedHash_; } + [[nodiscard]] bool m4ScenarioApplying() const noexcept { return m4ScenarioApplying_; } + [[nodiscard]] bool m4ScenariosVerified() const noexcept { return m4ScenariosVerified_; } + [[nodiscard]] QString m4ScenarioStatus() const { return m4ScenarioStatus_; } [[nodiscard]] QString profileDeploymentProgressSummary() const; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; @@ -286,6 +293,7 @@ class DeviceController : public QObject { virtual bool deployProfile(const QVariantMap& profile); // Exclusive SCD-based, operator-initiated M4 transaction, not V1 auto sync. bool deployMultiSv(const QByteArray& arm3); + bool applyMultiSvScenarios(const QVariantList& drafts); void abandonProfileDeployment(); @@ -434,6 +442,9 @@ class DeviceController : public QObject { bool dispatchNextM4(); bool processM4Line(const QString& line); void cancelM4(const QString& reason, bool clearReady = true); + void cancelM4Scenarios(const QString& reason); + bool dispatchM4ScenarioStep(); + bool processM4ScenarioLine(const QString& line); void failScenarioStart(const QString& message); bool submitScenario(const QVariantMap& scenario, bool starting); void resetScenarioStart() noexcept; @@ -508,6 +519,14 @@ class DeviceController : public QObject { quint64 m4SessionGeneration_{0}; bool m4Deploying_{false}; bool m4Ready_{false}; + M4ThreeMuScenarioApply m4Scenarios_; + QTimer m4ScenarioTimer_; + QElapsedTimer m4ScenarioClock_; + QString m4ScenarioStatus_{QStringLiteral("Three MU scenarios not applied")}; + quint64 m4ScenarioSession_{0}; + bool m4ScenarioApplying_{false}; + bool m4ScenariosVerified_{false}; + bool m4ScenarioDispatching_{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/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp index 905ef8853..e95115b28 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "M4MultiSvUploadTransaction.hpp" +#include "M4ThreeMuScenarioApply.hpp" #include "SclProfileModel.hpp" #include @@ -139,6 +140,93 @@ void negativeCases(const QByteArray& image) { check(x.stage() == M4MultiSvUploadTransaction::Stage::failed, "boot change invalidates the transaction"); } + +QVariantMap nominalMu(double hz) { + QVariantList current, voltage; + for (int i=0; i<4; ++i) { + const double phase = (i == 1 ? -120.0 : i == 2 ? 120.0 : 0.0); + current.push_back(QVariant{QVariantList{1.0 + 0.1 * i, phase, true, qulonglong{0}}}); + voltage.push_back(QVariant{QVariantList{57.74, phase, true, qulonglong{0}}}); + } + return QVariantMap{ + {QStringLiteral("hz"), hz}, + {QStringLiteral("currentScale"), 100000.0}, + {QStringLiteral("voltageScale"), 100.0}, + {QStringLiteral("current"), current}, + {QStringLiteral("voltage"), voltage}, + {QStringLiteral("ct"), QVariantMap{ + {QStringLiteral("enabled"), false}, + {QStringLiteral("dc"), 0.0}, + {QStringLiteral("harmonic"), 0.0}, + {QStringLiteral("order"), 2}, + {QStringLiteral("clip"), 100.0}}} + }; +} +QStringList applyStep(M4ThreeMuScenarioApply& x, int slot) { + const auto batch = x.takeCommands(); + check(batch.size() == 12, "fixed 12-command staged MU transaction"); + const QString lead = QStringLiteral("SVSTREAM %1 CFG ").arg(slot); + check(batch.first().startsWith(lead + QStringLiteral("BEGIN ")) && + batch.at(1).startsWith(lead + QStringLiteral("FREQ ")) && + batch.at(10).startsWith(lead + QStringLiteral("SHAPE ")) && + batch.last().startsWith(lead + QStringLiteral("COMMIT ")), + "bounded slot-qualified staged CFG"); + check(x.takeCommands().isEmpty(), "no double-dispatch before COMMIT"); + return batch; +} +void testThreeMuScenarioApply() { + QVariantList drafts{nominalMu(50.0), nominalMu(60.0), nominalMu(0.0)}; + M4ThreeMuScenarioApply tx; + auto invalid = drafts; + auto third = invalid.at(2).toMap(); + third[QStringLiteral("current")] = QVariantList{}; + invalid[2] = third; + check(!tx.start(invalid, kBoot), "validate ALL 3 before any serial dispatch"); + check(tx.takeCommands().isEmpty(), "invalid MU3 must not stage MU1"); + check(tx.start(drafts, kBoot), "three valid isolated MU snapshots"); + for (int slot=1; slot<=3; ++slot) { + const auto batch = applyStep(tx, slot); + const QString token = batch.first().section(QLatin1Char(' '), 4, 4); + const QString hash = batch.last().section(QLatin1Char(' '), 5, 5); + check(!tx.receive(QStringLiteral("SVSTREAM CFG STAGED slot=%1 verb=FREQ").arg(slot), kBoot), + "generic staged line does not bypass COMMIT"); + const QString committed = QStringLiteral( + "SVSTREAM CFG COMMITTED slot=%1 token=%2 hash=%3 generation=%4") + .arg(slot).arg(token).arg(hash).arg(slot + 5); + check(tx.receive(committed, kBoot), "exact MU COMMIT"); + const auto readback = tx.takeCommands(); + check(readback.size() == 1 && + readback.first() == QStringLiteral("SVSTREAM %1 CFG READBACK %2").arg(slot).arg(token), + "independent firmware READBACK after COMMIT"); + check(!tx.receive(QStringLiteral("SVSTREAM CFG STAGED slot=%1 verb=SET").arg(slot), kBoot), + "readback cannot be satisfied by SET ACK"); + check(tx.receive(QStringLiteral("SVSTREAM CFG READBACK slot=%1 token=%2 hash=%3 generation=%4") + .arg(slot).arg(token).arg(hash).arg(slot + 5), kBoot), + "exact slot-bound readback"); + check(tx.verifiedSlots() == slot, "one verified MU per complete bank"); + } + check(tx.verified() && tx.generation(0) == 6 && + tx.generation(1) == 7 && tx.generation(2) == 8, + "three independently committed MCU generations"); + M4ThreeMuScenarioApply rejected; + check(rejected.start(drafts, kBoot), "negative fixture started"); + const auto first = applyStep(rejected, 1); + const auto token = first.first().section(QLatin1Char(' '), 4, 4); + check(rejected.receive(QStringLiteral( + "SVSTREAM CFG COMMITTED slot=1 token=%1 hash=DEADBEEF generation=6").arg(token), kBoot), + "mismatched commit classified"); + check(!rejected.verified() && + rejected.stage() == M4ThreeMuScenarioApply::Stage::failed, + "wrong hash cannot advance MU2"); + M4ThreeMuScenarioApply reboot; + check(reboot.start(drafts, kBoot), "boot test staged"); + static_cast(applyStep(reboot, 1)); + check(reboot.receive(QStringLiteral("SVSTREAM CFG COMMITTED slot=1"), + QStringLiteral("0000000000000000")) && + reboot.stage() == M4ThreeMuScenarioApply::Stage::failed, + "firmware reboot invalidates all MU proof"); +} + } // namespace int main(int argc, char** argv) { QCoreApplication app(argc, argv); @@ -146,7 +234,8 @@ int main(int argc, char** argv) { const auto image = fixtureEnvelope(); happyPath(image); negativeCases(image); - qInfo("PASS: ACK-driven M4 48B chunks, BEGIN/VALIDATE/COMMIT/SHOW/SELECT, " + testThreeMuScenarioApply(); + qInfo("PASS: 3-MU canonical scenario preflight/COMMIT/READBACK, and ACK-driven M4 48B chunks, BEGIN/VALIDATE/COMMIT/SHOW/SELECT, " "full hash and three-MU readback, stale transaction and boot-fault guards"); return 0; } catch (const std::exception& e) { diff --git a/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp b/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp new file mode 100644 index 000000000..fe580e8ce --- /dev/null +++ b/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "M4ThreeMuScenarioApply.hpp" +#include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include +#include +#include +#include +#include + +using namespace ar::iec61850::sampled_values; + +namespace { +constexpr std::array kIds{"IA","IB","IC","IN","UA","UB","UC","UN"}; +QString hexHash(std::uint64_t hash) { + return QString::number(static_cast(hash), 16) + .rightJustified(16, QLatin1Char('0')).toUpper(); +} +bool finitePositive(double n) { return std::isfinite(n) && n > 0.0; } +} + +bool M4ThreeMuScenarioApply::buildSlot( + const QVariantMap& draft, int slot, Slot& out, QString& error) { + const auto invalid = [&error](const QString& why) { + error = why; return false; + }; + bool hzOk = false, iOk = false, uOk = false; + const double hz = draft.value(QStringLiteral("hz")).toDouble(&hzOk); + const double ci = draft.value(QStringLiteral("currentScale")).toDouble(&iOk); + const double cu = draft.value(QStringLiteral("voltageScale")).toDouble(&uOk); + const auto current = draft.value(QStringLiteral("current")).toList(); + const auto voltage = draft.value(QStringLiteral("voltage")).toList(); + if (!hzOk || !std::isfinite(hz) || (hz != 0.0 && hz < 1.0) || + hz < 0.0 || hz > 1000.0 || !iOk || !uOk || + !finitePositive(ci) || !finitePositive(cu) || + current.size() != 4 || voltage.size() != 4) + return invalid(QStringLiteral("Invalid frequency, scale or 4I+4V layout.")); + SvLiveSignalState normalized{}; + normalized.frequency_millihz = static_cast(std::llround(hz * 1000.0)); + const QString prefix = QStringLiteral("SVSTREAM %1 CFG ").arg(slot); + QStringList batch; + batch.reserve(12); + batch << prefix + QStringLiteral("FREQ %1").arg(normalized.frequency_millihz); + for (int i = 0; i < 8; ++i) { + const QVariantList row = (i < 4 ? current.at(i) : voltage.at(i-4)).toList(); + if (row.size() != 4 || row.at(2).metaType().id() != QMetaType::Bool) + return invalid(QStringLiteral("Invalid channel shape or enabled type.")); + bool magOk{}, phaseOk{}, qOk{}; + const double magnitude = row[0].toDouble(&magOk); + const double phase = row[1].toDouble(&phaseOk); + const auto quality = row[3].toULongLong(&qOk); + const double scaled = magnitude * (i < 4 ? ci : cu); + const double angle = phase * 1000.0; + if (!magOk || !phaseOk || !qOk || + !std::isfinite(scaled) || !std::isfinite(angle) || + (hz > 0.0 && magnitude < 0.0) || + scaled <= static_cast(std::numeric_limits::min()) || + scaled > static_cast(std::numeric_limits::max()) || + angle < static_cast(std::numeric_limits::min()) || + angle > static_cast(std::numeric_limits::max()) || + quality > std::numeric_limits::max()) + return invalid(QStringLiteral("Channel wire data out of bounds.")); + auto& ch = normalized.channels[static_cast(i)]; + ch.rms_counts = static_cast(std::llround(scaled)); + ch.phase_millidegrees = static_cast(std::llround(angle)); + ch.quality = static_cast(quality); + ch.enabled = row[2].toBool(); + batch << prefix + QStringLiteral("SET %1 %2 %3 %4 %5") + .arg(QString::fromLatin1(kIds[static_cast(i)])) + .arg(ch.rms_counts).arg(ch.phase_millidegrees) + .arg(ch.quality).arg(ch.enabled ? 1 : 0); + } + const auto shape = draft.value(QStringLiteral("ct")).toMap(); + bool dcOk{}, harmonicOk{}, clipOk{}, orderOk{}; + const double dc = shape.value(QStringLiteral("dc")).toDouble(&dcOk); + const double harmonic = shape.value(QStringLiteral("harmonic")).toDouble(&harmonicOk); + const double clip = shape.value(QStringLiteral("clip")).toDouble(&clipOk); + const auto order = shape.value(QStringLiteral("order")).toInt(&orderOk); + const bool enabled = shape.value(QStringLiteral("enabled")).toBool(); + if (!dcOk || !harmonicOk || !clipOk || !orderOk || + !std::isfinite(dc) || !std::isfinite(harmonic) || !std::isfinite(clip) || + dc < -300 || dc > 300 || harmonic < 0 || harmonic > 300 || + clip < 1 || clip > 1000 || order < 2 || order > 63 || + (enabled && hz == 0.0)) + return invalid(QStringLiteral("Invalid CT saturation settings.")); + normalized.current_shape.enabled = enabled; + normalized.current_shape.dc_offset_permille = static_cast(std::llround(dc*10.0)); + normalized.current_shape.harmonic_permille = static_cast(std::llround(harmonic*10.0)); + normalized.current_shape.clip_permille = static_cast(std::llround(clip*10.0)); + normalized.current_shape.harmonic_order = static_cast(order); + if (!SvLiveSignalBank::validate(normalized)) + return invalid(QStringLiteral("Firmware canonical state validation failed.")); + out.token = QRandomGenerator::global()->generate64(); + if (out.token == 0U) out.token = 1U; + out.hash = sv_scenario_hash(normalized); + batch.prepend(prefix + QStringLiteral("BEGIN %1").arg(out.token)); + batch << prefix + QStringLiteral("SHAPE %1 %2 %3 %4 %5") + .arg(enabled ? 1 : 0).arg(normalized.current_shape.dc_offset_permille) + .arg(normalized.current_shape.harmonic_permille) + .arg(order).arg(normalized.current_shape.clip_permille); + batch << prefix + QStringLiteral("COMMIT %1 %2") + .arg(out.token).arg(hexHash(out.hash)); + out.batch = std::move(batch); + return true; +} + +bool M4ThreeMuScenarioApply::start(const QVariantList& scenarios, const QString& bootId) { + if (active() || scenarios.size() != 3 || bootId.size() != 16) return false; + std::array staged{}; + for (int i=0; i<3; ++i) { + const auto draft = scenarios.at(i).toMap(); + QString error; + if (!buildSlot(draft, i+1, staged[static_cast(i)], error)) { + error_ = QStringLiteral("MU%1 rejected before serial transfer: %2").arg(i+1).arg(error); + stage_ = Stage::failed; + return false; + } + } + slots_ = std::move(staged); + boot_ = bootId; + slot_ = 0; + verifiedSlots_ = 0; + error_.clear(); + stage_ = Stage::send_batch; + return true; +} +QStringList M4ThreeMuScenarioApply::takeCommands() { + if (stage_ == Stage::send_batch) { + stage_ = Stage::wait_commit; + return slots_[static_cast(slot_)].batch; + } + if (stage_ == Stage::send_readback) { + stage_ = Stage::wait_readback; + return {QStringLiteral("SVSTREAM %1 CFG READBACK %2") + .arg(slot_+1).arg(slots_[static_cast(slot_)].token)}; + } + return {}; +} +QString M4ThreeMuScenarioApply::field(const QString& s, const char* key) { + const QString term = QString::fromLatin1(key) + QLatin1Char('='); + const auto i = s.indexOf(term); + if (i < 0 || (i > 0 && !s.at(i-1).isSpace())) return {}; + const auto begin = i + term.size(); + auto end = s.indexOf(QLatin1Char(' '), begin); + if (end < 0) end = s.size(); + return s.mid(begin, end-begin).trimmed(); +} +void M4ThreeMuScenarioApply::fail(const QString& why) { + error_ = why; + stage_ = Stage::failed; +} +void M4ThreeMuScenarioApply::cancel(const QString& why) { + if (active()) fail(why); +} +bool M4ThreeMuScenarioApply::receive(const QString& raw, const QString& currentBootId) { + if (!active()) return false; + if (currentBootId != boot_) { + fail(QStringLiteral("Boot epoch changed during 3-MU scenario apply.")); + return true; + } + const auto offset = raw.indexOf(QStringLiteral("SVSTREAM ")); + if (offset < 0) return false; + const QString line = raw.mid(offset).trimmed(); + if (line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("REJECT"), Qt::CaseInsensitive)) { + fail(QStringLiteral("Firmware rejected MU%1 transaction: %2").arg(slot_+1).arg(line.left(180))); + return true; + } + const bool commit = stage_ == Stage::wait_commit && + line.startsWith(QStringLiteral("SVSTREAM CFG COMMITTED ")); + const bool readback = stage_ == Stage::wait_readback && + line.startsWith(QStringLiteral("SVSTREAM CFG READBACK ")); + if (!commit && !readback) return false; + bool slotOk{}, tokenOk{}, hashOk{}, genOk{}; + const unsigned slotValue = field(line, "slot").toUInt(&slotOk); + const auto token = field(line, "token").toULongLong(&tokenOk); + const auto hash = field(line, "hash").toULongLong(&hashOk, 16); + const auto generation = field(line, "generation").toULongLong(&genOk); + auto& current = slots_[static_cast(slot_)]; + if (!slotOk || slotValue != static_cast(slot_+1) || + !tokenOk || token != current.token || !hashOk || hash != current.hash || + !genOk || generation == 0 || + (readback && generation != current.generation)) { + fail(QStringLiteral("MU%1 firmware token/hash/generation mismatch.").arg(slot_+1)); + return true; + } + if (commit) { + current.generation = generation; + stage_ = Stage::send_readback; + } else { + ++verifiedSlots_; + if (++slot_ == 3) stage_ = Stage::verified; + else stage_ = Stage::send_batch; + } + return true; +} diff --git a/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp b/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp new file mode 100644 index 000000000..ae736244f --- /dev/null +++ b/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/sampled_values/live_signal_state.hpp" +#include +#include +#include +#include +#include +#include + +// Fixed-capacity host control-plane only: validates ALL three before any UART +// mutation, and requires a matching firmware COMMIT + READBACK per MU. +// The caller remains the sole serial owner and enforces session/timeout/STOP. +class M4ThreeMuScenarioApply final { +public: + enum class Stage { idle, send_batch, wait_commit, send_readback, wait_readback, verified, failed }; + bool start(const QVariantList& scenarios, const QString& bootId); + QStringList takeCommands(); // exclusive batch, or a single readback + bool receive(const QString& raw, const QString& currentBootId); + void cancel(const QString& why); + [[nodiscard]] bool active() const noexcept { return stage_ != Stage::idle && + stage_ != Stage::verified && stage_ != Stage::failed; } + [[nodiscard]] bool verified() const noexcept { return stage_ == Stage::verified; } + [[nodiscard]] Stage stage() const noexcept { return stage_; } + [[nodiscard]] QString error() const { return error_; } + [[nodiscard]] int slot() const noexcept { return slot_ + 1; } + [[nodiscard]] int verifiedSlots() const noexcept { return verifiedSlots_; } + [[nodiscard]] std::uint64_t generation(int slot) const noexcept { + return slot >= 0 && slot < 3 ? slots_[static_cast(slot)].generation : 0; + } +private: + struct Slot final { + QStringList batch; + std::uint64_t token{}; + std::uint64_t hash{}; + std::uint64_t generation{}; + }; + static bool buildSlot(const QVariantMap& draft, int slot, Slot& out, QString& error); + static QString field(const QString& s, const char* key); + void fail(const QString& why); + std::array slots_{}; + QString boot_; + QString error_; + Stage stage_{Stage::idle}; + int slot_{0}; + int verifiedSlots_{0}; +}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 2821a41ac..a9e17a3bd 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -177,6 +177,7 @@ bool SmartSessionController::canDeployMultiSv() const noexcept { return deviceControlAvailable() && profiles_ != nullptr && profiles_->m4DraftSupported() && !device_->running() && !device_->profileDeploying() && !device_->m4Deploying() && + !device_->m4ScenarioApplying() && !device_->scenarioApplying() && !device_->ptpStarting() && profileSyncStage_ != ProfileSyncStage::deploying && device_->capabilities().contains(QStringLiteral("MULTISV-CONFIG-STAGING-V2"), @@ -184,12 +185,21 @@ bool SmartSessionController::canDeployMultiSv() const noexcept { device_->capabilities().contains(QStringLiteral("MULTISV-TX-LAB-PREVIEW"), Qt::CaseInsensitive); } +bool SmartSessionController::canApplyMultiSvScenarios() const noexcept { + return m4Mode_ && deviceControlAvailable() && profiles_ != nullptr && + device_->m4Ready() && !device_->m4Deploying() && + !device_->m4ScenarioApplying() && !device_->running() && + !device_->scenarioApplying() && !device_->ptpStarting() && + profiles_->m4DraftSupported() && + device_->m4DeployedHash() == profiles_->m4ConfigurationHash(); +} bool SmartSessionController::liveControlReady() const noexcept { return !m4Mode_ && deviceControlAvailable() && !device_->profileDeploying() && (startReady_ || device_->running()); } bool SmartSessionController::engineeringEditable() const noexcept { - return device_ != nullptr && !device_->running() && !updateRequested_ && + return device_ != nullptr && !device_->running() && !device_->m4Deploying() && + !device_->m4ScenarioApplying() && !updateRequested_ && portOwner_ != PortOwner::firmwareTool && (firmware_ == nullptr || !firmware_->busy()); } bool SmartSessionController::firmwareUpdateRequired() const noexcept { return firmwareUpdateRequired_; } @@ -366,7 +376,8 @@ 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() && !device_->m4Deploying()) return true; + if (!device_->running() && !device_->scenarioApplying() && + !device_->m4Deploying() && !device_->m4ScenarioApplying()) return true; if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; return device_->stop(); } @@ -430,6 +441,13 @@ bool SmartSessionController::requestDeployMultiSv() { return started; } +bool SmartSessionController::requestApplyMultiSvScenarios(const QVariantList& drafts) { + if (!canApplyMultiSvScenarios() || drafts.size() != 3) return false; + const bool started = device_->applyMultiSvScenarios(drafts); + reconcile(); + return started; +} + bool SmartSessionController::requestProfileSync() { if (!canDeployProfile()) return false; resetProfileSync(true); @@ -1062,6 +1080,11 @@ QString SmartSessionController::diagnosticsText() const { add(QStringLiteral("Firmware protocol compatible"), !device_->deviceVerified() ? QStringLiteral("UNKNOWN (identity not verified)") : firmwareIsCompatible() ? QStringLiteral("yes") : QStringLiteral("no")); + add(QStringLiteral("M4 roster verified"), device_->m4Ready() ? QStringLiteral("yes") : QStringLiteral("no")); + add(QStringLiteral("M4 deployed identity hash"), device_->m4DeployedHash()); + add(QStringLiteral("M4 roster upload"), device_->m4DeploymentStatus()); + add(QStringLiteral("M4 3-bank readback"), device_->m4ScenarioStatus()); + add(QStringLiteral("M4 all scenarios verified"), device_->m4ScenariosVerified() ? QStringLiteral("yes") : QStringLiteral("no")); add(QStringLiteral("Profile generation"), device_->profileGeneration()); add(QStringLiteral("Profile armed"), device_->profileArmed() ? QStringLiteral("yes") : QStringLiteral("no")); add(QStringLiteral("SMV running"), device_->running() ? QStringLiteral("yes") : QStringLiteral("no")); @@ -1433,10 +1456,14 @@ void SmartSessionController::reconcile() { if (m4Mode_) { const QString phase = device_->m4Deploying() ? QStringLiteral("M4 DEPLOYING") + : device_->m4ScenarioApplying() ? QStringLiteral("M4 APPLYING MU BANKS") + : device_->m4ScenariosVerified() ? QStringLiteral("M4 3/3 MU VERIFIED") : 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.") + const QString message = device_->m4ScenarioApplying() || device_->m4ScenariosVerified() + ? device_->m4ScenarioStatus() + : device_->m4Ready() + ? QStringLiteral("Three firmware SV identities verified; apply all 3 MU scenarios next. START remains blocked.") : device_->m4DeploymentStatus(); setPresentation(phase, message, false, false); return; diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index c8c6d40aa..942c13a29 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -23,6 +23,7 @@ class SmartSessionController : public QObject { 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 canApplyMultiSvScenarios READ canApplyMultiSvScenarios 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) @@ -79,6 +80,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool startReady() const noexcept; [[nodiscard]] bool canDeployProfile() const noexcept; [[nodiscard]] bool canDeployMultiSv() const noexcept; + [[nodiscard]] bool canApplyMultiSvScenarios() const noexcept; [[nodiscard]] bool liveControlReady() const noexcept; [[nodiscard]] bool engineeringEditable() const noexcept; [[nodiscard]] bool firmwareUpdateRequired() const noexcept; @@ -191,6 +193,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestDisconnect(); Q_INVOKABLE bool requestProfileSync(); Q_INVOKABLE bool requestDeployMultiSv(); + Q_INVOKABLE bool requestApplyMultiSvScenarios(const QVariantList& drafts); Q_INVOKABLE bool requestSetFrequency(double hz); Q_INVOKABLE bool requestSetSignal( const QString& signalId, diff --git a/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md b/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md new file mode 100644 index 000000000..7cbdffe92 --- /dev/null +++ b/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md @@ -0,0 +1,23 @@ +# M4-P0E3A — Three independent MU scenario COMMIT/readback from Studio + +**Scope:** Studio now submits exactly three frozen 4I+4V per-MU signal banks using the already-merged P0D3 firmware protocol. **GROUP START REMAINS BLOCKED** until a separate group-START verification phase and physical P0F acceptance; this change does not claim 12kfps. + +## Engineer workflow + +1. Open validated three-IED SCD in the single Studio GUI. Select each MU page and edit independently (I/U magnitude, phase, quality, enable state, frequency and CT shape). +2. Press **Deploy 3 MU**, then wait for exact **ARM3** firmware SHOW/SELECT roster verification on the *current boot*. +3. Press **Apply 3 MU**. The host first freezes all three drafts and validates each typed wire field. If *any* MU is invalid, **zero** CFG commands are enqueued. +4. Using the same existing `DeviceIoWorker`, transmit a fixed **12-command exclusive batch** for MU1: `SVSTREAM 1 CFG BEGIN/FREQ/8 SET/SHAPE/COMMIT`. Its `sv_scenario_hash` is computed from the exact canonical typed `SvLiveSignalState`, not GUI JSON or a new hashing scheme. +5. Do **not** advance to MU2 until firmware responds with matching `SVSTREAM CFG COMMITTED slot=1 token=... hash=... generation=...` AND matching fresh `SVSTREAM CFG READBACK...`. Repeat for MU2 and MU3 with their independent token/hash/generation. An acknowledged staging step does not count as COMMIT/READBACK. +6. The exact 3/3 confirmation is a **board RAM signal-bank proof**, not START or Ethernet proof. UI and copied diagnostics distinguish roster readiness from signal-bank readiness, and the legacy single-SV START remains gated. +7. Operator **STOP/F6** cancels an in-progress multi-MU apply. The sole serial worker clears unsent exclusive commands and reserves a priority STOP slot. USB loss, new boot/session, timeout, firmware rejection and Studio shutdown revoke host readback authority; Studio exit itself never sends STOP. + +## Safety and performance + +- No new task, timer or heap use in the firmware 250 µs SV hot path. +- Host-only one-shot 7 s ACK/readback stage timer, plus a 60 s monotonic hard bound. No automatic replays. Firmware still enforces its own 8 s atomic staging deadline. +- Per-MU atomicity is **not** group atomicity: MU1 and MU2 might be committed when MU3 is rejected. Failure status explicitly says partial board state is possible. START remains inhibited. +- Supervisor permits Apply only if current SCD ARM3 hash matches firmware-selected roster, the board is verified and STOPPED, and no competing profile, PTP startup, M4 upload or scenario transaction owns serial. +- Existing production binary and unit harness targets link the exact same `M4ThreeMuScenarioApply` C++ implementation. Added negative tests to the already-existing `arstack_m4_upload_harness` and STOP isolation to S8 deterministic session harness. No workflow, job or matrix added. + +**Next:** P0E3B full independent live bank SHOW including CT shape and reconnect adoption; P0E3C verified group START without deselecting M4 and per-slot START/STOP; P0F physical 3×4,000fps/PTP same-epoch capture. M3.H.3 Stable remains immutable. From baa6f52e23780d9a349be7817ed27ff05e6ec9f9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 17:00:16 +0700 Subject: [PATCH 2/4] fix(M4-P0E3A): keep firmware DSP headers out of Qt moc/public header Qt's slots macro collides with an internal DSP slots identifier when live_signal_state.hpp is included after Qt in DeviceController.hpp. Keep the M4 scenario public header transport-only, and load live_signal_transaction.hpp first in its .cpp. Addresses exact compiler log; no ABI or runtime change. --- apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp | 2 +- apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp b/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp index fe580e8ce..0caf3c338 100644 --- a/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp +++ b/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -#include "M4ThreeMuScenarioApply.hpp" #include "ariec61850/sampled_values/live_signal_transaction.hpp" +#include "M4ThreeMuScenarioApply.hpp" #include #include #include diff --git a/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp b/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp index ae736244f..66d32b890 100644 --- a/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp +++ b/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #pragma once -#include "ariec61850/sampled_values/live_signal_state.hpp" #include #include #include From ce4095d5b056cfe5dd5ca2ba4ddc76f5a38cd554 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 17:06:32 +0700 Subject: [PATCH 3/4] fix(M4-P0E3A): retire cancelled exclusive UART CFG tail before new authority Serial worker now drops all unsent exclusive MU CFG commands on failed/timeout transaction, but does not synthesize SV/PTP STOP. Its retirement ACK is held until an active write drains; DeviceController maintains the command reservation meanwhile and never infers firmware bank rollback. Add S8 queue retirement and in-flight regression. Addresses Codex P2 safety review; no CI configuration or firmware changes. --- .../src/DeterministicSessionHarness.cpp | 41 ++++++++++++++++++- apps/arstack_studio/src/DeviceController.cpp | 34 +++++++++++++-- apps/arstack_studio/src/DeviceController.hpp | 3 +- apps/arstack_studio/src/DeviceIoWorker.cpp | 29 +++++++++++++ apps/arstack_studio/src/DeviceIoWorker.hpp | 4 ++ docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md | 1 + 6 files changed, 105 insertions(+), 7 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index a0040860b..2fd79e18e 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -60,6 +60,7 @@ class DeterministicSessionHarness final { {"M4 roster never authorizes V1 START or automatic profile sync", m4RosterIsolationAndV1StartGuard()}, {"M4 upload STOP revokes transaction proof before serial STOP", m4StopCancelsUpload()}, {"M4 per-MU CFG STOP aborts serial queue without V1 START", m4PerMuScenarioStopGuard()}, + {"M4 timeout retires unsent exclusive CFG without unsolicited STOP", m4RetireExclusiveQueueWithoutStop()}, {"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()}, @@ -2091,10 +2092,46 @@ class DeterministicSessionHarness final { session.state() != QStringLiteral("M4 APPLYING MU BANKS")) return false; static_cast(session.requestStop()); return !d.m4ScenarioApplying_ && !d.m4ScenariosVerified_ && - !d.m4ScenarioTimer_.isActive() && + d.m4ScenarioRetiring_ && !d.m4ScenarioTimer_.isActive() && d.m4ScenarioStatus().contains(QStringLiteral("STOP")) && !session.requestStartWithScenario({}) && - session.state() == QStringLiteral("M4 ROSTER VERIFIED"); + !session.canApplyMultiSvScenarios() && !session.startReady(); + } + + static bool m4RetireExclusiveQueueWithoutStop() { + DeviceIoWorker worker; + worker.activeGeneration_ = 77; + using Command = DeviceIoWorker::PendingCommand; + worker.commandQueue_.enqueue(Command{ + QStringLiteral("SVSTREAM 1 CFG COMMIT 1 FFFF"), + QByteArrayLiteral("SVSTREAM 1 CFG COMMIT 1 FFFF\n"), false, true}); + worker.commandQueue_.enqueue(Command{ + QStringLiteral("HEARTBEAT"), QByteArrayLiteral("HEARTBEAT\n"), true, false}); + worker.exclusiveCommandsRemaining_ = 1; + bool retired = false; + QObject::connect(&worker, &DeviceIoWorker::exclusiveCommandsRetired, + &worker, [&retired](quint64 generation) { + if (generation == 77) retired = true; + }); + worker.retireExclusiveCommands(77); + const bool cleared = retired && worker.commandQueue_.size() == 1 && + worker.commandQueue_.head().text == QStringLiteral("HEARTBEAT") && + worker.exclusiveCommandsRemaining_ == 0 && + !worker.svStopPending_ && !worker.ptpStopPending_; + // If one UART command was already being written, acknowledgement of + // retirement must wait until that write is drained. + retired = false; + worker.writeActive_ = true; + worker.activeCommand_ = Command{ + QStringLiteral("SVSTREAM 2 CFG SHAPE 0 0 0 2 1000"), + QByteArrayLiteral("SVSTREAM 2 CFG SHAPE 0 0 0 2 1000\n"), false, true}; + worker.exclusiveCommandsRemaining_ = 1; + worker.retireExclusiveCommands(77); + const bool waited = !retired && worker.retireExclusivePending_ && + worker.exclusiveCommandsRemaining_ == 1; + worker.completeActiveWrite(); + return cleared && waited && retired && !worker.retireExclusivePending_ && + !worker.svStopPending_ && !worker.ptpStopPending_; } static bool workerTeardownCompletes() { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 4109e8103..8759a5332 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -439,6 +439,14 @@ void DeviceController::connectWorkerSignals() { } }); + connect(ioWorker_, &DeviceIoWorker::exclusiveCommandsRetired, this, + [this](const quint64 generation) { + if (!workerEventIsCurrent(sessionGeneration_, generation) || + !m4ScenarioRetiring_) return; + m4ScenarioRetiring_ = false; + emit m4DeploymentChanged(); + }); + connect(ioWorker_, &DeviceIoWorker::commandRejected, this, [this](const quint64 generation, const QString& message) { if (!workerEventIsCurrent(sessionGeneration_, generation)) return; @@ -558,6 +566,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; cancelM4Scenarios(QStringLiteral("Device session changed; per-MU proof revoked.")); + m4ScenarioRetiring_ = false; // new worker epoch discards old queued commands cancelM4(QStringLiteral("Device session generation changed; previous M4 proof revoked.")); resetPtpStartup(); resetScenarioStart(); @@ -752,6 +761,7 @@ void DeviceController::handlePortOpened(const QString& portName, const bool auto profileWasReused_ = false; profileDeploying_ = false; cancelM4Scenarios(QStringLiteral("Port reopened; per-MU proof revoked.")); + m4ScenarioRetiring_ = false; cancelM4(QStringLiteral("Serial session reopened; previous M4 proof retired.")); deferredShow_ = false; deferredProfileShow_ = false; @@ -785,6 +795,7 @@ void DeviceController::handlePortClosed(const QString& portName) { profileArmed_ = false; profileWasReused_ = false; cancelM4Scenarios(QStringLiteral("Port closed; per-MU readback invalid.")); + m4ScenarioRetiring_ = false; cancelM4(QStringLiteral("Serial session closed; M4 readback no longer valid.")); deferredShow_ = false; deferredProfileShow_ = false; @@ -1119,7 +1130,7 @@ bool DeviceController::deployMultiSv(const QByteArray& arm3) { // 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_ || m4ScenarioApplying_ || + running_ || profileDeploying_ || m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || scenarioStage_ != ScenarioStage::idle || ptpStarting() || !identity_.capabilities.contains(QStringLiteral("MULTISV-CONFIG-STAGING-V2"), Qt::CaseInsensitive) || @@ -1215,7 +1226,7 @@ bool DeviceController::processM4Line(const QString& line) { bool DeviceController::applyMultiSvScenarios(const QVariantList& drafts) { if (!connected_ || !deviceVerified_ || !ioWorkerReady_ || !m4Ready_ || - m4Deploying_ || m4ScenarioApplying_ || running_ || ptpStarting() || + m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || running_ || ptpStarting() || profileDeploying_ || scenarioStage_ != ScenarioStage::idle || identity_.bootId.size() != 16 || sessionGeneration_ == 0) { setError(QStringLiteral("Three-MU CFG requires verified selected ARM3 roster, " @@ -1241,6 +1252,21 @@ bool DeviceController::applyMultiSvScenarios(const QVariantList& drafts) { void DeviceController::cancelM4Scenarios(const QString& reason) { if (!m4ScenarioApplying_ && !m4ScenariosVerified_) return; m4ScenarioTimer_.stop(); + const bool mustRetire = m4ScenarioApplying_ && connected_ && ioWorkerReady_ && + ioWorker_ != nullptr && sessionGeneration_ == m4ScenarioSession_; + // Hold the transaction reservation until the one serial worker confirms + // that no stale exclusive CFG tail remains queued or in flight. + if (mustRetire) { + m4ScenarioRetiring_ = true; + DeviceIoWorker* const worker = ioWorker_; + const quint64 generation = sessionGeneration_; + if (!QMetaObject::invokeMethod(worker, [worker, generation] { + worker->retireExclusiveCommands(generation); + }, Qt::QueuedConnection)) { + setError(QStringLiteral("Could not retire M4 serial queue; reconnect before next apply.")); + // Fail closed: reservation remains until a new session. + } + } if (m4Scenarios_.active()) m4Scenarios_.cancel(reason); const int verified = m4Scenarios_.verifiedSlots(); m4ScenarioApplying_ = false; @@ -2064,7 +2090,7 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } return true; }; - if (m4ScenarioApplying_) { + if (m4ScenarioApplying_ || m4ScenarioRetiring_) { const bool one = !exclusive && commands.size() == 1; const QString command = one ? commands.constFirst() : QString{}; const bool safety = one && (command == QStringLiteral("STOP") || @@ -2136,7 +2162,7 @@ bool DeviceController::sendCommandBatch(const QStringList& commands, const bool } void DeviceController::flushDeferredReadbacks() { - if (m4Deploying_ || m4ScenarioApplying_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || + if (m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || profileDeploying_ || binaryProfileStage_ != BinaryProfileStage::idle || scenarioStage_ != ScenarioStage::idle || !connected_ || !ioWorkerReady_ || ioWorker_ == nullptr || sessionGeneration_ == 0) return; diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 22c5f3e05..ea47b04b3 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -191,7 +191,7 @@ class DeviceController : public QObject { [[nodiscard]] bool m4Ready() const noexcept { return m4Ready_; } [[nodiscard]] QString m4DeploymentStatus() const { return m4Status_; } [[nodiscard]] QString m4DeployedHash() const { return m4DeployedHash_; } - [[nodiscard]] bool m4ScenarioApplying() const noexcept { return m4ScenarioApplying_; } + [[nodiscard]] bool m4ScenarioApplying() const noexcept { return m4ScenarioApplying_ || m4ScenarioRetiring_; } [[nodiscard]] bool m4ScenariosVerified() const noexcept { return m4ScenariosVerified_; } [[nodiscard]] QString m4ScenarioStatus() const { return m4ScenarioStatus_; } [[nodiscard]] QString profileDeploymentProgressSummary() const; @@ -525,6 +525,7 @@ class DeviceController : public QObject { QString m4ScenarioStatus_{QStringLiteral("Three MU scenarios not applied")}; quint64 m4ScenarioSession_{0}; bool m4ScenarioApplying_{false}; + bool m4ScenarioRetiring_{false}; bool m4ScenariosVerified_{false}; bool m4ScenarioDispatching_{false}; // One coalesced readback of each kind; no second serial writer or retry loop. diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 980931306..20a3f5e59 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -155,6 +155,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto } exclusiveCommandsRemaining_ = 0; + retireExclusivePending_ = false; writeActive_ = false; activeCommand_ = {}; closePortInternal(true); @@ -278,6 +279,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa ptpStopPending_ = false; svStopPending_ = false; exclusiveCommandsRemaining_ = 0; + retireExclusivePending_ = false; writeActive_ = false; activeCommand_ = {}; lastPort_ = portName.trimmed(); @@ -341,6 +343,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { ptpStopPending_ = false; svStopPending_ = false; exclusiveCommandsRemaining_ = 0; + retireExclusivePending_ = false; writeActive_ = false; activeCommand_ = {}; @@ -478,6 +481,28 @@ bool DeviceIoWorker::enqueueCommandsInternal( return true; } +void DeviceIoWorker::retireExclusiveCommands(const quint64 generation) { + if (generation == 0 || generation != activeGeneration_) return; + // Serial owner only. Drop the unsent CFG tail; never synthesize STOP + // merely because the GUI lost an ACK. A command already written to + // QSerialPort may still be processed by firmware, so the host must + // report uncertain/partial commit and require fresh readback. + QQueue retained; + while (!commandQueue_.isEmpty()) { + auto command = commandQueue_.dequeue(); + if (!command.exclusive) retained.enqueue(std::move(command)); + } + commandQueue_ = std::move(retained); + exclusiveCommandsRemaining_ = writeActive_ && activeCommand_.exclusive ? 1 : 0; + if (exclusiveCommandsRemaining_ > 0) { + retireExclusivePending_ = true; + } else { + retireExclusivePending_ = false; + emit exclusiveCommandsRetired(generation); + } + pumpWriteQueue(); +} + void DeviceIoWorker::queuePrioritySvStop() { // The emergency STOP slot is outside the 64-entry normal queue. // Discard remaining batch commands, including an unsent START, so @@ -572,6 +597,10 @@ void DeviceIoWorker::completeActiveWrite() { --exclusiveCommandsRemaining_; } emit commandTransmitted(activeGeneration_, completed.text, completed.quiet); + if (retireExclusivePending_ && exclusiveCommandsRemaining_ == 0) { + retireExclusivePending_ = false; + emit exclusiveCommandsRetired(activeGeneration_); + } if (svStopPending_ || ptpStopPending_) { pumpWriteQueue(); // safety priority never waits for line pacing } else if (writeGapTimer_ != nullptr) { diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 1ad0dba2c..dfcdfe649 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -165,6 +165,8 @@ public slots: bool exclusive, quint64 generation); void setHeartbeatEnabled(bool enabled, quint64 generation); + // Retire unsent exclusive CFG commands without stopping autonomous SV/PTP. + void retireExclusiveCommands(quint64 generation); signals: void ready(quint64 generation, bool serialAffinityValid); @@ -180,6 +182,7 @@ public slots: void lineReceived(quint64 generation, const QString& line); void commandTransmitted(quint64 generation, const QString& command, bool quiet); void commandRejected(quint64 generation, const QString& message); + void exclusiveCommandsRetired(quint64 generation); void transportError(quint64 generation, const QString& message, bool fatal); // Emitted only after the worker has CLOSED a failed verified COM port. // Deliberate user disconnect must never emit an automatic recovery event. @@ -232,6 +235,7 @@ public slots: // STOP priority remains higher. This is transport queue ownership, not // a duplicate firmware RUNNING/LOCK state. bool heartbeatPending_{false}; + bool retireExclusivePending_{false}; PendingCommand activeCommand_; QString lastPort_; diff --git a/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md b/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md index 7cbdffe92..ee9b789d2 100644 --- a/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md +++ b/docs/M4_P0E3A_THREE_MU_SCENARIO_APPLY.md @@ -16,6 +16,7 @@ - No new task, timer or heap use in the firmware 250 µs SV hot path. - Host-only one-shot 7 s ACK/readback stage timer, plus a 60 s monotonic hard bound. No automatic replays. Firmware still enforces its own 8 s atomic staging deadline. +- On timeout/rejection/cancellation, Studio asks the **existing sole DeviceIoWorker** to retire every *unsent* exclusive CFG command and waits for acknowledgement after any active UART write drains. No implicit SV/PTP STOP is emitted. The already-written command may still execute on the board, so a partially applied MU set is explicitly **unverified**, and a subsequent operation requires a fresh transaction/readback. This prevents stale queued COMMIT from mutating the board after the UI releases command ownership. - Per-MU atomicity is **not** group atomicity: MU1 and MU2 might be committed when MU3 is rejected. Failure status explicitly says partial board state is possible. START remains inhibited. - Supervisor permits Apply only if current SCD ARM3 hash matches firmware-selected roster, the board is verified and STOPPED, and no competing profile, PTP startup, M4 upload or scenario transaction owns serial. - Existing production binary and unit harness targets link the exact same `M4ThreeMuScenarioApply` C++ implementation. Added negative tests to the already-existing `arstack_m4_upload_harness` and STOP isolation to S8 deterministic session harness. No workflow, job or matrix added. From e1a282dc993ee534cfe25f6c7f795445798c594f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Mon, 26 Oct 2026 17:11:34 +0700 Subject: [PATCH 4/4] test(M4-P0E3A): model worker-ready event in deterministic STOP retirement fixture The seedVerified helper models semantic board identity before the asynchronous DeviceIoWorker ready signal. Explicitly mark the existing worker ready in the M4 STOP/retirement regression so the test exercises queued exclusive retirement, rather than failing its admission check. --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 2fd79e18e..c03482f44 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -2082,6 +2082,11 @@ class DeterministicSessionHarness final { seedVerified(fixture, identity(), QStringLiteral("COM7"), false); auto& d = fixture.device; auto& session = fixture.session; + // The deterministic seed models a verified board but not always the + // asynchronous DeviceIoWorker::ready event. Model a fully ready + // serial owner explicitly so this test exercises retirement ACK, + // rather than a not-yet-initialized worker startup race. + d.ioWorkerReady_ = true; session.m4Mode_ = true; d.m4Ready_ = true; d.m4ScenarioApplying_ = true;