diff --git a/apps/arstack_studio/qml/Main.qml b/apps/arstack_studio/qml/Main.qml index 82f61a0da..d2fd723bb 100644 --- a/apps/arstack_studio/qml/Main.qml +++ b/apps/arstack_studio/qml/Main.qml @@ -55,6 +55,9 @@ ApplicationWindow { // All edits are a draft until the matching command is queued. // Never label a visible draft as proven transmitted waveform data. pendingApply = true + scenarioRevision += 1 + if (workflowBar.injectionRunning && liveApplyArmed) + Qt.callLater(root.submitLiveScenario) } function restoreLocalScenario() { const saved = scenarioSettings.lastScenario @@ -114,6 +117,10 @@ ApplicationWindow { property bool phaseLink: false property bool profileDirty: false property bool pendingApply: true + property int scenarioRevision: 0 + property int liveInFlightRevision: -1 + property bool liveApplyJustSucceeded: false + property bool liveApplyArmed: false // Session-only operator intent. Never persist across application launches: // an explicit PTP ON can be restored after a firmware reflash/reconnect // without introducing an uncontrolled power-on publisher. @@ -195,10 +202,36 @@ ApplicationWindow { function onDeviceMessage(message) { root.showMessage(message, false) } + function onRunningChanged() { + // Unsolicited RUNNING telemetry (including after reconnect) does + // not authorize applying an old GUI draft to the physical publisher. + if (!device.running) root.liveApplyArmed = false + } + function onLastErrorChanged() { + if (root.liveInFlightRevision >= 0) root.liveApplyArmed = false + } function onDeviceVerifiedChanged() { root.pendingApply = true + root.liveInFlightRevision = -1 + root.liveApplyArmed = false root.offlinePreviewRunning = false } + function onLiveScenarioApplied() { + root.liveApplyJustSucceeded = true + // Commit/readback ACK precedes this signal. No optimistic QUEUED=APPLIED. + if (root.liveInFlightRevision >= 0 && + root.liveInFlightRevision === root.scenarioRevision) + root.pendingApply = false + } + function onScenarioApplicationFinished() { + const newerDraft = root.liveInFlightRevision >= 0 && + root.scenarioRevision > root.liveInFlightRevision + root.liveInFlightRevision = -1 + const accepted = root.liveApplyJustSucceeded + root.liveApplyJustSucceeded = false + if (accepted && newerDraft && workflowBar.injectionRunning && root.liveApplyArmed) + Qt.callLater(root.submitLiveScenario) + } } Connections { @@ -441,15 +474,11 @@ ApplicationWindow { } function sendSignal(group, row) { - if (!workflowBar.session || !workflowBar.session.liveControlReady) return true - var signal = groupModel(group).get(row) - return workflowBar.session.requestSetSignal( - signal.signalId, - signal.magnitude, - signal.phase, - Number(signal.quality), - currentScale, - voltageScale) + // All operator edits use exactly one complete GUI scenario snapshot. + // A single SET cannot maintain 4I+4V frame-level atomicity. + if (workflowBar.injectionRunning) + Qt.callLater(root.submitLiveScenario) + return true } function sendLinkedGroup(group) { @@ -489,23 +518,46 @@ ApplicationWindow { workflowBar.session.liveControlReady) { const queued = phaseLink && row < 3 ? sendLinkedGroup(group) : sendSignal(group, row) - if (!queued) showMessage("Signal update not queued; retry or Stop/Start.", true) - else pendingApply = false + if (!queued) showMessage("Signal update not accepted; Stop/Start to recover.", true) } return true } + function activateLiveAutoApplyAfterStart() { + liveApplyArmed = true + if (pendingApply && workflowBar.injectionRunning) + Qt.callLater(root.submitLiveScenario) + } + + function cancelLiveAutoApply() { + liveApplyArmed = false + liveInFlightRevision = -1 + liveApplyJustSucceeded = false + } + + function submitLiveScenario() { + if (!pendingApply || !liveApplyArmed || !workflowBar.injectionRunning || + !workflowBar.session || !workflowBar.session.liveControlReady) + return false + // A newer edit is retained in the GUI. Completion of the previous + // exact-revision firmware transaction schedules the newer snapshot. + if (device.scenarioApplying) return true + const snapshot = startScenarioSnapshot() + if (!snapshot) return false + if (!workflowBar.session.requestApplyLiveScenario(snapshot)) { + showMessage("Atomic SV live update rejected. Board state not verified.", true) + return false + } + liveInFlightRevision = scenarioRevision + return true + } + function applyAllSignals() { - if (!workflowBar.session || !workflowBar.session.liveControlReady) return false - var ok = workflowBar.session.requestSetFrequency(signalFrequency) - for (var group = 0; group < 2; ++group) - for (var row = 0; row < 4; ++row) ok = sendSignal(group, row) && ok - ok = workflowBar.session.requestSetCtSaturation(ctSaturationEnabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) && ok - pendingApply = !ok - showMessage(ok ? "Local draft commands queued. Verify physical SV on wire; SETs are not atomic." - : "One or more draft commands could not be queued; retry after checking the board.", !ok) - return ok + if (!workflowBar.injectionRunning) { + showMessage("Apply the full scenario using Start when stopped.", false) + return false + } + return submitLiveScenario() } function startScenarioSnapshot() { // The table editors debounce keyboard edits for live preview. Flush @@ -532,8 +584,7 @@ ApplicationWindow { frequencyField.invalidInput = false refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetFrequency(value) + // markScenarioDirty() schedules full atomic CFG apply while running. return true } @@ -557,9 +608,7 @@ ApplicationWindow { ctSaturationEnabled = enabled refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetCtSaturation(enabled, ctDcOffsetPercent, - ctHarmonicPercent, ctHarmonicOrder, ctClipPercent) + // Full 4I+4V snapshot includes CT shaping in the same firmware commit. showMessage(enabled ? "CT saturation stress enabled ยท DC offset + 2nd harmonic + clipping approximation." : "CT saturation stress disabled.", false) @@ -628,7 +677,7 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) applyAllSignals() + // One complete scenario is scheduled by markScenarioDirty(). } function zeroAll() { @@ -639,8 +688,7 @@ ApplicationWindow { selectSignal(activeGroup, activeRow) refreshPreview() markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestZero() + // Zero currents and voltages together using the atomic CFG path. } function setActiveQuality(value) { @@ -648,8 +696,7 @@ ApplicationWindow { groupModel(activeGroup).setProperty(activeRow, "quality", unsignedValue) activeQuality = unsignedValue markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply) - workflowBar.session.requestSetQuality(activeSignal, unsignedValue) + // Quality travels in the same hash-verified full scenario transaction. } Shortcut { sequence: "Ctrl+B"; enabled: !workflowBar.startQueued; onActivated: root.balanced() } @@ -1043,8 +1090,7 @@ ApplicationWindow { if (value > 0) root.previousAcFrequency = value root.refreshPreview() root.markScenarioDirty() - if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply) - workflowBar.session.requestSetFrequency(value) + // Full scenario apply is scheduled by markScenarioDirty(). } else invalidInput = true } onEditingFinished: { @@ -1074,15 +1120,8 @@ ApplicationWindow { root.markScenarioDirty() if (workflowBar.injectionRunning && workflowBar.session && workflowBar.session.liveControlReady) { - // Persist *all eight current table values* on - // live link changes; the displayed draft - // must never silently diverge from TX. - var ok = true - for (var group = 0; group < 2; ++group) - for (var row = 0; row < 4; ++row) - ok = root.sendSignal(group, row) && ok - if (ok) root.pendingApply = false - else root.showMessage("Not all live phases were sent. Stop/Start to apply the table.", true) + // markScenarioDirty() already schedules the same + // canonical 8-channel CFG transaction. } } font.family: root.uiFont diff --git a/apps/arstack_studio/qml/SignalMatrix.qml b/apps/arstack_studio/qml/SignalMatrix.qml index ac323221d..cb8edd52b 100644 --- a/apps/arstack_studio/qml/SignalMatrix.qml +++ b/apps/arstack_studio/qml/SignalMatrix.qml @@ -283,8 +283,8 @@ Rectangle { matrix.controller.selectSignal(matrix.groupIndex, signalRow.rowIndex) matrix.controller.refreshPreview() matrix.controller.markScenarioDirty() - if (matrix.session && matrix.session.liveControlReady && !matrix.controller.pendingApply) - matrix.session.requestSetEnabled(signalRow.sid, checked) + // ENABLE joins RMS, phase, quality, frequency and CT + // shaping in the same verified eight-channel CFG commit. } } diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 63c9e3d0d..a1f2b7767 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -16,6 +16,7 @@ SurfacePanel { property string monoFont: "Inter" property bool compact: false property bool startQueued: false + property int startQueuedRevision: -1 Timer { id: startWatchdog interval: 8000 @@ -31,9 +32,18 @@ SurfacePanel { target: ribbon.device function onRunningChanged() { if (ribbon.device.running) { + if (!ribbon.startQueued) { + // Positive RUNNING telemetry may originate from a recovered + // pre-existing board session. It is NOT an operator START. + controller.cancelLiveAutoApply() + controller.pendingApply = true + return + } ribbon.startQueued = false startWatchdog.stop() - controller.pendingApply = false + // Edits made after the immutable START snapshot must still be applied. + controller.pendingApply = controller.scenarioRevision !== ribbon.startQueuedRevision + controller.activateLiveAutoApplyAfterStart() } } function onLastErrorChanged() { @@ -295,6 +305,7 @@ SurfacePanel { } else { // A successful queue call is NOT the board START ACK. // Keep the draft dirty until RUNNING evidence arrives. + ribbon.startQueuedRevision = controller.scenarioRevision ribbon.startQueued = true startWatchdog.restart() } @@ -304,6 +315,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 + controller.cancelLiveAutoApply() 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 b12981781..fc7818910 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -50,6 +50,8 @@ class DeterministicSessionHarness final { {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, {"legacy bare SV START refuses stale eight-channel state", legacyBareStartFailsClosed()}, {"SV commit ACK/readback START chain is generation bound", svTransactionAckChain()}, + {"running SV live CFG verified without START replay", svLiveAckNoStart()}, + {"RUNNING legacy SET intents rejected before serial", svLiveLegacyIntentsRejected()}, {"SV stale ACK and mismatched firmware readback fail closed", svTransactionNegativeAcks()}, {"STOP cancels pending SV commit-to-START chain", svStopCancelsPendingStart()}, {"ABC live SET encoder remains consistent and bounded", abcSetpointBatchContract()}, @@ -549,6 +551,40 @@ class DeterministicSessionHarness final { d.scenarioProfileGeneration_ = d.profileGeneration_; } + static bool svLiveLegacyIntentsRejected() { + Fixture f; + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), true); + if (!f.session.liveControlReady()) return false; + return !f.session.requestSetFrequency(60.0) && + !f.session.requestSetSignal(QStringLiteral("IA"), 1.0, 0.0, 0, + 1000.0, 100.0) && + !f.session.requestSetQuality(QStringLiteral("IA"), 1) && + !f.session.requestSetEnabled(QStringLiteral("IA"), false) && + !f.session.requestSetCtSaturation(true, 1.0, 1.0, 2, 100.0) && + !f.session.requestZero(); + } + + static bool svLiveAckNoStart() { + Fixture f; + seedScenarioAck(f); + auto& d = f.device; + d.running_ = true; + d.scenarioLiveUpdate_ = true; + bool applied = false; + QObject::connect(&d, &DeviceController::liveScenarioApplied, &d, + [&applied] { applied = true; }); + d.processLine(QStringLiteral( + "CFG COMMITTED token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + if (d.scenarioStage_ != DeviceController::ScenarioStage::awaiting_readback || + !d.running()) return false; + d.processLine(QStringLiteral( + "CFG READBACK token=4242 hash=ABCDEF1234567890 generation=91 boot=0123456789ABCDEF")); + return applied && d.running() && + d.scenarioStage_ == DeviceController::ScenarioStage::idle && + !d.scenarioApplying(); + } + static bool svTransactionAckChain() { Fixture f; seedScenarioAck(f); @@ -773,16 +809,18 @@ class DeterministicSessionHarness final { DeviceIoWorker worker; for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ - QStringLiteral("CFG SET IA 1000 0 0"), - QByteArrayLiteral("CFG SET IA 1000 0 0\\n"), false, true}); + i == 0 ? QStringLiteral("PTP STOP") : QStringLiteral("CFG SET IA 1000 0 0"), + i == 0 ? QByteArrayLiteral("PTP STOP\\n") : + QByteArrayLiteral("CFG SET IA 1000 0 0\\n"), false, i != 0}); } worker.exclusiveCommandsRemaining_ = DeviceIoWorker::commandQueueCapacity(); worker.queuePrioritySvStop(); const bool cancelled = worker.commandQueue_.isEmpty() && worker.exclusiveCommandsRemaining_ == 0 && - worker.svStopPending_; + worker.svStopPending_ && worker.ptpStopPending_; worker.queuePrioritySvStop(); - return cancelled && worker.svStopPending_ && worker.commandQueue_.isEmpty(); + return cancelled && worker.svStopPending_ && worker.ptpStopPending_ && + worker.commandQueue_.isEmpty(); } static bool ptpSafetyStopBypassesFullQueue() { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index eda4812ef..007d60454 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -663,7 +663,17 @@ bool DeviceController::start() { } bool DeviceController::startWithScenario(const QVariantMap& scenario) { - if (!deviceVerified_ || running_ || scenarioStage_ != ScenarioStage::idle) return false; + return submitScenario(scenario, true); +} + +bool DeviceController::applyLiveScenario(const QVariantMap& scenario) { + return submitScenario(scenario, false); +} + +bool DeviceController::submitScenario(const QVariantMap& scenario, const bool starting) { + // Exactly one source-to-wire normalization regardless of whether this is + // a stopped START or a live frame-boundary update. + if (!deviceVerified_ || running_ == starting || scenarioStage_ != ScenarioStage::idle) return false; if (!identity_.capabilities.contains(QStringLiteral("SV-CONFIG-TXN-V1"), Qt::CaseInsensitive) || identity_.bootId.size() != 16 || sessionGeneration_ == 0) { setError(QStringLiteral("Firmware does not support verified SV configuration transactions; START refused.")); @@ -755,6 +765,7 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) { // Only the immutable staging+COMMIT goes into the bounded exclusive FIFO. // START is dispatched later, after firmware ACK and independent bank readback. scenarioStage_ = ScenarioStage::awaiting_commit; + scenarioLiveUpdate_ = !starting; scenarioToken_ = token; scenarioHash_ = hash; scenarioBoot_ = identity_.bootId; @@ -1478,6 +1489,7 @@ void DeviceController::resetScenarioStart() noexcept { const bool wasPending = scenarioStage_ != ScenarioStage::idle; scenarioTimer_.stop(); scenarioStage_ = ScenarioStage::idle; + scenarioLiveUpdate_ = false; scenarioToken_ = 0; scenarioHash_ = 0; scenarioSessionGeneration_ = 0; @@ -1603,6 +1615,13 @@ void DeviceController::processLine(const QString& rawLine) { scenarioReadback.captured(3).toULongLong() != scenarioSignalGeneration_) { failScenarioStart(QStringLiteral("SV firmware readback differs from committed scenario.")); } else { + if (scenarioLiveUpdate_) { + // A live commit already atomically changed the publisher bank. + // Never restart phase, smpCnt or publisher for a live edit. + emit liveScenarioApplied(); + resetScenarioStart(); + return; + } scenarioStage_ = ScenarioStage::awaiting_start_ack; const QString hash = QString::number(scenarioHash_, 16).toUpper() .rightJustified(16, QLatin1Char('0')); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index ee22c3f63..d949178e2 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -50,6 +50,7 @@ class DeviceController : public QObject { Q_PROPERTY(QStringList capabilities READ capabilities NOTIFY deviceIdentityChanged) Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged) Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(bool scenarioApplying READ scenarioApplying NOTIFY scenarioApplicationFinished) Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged) Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged) Q_PROPERTY(QString logText READ logText NOTIFY logTextChanged) @@ -215,6 +216,7 @@ class DeviceController : public QObject { virtual bool start(); // Single validated, bounded FIFO transaction. START is always last. bool startWithScenario(const QVariantMap& scenario); + bool applyLiveScenario(const QVariantMap& scenario); bool stop(); virtual bool zero(); virtual bool setFrequency(double hz); @@ -289,6 +291,7 @@ class DeviceController : public QObject { void ptpStateChanged(); void deviceMessage(const QString& message); void scenarioApplicationFinished(); + void liveScenarioApplied(); void portReleased(quint64 generation, const QString& portName); protected: @@ -356,6 +359,7 @@ class DeviceController : public QObject { bool sendNextBinaryProfileChunk(); void resetBinaryProfileTransfer(); void failBinaryProfileDeployment(const QString& message); + bool submitScenario(const QVariantMap& scenario, bool starting); void failScenarioStart(const QString& message); void resetScenarioStart() noexcept; void resetTelemetry(); @@ -406,6 +410,7 @@ class DeviceController : public QObject { QTimer ptpStartTimer_; QTimer scenarioTimer_; ScenarioStage scenarioStage_{ScenarioStage::idle}; + bool scenarioLiveUpdate_{false}; quint64 scenarioToken_{0}; quint64 scenarioHash_{0}; quint64 scenarioSessionGeneration_{0}; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 75c4d48c4..6bd1cf59d 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -437,7 +437,13 @@ void DeviceIoWorker::queuePrioritySvStop() { // The emergency STOP slot is outside the 64-entry normal queue. // Discard remaining batch commands, including an unsent START, so // cancellation cannot accidentally complete a stale configuration. - commandQueue_.clear(); + // Do not erase the independent emergency PTP STOP when SV STOP revokes + // outstanding configuration and START commands. + while (!commandQueue_.isEmpty()) { + const auto abandoned = commandQueue_.dequeue(); + if (abandoned.text == QStringLiteral("PTP STOP")) + ptpStopPending_ = true; + } exclusiveCommandsRemaining_ = (writeActive_ && activeCommand_.exclusive) ? 1 : 0; if (!(writeActive_ && activeCommand_.text == QStringLiteral("STOP"))) svStopPending_ = true; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index 6f7d545df..f731cc919 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -316,6 +316,12 @@ bool SmartSessionController::requestStartWithScenario(const QVariantMap& scenari return device_->startWithScenario(scenario); } +bool SmartSessionController::requestApplyLiveScenario(const QVariantMap& scenario) { + if (!liveControlReady() || device_ == nullptr || !device_->running() || + device_->scenarioApplying()) return false; + return device_->applyLiveScenario(scenario); +} + bool SmartSessionController::requestStop() { if (device_ == nullptr) return false; // STOP is a cancellation authority even before SV RUNNING telemetry: an @@ -381,7 +387,7 @@ bool SmartSessionController::requestProfileSync() { } bool SmartSessionController::requestSetFrequency(const double hz) { - return liveControlReady() && device_ != nullptr && device_->setFrequency(hz); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setFrequency(hz); } bool SmartSessionController::requestSetSignal( @@ -391,7 +397,7 @@ bool SmartSessionController::requestSetSignal( const quint32 quality, const double currentCountsPerAmp, const double voltageCountsPerVolt) { - return liveControlReady() && device_ != nullptr && + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setSignal( signalId, magnitude, @@ -402,7 +408,7 @@ bool SmartSessionController::requestSetSignal( } bool SmartSessionController::requestSetQuality(const QString& signalId, const quint32 quality) { - return liveControlReady() && device_ != nullptr && device_->setQuality(signalId, quality); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setQuality(signalId, quality); } bool SmartSessionController::requestSetCtSaturation( @@ -411,7 +417,7 @@ bool SmartSessionController::requestSetCtSaturation( const double harmonicPercent, const int harmonicOrder, const double clipPercent) { - return liveControlReady() && device_ != nullptr && + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setCtSaturation( enabled, dcOffsetPercent, @@ -421,7 +427,7 @@ bool SmartSessionController::requestSetCtSaturation( } bool SmartSessionController::requestZero() { - return liveControlReady() && device_ != nullptr && device_->zero(); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->zero(); } bool SmartSessionController::requestPtpRefresh() { diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index 495f99f01..4275199e4 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -166,6 +166,7 @@ class SmartSessionController : public QObject { // can mutate device connection, output, live values, profile state or PTP. Q_INVOKABLE bool requestStart(); Q_INVOKABLE bool requestStartWithScenario(const QVariantMap& scenario); + Q_INVOKABLE bool requestApplyLiveScenario(const QVariantMap& scenario); Q_INVOKABLE bool requestStop(); Q_INVOKABLE bool requestConnect(); Q_INVOKABLE void requestRefreshPorts(); @@ -181,7 +182,7 @@ class SmartSessionController : public QObject { double currentCountsPerAmp, double voltageCountsPerVolt); Q_INVOKABLE bool requestSetEnabled(const QString& signalId, const bool enabled) { - return liveControlReady() && device_ != nullptr && device_->setEnabled(signalId, enabled); + return liveControlReady() && device_ != nullptr && !device_->running() && device_->setEnabled(signalId, enabled); } Q_INVOKABLE bool requestSetQuality(const QString& signalId, quint32 quality); Q_INVOKABLE bool requestSetCtSaturation( diff --git a/apps/arstack_studio/src/StudioDeviceController.hpp b/apps/arstack_studio/src/StudioDeviceController.hpp index f1ef34b2a..a0f4729c2 100644 --- a/apps/arstack_studio/src/StudioDeviceController.hpp +++ b/apps/arstack_studio/src/StudioDeviceController.hpp @@ -247,7 +247,7 @@ class StudioDeviceController : public DeviceController { // Profile deployment is an exclusive transport transaction. Health // observation is deferred instead of interleaving another command or // treating intentional queue exclusivity as a missed response. - if (profileDeploying()) return; + if (profileDeploying() || scenarioApplying()) return; if (healthProbeOutstanding_) { ++missedHealthReplies_; @@ -288,6 +288,11 @@ class StudioDeviceController : public DeviceController { // Preserve last-value-wins edits while firmware applies the immutable START snapshot. // Retry once at terminal transaction notification; never silently drop newer edits. if (scenarioApplying()) return true; + if (running()) { + clearPendingLiveCommands(); + emit deviceMessage(QStringLiteral("Live edits require verified atomic 4I+4V scenario commit.")); + return false; + } if (!deviceVerified()) { clearPendingLiveCommands(); return false; diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 55d7258bc..c2bb51429 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -114,8 +114,10 @@ def main() -> None: and '!root.startPtpRole("SOURCE")' in main_qml, "default PTP start must be one-shot and deliberate OFF must cancel it") require("phaseLink && row < 3" in main_qml - and "sendLinkedGroup(group) : sendSignal(group, row)" in main_qml, - "edited 3-phase angles must be transmitted from the same table model") + and "sendLinkedGroup(group) : sendSignal(group, row)" in main_qml + and "scenarioRevision += 1" in main_qml + and "Qt.callLater(root.submitLiveScenario)" in main_qml, + "edited 3-phase angles must submit the complete table through atomic live CFG") controller = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require("logUpdateTimer_.setInterval(100)" in controller and "if (!logUpdateTimer_.isActive()) logUpdateTimer_.start();" in controller, @@ -138,14 +140,31 @@ def main() -> None: "checkbox must keep one stable custom indicator on hover") require("anchors.centerIn: parent" in calm, "button caption needs horizontal centering") - require("root.sendSignal(group, row)" in main_qml + require("function submitLiveScenario()" in main_qml + and "workflowBar.session.requestApplyLiveScenario(snapshot)" in main_qml + and "onLiveScenarioApplied()" in main_qml and "Layout.fillHeight: false" in main_qml and "Layout.preferredHeight: 196" in main_qml, - "tables and live sent phases must use explicit bounded layout") + "bounded tables must map to atomic 4I4V live commits and verified readback") require('property bool ptpDefaultStartAttempted: false' in main_qml and 'root.startPtpRole("SOURCE")' in main_qml and "device.deviceVerified" in main_qml, "default PTP must reuse verified session ownership") + require("requestSetSignal(" not in main_qml + and "requestSetFrequency(" not in main_qml + and "requestSetCtSaturation(" not in main_qml + and "requestSetQuality(" not in main_qml + and "requestSetEnabled(" not in source("SignalMatrix.qml") + and "requestZero(" not in main_qml + and "root.pendingApply = false" in main_qml, + "RUNNING edits must not queue fragmented 4I4V commands or optimistic GUI success") + workflow_src = source("WorkflowBar.qml") + require("if (!ribbon.startQueued)" in workflow_src + and "controller.cancelLiveAutoApply()" in workflow_src + and "controller.activateLiveAutoApplyAfterStart()" in workflow_src + and "property bool liveApplyArmed: false" in main_qml + and "if (!device.running) root.liveApplyArmed = false" in main_qml, + "reconnect RUNNING telemetry must never auto-apply a stale 4I4V GUI draft") device_cpp = (STUDIO / "src/DeviceController.cpp").read_text(encoding="utf-8") require('commands << QStringLiteral("CFG FREQ %1")' in device_cpp and "const double phase = row.at(1).toDouble(&phaseOk);" in device_cpp diff --git a/embedded/esp32p4_smv_injector/main/live_control.cpp b/embedded/esp32p4_smv_injector/main/live_control.cpp index cfef828b1..24aa00477 100644 --- a/embedded/esp32p4_smv_injector/main/live_control.cpp +++ b/embedded/esp32p4_smv_injector/main/live_control.cpp @@ -432,8 +432,8 @@ void handle_line(char* line) noexcept { const bool valid = parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) && parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), boot, 16) && !has_extra_token(&save) && boot == g_boot_id && - !g_running.load(std::memory_order_acquire) && - g_scenario_txn.begin(token, g_signal_bank.snapshot(), monotonic_ms()); + g_scenario_txn.begin(token, g_signal_bank.snapshot(), monotonic_ms(), + g_running.load(std::memory_order_acquire)); if (!valid) { g_scenario_txn.reset(); ESP_LOGE(kTag, "CFG BEGIN rejected"); return; } ESP_LOGI(kTag, "CFG BEGIN token=%llu accepted boot=%016llX", static_cast(token), static_cast(g_boot_id)); @@ -443,7 +443,8 @@ void handle_line(char* line) noexcept { std::uint64_t token{}, hash{}; if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || !parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), hash, 16) || - has_extra_token(&save) || g_running.load(std::memory_order_acquire)) { + has_extra_token(&save) || + g_scenario_txn.began_running() != g_running.load(std::memory_order_acquire)) { g_scenario_txn.reject(); ESP_LOGE(kTag, "CFG COMMIT rejected: malformed or running"); return; } @@ -463,6 +464,7 @@ void handle_line(char* line) noexcept { std::uint64_t token{}; if (!parse_u64(strtok_r(nullptr, kTokenDelimiters.data(), &save), token, 10) || has_extra_token(&save) || + g_scenario_txn.began_running() != g_running.load(std::memory_order_acquire) || !g_scenario_txn.confirm_readback(g_signal_bank, token, monotonic_ms())) { ESP_LOGE(kTag, "CFG READBACK rejected"); return; } @@ -482,13 +484,26 @@ void handle_line(char* line) noexcept { std::strcmp(command, "ENABLE") == 0 || std::strcmp(command, "QUALITY") == 0 || std::strcmp(command, "SHAPE") == 0; if (!staged || !g_scenario_txn.staging() || - g_running.load(std::memory_order_acquire)) { + g_scenario_txn.began_running() != g_running.load(std::memory_order_acquire)) { g_scenario_txn.reject(); ESP_LOGE(kTag, "CFG rejected: no staging or invalid command"); return; } } + // Legacy per-channel edits are explicitly refused while publishing: + // each such command otherwise creates a visible intermediate 4I+4V frame. + // Full CFG draft/commit is the only RUNNING setpoint mutation path. + if (!staged && g_running.load(std::memory_order_acquire) && + (std::strcmp(command, "FREQ") == 0 || + std::strcmp(command, "SET") == 0 || + std::strcmp(command, "ENABLE") == 0 || + std::strcmp(command, "QUALITY") == 0 || + std::strcmp(command, "SHAPE") == 0)) { + ESP_LOGE(kTag, "Live edit rejected: use atomic CFG transaction"); + return; + } + if (std::strcmp(command, "HEARTBEAT") == 0) { if (has_extra_token(&save)) { ESP_LOGE(kTag, "Usage: HEARTBEAT"); diff --git a/include/ariec61850/sampled_values/live_signal_transaction.hpp b/include/ariec61850/sampled_values/live_signal_transaction.hpp index f3a12439e..b49c2e7fb 100644 --- a/include/ariec61850/sampled_values/live_signal_transaction.hpp +++ b/include/ariec61850/sampled_values/live_signal_transaction.hpp @@ -45,19 +45,21 @@ class SvScenarioTransaction final { }; [[nodiscard]] bool begin(const std::uint64_t token, const SvLiveSignalState& current, - const std::uint32_t now) noexcept { + const std::uint32_t now, const bool running = false) noexcept { reset(); if (token == 0U) return false; token_ = token; staged_ = current; began_ms_ = now; staging_ = true; + began_running_ = running; return true; } [[nodiscard]] SvLiveSignalState& draft() noexcept { return staged_; } [[nodiscard]] bool staging() const noexcept { return staging_; } [[nodiscard]] std::uint64_t token() const noexcept { return token_; } + [[nodiscard]] bool began_running() const noexcept { return began_running_; } [[nodiscard]] std::uint64_t checksum() const noexcept { return checksum_; } [[nodiscard]] std::uint64_t applied_generation() const noexcept { return generation_; } @@ -113,12 +115,13 @@ class SvScenarioTransaction final { const std::uint64_t token, const std::uint64_t hash, const std::uint32_t now) const noexcept { - return readback_seen_ && checksum_ == hash && readback_matches(bank, token, now); + return !began_running_ && readback_seen_ && checksum_ == hash && readback_matches(bank, token, now); } void consume_start() noexcept { reset(); } void reset() noexcept { staging_ = false; committed_ = false; rejected_ = false; readback_seen_ = false; + began_running_ = false; mask_ = 0; token_ = 0; checksum_ = 0; generation_ = 0; began_ms_ = 0; } @@ -137,5 +140,6 @@ class SvScenarioTransaction final { bool committed_{false}; bool rejected_{false}; bool readback_seen_{false}; + bool began_running_{false}; }; } // namespace ar::iec61850::sampled_values diff --git a/tests/test_sv_scenario_transaction.cpp b/tests/test_sv_scenario_transaction.cpp index e29921818..4c732ba9d 100644 --- a/tests/test_sv_scenario_transaction.cpp +++ b/tests/test_sv_scenario_transaction.cpp @@ -106,5 +106,36 @@ int main() { check(!tx.readback_matches(bank, 601, 105), "unexpected bank generation revokes ticket"); check(!tx.can_start(bank, 601, hash, 105), "unexpected live edit refuses START"); } + { + SvLiveSignalBank bank; + SvScenarioTransaction tx; + const auto before = bank.snapshot(); + check(populate(tx, 701, bank, 100), "running snapshot staged"); + // BEGIN on a running publisher carries an explicit live-mode flag. + check(tx.begin(702, before, 101, true), "RUNNING scenario transaction allowed"); + auto& live = tx.draft(); + live.frequency_millihz = 60000U; + tx.mark_frequency(); + for (std::size_t i = 0; i < live.channels.size(); ++i) { + live.channels[i].rms_counts = static_cast(300 + i); + live.channels[i].phase_millidegrees = static_cast(i * 12000); + live.channels[i].enabled = i != 7; + live.channels[i].quality = static_cast(100 + i); + tx.mark_set(i); + tx.mark_enabled(i); + } + live.current_shape = {true, 0, 210, 2, 890}; + tx.mark_shape(); + const auto liveHash = sv_scenario_hash(live); + check(bank.snapshot() == before, "RUNNING stage does not leak partial channel state"); + check(tx.commit(bank, 702, liveHash, 102) == SvScenarioTransaction::Result::accepted, + "RUNNING complete transaction commits atomically"); + check(bank.snapshot().frequency_millihz == 60000U && + bank.snapshot().channels[7].quality == 107U && + bank.snapshot().channels[7].enabled == false, + "all 4I4V live values published in one generation"); + check(tx.confirm_readback(bank, 702, 103), "RUNNING transaction independent readback"); + check(!tx.can_start(bank, 702, liveHash, 103), "RUNNING ticket cannot ever become START ticket"); + } return failures == 0 ? 0 : 1; }