From b4c432752b7b1a5cee3a75a77a83e3600c0b2c57 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:21:18 +0700 Subject: [PATCH 01/15] fix(studio): expose authoritative SV scenario transaction state to QML --- apps/arstack_studio/src/DeviceController.hpp | 2 ++ 1 file changed, 2 insertions(+) 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); From 870ea881d6d5af544e351b13c76ca0f70f153337 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:21:22 +0700 Subject: [PATCH 02/15] fix(studio): signal SV START transaction lifecycle only from device coordinator --- apps/arstack_studio/src/DeviceController.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 17619f2cc..82e1ebbb4 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; } @@ -1693,7 +1694,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) { From efc90b2e36b1b975d89e764552ca79cc3b73e902 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:21:57 +0700 Subject: [PATCH 03/15] fix(studio): remove competing injection START watchdog and unrelated-error bounce --- apps/arstack_studio/qml/WorkflowBar.qml | 39 ++++--------------------- 1 file changed, 5 insertions(+), 34 deletions(-) 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() { From b70074f5e411142eef19609b7e70aea636a08b33 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:22:35 +0700 Subject: [PATCH 04/15] test(studio): assert native single-owner SV START state and remove UI watchdog --- apps/arstack_studio/tests/check_operator_workspace.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 41393b6eb..1387e4310 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 From 9169f89a05d66db7329bb43ebad1463cc74bc93d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:23:38 +0700 Subject: [PATCH 05/15] fix(ptp): adopt readback-matched active SOURCE without restart and preserve running on stale-role rejection --- apps/arstack_studio/src/DeviceController.cpp | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 82e1ebbb4..6e081bdf5 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1437,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_; @@ -1497,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(); From cbd8aba296fe8d48e5dd838f24c5a6365494424b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:07 +0700 Subject: [PATCH 06/15] test(ptp): prove active runtime adoption and never-fake-OFF on stale-role rejection --- .../src/DeterministicSessionHarness.cpp | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) 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; From bd922da34ead282ce8e56dc4fb6a93a5bd7e10c3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:19 +0700 Subject: [PATCH 07/15] test(clipboard): consolidate native OS readback regression from PR 266 --- apps/arstack_studio/src/ClipboardHarness.cpp | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 apps/arstack_studio/src/ClipboardHarness.cpp 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; +} From c987bd54f6502b4d6103a841fb8f9c12870034e3 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:39 +0700 Subject: [PATCH 08/15] ci(studio): port production clipboard harness from PR 266 to consolidated branch --- apps/arstack_studio/CMakeLists.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From cc1e6c0371dc7d1254da19ba4dbc6513be2c74f6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:42 +0700 Subject: [PATCH 09/15] ci(studio): port production clipboard harness from PR 266 to consolidated branch --- .github/workflows/arstack-studio-qt.yml | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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: | From af25366adf60d37b998f92c96d84ff8ad5f4adf5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:24:54 +0700 Subject: [PATCH 10/15] ci(preview): require native clipboard roundtrip alongside installed GUI probe --- .github/workflows/arstack-studio-native-preview.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 From 0300fba9741b862067f448b76e8191d743cd432b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:35:10 +0700 Subject: [PATCH 11/15] fix(ptp firmware): report physical SOURCE runtime separately from fresh timing clock lease --- .../esp32p4_smv_injector/main/ptp_mode_facade.cpp | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 { From 927da08e14e1c28c966b075f6bc6ff2d25e22b36 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:35:34 +0700 Subject: [PATCH 12/15] test(ptp): active SOURCE runtime never licenses stale/unqualified timing clock --- tests/test_ptp_canonical_authority.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) 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; From 4871e8d708deec1f8975793230399c43afef7583 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:36:39 +0700 Subject: [PATCH 13/15] test(studio): structurally guard separation of PTP physical runtime and qualified clock lease --- apps/arstack_studio/tests/check_operator_workspace.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 1387e4310..99b27e2cb 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -496,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 = (ROOT / "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") From e34206c48f103751c0676721653d85561cdecc59 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:36:49 +0700 Subject: [PATCH 14/15] test(studio): reference repo-root firmware facade via resolved Studio path --- apps/arstack_studio/tests/check_operator_workspace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/tests/check_operator_workspace.py b/apps/arstack_studio/tests/check_operator_workspace.py index 99b27e2cb..1d0978790 100644 --- a/apps/arstack_studio/tests/check_operator_workspace.py +++ b/apps/arstack_studio/tests/check_operator_workspace.py @@ -496,7 +496,7 @@ def main() -> None: "Timing Copy diagnostics must use imported GUI singleton, verify native clipboard, " "and offer full selectable manual fallback") - ptp_facade = (ROOT / "embedded/esp32p4_smv_injector/main/ptp_mode_facade.cpp").read_text(encoding="utf-8") + 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, From 6c97195e80c0058f675ad2bf6f4be053687453dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 21 Oct 2026 05:38:03 +0700 Subject: [PATCH 15/15] fix(studio): atomic-visible SV START ACK handoff prevents transient button re-enable --- apps/arstack_studio/src/DeviceController.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 6e081bdf5..ee6a1ed76 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1894,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; }