diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 0187522bd..29e51c6f3 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -376,6 +376,14 @@ ApplicationWindow { autoLiveQueuedRevision = -1 showMessage("Three MU pages ready. Use Deploy 3 MU for verified firmware roster upload; START remains gated.", false) } + // Flush the active MU editor and compare all three frozen drafts to the + // hashes actually COMMITTED+READBACK, never to the visible selected page. + function m4StartDrafts() { + if (!m4Pages.active || !root.commitFrequencyEditor() || + !currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits() || + !m4Pages.saveCurrent(root.startScenarioSnapshot(false))) return null + return [m4Pages.draftFor(0), m4Pages.draftFor(1), m4Pages.draftFor(2)] + } function m4SelectPage(slot) { if (!m4Pages.active || slot === m4Pages.selectedSlot) return // A tab click need not blur the focused NumericField; commit first. @@ -1376,7 +1384,9 @@ ApplicationWindow { visible: device.m4ReconnectMode === "SELECTED" text: "BOARD ROSTER / SCD · " + (workflowBar.session ? workflowBar.session.m4SclRosterMatch : "NOT VERIFIED") + - " · START GATED" + (device.running ? " · RUNNING" : + (workflowBar.session && workflowBar.session.canStartMultiSv ? + " · READY" : " · START REQUIRES PROOFS")) color: workflowBar.session && workflowBar.session.m4SclRosterMatch === "MATCHED" ? studioTheme.green : studioTheme.amber font.family: root.uiFont @@ -1385,10 +1395,10 @@ ApplicationWindow { Label { visible: m4Pages.active text: device.m4ScenarioApplying ? "APPLYING · MU BANK READBACK REQUIRED" - : device.m4ScenariosVerified && device.m4DeployedHash === sclProfiles.m4ConfigurationHash ? "3/3 MU BANKS VERIFIED · START GATED" + : device.m4ScenariosVerified && device.m4DeployedHash === sclProfiles.m4ConfigurationHash ? "3/3 MU BANKS VERIFIED · CHECK GROUP START" : device.m4Deploying ? "UPLOADING · VERIFIED ACK REQUIRED" : device.m4Ready && device.m4DeployedHash === sclProfiles.m4ConfigurationHash - ? "3 MU ROSTER VERIFIED · START GATED" : "DRAFT · NOT DEPLOYED" + ? "3 MU ROSTER VERIFIED · APPLY BANKS" : "DRAFT · NOT DEPLOYED" color: device.m4Ready && device.m4DeployedHash === sclProfiles.m4ConfigurationHash ? studioTheme.green : studioTheme.amber font.family: root.uiFont diff --git a/apps/arstack_studio/qml/ModernRibbon.qml b/apps/arstack_studio/qml/ModernRibbon.qml index 2775a6f09..56537a6e8 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 || bar.device.m4ScenarioApplying) ? "danger" : "neutral" + tone: (bar.workflow.injectionRunning || bar.device.m4GroupNeedsStop || 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 || bar.device.m4ScenarioApplying) + enabled: (bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning || bar.device.m4Deploying || bar.device.m4ScenarioApplying || bar.device.m4GroupNeedsStop) && !bar.session.updatingFirmware toolTipText: "" onClicked: { diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index d96969bb1..60b18547a 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -28,7 +28,8 @@ SurfacePanel { // interactive while firmware verifies it; new edits coalesce and drain. readonly property bool startQueued: Boolean( startIntentPending || (ribbon.device && ribbon.device.scenarioApplying && - !ribbon.device.running)) + !ribbon.device.running)) || + Boolean(ribbon.device && ribbon.device.m4GroupStarting) Timer { id: startIntentExpiry interval: 6500 @@ -341,7 +342,15 @@ SurfacePanel { function requestStart() { if (ribbon.m4DraftOnly) { - controller.showMessage("M4 draft pages are not deployed. START blocked until MULTISV deploy/readback is implemented.", true) + if (!smartSession.canStartMultiSv) { + controller.showMessage("3-MU START blocked: " + device.m4GroupStatus + + ". Deploy the matching SCD and Apply all three MU banks first.", true) + return + } + const drafts = controller.m4StartDrafts() + if (!drafts || drafts.length !== 3 || + !smartSession.requestStartMultiSv(drafts)) + controller.showMessage("3-MU START refused: editor changed or board proof no longer current.", true) return } if (ribbon.injectionRunning || ribbon.startQueued) return @@ -406,7 +415,7 @@ SurfacePanel { } // A pending CFG transaction can progress to START: Stop must remain // actionable before RUNNING telemetry, not only after it. - if (!ribbon.injectionRunning && !ribbon.startQueued) return + if (!ribbon.injectionRunning && !ribbon.startQueued && !device.m4GroupNeedsStop) return if (!smartSession.requestStop()) { controller.showMessage("Studio could not cancel the current device session.", true) return diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 4228250f6..9625077e4 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -47,6 +47,7 @@ class DeterministicSessionHarness final { {"retry after unverified STOP cannot probe ROM without renewed consent", unverifiedFirmwareRetryNeverProbesRom()}, {"slow firmware STOP suspends ordinary health teardown, resumes afterward", maintenanceStopDoesNotTriggerHealthDisconnect()}, {"firmware STOP proof readbacks survive M4 exclusive retirement", maintenanceProofReadbackSurvivesM4Retirement()}, + {"M4 group STOP ACK preserves existing firmware-quiescence SHOW/SVLIFE proof", m4MaintenanceStopUsesFirmwareProof()}, {"real timed-out firmware subprocess retires before COM release", firmwareWorkerTimeoutAwaitsProcessExit()}, {"current identity -> READY without recovery", currentIdentityNeverRecovers()}, {"legacy identity -> firmware update", legacyIdentityOffersUpdate()}, @@ -83,6 +84,7 @@ class DeterministicSessionHarness final { {"M4 per-MU CFG STOP aborts serial queue without V1 START", m4PerMuScenarioStopGuard()}, {"M4 timeout retires unsent exclusive CFG without unsolicited STOP", m4RetireExclusiveQueueWithoutStop()}, {"M4 board inspection defers refresh but PTP STOP preempts it", m4BoardInspectionPtpSafetyPreempts()}, + {"M4 group START/STOP requires exact boot/roster/token/mask RUNNING proof", m4GroupReadbackFailClosed()}, {"M4 reconnect selected roster blocks legacy V1 sync and awaits read-only banks", m4ReconnectSelectedGate()}, {"M4 reconnect uncertain roster never guesses legacy", m4ReconnectUnknownGate()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, @@ -219,6 +221,31 @@ class DeterministicSessionHarness final { } + static bool m4MaintenanceStopUsesFirmwareProof() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + auto& d = f.device; + d.ioWorkerReady_ = true; + d.m4GroupSession_ = d.sessionGeneration_; + d.m4GroupBoot_ = d.identity_.bootId; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_stop_ack; + d.m4GroupInFlight_.clear(); + d.operatorStopPending_ = true; + d.firmwareQuiescenceActive_ = true; + d.firmwareQuiescenceSession_ = d.sessionGeneration_; + d.firmwareQuiescenceBoot_ = d.identity_.bootId; + d.processLine(QStringLiteral("STOP accepted: SV transmission suppressed")); + const bool routed = d.firmwareSvStopAck_ && !d.firmwareSvStoppedReadback_ && + d.m4GroupStage_ == DeviceController::M4GroupStage::uncertain && + !d.operatorStopPending_ && !d.m4GroupTimer_.isActive(); + d.processLine(QStringLiteral("SVLIFE state=STOPPED stop_cause=OPERATOR_STOP stop_seq=1")); + const bool verified = d.firmwareSvStoppedReadback_ && + !d.firmwareQuiescenceVerified(); // PTP still needs separate STOP proof + d.endFirmwareQuiescence(); + return routed && verified; + } + static bool maintenanceProofReadbackSurvivesM4Retirement() { Fixture f; if (!f.profileReady) return false; @@ -2450,6 +2477,63 @@ class DeterministicSessionHarness final { d.deferredPtpShow_ && d.m4BoardSnapshots_.isEmpty(); } + static bool m4GroupReadbackFailClosed() { + Fixture fixture; + if (!fixture.profileReady) return false; + seedVerified(fixture, identity(), QStringLiteral("COM7"), false); + auto& d = fixture.device; + d.m4GroupSession_ = d.sessionGeneration_; + d.m4GroupBoot_ = d.identity_.bootId; + d.m4GroupRoster_ = QStringLiteral("0123456789ABCDEF"); + d.m4GroupToken_ = 42U; + d.m4ReconnectRosterGeneration_ = 5U; + const auto state = [&d](const int running, const unsigned mask = 7, + const QString& boot = QString{}) { + return QStringLiteral("I (123) sv: M4STATE token=42 boot=%1 " + "roster=0123456789ABCDEF generation=5 selected=1 mask=%2 running=%3") + .arg(boot.isEmpty() ? d.m4GroupBoot_ : boot).arg(mask).arg(running); + }; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_start_readback; + if (!d.processM4GroupLine(state(1)) || + d.m4GroupStage_ != DeviceController::M4GroupStage::running_confirmed || + !d.running() || d.m4GroupStarting()) return false; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_stop_readback; + d.operatorStopPending_ = true; + if (!d.processM4GroupLine(state(0)) || + d.m4GroupStage_ != DeviceController::M4GroupStage::confirmed_stopped || + d.running() || !d.m4GroupStartAllowed() || + d.operatorStopPending_) return false; + // MU2 may have been disabled independently; group STOP still + // verifies the single publisher is STOPPED with that 3-bit mask. + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_stop_readback; + d.operatorStopPending_ = true; + if (!d.processM4GroupLine(state(0, 5)) || + d.m4GroupStage_ != DeviceController::M4GroupStage::confirmed_stopped || + d.operatorStopPending_) return false; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_start_readback; + d.operatorStopPending_ = true; + if (!d.processM4GroupLine(state(1)) || !d.running() || d.operatorStopPending_) + return false; + d.operatorStopPending_ = false; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_start_readback; + if (!d.processM4GroupLine(state(1, 3)) || + d.m4GroupStage_ != DeviceController::M4GroupStage::uncertain || + d.m4GroupStartAllowed() || !d.m4GroupNeedsStop()) return false; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_start_readback; + if (!d.processM4GroupLine(state(1, 7, QStringLiteral("DEADBEEFDEADBEEF"))) || + d.m4GroupStage_ != DeviceController::M4GroupStage::uncertain) return false; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_start_ack; + d.m4GroupInFlight_.clear(); + if (!d.processM4GroupLine(QStringLiteral( + "M4START accepted token=41 boot=%1 roster=0123456789ABCDEF mask=7") + .arg(d.m4GroupBoot_)) || + d.m4GroupStage_ != DeviceController::M4GroupStage::uncertain) return false; + d.m4GroupStage_ = DeviceController::M4GroupStage::awaiting_stop_readback; + if (!d.processM4GroupLine(state(1)) || + d.m4GroupStage_ != DeviceController::M4GroupStage::uncertain) return false; + return true; + } + static bool m4ReconnectSelectedGate() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 1b7aeb308..4d9c4e346 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -164,6 +164,13 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { if (m4BoardInspecting_) cancelM4Board( QStringLiteral("3-MU board inspection timed out; no snapshot adopted.")); }); + m4GroupTimer_.setSingleShot(true); + m4GroupTimer_.setInterval(7000); + connect(&m4GroupTimer_, &QTimer::timeout, this, [this] { + if (m4GroupStarting()) failM4Group(QStringLiteral( + "M4 START/STOP readback timed out; board output is UNCONFIRMED. " + "Use explicit STOP and fresh board diagnostics before retry.")); + }); m4ScenarioTimer_.setSingleShot(true); m4ScenarioTimer_.setInterval(7000); connect(&m4ScenarioTimer_, &QTimer::timeout, this, [this] { @@ -453,6 +460,11 @@ void DeviceController::connectWorkerSignals() { m4BoardInFlight_.clear(); m4BoardTimer_.start(); } + if (m4GroupStarting() && generation == m4GroupSession_ && + command == m4GroupInFlight_) { + m4GroupInFlight_.clear(); + m4GroupTimer_.start(); + } if (m4Deploying_ && generation == m4SessionGeneration_ && command == m4InFlightCommand_) { // Worker has actually written the UART bytes; only now are @@ -482,6 +494,8 @@ void DeviceController::connectWorkerSignals() { cancelM4(QStringLiteral("M4 serial command rejected: ") + message); if (m4ScenarioApplying_) cancelM4Scenarios(QStringLiteral("Per-MU serial command rejected: ") + message); + if (m4GroupStarting()) + failM4Group(QStringLiteral("M4 group command rejected by serial owner: ") + message); if (scenarioStage_ != ScenarioStage::idle) failScenarioStart(QStringLiteral("SV scenario serial command rejected: ") + message); if (profileDeploying_) { @@ -515,6 +529,8 @@ void DeviceController::connectWorkerSignals() { cancelM4(QStringLiteral("M4 transport failure: ") + message); if (m4ScenarioApplying_) cancelM4Scenarios(QStringLiteral("Per-MU transport failure: ") + message); + if (m4GroupStarting()) + failM4Group(QStringLiteral("M4 group transport error: ") + message); if (scenarioStage_ != ScenarioStage::idle) failScenarioStart(QStringLiteral("SV transaction transport error: ") + message); setError(message); @@ -829,6 +845,12 @@ void DeviceController::handlePortClosed(const QString& portName) { profileArmed_ = false; profileWasReused_ = false; resetM4Reconnect(QStringLiteral("LEGACY")); + // A USB disconnect never sends STOP; local group confirmation is revoked. + m4GroupTimer_.stop(); + m4GroupInFlight_.clear(); + m4GroupStage_ = M4GroupStage::idle; + m4GroupStatus_ = QStringLiteral("USB disconnected: board output unknown until fresh readback"); + emit m4DeploymentChanged(); cancelM4Board(QStringLiteral("Port closed; board MU snapshot revoked.")); cancelM4Scenarios(QStringLiteral("Port closed; per-MU readback invalid.")); m4ScenarioRetiring_ = false; @@ -865,6 +887,174 @@ bool DeviceController::start() { return false; } +void DeviceController::failM4Group(const QString& why) { + m4GroupTimer_.stop(); + m4GroupInFlight_.clear(); + m4GroupStage_ = M4GroupStage::uncertain; + m4GroupStatus_ = why; + emit m4DeploymentChanged(); + setError(why); +} + +bool DeviceController::startMultiSvGroup(const QVariantList& drafts) { + const bool admitted = connected_ && deviceVerified_ && ioWorkerReady_ && + identity_.bootId.size() == 16 && sessionGeneration_ != 0 && + !running_ && m4GroupStartAllowed() && m4Ready_ && m4ScenariosVerified_ && + !m4Deploying_ && !m4ScenarioApplying_ && !m4ScenarioRetiring_ && + !m4BoardInspecting_ && !profileDeploying_ && + scenarioStage_ == ScenarioStage::idle && !ptpStarting() && + !firmwareQuiescenceActive_ && + ptpEthernetLink_ == QStringLiteral("UP") && + m4ReconnectMode_ == QStringLiteral("SELECTED") && + m4ReconnectBoot_ == identity_.bootId && + m4ReconnectStreamMask_ == 0x07U && + m4ReconnectRosterLines_.size() == 3 && + !m4DeployedHash_.isEmpty() && + m4ReconnectRosterHash_.compare(m4DeployedHash_, Qt::CaseInsensitive) == 0 && + identity_.capabilities.contains(QStringLiteral("MULTISV-GROUPSTART-V1"), + Qt::CaseInsensitive) && + m4Scenarios_.matchesDrafts(drafts); + if (!admitted) { + setError(QStringLiteral("M4START refused: exact SCD/board roster, " + "3 freshly applied MU banks, unchanged editor drafts, link UP, " + "idle serial session and group-capable firmware required.")); + return false; + } + const quint64 generated = QRandomGenerator::global()->generate64(); + const quint64 token = generated != 0U ? generated : 1U; + QStringList terms{QStringLiteral("M4START"), QString::number(token), + identity_.bootId, m4DeployedHash_}; + for (int i = 0; i < 3; ++i) { + terms << QString::number(static_cast(m4Scenarios_.generation(i))) + << QString::number(static_cast(m4Scenarios_.hash(i)), 16) + .rightJustified(16, QLatin1Char('0')).toUpper(); + } + const QString command = terms.join(QLatin1Char(' ')); + // ESP32-P4 line buffer is 192 bytes including NUL. Rejection is local; + // never truncate an authorization transaction or transmit a partial proof. + if (command.size() >= 192) return false; + m4GroupToken_ = token; + m4GroupBoot_ = identity_.bootId; + m4GroupRoster_ = m4DeployedHash_; + m4GroupSession_ = sessionGeneration_; + m4GroupInFlight_ = command; + m4GroupStage_ = M4GroupStage::awaiting_start_ack; + m4GroupStatus_ = QStringLiteral("M4START sent; waiting for board ACK and authoritative RUNNING readback"); + emit m4DeploymentChanged(); + if (!sendCommandBatch({command}, false, true)) { + failM4Group(QStringLiteral("M4START could not reserve the existing serial worker.")); + return false; + } + m4GroupTimer_.start(); + return true; +} + +bool DeviceController::processM4GroupLine(const QString& line) { + if (!m4GroupStarting()) return false; + if (m4GroupSession_ != sessionGeneration_ || + m4GroupBoot_ != identity_.bootId || !connected_ || !deviceVerified_) { + failM4Group(QStringLiteral("M4 group proof invalidated by boot/session change.")); + return true; + } + const auto extract = [&line](const QString& key) -> QString { + const QRegularExpression re(QStringLiteral("(?:^|\\s)") + + QRegularExpression::escape(key) + QStringLiteral("=([^\\s]+)")); + return re.match(line).captured(1); + }; + if (m4GroupStage_ == M4GroupStage::awaiting_start_ack && + line.contains(QStringLiteral("M4START rejected"))) { + if (!m4GroupInFlight_.isEmpty()) return true; + failM4Group(QStringLiteral("Firmware rejected M4START: ") + line.right(160)); + return true; + } + if (m4GroupStage_ == M4GroupStage::awaiting_start_ack && + line.contains(QStringLiteral("M4START accepted "))) { + if (!m4GroupInFlight_.isEmpty()) return true; + bool tokOk = false, bootOk = false, hashOk = false, maskOk = false; + const auto token = extract(QStringLiteral("token")).toULongLong(&tokOk); + const auto boot = extract(QStringLiteral("boot")).toULongLong(&bootOk, 16); + const auto roster = extract(QStringLiteral("roster")).toULongLong(&hashOk, 16); + const auto mask = extract(QStringLiteral("mask")).toUInt(&maskOk); + if (!tokOk || token != m4GroupToken_ || !bootOk || + boot != m4GroupBoot_.toULongLong(nullptr, 16) || !hashOk || + roster != m4GroupRoster_.toULongLong(nullptr, 16) || !maskOk || mask != 7U) { + failM4Group(QStringLiteral("M4START ACK token/boot/roster/mask mismatch.")); + return true; + } + m4GroupStage_ = M4GroupStage::awaiting_start_readback; + m4GroupInFlight_ = QStringLiteral("M4SHOW"); + if (!sendCommand(m4GroupInFlight_)) { + failM4Group(QStringLiteral("M4START accepted but board state cannot be read back.")); + } else m4GroupTimer_.start(); + return true; + } + if (m4GroupStage_ == M4GroupStage::awaiting_stop_ack && + line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"))) { + if (!m4GroupInFlight_.isEmpty()) return true; + // The firmware maintenance supervisor already owns STOP ACK -> + // fresh SHOW/SVLIFE -> PTP STOP proof. Never hijack that verified + // handoff with M4SHOW (which is forbidden during quiescence). + if (firmwareQuiescenceActive_) { + m4GroupTimer_.stop(); + m4GroupInFlight_.clear(); + m4GroupStage_ = M4GroupStage::uncertain; + m4GroupStatus_ = QStringLiteral("Maintenance STOP ACK; waiting for firmware SV/PTP readbacks."); + emit m4DeploymentChanged(); + return false; // let the EXISTING firmware STOP handler own ACK/SHOW + } + if (line.contains(QStringLiteral("fail-closed runtime"), Qt::CaseInsensitive)) { + failM4Group(QStringLiteral("Runtime fail-safe STOP is not an operator STOP ACK.")); + return false; + } + m4GroupStage_ = M4GroupStage::awaiting_stop_readback; + m4GroupInFlight_ = QStringLiteral("M4SHOW"); + if (!sendCommand(m4GroupInFlight_)) + failM4Group(QStringLiteral("STOP ACK received but board STOPPED readback unavailable.")); + else m4GroupTimer_.start(); + return true; + } + if ((m4GroupStage_ == M4GroupStage::awaiting_start_readback || + m4GroupStage_ == M4GroupStage::awaiting_stop_readback) && + line.contains(QStringLiteral("M4STATE "))) { + if (!m4GroupInFlight_.isEmpty()) return true; + bool tokOk=false, bootOk=false, hashOk=false, genOk=false; + bool selectedOk=false, maskOk=false, runningOk=false; + const auto token=extract(QStringLiteral("token")).toULongLong(&tokOk); + const auto boot=extract(QStringLiteral("boot")).toULongLong(&bootOk,16); + const auto roster=extract(QStringLiteral("roster")).toULongLong(&hashOk,16); + const auto gen=extract(QStringLiteral("generation")).toULongLong(&genOk); + const auto selected=extract(QStringLiteral("selected")).toUInt(&selectedOk); + const auto mask=extract(QStringLiteral("mask")).toUInt(&maskOk); + const auto running=extract(QStringLiteral("running")).toUInt(&runningOk); + const bool stopping=m4GroupStage_ == M4GroupStage::awaiting_stop_readback; + // STOP is authoritative for the shared publisher regardless of + // legitimate per-MU TX gating (0..7); START requires all 3 enabled. + const bool validMask = maskOk && (stopping ? mask <= 7U : mask == 7U); + if (!tokOk || (!stopping && token != m4GroupToken_) || + !bootOk || boot != m4GroupBoot_.toULongLong(nullptr,16) || + !hashOk || roster != m4GroupRoster_.toULongLong(nullptr,16) || + !genOk || gen != m4ReconnectRosterGeneration_ || + !selectedOk || selected != 1U || !validMask || + !runningOk || running != (stopping ? 0U : 1U)) { + failM4Group(QStringLiteral("M4 board state readback rejected: boot/roster/epoch/mask/RUNNING mismatch.")); + return true; + } + m4GroupTimer_.stop(); + m4GroupInFlight_.clear(); + m4GroupStage_ = stopping ? M4GroupStage::confirmed_stopped : + M4GroupStage::running_confirmed; + m4GroupStatus_ = stopping + ? QStringLiteral("3-MU STOPPED confirmed by board; wire capture remains independent.") + : QStringLiteral("3-MU RUNNING confirmed by board; 12kfps PCAP acceptance PENDING."); + if (!stopping) operatorStopPending_ = false; // stale prior STOP must not mask the next PHY incident + setRunning(!stopping); // classify a verified STOP with its operator latch intact + if (stopping) operatorStopPending_ = false; + emit m4DeploymentChanged(); + return true; + } + return false; +} + bool DeviceController::startWithScenario(const QVariantMap& scenario) { if (m4Ready_ || m4Deploying_ || m4ScenarioApplying_) { setError(QStringLiteral("Legacy V1 START blocked while an M4 roster is selected. Configure all three MU scenarios first.")); @@ -992,6 +1182,28 @@ bool DeviceController::submitScenario(const QVariantMap& scenario, const bool st return true; } bool DeviceController::stop() { + const bool groupStop = m4GroupStarting() || + (m4ReconnectMode_ == QStringLiteral("SELECTED") && + (running_ || m4GroupStage_ == M4GroupStage::uncertain || + m4GroupStage_ == M4GroupStage::running_confirmed)); + if (groupStop) { + // Retire not-yet-written exclusive M4START before priority STOP. + if (ioWorker_ != nullptr && ioThread_.isRunning()) { + DeviceIoWorker* const worker = ioWorker_; + const quint64 generation = sessionGeneration_; + static_cast(QMetaObject::invokeMethod(worker, [worker, generation] { + worker->retireExclusiveCommands(generation); + }, Qt::QueuedConnection)); + } + m4GroupTimer_.start(); + m4GroupStage_ = M4GroupStage::awaiting_stop_ack; + m4GroupSession_ = sessionGeneration_; + m4GroupBoot_ = identity_.bootId; + m4GroupRoster_ = m4ReconnectRosterHash_; + m4GroupInFlight_ = QStringLiteral("STOP"); + m4GroupStatus_ = QStringLiteral("Explicit STOP sent; verifying board STOPPED readback"); + emit m4DeploymentChanged(); + } cancelM4Board(QStringLiteral("Operator STOP: board MU snapshots must be inspected again.")); if (m4ScenarioApplying_) cancelM4Scenarios(QStringLiteral("Explicit STOP cancelled per-MU CFG transaction.")); if (m4Deploying_) cancelM4(QStringLiteral("M4 upload cancelled by explicit operator STOP.")); @@ -1001,7 +1213,9 @@ bool DeviceController::stop() { .arg(sessionGeneration_).arg(identity_.bootId) .arg(running_ ? 1 : 0).arg(scenarioStage_ != ScenarioStage::idle ? 1 : 0)); resetScenarioStart(); - return sendCommand(QStringLiteral("STOP")); + const bool sent = sendCommand(QStringLiteral("STOP")); + if (!sent && groupStop) failM4Group(QStringLiteral("M4 priority STOP was not accepted by serial worker.")); + return sent; } bool DeviceController::zero() { return sendCommand(QStringLiteral("ZERO")); } @@ -1284,7 +1498,8 @@ bool DeviceController::applyMultiSvScenarios(const QVariantList& drafts) { if (m4BoardInspecting_) return false; cancelM4Board(QStringLiteral("M4 scenario changed; previous bank snapshot stale.")); if (!connected_ || !deviceVerified_ || !ioWorkerReady_ || !m4Ready_ || - m4Deploying_ || m4ScenarioApplying_ || m4ScenarioRetiring_ || running_ || ptpStarting() || + !m4GroupStartAllowed() || 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, " @@ -2744,6 +2959,7 @@ void DeviceController::processLine(const QString& rawLine) { appendLog(QStringLiteral("←"), line); if (processM4Line(line)) return; if (processM4ScenarioLine(line)) return; + if (processM4GroupLine(line)) return; if (processM4BoardLine(line)) return; if (processM4ReconnectLine(line)) return; diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index e91163e74..77aec0986 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -83,6 +83,9 @@ class DeviceController : public QObject { 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(bool m4GroupStarting READ m4GroupStarting NOTIFY m4DeploymentChanged) + Q_PROPERTY(bool m4GroupNeedsStop READ m4GroupNeedsStop NOTIFY m4DeploymentChanged) + Q_PROPERTY(QString m4GroupStatus READ m4GroupStatus NOTIFY m4DeploymentChanged) Q_PROPERTY(bool m4BoardInspecting READ m4BoardInspecting NOTIFY m4BoardChanged) Q_PROPERTY(QString m4ReconnectMode READ m4ReconnectMode NOTIFY m4ReconnectModeChanged) Q_PROPERTY(QString m4BoardRosterHash READ m4BoardRosterHash NOTIFY m4ReconnectModeChanged) @@ -200,6 +203,22 @@ class DeviceController : public QObject { [[nodiscard]] QString m4DeployedHash() const { return m4DeployedHash_; } [[nodiscard]] bool m4ScenarioApplying() const noexcept { return m4ScenarioApplying_ || m4ScenarioRetiring_; } [[nodiscard]] bool m4ScenariosVerified() const noexcept { return m4ScenariosVerified_; } + [[nodiscard]] bool m4GroupStarting() const noexcept { + return m4GroupStage_ == M4GroupStage::awaiting_start_ack || + m4GroupStage_ == M4GroupStage::awaiting_start_readback || + m4GroupStage_ == M4GroupStage::awaiting_stop_ack || + m4GroupStage_ == M4GroupStage::awaiting_stop_readback; + } + [[nodiscard]] bool m4GroupNeedsStop() const noexcept { + return m4GroupStarting() || + m4GroupStage_ == M4GroupStage::running_confirmed || + m4GroupStage_ == M4GroupStage::uncertain; + } + [[nodiscard]] bool m4GroupStartAllowed() const noexcept { + return m4GroupStage_ == M4GroupStage::idle || + m4GroupStage_ == M4GroupStage::confirmed_stopped; + } + [[nodiscard]] QString m4GroupStatus() const { return m4GroupStatus_; } [[nodiscard]] bool m4BoardInspecting() const noexcept { return m4BoardInspecting_; } [[nodiscard]] QVariantList m4BoardSnapshots() const { return m4BoardSnapshots_; } [[nodiscard]] QString m4BoardStatus() const { return m4BoardStatus_; } @@ -290,6 +309,9 @@ class DeviceController : public QObject { virtual bool start(); // Single validated, bounded FIFO transaction. START is always last. bool startWithScenario(const QVariantMap& scenario); + // Explicit M4 operator START: exact SCD draft, 3 committed MU banks, + // selected firmware roster and fresh board readback are mandatory. + bool startMultiSvGroup(const QVariantList& drafts); bool applyLiveScenario(const QVariantMap& scenario); bool stop(); virtual bool zero(); @@ -419,6 +441,10 @@ class DeviceController : public QObject { }; enum class ScenarioStage { idle, awaiting_commit, awaiting_readback, awaiting_start_ack }; + enum class M4GroupStage { + idle, awaiting_start_ack, awaiting_start_readback, running_confirmed, + awaiting_stop_ack, awaiting_stop_readback, confirmed_stopped, uncertain + }; enum class BinaryProfileStage { idle, @@ -474,6 +500,8 @@ class DeviceController : public QObject { void cancelM4Scenarios(const QString& reason); bool dispatchM4ScenarioStep(); bool processM4ScenarioLine(const QString& line); + bool processM4GroupLine(const QString& line); + void failM4Group(const QString& why); bool processM4BoardLine(const QString& line); bool processM4ReconnectLine(const QString& line); void resetM4Reconnect(const QString& mode); @@ -567,6 +595,14 @@ class DeviceController : public QObject { bool m4ScenarioApplying_{false}; bool m4ScenarioRetiring_{false}; bool m4ScenariosVerified_{false}; + M4GroupStage m4GroupStage_{M4GroupStage::idle}; + QTimer m4GroupTimer_; + QString m4GroupInFlight_; + QString m4GroupBoot_; + QString m4GroupRoster_; + QString m4GroupStatus_{QStringLiteral("M4 group START requires verified roster and 3 MU banks")}; + quint64 m4GroupToken_{0}; + quint64 m4GroupSession_{0}; bool m4ScenarioDispatching_{false}; std::unique_ptr m4BoardReadback_; QTimer m4BoardTimer_; diff --git a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp index e5dc249db..bf1c05758 100644 --- a/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp +++ b/apps/arstack_studio/src/M4MultiSvUploadHarness.cpp @@ -256,6 +256,15 @@ void testThreeMuScenarioApply() { check(tx.verified() && tx.generation(0) == 6 && tx.generation(1) == 7 && tx.generation(2) == 8, "three independently committed MCU generations"); + check(tx.matchesDrafts(drafts) && tx.hash(0) != 0U && + tx.hash(1) != 0U && tx.hash(2) != 0U, + "3 current editor hashes must match firmware verified banks"); + auto changed = drafts; + auto mutated = changed.at(1).toMap(); + mutated[QStringLiteral("hz")] = 61.0; + changed[1] = mutated; + check(!tx.matchesDrafts(changed), "edited MU2 must invalidate group start"); + check(!tx.matchesDrafts(invalid), "missing MU3 must invalidate group start"); M4ThreeMuScenarioApply rejected; check(rejected.start(drafts, kBoot), "negative fixture started"); const auto first = applyStep(rejected, 1); diff --git a/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp b/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp index 0caf3c338..e270e89a7 100644 --- a/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp +++ b/apps/arstack_studio/src/M4ThreeMuScenarioApply.cpp @@ -123,6 +123,18 @@ bool M4ThreeMuScenarioApply::start(const QVariantList& scenarios, const QString& stage_ = Stage::send_batch; return true; } +bool M4ThreeMuScenarioApply::matchesDrafts(const QVariantList& drafts) const { + if (!verified() || drafts.size() != 3) return false; + for (int i = 0; i != 3; ++i) { + Slot candidate{}; + QString error; + if (!buildSlot(drafts.at(i).toMap(), i + 1, candidate, error) || + candidate.hash != slots_[static_cast(i)].hash || + slots_[static_cast(i)].generation == 0U) return false; + } + return true; +} + QStringList M4ThreeMuScenarioApply::takeCommands() { if (stage_ == Stage::send_batch) { stage_ = Stage::wait_commit; diff --git a/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp b/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp index 66d32b890..dfc6de325 100644 --- a/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp +++ b/apps/arstack_studio/src/M4ThreeMuScenarioApply.hpp @@ -25,6 +25,13 @@ class M4ThreeMuScenarioApply final { [[nodiscard]] QString error() const { return error_; } [[nodiscard]] int slot() const noexcept { return slot_ + 1; } [[nodiscard]] int verifiedSlots() const noexcept { return verifiedSlots_; } + // A UI draft must still equal the three applied wire-bank hashes at START. + // Validation uses the SAME canonical encoder as the actual CFG upload. + [[nodiscard]] bool matchesDrafts(const QVariantList& drafts) const; + [[nodiscard]] std::uint64_t hash(int slot) const noexcept { + return verified() && slot >= 0 && slot < 3 + ? slots_[static_cast(slot)].hash : 0U; + } [[nodiscard]] std::uint64_t generation(int slot) const noexcept { return slot >= 0 && slot < 3 ? slots_[static_cast(slot)].generation : 0; } diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 5b0838243..a9803d24b 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -210,6 +210,21 @@ bool SmartSessionController::canApplyMultiSvScenarios() const noexcept { profiles_->m4DraftSupported() && device_->m4DeployedHash() == profiles_->m4ConfigurationHash(); } +bool SmartSessionController::canStartMultiSv() const noexcept { + return m4Mode_ && deviceControlAvailable() && profiles_ != nullptr && + m4SclRosterMatch_ == QStringLiteral("MATCHED") && + profiles_->m4DraftSupported() && !device_->running() && + device_->m4Ready() && device_->m4ScenariosVerified() && + device_->m4GroupStartAllowed() && !device_->m4Deploying() && + !device_->m4ScenarioApplying() && !device_->m4BoardInspecting() && + !device_->profileDeploying() && !device_->scenarioApplying() && + !device_->ptpStarting() && device_->ptpEthernetLink() == QStringLiteral("UP") && + device_->m4ReconnectMode() == QStringLiteral("SELECTED") && + device_->m4DeployedHash() == profiles_->m4ConfigurationHash() && + device_->capabilities().contains(QStringLiteral("MULTISV-GROUPSTART-V1"), + Qt::CaseInsensitive); +} + bool SmartSessionController::canInspectMultiSvBoard() const noexcept { // Explicit board inspection remains available after reconnect, including // autonomous RUNNING state, without selecting or deploying any GUI draft. @@ -405,7 +420,8 @@ bool SmartSessionController::requestStop() { // 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() && !device_->m4ScenarioApplying()) return true; + !device_->m4Deploying() && !device_->m4ScenarioApplying() && + !device_->m4GroupNeedsStop()) return true; if (portOwner_ != PortOwner::deviceSession || updateRequested_) return false; return device_->stop(); } @@ -476,6 +492,13 @@ bool SmartSessionController::requestApplyMultiSvScenarios(const QVariantList& dr return started; } +bool SmartSessionController::requestStartMultiSv(const QVariantList& drafts) { + if (!canStartMultiSv() || drafts.size() != 3) return false; + const bool accepted = device_->startMultiSvGroup(drafts); + reconcile(); + return accepted; +} + bool SmartSessionController::requestInspectMultiSvBoard() { if (!canInspectMultiSvBoard()) return false; // Never change the running board or rewrite a restored draft. @@ -1652,8 +1675,23 @@ void SmartSessionController::reconcile() { // selected MULTISV roster is still unknown or already selected. A board // may continue publishing independently of the USB/Studio lifetime. if (device_->m4ReconnectMode() != QStringLiteral("LEGACY")) { - if (device_->m4ReconnectMode() == QStringLiteral("SELECTED")) + if (device_->m4ReconnectMode() == QStringLiteral("SELECTED")) { m4Mode_ = true; + if (device_->running()) { + setPresentation(QStringLiteral("RUNNING"), + QStringLiteral("Three-MU publisher RUNNING on the board; " + "wire-level 3x4000 fps acceptance is still pending."), + false, false); + return; + } + if (device_->m4GroupNeedsStop()) { + setPresentation(QStringLiteral("M4 STATE UNCONFIRMED"), + device_->m4GroupStatus() + + QStringLiteral(" Use explicit STOP for fail-closed recovery."), + false, false); + return; + } + } setPresentation(device_->m4ReconnectMode() == QStringLiteral("PENDING") ? QStringLiteral("READING BOARD ROSTER") : device_->m4ReconnectMode() == QStringLiteral("SELECTED") diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index da4be66f8..0eda1d6b8 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -24,6 +24,7 @@ class SmartSessionController : public QObject { 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 canStartMultiSv READ canStartMultiSv NOTIFY stateChanged) Q_PROPERTY(bool canInspectMultiSvBoard READ canInspectMultiSvBoard NOTIFY stateChanged) Q_PROPERTY(QString m4SclRosterMatch READ m4SclRosterMatch NOTIFY stateChanged) Q_PROPERTY(bool liveControlReady READ liveControlReady NOTIFY stateChanged) @@ -83,6 +84,7 @@ class SmartSessionController : public QObject { [[nodiscard]] bool canDeployProfile() const noexcept; [[nodiscard]] bool canDeployMultiSv() const noexcept; [[nodiscard]] bool canApplyMultiSvScenarios() const noexcept; + [[nodiscard]] bool canStartMultiSv() const noexcept; [[nodiscard]] bool canInspectMultiSvBoard() const noexcept; [[nodiscard]] QString m4SclRosterMatch() const { return m4SclRosterMatch_; } [[nodiscard]] bool liveControlReady() const noexcept; @@ -204,6 +206,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestProfileSync(); Q_INVOKABLE bool requestDeployMultiSv(); Q_INVOKABLE bool requestApplyMultiSvScenarios(const QVariantList& drafts); + Q_INVOKABLE bool requestStartMultiSv(const QVariantList& drafts); Q_INVOKABLE bool requestInspectMultiSvBoard(); Q_INVOKABLE bool requestSetFrequency(double hz); Q_INVOKABLE bool requestSetSignal( diff --git a/docs/M4_P0E3C_GROUP_START_STOP_ENGINEERING_PREVIEW.md b/docs/M4_P0E3C_GROUP_START_STOP_ENGINEERING_PREVIEW.md new file mode 100644 index 000000000..c08b9d4be --- /dev/null +++ b/docs/M4_P0E3C_GROUP_START_STOP_ENGINEERING_PREVIEW.md @@ -0,0 +1,48 @@ +# M4-P0E3C — Verified three-MU group START/STOP (engineering preview) + +This implementation reuses the sole ESP32-P4 publisher, physical-link owner, +shared PTP/sample epoch, three canonical ARM3 profiles, and Studio serial worker. +It does not activate on reconnect, GUI discovery, SCL import, or firmware flashing. + +## Operator flow + +Open exact 3-IED SCD → Deploy 3 MU (ARM3 COMMIT/SHOW/SELECT) → Apply 3 MU +(three independent CFG COMMIT/READBACK) → press the existing START button. +The current three drafts must hash-identically match the applied bank proofs. +SCD must be MATCHED to the board's selected 3-STREAM roster, the Ethernet PHY +must report UP, and firmware must advertise MULTISV-GROUPSTART-V1. + +## Device protocol + +`M4START ` + +The existing UART control task verifies: current boot, immutable committed +roster hash, selected mode, no active upload, TX mask 0x07, all three current +bank generations/hashes, unique request token, fresh operator heartbeat, +PHY UP and STOPPED publisher. A legacy `START` cannot start a selected M4 +roster. No extra timer/task, heap allocation, or work is added to 250us TX. + +On acceptance, the existing publisher task starts the existing 3-SV path. +Studio waits for exact `M4START accepted token/boot/roster` then sends `M4SHOW`. +Firmware returns one board-control-plane `M4STATE token/boot/roster/generation/ +selected/mask/running`. Only a matching fresh response with **all 3 TX-enable bits** marks RUNNING. +Group STOP requires matching boot/roster and `running=0`, but permits any +valid 3-bit per-MU mask because independently stopped streams must not block +verification of the shared publisher STOP. Neither control-plane state proves +any packet on physical Ethernet. + +The existing priority `STOP` cancels a pending group START; Studio retires +unsent exclusive commands and requires `STOP accepted` plus a fresh +`M4STATE running=0` before calling STOPPED verified. A timeout or transport +error is **UNCERTAIN**, never fabricated as STOPPED. In that case the existing +STOP action remains available even if RUNNING telemetry was not received. USB disconnect clears +local proofs without sending STOP or automatically re-arming the board. + +## Acceptance boundary + +Software gates: Qt/Linux/Windows harnesses, ESP32-P4 exact firmware image, +negative roster/hash/boot/mask/draft and lifecycle tests, paired preview. +Hardware remains **PENDING** until matched-board independent PCAP proves +three 4I+4V SV streams at 4000 frames/s each (12000 aggregate), identical +sample epoch, correct SCL MAC/APPID/VLAN and real values, plus sustained +TX budget and explicit STOP/reconnect behavior. Preserve M3.H.3 stable assets. diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index 336ddfc9d..5c964ad5a 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -84,6 +84,8 @@ std::atomic g_sv_stop_heartbeat_age_ms{0U}; portMUX_TYPE g_control_lease_mux = portMUX_INITIALIZER_UNLOCKED; esp_timer_handle_t g_control_lease_timer{nullptr}; std::uint64_t g_boot_id{0U}; +// Replay guard for explicit M4 starts; never a second publisher-state owner. +std::uint64_t g_last_m4_start_token{0U}; void wake_publisher() noexcept { const auto task = g_publisher_task.load(std::memory_order_acquire); @@ -270,7 +272,7 @@ void print_identity() noexcept { const char* version = app != nullptr ? app->version : "unknown"; #if CONFIG_AR_PTP_LAB_TX const char* capabilities = - "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2,MULTISV-TX-LAB-PREVIEW"; + "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PTP-P2,PTP-GM-BENCH-V1,PTP-POLICY-M3D-V1,SMPSYNCH-AUTO,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2,MULTISV-TX-LAB-PREVIEW,MULTISV-GROUPSTART-V1"; #else const char* capabilities = "SMV-4I4V,PROFILE,LIVE-SETPOINTS,SV-LAN-AUTONOMY-V1,SV-CONFIG-TXN-V1,PROFILE-BINARY-V1,PROFILE-REUSE-V1,MULTISV-CONFIG-STAGING-V2"; @@ -750,7 +752,74 @@ void handle_line(char* line) noexcept { return; } + // Explicit 3-MU start is NEVER the legacy V1 CFG/START shortcut. All + // caller-supplied bank proofs are compared to fresh firmware snapshots. + if (std::strcmp(command, "M4START") == 0) { + std::uint64_t token{}, boot{}, roster{}; + std::array generations{}, hashes{}; + bool parsed = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) && + parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) && + parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), roster, 16); + for (std::size_t i = 0U; i < generations.size(); ++i) { + const bool gen_ok = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), generations[i], 10); + const bool hash_ok = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hashes[i], 16); + parsed = parsed && gen_ok && hash_ok; + } + if (!parsed || has_extra_token(&save) || token == 0U || + token == g_last_m4_start_token || boot != g_boot_id || + g_running.load(std::memory_order_acquire) || + !multi_profile_group_start_matches(roster, generations, hashes)) { + ESP_LOGE(kTag, "M4START rejected: boot/roster/3-bank proof or replay invalid"); + return; + } + if (!arm_control_lease_if_fresh()) { + ESP_LOGE(kTag, "M4START rejected: live operator control-session HEARTBEAT required"); + return; + } + bool armed = false; + portENTER_CRITICAL(&g_publisher_lifecycle_mux); + if (g_physical_link_up.load(std::memory_order_relaxed) && + !g_running.load(std::memory_order_relaxed)) { + g_start_request.store(true, std::memory_order_release); + g_running.store(true, std::memory_order_release); + armed = true; + } + portEXIT_CRITICAL(&g_publisher_lifecycle_mux); + if (!armed) { + disarm_control_lease(); + ESP_LOGE(kTag, "M4START rejected: Ethernet PHY DOWN or publisher already running"); + return; + } + g_last_m4_start_token = token; + g_scenario_txn.reset(); + profile_control_abort_staging(); + wake_publisher(); + ESP_LOGI(kTag, "M4START accepted token=%llu boot=%016llX roster=%016llX mask=7", + static_cast(token), + static_cast(boot), + static_cast(roster)); + return; + } + if (std::strcmp(command, "M4SHOW") == 0) { + if (has_extra_token(&save)) { ESP_LOGE(kTag, "M4SHOW takes no arguments"); return; } + const std::uint64_t roster = multi_profile_roster_hash_if_selected(); + ESP_LOGI(kTag, + "M4STATE token=%llu boot=%016llX roster=%016llX generation=%llu selected=%u mask=%u running=%u", + static_cast(g_last_m4_start_token), + static_cast(g_boot_id), + static_cast(roster), + static_cast(multi_profile_roster_generation_if_selected()), + roster != 0U ? 1U : 0U, + static_cast(multi_profile_enabled_mask()), + g_running.load(std::memory_order_acquire) ? 1U : 0U); + return; + } + if (std::strcmp(command, "START") == 0) { + if (multi_profile_tx_roster_if_selected() != nullptr) { + ESP_LOGE(kTag, "START rejected: selected M4 roster requires explicit M4START with 3 bank proofs"); + return; + } std::uint64_t token{}, boot{}, hash{}; if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) || diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp index bac3c871d..c12e37370 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.cpp @@ -169,6 +169,36 @@ multi_profile_signal_snapshot(const std::size_t slot) noexcept { } +std::uint64_t multi_profile_roster_hash_if_selected() noexcept { + return multi_profile_tx_roster_if_selected() != nullptr + ? g_multi.committed_hash() : 0U; +} + +std::uint64_t multi_profile_roster_generation_if_selected() noexcept { + return multi_profile_tx_roster_if_selected() != nullptr + ? g_multi.generation() : 0U; +} + +bool multi_profile_group_start_matches( + const std::uint64_t roster_hash, + const std::array& generations, + const std::array& hashes) noexcept { + if (roster_hash == 0U || multi_profile_roster_hash_if_selected() != roster_hash || + g_multi.uploading() || g_mu_stream_gate.snapshot_mask() != 0x07U) return false; + for (std::size_t i = 0U; i < generations.size(); ++i) { + const auto* const authoritative = g_mu_signals.control_bank_for_serial_writer(i); + if (authoritative == nullptr || + !g_mu_transactions[i].verified_current_bank(*authoritative)) return false; + const auto bank = multi_profile_signal_snapshot(i); + if (generations[i] == 0U || hashes[i] == 0U || + bank.generation != generations[i] || + !ar::iec61850::sampled_values::SvLiveSignalBank::validate(bank) || + ar::iec61850::sampled_values::sv_scenario_hash(bank) != hashes[i]) + return false; + } + return true; +} + [[nodiscard]] bool parse_u64_token(const char* text, std::uint64_t& output, const int base) noexcept { if (!text || !*text || *text == '-') return false; diff --git a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp index 921ab49d5..389cf4a1d 100644 --- a/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp +++ b/embedded/esp32p4_smv_injector/main/multi_profile_control.hpp @@ -2,6 +2,7 @@ #pragma once #include "ariec61850/sampled_values/compiled_device_profile.hpp" #include "ariec61850/sampled_values/live_signal_state.hpp" +#include #include #include @@ -19,6 +20,16 @@ multi_profile_signal_snapshot(std::size_t slot) noexcept; // canonical MU1/MU2/MU3 Ethernet submissions without resetting the grid. [[nodiscard]] std::uint8_t multi_profile_enabled_mask() noexcept; +// Control-task readback: the selected immutable roster is the sole profile authority. +[[nodiscard]] std::uint64_t multi_profile_roster_hash_if_selected() noexcept; +[[nodiscard]] std::uint64_t multi_profile_roster_generation_if_selected() noexcept; +// Requires three current bank generations + canonical hashes and all three TX bits. +// No new publisher owner, no heap, no wire-time work. +[[nodiscard]] bool multi_profile_group_start_matches( + std::uint64_t roster_hash, + const std::array& generations, + const std::array& hashes) noexcept; + // Null means legacy V1 START. Non-null is the selected, validated, committed // 3-profile V2 roster. Never retain across STOP and a future control commit. // Capacity is guaranteed by M4ThreeSvBinaryCodec (exactly three records). diff --git a/include/ariec61850/sampled_values/live_signal_transaction.hpp b/include/ariec61850/sampled_values/live_signal_transaction.hpp index 62cccf2c1..c7b242464 100644 --- a/include/ariec61850/sampled_values/live_signal_transaction.hpp +++ b/include/ariec61850/sampled_values/live_signal_transaction.hpp @@ -118,6 +118,18 @@ class SvScenarioTransaction final { return readback_seen_; } + // Three-MU activation requires an independent COMMIT + READBACK proof for + // each bank. Unlike V1's 8-second START ticket, this remains valid across + // sequential MU uploads until the bank generation/hash changes or a + // new BEGIN/SELECT resets this transaction. No new state owner. + [[nodiscard]] bool verified_current_bank(const SvLiveSignalBank& bank) const noexcept { + if (!committed_ || !readback_seen_ || began_running_) return false; + const auto observed = bank.snapshot(); + return observed.generation != 0U && + observed.generation == generation_ && + sv_scenario_hash(observed) == checksum_; + } + [[nodiscard]] bool can_start(const SvLiveSignalBank& bank, const std::uint64_t token, const std::uint64_t hash, diff --git a/tests/test_board_autonomous_publisher_contract.py b/tests/test_board_autonomous_publisher_contract.py index bd6a2b944..278df13b6 100644 --- a/tests/test_board_autonomous_publisher_contract.py +++ b/tests/test_board_autonomous_publisher_contract.py @@ -56,6 +56,17 @@ assert app_boot.index("esp_eth_start(eth_handle)") < app_boot.index("live_control_initialize(") assert "SV-LAN-AUTONOMY-V1" in fw assert "SESSION-LEASE" not in fw +# M4 must use a distinct, exact-3-bank command and the SAME publisher state. +assert 'std::strcmp(command, "M4START") == 0' in fw +assert "multi_profile_group_start_matches(roster, generations, hashes)" in fw +assert "g_physical_link_up.load(std::memory_order_relaxed)" in fw.split( + 'if (std::strcmp(command, "M4START") == 0)', 1)[1].split( + 'if (std::strcmp(command, "M4SHOW") == 0)', 1)[0] +assert "M4STATE token=" in fw +multi = (root / "embedded/esp32p4_smv_injector/main/multi_profile_control.cpp").read_text() +assert "g_mu_stream_gate.snapshot_mask() != 0x07U" in multi +assert "sv_scenario_hash(bank) != hashes[i]" in multi +assert "bank.generation != generations[i]" in multi assert "SV-LAN-AUTONOMY-V1" in device assert "SVBANK BEGIN generation=" in fw and "SVBANK END generation=" in fw assert "SVBANK BEGIN" in device and "SVBANK END" in device diff --git a/tests/test_m4_studio_qml_safety_contract.py b/tests/test_m4_studio_qml_safety_contract.py index 82dafae50..5e8d16973 100644 --- a/tests/test_m4_studio_qml_safety_contract.py +++ b/tests/test_m4_studio_qml_safety_contract.py @@ -43,5 +43,17 @@ def test_restore_clears_old_frequency_editor_state(self): self.assertIn("frequencyField.invalidInput = false", restore) + def test_m4_group_start_reuses_existing_editors_and_fail_closed_supervisor(self): + ribbon = (ROOT / "apps/arstack_studio/qml/WorkflowBar.qml").read_text(encoding="utf-8") + self.assertIn("controller.m4StartDrafts()", ribbon) + self.assertIn("smartSession.canStartMultiSv", ribbon) + self.assertIn("smartSession.requestStartMultiSv(drafts)", ribbon) + self.assertIn("m4Pages.saveCurrent(root.startScenarioSnapshot(false))", QML) + self.assertIn("3-MU START refused", ribbon) + self.assertIn("device.m4GroupNeedsStop", ribbon) + modern = (ROOT / "apps/arstack_studio/qml/ModernRibbon.qml").read_text(encoding="utf-8") + self.assertIn("bar.device.m4GroupNeedsStop", modern) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sv_scenario_transaction.cpp b/tests/test_sv_scenario_transaction.cpp index 4f9122696..1a20eb190 100644 --- a/tests/test_sv_scenario_transaction.cpp +++ b/tests/test_sv_scenario_transaction.cpp @@ -42,13 +42,16 @@ int main() { check(bank.snapshot().generation == before.generation + 1, "one generation for entire scenario"); check(!tx.can_start(bank, 101, hash, 105), "no START before explicit readback"); + check(!tx.verified_current_bank(bank), "M4 group blocked before readback"); check(tx.confirm_readback(bank, 101, 105), "firmware applied-state readback"); + check(tx.verified_current_bank(bank), "M4 bank proof requires COMMIT + READBACK"); check(tx.readback_matches(bank, 101, 105) && tx.can_start(bank, 101, hash, 106), "ACK/readback and token/hash match"); check(!tx.can_start(bank, 102, hash, 106) && !tx.can_start(bank, 101, hash ^ 1, 106), "stale token and altered hash refuse START"); tx.consume_start(); check(!tx.can_start(bank, 101, hash, 106), "token cannot replay after START"); + check(!tx.verified_current_bank(bank), "consumed bank proof cannot authorize M4"); } { SvLiveSignalBank bank; @@ -104,6 +107,7 @@ int main() { unsolicited.channels[2].phase_millidegrees += 1; check(bank.publish(unsolicited), "external live edit"); check(!tx.readback_matches(bank, 601, 105), "unexpected bank generation revokes ticket"); + check(!tx.verified_current_bank(bank), "changed bank revokes M4 group proof"); check(!tx.can_start(bank, 601, hash, 105), "unexpected live edit refuses START"); } { @@ -169,6 +173,8 @@ int main() { "running transaction requires exact independent readback"); check(!tx.can_start(bank, 803, live_hash, 221), "live commit cannot rearm START and reset smpCnt/phase"); + check(!tx.verified_current_bank(bank), + "running live edit cannot grant M4 START authority"); } return failures == 0 ? 0 : 1; }