Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0e401f6
feat(core): tag transaction as running-live or stopped-start, prohibi…
masarray Oct 19, 2026
a04dbf9
feat(firmware): atomically publish 4I4V CFG while running; reject non…
masarray Oct 19, 2026
560fd8d
feat(studio): reuse one scenario serializer and ACK pipeline for STAR…
masarray Oct 19, 2026
73584b5
feat(studio): verified live CFG applies without restarting SV publish…
masarray Oct 19, 2026
64f979f
feat(supervisor): expose atomic live scenario intent via existing ses…
masarray Oct 19, 2026
4be0faa
feat(supervisor): gate live apply by verified running session without…
masarray Oct 19, 2026
dd5c6c6
fix(studio): exclude intentional CFG apply from health-probe false re…
masarray Oct 19, 2026
2a31101
feat(qml): send complete 4I4V live snapshot via confirmed CFG with re…
masarray Oct 19, 2026
6127c50
feat(qt): expose read-only in-flight CFG status to QML for safe live-…
masarray Oct 19, 2026
f2ea2de
fix(qml): rebase live edits only after positive ACK; prevent STOP/tim…
masarray Oct 19, 2026
20fa93e
fix(qml): preserve edits newer than START snapshot and suppress live …
masarray Oct 19, 2026
318ca99
fix(serial): SV emergency STOP must never discard queued independent …
masarray Oct 19, 2026
19d2f90
test(core): running 4I4V live update atomicity, exact quality/enable,…
masarray Oct 19, 2026
67776e0
test(studio): LIVE ACK cannot send START; SV emergency STOP retains q…
masarray Oct 19, 2026
9ffc7ac
fix(supervisor): forbid legacy fragmented live mutations during runni…
masarray Oct 19, 2026
a2ccabe
fix(supervisor): disallow nontransactional ENABLE intent while SV pub…
masarray Oct 19, 2026
0e71eea
fix(studio): prevent deferred legacy SET flush from bypassing RUNNING…
masarray Oct 19, 2026
1a20850
test(studio): negative regression forbids fragmented RUNNING GUI intents
masarray Oct 19, 2026
332b7b0
fix(firmware): reject live CFG readback when publisher has stopped si…
masarray Oct 19, 2026
6f8ac27
fix(qml): channel enabled toggle follows atomic full-snapshot live au…
masarray Oct 19, 2026
fab62df
test(qml): enforce atomic running CFG for all setpoint controls, no o…
masarray Oct 19, 2026
9e54620
test(studio): read SignalMatrix source explicitly before late layout …
masarray Oct 19, 2026
82aab92
fix(qml): never arm automatic live waveform changes from unsolicited …
masarray Oct 19, 2026
96c98fe
fix(qml): arm live commit only after current operator START ACK, not …
masarray Oct 19, 2026
a858967
test(qml): reconnect RUNNING cannot authorize automatic live commit w…
masarray Oct 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 81 additions & 42 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
}

Expand All @@ -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)
Expand Down Expand Up @@ -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() {
Expand All @@ -639,17 +688,15 @@ 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) {
var unsignedValue = Number(value) >>> 0
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() }
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions apps/arstack_studio/qml/SignalMatrix.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

Expand Down
14 changes: 13 additions & 1 deletion apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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()
}
Expand All @@ -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
Expand Down
46 changes: 42 additions & 4 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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()},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down
21 changes: 20 additions & 1 deletion apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +669 to +670

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Negotiate live CFG support separately

When Studio connects to a previous same-version build, that build remains compatible because build mismatch is advisory and it already advertises SV-CONFIG-TXN-V1, but it rejects CFG BEGIN while running. This new method therefore submits an unsupported live batch; processLine() does not recognize CFG BEGIN rejected as terminal, so eight seconds later failScenarioStart() sends STOP, unexpectedly terminating an otherwise valid injection on its first edit. Conversely, the new firmware retains LIVE-SETPOINTS while rejecting the legacy running SET path used by older Studio builds. Add a distinct capability or bump the protocol/version, then gate both behaviors on it.

AGENTS.md reference: AGENTS.md:L235-L236

Useful? React with 👍 / 👎.

}

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."));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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'));
Expand Down
Loading
Loading