Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/esp32p4-smv-injector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
# On draft consolidation, do not run ordinary expensive standalone builds.
# The Studio Native Preview explicitly requests a paired firmware via
# workflow_call phase_sync_preview=true; allow only that purposeful build.
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022') }}
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft || (inputs.phase_sync_preview == true && (github.event.pull_request.head.ref == 'feat/ptp-source-policy-fsm-20261022' || github.event.pull_request.head.ref == 'fix/autolive-from-pcap-proven-7c95-20261023')) }}
runs-on: ubuntu-latest
timeout-minutes: 25
env:
Expand Down
133 changes: 97 additions & 36 deletions apps/arstack_studio/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,41 @@ ApplicationWindow {
return { version: 1, hz: signalFrequency, current: values(currentModel),
voltage: values(voltageModel), linked: phaseLink }
}
// One full 4I+4V firmware transaction per committed edit, coalesced across
// focus/Enter/context-menu events. Never emit serial commands per keystroke.
Timer {
id: autoLiveApplyTimer
interval: 40
repeat: false
onTriggered: root.drainAutoLiveEdits()
}
function scheduleAutoLiveEdit() {
if (!device.running || !device.deviceVerified || !workflowBar.injectionRunning)
return
autoLiveQueuedRevision = scenarioRevision
autoLiveApplyTimer.restart()
}
function drainAutoLiveEdits() {
if (autoLiveQueuedRevision < 0) return
if (!device.deviceVerified || !device.running || !workflowBar.injectionRunning) {
autoLiveQueuedRevision = -1
return
}
// No START or STOP: wait for the previous verified CFG COMMIT+readback
// to complete, then submit the newest snapshot, never stale drafts.
if (!workflowBar.session || !workflowBar.session.liveControlReady ||
device.scenarioApplying) return
autoLiveQueuedRevision = -1
// On rejection, retain pendingApply and surface the error. Do not
// silently retry forever or advance the claimed applied revision.
applyLiveScenario()
}
function markScenarioDirty() {
if (restoringScenario) return
saveScenarioTimer.restart()
// 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
scheduleAutoLiveEdit()
}
function restoreLocalScenario() {
const saved = scenarioSettings.lastScenario
Expand Down Expand Up @@ -132,6 +160,7 @@ ApplicationWindow {
property bool pendingApply: true
property int scenarioRevision: 0
property int liveSubmissionRevision: -1
property int autoLiveQueuedRevision: -1
// 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 @@ -228,8 +257,24 @@ ApplicationWindow {
function onDeviceVerifiedChanged() {
root.pendingApply = true
root.liveSubmissionRevision = -1
root.autoLiveQueuedRevision = -1
autoLiveApplyTimer.stop()
root.offlinePreviewRunning = false
}
function onRunningChanged() {
// An operator STOP or a firmware fault cancels unsent live edits.
// Reconnect/START must never replay edits from a previous session.
if (!device.running) {
root.autoLiveQueuedRevision = -1
autoLiveApplyTimer.stop()
}
}
function onScenarioApplicationFinished() {
// The board ACK/readback gate is now idle. If the operator edited
// another field during the transaction, drain that newest edit.
if (root.autoLiveQueuedRevision >= 0 && device.running)
autoLiveApplyTimer.restart()
}
function onLiveScenarioApplied() {
if (root.liveSubmissionRevision >= 0 &&
root.liveSubmissionRevision === root.scenarioRevision) {
Expand All @@ -245,6 +290,14 @@ ApplicationWindow {

Connections {
target: workflowBar.session
function onStateChanged() {
// If a committed edit was queued while the serial/session
// transport was briefly not ready, deliver it once the
// verified RUNNING session becomes writable again.
if (root.autoLiveQueuedRevision >= 0 && device.running &&
!device.scenarioApplying)
autoLiveApplyTimer.restart()
}
function onFirmwareUpdateFinished(success) {
// Reset during firmware install stops the board's PTP SOURCE.
// If the operator explicitly enabled it in this live Studio
Expand Down Expand Up @@ -588,10 +641,11 @@ ApplicationWindow {
: "One or more draft commands could not be queued; retry after checking the board.", !ok)
return ok
}
function startScenarioSnapshot() {
function startScenarioSnapshot(flushDrafts) {
// Flush every local draft once before serializing the canonical
// 4I+4V scenario. An invalid draft cannot be sent to the board.
if (!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits()) {
if (flushDrafts !== false &&
(!currentMatrix.flushPendingEdits() || !voltageMatrix.flushPendingEdits())) {
showMessage("Invalid signal input: correct highlighted field before Start.", true)
return null
}
Expand Down Expand Up @@ -620,7 +674,9 @@ ApplicationWindow {
function applyLiveScenario() {
if (!workflowBar.injectionRunning || !workflowBar.session ||
!workflowBar.session.liveControlReady || device.scenarioApplying) return false
const snapshot = startScenarioSnapshot()
// Live must transmit committed model values only. A second text cell
// that is still being typed remains a draft until Enter/focus-out.
const snapshot = startScenarioSnapshot(false)
if (!snapshot) return false
const revision = scenarioRevision
if (!workflowBar.session.requestApplyLiveScenario(snapshot)) {
Expand All @@ -635,16 +691,34 @@ ApplicationWindow {

function setFrequencyValue(value) {
if (!validFrequency(value)) return false
if (signalFrequency === value) {
frequencyField.text = value.toFixed(3)
frequencyField.frequencyDirty = false
frequencyField.invalidInput = false
return true
}
signalFrequency = value
if (value > 0) previousAcFrequency = value
frequencyField.text = value.toFixed(3)
frequencyField.frequencyDirty = false
frequencyField.invalidInput = false
refreshPreview()
markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !pendingApply)
workflowBar.session.requestSetFrequency(value)
return true
}
function commitFrequencyEditor() {
if (!frequencyField.frequencyDirty) return true
const value = parseOperatorNumber(frequencyField.text)
if (!validFrequency(value)) {
frequencyField.text = signalFrequency.toFixed(3)
frequencyField.invalidInput = false
frequencyField.frequencyDirty = false
showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)
return false
}
frequencyField.frequencyDirty = false
return setFrequencyValue(value)
}

function setWaveformMode(mode) {
if (mode === "DC") {
Expand Down Expand Up @@ -876,7 +950,8 @@ ApplicationWindow {
Label {
text: root.pendingApply
? (workflowBar.injectionRunning
? "4I + 4V · Pending · Apply live"
? (device.scenarioApplying ? "4I + 4V · Applying live..." :
"4I + 4V · Auto-apply pending")
: "4I + 4V · Setpoints pending · Start to apply")
: "4I + 4V · 4000 samples/s"
color: root.pendingApply
Expand Down Expand Up @@ -1142,18 +1217,13 @@ ApplicationWindow {
Label { text: "4I + 4V Injection"; color: studioTheme.text; font.family: root.uiFont; font.pixelSize: 17; font.weight: Font.DemiBold }
}
Item { Layout.fillWidth: true }
// START still uses the visible setpoints.
// Apply live is shown only while SV is running.
CalmButton {
Label {
visible: workflowBar.injectionRunning
theme: studioTheme
uiFont: root.uiFont
tone: "accent"
text: device.scenarioApplying ? "Verifying..." : "Apply live"
enabled: root.pendingApply && !device.scenarioApplying &&
workflowBar.session && workflowBar.session.liveControlReady
toolTipText: "Apply all 4I+4V values without restarting SV"
onClicked: root.applyLiveScenario()
text: device.scenarioApplying ? "LIVE · VERIFYING" : "LIVE · AUTO"
color: device.scenarioApplying ? studioTheme.amber : studioTheme.green
font.family: root.uiFont
font.pixelSize: 10
font.weight: Font.DemiBold
}
}

Expand Down Expand Up @@ -1217,25 +1287,16 @@ ApplicationWindow {
suffixText: "Hz"
enabled: true
validator: DoubleValidator { bottom: 0.0; top: 1000.0; decimals: 3 }
property bool frequencyDirty: false
onTextEdited: {
var value = root.parseOperatorNumber(text)
if (root.validFrequency(value)) {
invalidInput = false
root.signalFrequency = value
if (value > 0) root.previousAcFrequency = value
root.refreshPreview()
root.markScenarioDirty()
if (workflowBar.session && workflowBar.session.liveControlReady && !root.pendingApply)
workflowBar.session.requestSetFrequency(value)
} else invalidInput = true
frequencyDirty = true
invalidInput = !root.validFrequency(
root.parseOperatorNumber(text))
}
onEditingFinished: {
var value = root.parseOperatorNumber(text)
if (!root.validFrequency(value)) {
text = root.signalFrequency.toFixed(3)
invalidInput = false
root.showMessage("Frequency must be within 0..1000 Hz (0 = DC).", true)
} else text = value.toFixed(3)
onEditingFinished: root.commitFrequencyEditor()
onAccepted: root.commitFrequencyEditor()
onActiveFocusChanged: {
if (!activeFocus) root.commitFrequencyEditor()
}
}

Expand Down
5 changes: 4 additions & 1 deletion apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ SurfacePanel {
property bool startIntentPending: false
property string startIntentBoot: ""
property double startIntentDeadline: 0
// A RUNNING live CFG transaction is not a START. Keep 4I4V editors
// interactive while firmware verifies it; new edits coalesce and drain.
readonly property bool startQueued: Boolean(
startIntentPending || (ribbon.device && ribbon.device.scenarioApplying))
startIntentPending || (ribbon.device && ribbon.device.scenarioApplying &&
!ribbon.device.running))
Timer {
id: startIntentExpiry
interval: 6500
Expand Down
43 changes: 33 additions & 10 deletions apps/arstack_studio/src/SvCaptureController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,10 @@ void SvCaptureController::sampleTelemetry(const QVariantMap& passiveSnapshot) {
"stop cause; packets recorded before STOP are only partial evidence.");
abortRequested_ = true;
capture_.terminate();
setStatus(QStringLiteral("CAPTURE INVALID: ") + captureInterruptionReason_);
QTimer::singleShot(2500, &capture_, [this] {
if (capture_.state() != QProcess::NotRunning) capture_.kill();
});
setStatus(QStringLiteral("CAPTURE INTERRUPTED: ") + captureInterruptionReason_);
}
}
void SvCaptureController::abortCapture() {
Expand All @@ -391,9 +394,17 @@ void SvCaptureController::onCaptureFinished(int exitCode, QProcess::ExitStatus e
const QString reason = captureInterruptionReason_.isEmpty()
? QStringLiteral("Capture manually canceled before completion.")
: captureInterruptionReason_;
writeInvalidCaptureReport(reason);
setStatus(QStringLiteral("CAPTURE INVALID: ") + reason +
QStringLiteral(" See _report.json."));
// A partial PCAP still carries independent physical SV/PTP evidence.
// Decode it to all five outputs and preserve INVALID_CAPTURE status;
// never replace genuine wire frame counts with the placeholder zero.
captureInterruptionReason_ = reason;
if (QFileInfo(activePcap_).size() >= 64) {
decodeCapturedFile();
} else {
writeInvalidCaptureReport(reason);
setStatus(QStringLiteral("CAPTURE INVALID: ") + reason +
QStringLiteral(" See _report.json."));
}
return;
}
if (exitCode != 0 || exitStatus != QProcess::NormalExit ||
Expand All @@ -414,7 +425,8 @@ void SvCaptureController::decodeCapturedFile() {
setStatus(QStringLiteral("Writing filtered SV CSV and JSON report (off the UI thread)..."));
decoder_.setFuture(QtConcurrent::run(&SvCaptureController::decodePcap,
activePcap_, activeCsv_, activeSeconds_, activeEvents_, activeJson_, activeSecondsRequested_, activeAdapter_,
activeStudioTelemetry_, activeStudioTelemetrySeries_));
activeStudioTelemetry_, activeStudioTelemetrySeries_,
captureInterruptionReason_));
emit stateChanged();
}
void SvCaptureController::openOutputFolder() {
Expand All @@ -432,7 +444,8 @@ QVariantMap SvCaptureController::decodePcap(
const QString& pcapFile, const QString& csvFile, const QString& secondsFile,
const QString& eventsFile, const QString& jsonFile, int requestedSeconds, const QString& adapter,
const QVariantMap& studioTelemetry,
const QVariantList& studioTelemetrySeries) {
const QVariantList& studioTelemetrySeries,
const QString& interruptionReason) {
QVariantMap result;
result.insert(QStringLiteral("json"), jsonFile);
result.insert(QStringLiteral("status"), QStringLiteral("Capture failed: PCAP parser error."));
Expand Down Expand Up @@ -795,10 +808,17 @@ QVariantMap SvCaptureController::decodePcap(
const bool usable = parseFailure.isEmpty() && matched > 0;
const bool anomalies = missing > 0 || duplicates > 0 ||
malformed > 0 || malformedPtp > 0 || backwardOrResetEvents > 0;
report.insert(QStringLiteral("status"), !usable ? QStringLiteral("INVALID_CAPTURE")
report.insert(QStringLiteral("status"), !usable || !interruptionReason.isEmpty()
? QStringLiteral("INVALID_CAPTURE")
: anomalies ? QStringLiteral("CAPTURED_WITH_ANOMALIES")
: QStringLiteral("CAPTURED_NOT_ACCEPTED"));
if (!parseFailure.isEmpty()) report.insert(QStringLiteral("error"), parseFailure);
if (!interruptionReason.isEmpty()) {
report.insert(QStringLiteral("error"), interruptionReason);
report.insert(QStringLiteral("capture_interrupted"), true);
report.insert(QStringLiteral("partial_wire_evidence"), true);
} else if (!parseFailure.isEmpty()) {
report.insert(QStringLiteral("error"), parseFailure);
}
QFile json(jsonFile);
if (!json.open(QIODevice::WriteOnly | QIODevice::Truncate) ||
json.write(QJsonDocument::fromVariant(report).toJson(QJsonDocument::Indented)) < 0) {
Expand All @@ -820,8 +840,11 @@ QVariantMap SvCaptureController::decodePcap(
});
}
result.insert(QStringLiteral("status"), usable
? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. "
"Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.")
? (interruptionReason.isEmpty()
? QStringLiteral("Capture saved: %1 SV / %2 PTP frames, %3 missing, %4 sync transitions. "
"Status: %5. Share report.json + events.csv first; keep PCAP for deep analysis.")
: QStringLiteral("INVALID/INTERRUPTED: %1 SV / %2 PTP frames, %3 gaps, %4 sync transitions. "
"Saved five partial-evidence files; do not treat as 120s continuous pass."))
.arg(matched).arg(ptpFrames).arg(missing).arg(syncTransitions)
.arg(anomalies ? QStringLiteral("ANOMALIES") : QStringLiteral("NOT PHYSICAL PASS"))
: formatError(QStringLiteral("Capture validation failed"),
Expand Down
3 changes: 2 additions & 1 deletion apps/arstack_studio/src/SvCaptureController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ class SvCaptureController : public QObject {
const QString& secondsFile, const QString& eventsFile, const QString& jsonFile,
int requestedSeconds, const QString& adapter,
const QVariantMap& studioTelemetry,
const QVariantList& studioTelemetrySeries);
const QVariantList& studioTelemetrySeries,
const QString& interruptionReason = QString());
QProcess discovery_;
QProcess capture_;
QFutureWatcher<QVariantMap> decoder_;
Expand Down
Loading
Loading