Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b4c4327
fix(studio): expose authoritative SV scenario transaction state to QML
masarray Oct 20, 2026
870ea88
fix(studio): signal SV START transaction lifecycle only from device c…
masarray Oct 20, 2026
efc90b2
fix(studio): remove competing injection START watchdog and unrelated-…
masarray Oct 20, 2026
b70074f
test(studio): assert native single-owner SV START state and remove UI…
masarray Oct 20, 2026
9169f89
fix(ptp): adopt readback-matched active SOURCE without restart and pr…
masarray Oct 20, 2026
cbd8aba
test(ptp): prove active runtime adoption and never-fake-OFF on stale-…
masarray Oct 20, 2026
bd922da
test(clipboard): consolidate native OS readback regression from PR 266
masarray Oct 20, 2026
c987bd5
ci(studio): port production clipboard harness from PR 266 to consolid…
masarray Oct 20, 2026
cc1e6c0
ci(studio): port production clipboard harness from PR 266 to consolid…
masarray Oct 20, 2026
af25366
ci(preview): require native clipboard roundtrip alongside installed G…
masarray Oct 20, 2026
0300fba
fix(ptp firmware): report physical SOURCE runtime separately from fre…
masarray Oct 20, 2026
927da08
test(ptp): active SOURCE runtime never licenses stale/unqualified tim…
masarray Oct 20, 2026
4871e8d
test(studio): structurally guard separation of PTP physical runtime a…
masarray Oct 20, 2026
e34206c
test(studio): reference repo-root firmware facade via resolved Studio…
masarray Oct 20, 2026
6c97195
fix(studio): atomic-visible SV START ACK handoff prevents transient b…
masarray Oct 20, 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
4 changes: 3 additions & 1 deletion .github/workflows/arstack-studio-native-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ jobs:
run: |
cmake -S apps/arstack_studio -B build-studio-preview -G "Visual Studio 17 2022" -A x64 -DARIEC61850_WARNINGS_AS_ERRORS=ON -DARSTACK_STUDIO_ENGINEERING_PREVIEW=ON
if ($LASTEXITCODE -ne 0) { throw 'Preview configuration failed' }
cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness --parallel 2
cmake --build build-studio-preview --config Release --target arstack_studio arstack_session_harness arstack_clipboard_harness --parallel 2
if ($LASTEXITCODE -ne 0) { throw 'Native Studio preview build failed' }
& "$PWD\build-studio-preview\Release\arstack_session_harness.exe"
if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' }
& "$PWD\build-studio-preview\Release\arstack_clipboard_harness.exe"
if ($LASTEXITCODE -ne 0) { throw 'Production native clipboard readback regression failed' }
# Differentiate product code regressions from missing deployed DLLs.
$nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe'
$probe = Start-Process -FilePath $nativeGui -ArgumentList '--check-reference-template' -Wait -PassThru
Expand Down
22 changes: 20 additions & 2 deletions .github/workflows/arstack-studio-qt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
shell: powershell
run: |
cmake --build build-arstack-studio --config Release `
--target arstack_session_harness arstack_windows_ownership_harness --parallel
--target arstack_session_harness arstack_clipboard_harness arstack_windows_ownership_harness --parallel

- name: S8A deterministic session harness on Windows
timeout-minutes: 1
Expand All @@ -98,6 +98,16 @@ jobs:
throw "S8A Windows deterministic harness failed with exit code $exitCode."
}

- name: Native Windows clipboard roundtrip regression
timeout-minutes: 1
shell: powershell
run: |
$exe = Join-Path $PWD 'build-arstack-studio\Release\arstack_clipboard_harness.exe'
& $exe
if ($LASTEXITCODE -ne 0) {
throw "Native Windows OS clipboard readback failed: exit $LASTEXITCODE"
}

- name: S8B cross-process ownership and serial-loss harness
timeout-minutes: 1
shell: powershell
Expand Down Expand Up @@ -216,7 +226,7 @@ jobs:
run: |
set -o pipefail
cmake --build build-arstack-studio \
--target arstack_studio arstack_session_harness arstack_capture_harness --parallel 2 \
--target arstack_studio arstack_session_harness arstack_capture_harness arstack_clipboard_harness --parallel 2 \
2>&1 | tee /tmp/arstack-studio-build.log

- name: 4I+4V reference template regression
Expand All @@ -240,6 +250,14 @@ jobs:
timeout 20s ./build-arstack-studio/arstack_session_harness \
2>&1 | tee /tmp/arstack-studio-session-harness.log

- name: Native GUI clipboard content/readback regression
shell: bash
run: |
set -o pipefail
timeout 15s env QT_QPA_PLATFORM=offscreen \
./build-arstack-studio/arstack_clipboard_harness \
2>&1 | tee /tmp/arstack-studio-clipboard-harness.log

- name: Native SV PCAP capture and CSV decoder regression
shell: bash
run: |
Expand Down
10 changes: 10 additions & 0 deletions apps/arstack_studio/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ qt_add_executable(arstack_session_harness
)

# Offline production wire decoder regression (no Npcap or hardware required).
# Isolated platform GUI clipboard verification (same source linked in Studio).
# No QML stubs, no fake service: tests actual QGuiApplication OS clipboard.
qt_add_executable(arstack_clipboard_harness
src/ClipboardHarness.cpp
src/StudioClipboard.cpp
src/StudioClipboard.hpp
)
target_link_libraries(arstack_clipboard_harness PRIVATE Qt6::Core Qt6::Gui)
target_compile_features(arstack_clipboard_harness PRIVATE cxx_std_20)

qt_add_executable(arstack_capture_harness
src/SvCaptureHarness.cpp
src/SvCaptureController.cpp
Expand Down
39 changes: 5 additions & 34 deletions apps/arstack_studio/qml/WorkflowBar.qml
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,16 @@ SurfacePanel {
property string uiFont: "Inter"
property string monoFont: "Inter"
property bool compact: false
property bool startQueued: false
Timer {
id: startWatchdog
interval: 8000
repeat: false
onTriggered: {
if (!ribbon.startQueued) return
ribbon.startQueued = false
controller.pendingApply = true
controller.showMessage("START not confirmed by ESP32-P4. Check board status.", true)
}
}
// Firmware transaction is the single source of truth. UI timers and
// unrelated PTP/serial errors must not bounce the injection START button.
readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)
Connections {
target: ribbon.device
function onRunningChanged() {
if (ribbon.device.running) {
ribbon.startQueued = false
startWatchdog.stop()
controller.pendingApply = false
}
}
function onLastErrorChanged() {
if (!ribbon.startQueued) return
ribbon.startQueued = false
startWatchdog.stop()
controller.pendingApply = true
if (ribbon.device.running) controller.pendingApply = false
Comment on lines 23 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Restore the pending marker after a failed START transaction

When pendingApply is already false after a previous successful run, a subsequent CFG commit/readback/START rejection or timeout now only clears scenarioApplying; because the broad error handler was removed and this connection only handles successful RUNNING evidence, pendingApply remains false. After such a failure the status strip incorrectly says the setpoints are applied even when the transaction failed before commit, so the authoritative transaction failure path needs to mark the draft pending without reacting to unrelated device errors.

Useful? React with 👍 / 👎.

}
function onDeviceVerifiedChanged() {
if (ribbon.device.deviceVerified) return
ribbon.startQueued = false
startWatchdog.stop()
controller.pendingApply = true
if (!ribbon.device.deviceVerified) controller.pendingApply = true
}
}
property bool updatePromptDeferred: false
Expand Down Expand Up @@ -297,11 +275,6 @@ SurfacePanel {
if (!accepted) {
controller.pendingApply = true
controller.showMessage("Start rejected. Check device status.", true)
} else {
// A successful queue call is NOT the board START ACK.
// Keep the draft dirty until RUNNING evidence arrives.
ribbon.startQueued = true
startWatchdog.restart()
}
}

Expand All @@ -313,8 +286,6 @@ SurfacePanel {
controller.showMessage("Studio could not cancel the current device session.", true)
return
}
ribbon.startQueued = false
startWatchdog.stop()
}

function openEngineeringDialog() {
Expand Down
34 changes: 34 additions & 0 deletions apps/arstack_studio/src/ClipboardHarness.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Native GUI clipboard contract: exercise the SAME production adapter as Studio.
#include "StudioClipboard.hpp"

#include <QClipboard>
#include <QGuiApplication>
#include <QString>

int main(int argc, char** argv) {
QGuiApplication app(argc, argv);
StudioClipboard clipboard;
auto* osClipboard = QGuiApplication::clipboard();
if (osClipboard == nullptr) return 2;

if (clipboard.copyText(QString{})) return 3; // no fabricated success
constexpr auto kHeader = "ARStack Studio field diagnostics";
const QString evidence = QString::fromUtf8(
"ARStack Studio field diagnostics\n"
"Installed firmware: df73fea0b68e7f47\n"
"PTP START error: PTPROLE rejected: stop PTP before changing operating role\n"
"SVSYNC event: ETHERNET_LINK_LOSS\n");
if (!evidence.startsWith(QString::fromLatin1(kHeader))) return 4;
if (!clipboard.copyText(evidence)) return 5;
if (osClipboard->text(QClipboard::Clipboard) != evidence) return 6;

const QString second = QString::fromUtf8(
"PTP diagnostic: SOURCE running\n"
"MAC phase ±2 us\n"
"SVSYNC ACQUIRED\n");
if (!clipboard.copyText(second)) return 7;
if (osClipboard->text(QClipboard::Clipboard) != second) return 8;
if (osClipboard->text(QClipboard::Clipboard) == evidence) return 9;
return 0;
}
40 changes: 40 additions & 0 deletions apps/arstack_studio/src/DeterministicSessionHarness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class DeterministicSessionHarness final {
{"stale generation release -> ignored", staleReleaseGenerationIsIgnored()},
{"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()},
{"PTP preflight readback guards active board and deferred role commands", ptpBoardRuntimePreflight()},
{"PTP preflight adopts same physical role with no disruptive STOP", ptpAlreadyActiveMatchingRole()},
{"PTP stale-role rejection never fabricates OFF", ptpRoleRejectPreservesBoardRunning()},
{"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()},
{"PTP SOURCE invalid 9-3 profile rejects before serial", ptpSourceProfilePreflight()},

Expand Down Expand Up @@ -782,6 +784,44 @@ class DeterministicSessionHarness final {
d.ptpStatus_.contains(QStringLiteral("external timing still unqualified"));
}

static bool ptpAlreadyActiveMatchingRole() {
Fixture f;
if (!f.profileReady) return false;
seedVerified(f, identity(), QStringLiteral("COM7"), false);
auto& d = f.device;
d.ptpAvailable_ = true;
d.ioWorkerReady_ = true;
if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) ||
d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_preflight)
return false;
d.processLine(ptpRoleEvidence(QStringLiteral("SOURCE")));
if (!d.ptpStarting()) return false;
d.processLine(QStringLiteral(
"PTP status=RUNNING Announce=10 Sync=10 FollowUp=10 PdelayFrames=0 TXfail=0"));
return !d.ptpStarting() && d.ptpRunning_ &&
d.ptpLastStartFailure_.isEmpty() &&
d.ptpStatus_.contains(QStringLiteral("already active")) &&
d.ptpStartPhase() == QStringLiteral("IDLE");
}

static bool ptpRoleRejectPreservesBoardRunning() {
Fixture f;
if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false;
auto& d = f.device;
d.processLine(QStringLiteral(
"E ar_smv_profile: PTPROLE rejected: stop PTP before changing operating role"));
if (d.ptpStarting() || !d.ptpRunning_ ||
!d.ptpLastStartFailure_.contains(QStringLiteral("PTPROLE rejected")) ||
!d.ptpStatus_.contains(QStringLiteral("active on board")))
return false;
if (!f.session.requestStopPtp()) return false;
d.processLine(QStringLiteral("PTP stop requested"));
d.processLine(ptpStoppedStatus());
return !d.ptpStarting() && !d.ptpRunning_ &&
d.ptpLastStartFailure_.contains(QStringLiteral("PTPROLE rejected")) &&
f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile());
}

static bool ptpBoardRuntimePreflight() {
Fixture running;
if (!running.profileReady) return false;
Expand Down
37 changes: 35 additions & 2 deletions apps/arstack_studio/src/DeviceController.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,7 @@ bool DeviceController::startWithScenario(const QVariantMap& scenario) {
scenarioProfileGeneration_ = profileGeneration_;
if (!sendCommandBatch(commands, false, true)) { resetScenarioStart(); return false; }
scenarioTimer_.start();
emit scenarioStateChanged();
signalFrequencyHz_ = hz;
return true;
}
Expand Down Expand Up @@ -1436,9 +1437,22 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole,
void DeviceController::advancePtpStartup(const QString& line) {
if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return;
if (ptpStartStage_ == PtpStartStage::awaiting_preflight) {
// PTP SHOW provides measured PTP2 role before status. Only adopt an
// already-active transmitter if THIS preflight saw the expected role;
// historical UI role cannot authorize mutation or claim an exact role.
const auto roleProof = kPtpMeasuredStateExpression.match(line);
if (roleProof.hasMatch())
ptpRuntimeRoleSeen_ = roleProof.captured(1).toUpper() == pendingPtpRole_;
const auto actual = kPtpStatusExpression.match(line);
if (!actual.hasMatch()) return;
if (actual.captured(1).compare(QStringLiteral("RUNNING"), Qt::CaseInsensitive) == 0) {
if (ptpRuntimeRoleSeen_) {
const QString existingRole = pendingPtpRole_;
resetPtpStartup();
ptpStatus_ = QStringLiteral("PTP %1 already active on board; external sync unqualified").arg(existingRole);
emit ptpStateChanged();
return; // No redundant role/config/start, no surprise STOP.
Comment on lines +1449 to +1454

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 Verify the active configuration before adopting it

When reconnecting to an already-running board whose role matches the requested role but whose domain, transportSpecific, VLAN, or cadence differs, this branch resets the startup transaction and reports the runtime as adopted without ever comparing pendingPtpConfig_ with the PTP config readback that follows the status line. The configured-start request therefore silently leaves the board transmitting with settings different from those selected in the expert panel; wait for and validate the configuration readback, or reject the adoption and require a verified stop.

AGENTS.md reference: AGENTS.md:L11-L18

Useful? React with 👍 / 👎.

}
ptpLastStartFailure_ = QStringLiteral(
"Board PTP is already RUNNING. Click Stop PTP, wait for verified STOPPED, then retry Start.");
ptpStatus_ = ptpLastStartFailure_;
Expand Down Expand Up @@ -1496,6 +1510,19 @@ void DeviceController::advancePtpStartup(const QString& line) {
line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) ||
line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive);
if (rejected) {
if (line.contains(QStringLiteral("PTPROLE rejected: stop PTP before changing operating role"),
Qt::CaseInsensitive)) {
// A physical role rejection is positive evidence the timing runtime
// is active. Do not reset it to OFF, and never auto-stop a source
// that the current Studio transaction has not proven it owns.
ptpLastStartFailure_ = QStringLiteral("Board PTP is still RUNNING: ") + line;
resetPtpStartup();
ptpRunning_ = true;
ptpStatus_ = QStringLiteral("PTP active on board; verify STOP before changing role");
emit ptpStateChanged();
setError(ptpLastStartFailure_);
return;
}
const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start ||
ptpStartStage_ == PtpStartStage::awaiting_runtime;
const QString step = ptpStartPhase();
Expand Down Expand Up @@ -1693,7 +1720,10 @@ void DeviceController::resetScenarioStart() noexcept {
scenarioSignalGeneration_ = 0;
scenarioBoot_.clear();
scenarioProfileGeneration_.clear();
if (wasPending) emit scenarioApplicationFinished();
if (wasPending) {
emit scenarioStateChanged();
emit scenarioApplicationFinished();
}
}

void DeviceController::failScenarioStart(const QString& message) {
Expand Down Expand Up @@ -1864,8 +1894,11 @@ void DeviceController::processLine(const QString& rawLine) {
failScenarioStart(QStringLiteral("Unexpected SV START ACK identity/hash."));
return;
}
resetScenarioStart();
// Publish verified RUNNING before clearing the pending START phase.
// Otherwise QML can briefly see neither RUNNING nor STARTING and
// bounce the button back to START during a successful ACK handoff.
setRunning(true);
resetScenarioStart();
return;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/arstack_studio/src/DeviceController.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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 scenarioStateChanged)
Q_PROPERTY(QString portName READ portName NOTIFY connectedChanged)
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
Q_PROPERTY(QString logText READ logText NOTIFY logTextChanged)
Expand Down Expand Up @@ -302,6 +303,7 @@ class DeviceController : public QObject {
void profileDeploymentProgress();
void ptpStateChanged();
void deviceMessage(const QString& message);
void scenarioStateChanged();
void scenarioApplicationFinished();
void portReleased(quint64 generation, const QString& portName);

Expand Down
14 changes: 12 additions & 2 deletions apps/arstack_studio/tests/check_operator_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ def main() -> None:
require('bar.workflow.injectionRunning || bar.workflow.startQueued || bar.controller.offlinePreviewRunning'
in ribbon_qml
and '!ribbon.injectionRunning && !ribbon.startQueued' in source("WorkflowBar.qml")
and 'startWatchdog.stop()' in source("WorkflowBar.qml"),
and 'readonly property bool startQueued: Boolean(ribbon.device && ribbon.device.scenarioApplying)' in source("WorkflowBar.qml")
and 'startWatchdog' not in source("WorkflowBar.qml"),
"SV STOP must remain actionable while the acknowledged scenario transaction is pending")
require("function flushPendingEdits()" in matrix
and "magnitudeApplyTimer.stop()" in matrix
Expand Down Expand Up @@ -312,7 +313,9 @@ def main() -> None:
and "const scenario = controller.startScenarioSnapshot()" in workflow
and "if (!scenario) return" in workflow
and "controller.applyAllSignals()" not in workflow
and "startWatchdog" in workflow and "onRunningChanged()" in workflow,
and "readonly property bool startQueued:" in workflow
and "scenarioStateChanged" in device_cpp
and "onRunningChanged()" in workflow,
"reconnect must not auto-transmit, but one-click Start must stage a bounded scenario")
require("offline-ptp-lab" in tray and "if (!tray.boardOnline)" in tray
and "PTP preview only. Connect ESP32-P4 to transmit." in tray
Expand Down Expand Up @@ -493,6 +496,13 @@ def main() -> None:
"Timing Copy diagnostics must use imported GUI singleton, verify native clipboard, "
"and offer full selectable manual fallback")

ptp_facade = (STUDIO.parents[1] / "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp").read_text(encoding="utf-8")
require("const bool source_clock_qualified = " in ptp_facade
and "status.is_running = ok && ar_ptp_source_runtime_active();" in ptp_facade
and "status.timing_authority_clock_usable = source_clock_qualified;" in ptp_facade,
"PTP SOURCE physical RUNNING must survive unqualified clock freshness; "
"canonical timing authority must remain independently fail-closed")

print("Studio operator workspace contract: PASS")


Expand Down
11 changes: 8 additions & 3 deletions embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -334,10 +334,15 @@ namespace {
const auto after = ar_ptp_source_generation();
status.role = AR_PTP_ROLE_LAB_SOURCE;
status.discipline_state = AR_PTP_DISCIPLINE_UNLOCKED;
status.is_running = ok && status.is_running &&
// Operator RUNNING is physical runtime ownership, not a timing LOCK.
// Freshness may lapse while the SOURCE task continues to emit frames.
// A transient freshness miss must revoke its clock lease WITHOUT
// telling Studio that a still-active transmitter has stopped.
const bool source_clock_qualified = ok && status.is_running &&
ar_ptp_source_is_running() && before != 0U && before == after;
status.timing_authority_generation = status.is_running ? after : 0U;
status.timing_authority_clock_usable = status.is_running;
status.is_running = ok && ar_ptp_source_runtime_active();
status.timing_authority_generation = source_clock_qualified ? after : 0U;
status.timing_authority_clock_usable = source_clock_qualified;
} else if (ptp_receiver_runtime_active()) {
ok = ptp_receiver_get_status(status);
} else {
Expand Down
11 changes: 11 additions & 0 deletions tests/test_ptp_canonical_authority.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ int main() {
CHECK(!source.selected_source);
e.runtime_ready = false;
CHECK(!canonical_ptp_authority(e).clock_usable);
// Physical SOURCE task may remain RUNNING when its timestamp freshness
// expires. UI runtime is true, but the canonical timing lease must
// remain fail-closed until fresh evidence restores the clock.
e.runtime_ready = true;
e.generation = 0U;
e.clock_usable_claim = false;
CHECK(!canonical_ptp_authority(e).clock_usable);
e.generation = 18U; // even a stale nonzero generation is insufficient
CHECK(!canonical_ptp_authority(e).clock_usable);
e.clock_usable_claim = true;
CHECK(canonical_ptp_authority(e).clock_usable);

e.role = PtpAuthorityRole::receiver;
e.runtime_ready = true;
Expand Down
Loading