diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 71680a0ee..5f7213f6f 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -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 diff --git a/.github/workflows/arstack-studio-qt.yml b/.github/workflows/arstack-studio-qt.yml index afd4ccb0a..97cbf821d 100644 --- a/.github/workflows/arstack-studio-qt.yml +++ b/.github/workflows/arstack-studio-qt.yml @@ -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 @@ -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 @@ -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 @@ -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: | diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 4fa8163e1..993df310e 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -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 diff --git a/apps/arstack_studio/qml/WorkflowBar.qml b/apps/arstack_studio/qml/WorkflowBar.qml index 09097c459..b113be911 100644 --- a/apps/arstack_studio/qml/WorkflowBar.qml +++ b/apps/arstack_studio/qml/WorkflowBar.qml @@ -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 } 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 @@ -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() } } @@ -313,8 +286,6 @@ SurfacePanel { controller.showMessage("Studio could not cancel the current device session.", true) return } - ribbon.startQueued = false - startWatchdog.stop() } function openEngineeringDialog() { diff --git a/apps/arstack_studio/src/ClipboardHarness.cpp b/apps/arstack_studio/src/ClipboardHarness.cpp new file mode 100644 index 000000000..b5107e620 --- /dev/null +++ b/apps/arstack_studio/src/ClipboardHarness.cpp @@ -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 +#include +#include + +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; +} diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 628ba4d4e..711613f78 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -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()}, @@ -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; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 17619f2cc..ee6a1ed76 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -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; } @@ -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. + } ptpLastStartFailure_ = QStringLiteral( "Board PTP is already RUNNING. Click Stop PTP, wait for verified STOPPED, then retry Start."); ptpStatus_ = ptpLastStartFailure_; @@ -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(); @@ -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) { @@ -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; } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index a4eb11ad5..70a555882 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -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) @@ -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); diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 41393b6eb..1d0978790 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -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 @@ -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 @@ -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") diff --git a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp index d3df56967..154b92a47 100644 --- a/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp +++ b/embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp @@ -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 { diff --git a/tests/test_ptp_canonical_authority.cpp b/tests/test_ptp_canonical_authority.cpp index b28ca061e..dd2a29394 100644 --- a/tests/test_ptp_canonical_authority.cpp +++ b/tests/test_ptp_canonical_authority.cpp @@ -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;