From 4ed8f69587741f4675becebcb0e6840139726367 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:15:27 +0700 Subject: [PATCH 01/22] fix(studio): acknowledge PTP role/config/start in native guided Timing workflow --- apps/arstack_studio/qml/PtpExpertPanel.qml | 68 ++---- .../src/DeterministicSessionHarness.cpp | 82 ++++++++ apps/arstack_studio/src/DeviceController.cpp | 193 +++++++++++++++--- apps/arstack_studio/src/DeviceController.hpp | 14 ++ .../src/SmartSessionController.cpp | 6 + .../src/SmartSessionController.hpp | 1 + docs/STUDIO_NATIVE_PTP_GUIDED_START.md | 31 +++ 7 files changed, 320 insertions(+), 75 deletions(-) create mode 100644 docs/STUDIO_NATIVE_PTP_GUIDED_START.md diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 852e301c6..5ad7de6e6 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -12,7 +12,7 @@ SurfacePanel { property string monoFont: "Inter" property var roleValues: ["SOURCE", "RECEIVER", "MONITOR"] property bool advancedVisible: false - property bool startPending: false + readonly property bool startPending: panel.device && panel.device.ptpStarting function roleIndex(role) { if (role === "RECEIVER") return 1 @@ -27,9 +27,8 @@ SurfacePanel { return 0 } - function applyProfile() { - if (!panel.session) return false - return panel.session.requestConfigurePtp({ + function ptpProfile() { + return { domain: Number(domainField.text), transportSpecific: Number(transportField.text), vlanEnabled: vlanCheck.checked, @@ -38,24 +37,18 @@ SurfacePanel { announceIntervalMs: Number(announceField.text), syncIntervalMs: Number(syncField.text), respondToPeerDelay: pdelayCheck.checked - }) + } + } + + function applyProfile() { + return panel.session && panel.session.requestConfigurePtp(panel.ptpProfile()) } function startSelectedMode() { if (!panel.session || !panel.device.ptpAvailable) return var role = panel.roleValues[modeCombo.currentIndex] - if (!panel.session.requestPtpRole(role)) { - if (panel.controller) panel.controller.showMessage("PTP mode could not be selected.", true) - return - } - if (!panel.applyProfile()) { - if (panel.controller) panel.controller.showMessage("PTP settings were rejected before start.", true) - return - } - panel.startPending = true - if (!panel.session.requestStartPtp()) { - panel.startPending = false - if (panel.controller) panel.controller.showMessage("PTP start command could not be sent.", true) + if (!panel.session.requestStartPtpConfigured(role, panel.ptpProfile())) { + if (panel.controller) panel.controller.showMessage("PTP start blocked. See the Timing status for details.", true) } } @@ -72,36 +65,11 @@ SurfacePanel { : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" Timer { - interval: 700 + // Status refresh only. Native C++ verifies ACKs and owns timeouts. + interval: 1000 repeat: true running: panel.startPending || panel.device.ptpRunning - onTriggered: { - if (panel.session) panel.session.requestPtpRefresh() - if (panel.device.ptpRunning) panel.startPending = false - } - } - - Timer { - interval: 3500 - repeat: false - running: panel.startPending - onTriggered: { - panel.startPending = false - if (panel.session) panel.session.requestPtpRefresh() - if (!panel.device.ptpRunning && panel.controller) - panel.controller.showMessage("PTP did not reach verified transmit state. Check Ethernet link and retry.", true) - } - } - - Connections { - target: panel.device - function onPtpStateChanged() { - if (panel.device.ptpRunning) { - panel.startPending = false - if (panel.controller && panel.sourceRole && panel.sourceEvidence) - panel.controller.showMessage("PTP Source active · Announce + Sync + Follow_Up verified.", false) - } - } + onTriggered: if (panel.session) panel.session.requestPtpRefresh() } ScrollView { @@ -228,7 +196,6 @@ SurfacePanel { text: "Stop PTP" enabled: panel.session && (panel.device.ptpRunning || panel.startPending) && !panel.session.updatingFirmware onClicked: { - panel.startPending = false panel.session.requestStopPtp() } } @@ -279,7 +246,7 @@ SurfacePanel { Label { Layout.columnSpan: 5 text: panel.sourceRole && panel.device.ptpRunning && panel.sourceEvidence - ? "Verified transmit path: Announce + Sync + Follow_Up counters are advancing with zero TX failures." + ? "Board reports Announce + Sync + Follow_Up TX with zero failures. Independent timing is not qualified." : panel.sourceRole && panel.startPending ? "Waiting for the first verified Announce + Sync + Follow_Up transmission…" : panel.device.ptpStatus @@ -344,8 +311,11 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont text: "Apply advanced settings" - enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning - onClicked: panel.applyProfile() + enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending + onClicked: { + if (!panel.applyProfile() && panel.controller) + panel.controller.showMessage("PTP settings rejected.", true) + } } } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index c5255a815..314d0addd 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -39,6 +39,10 @@ class DeterministicSessionHarness final { {"manual recovery intent survives IDENTIFY -> firmware required", manualRecoveryIntentArmsFirmwareOffer()}, {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, + {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, + {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, + {"PTP STOP cancels pending start", ptpGuidedStopCancelsStart()}, + {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, {"binary stale ACK identity -> fail closed", binaryProfileStaleAckFailsClosed()}, @@ -453,6 +457,84 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + static QVariantMap ptpValidProfile() { + return {{QStringLiteral("domain"), 0}, + {QStringLiteral("transportSpecific"), 0}, + {QStringLiteral("vlanEnabled"), false}, + {QStringLiteral("vlanId"), 100}, + {QStringLiteral("vlanPriority"), 4}, + {QStringLiteral("announceIntervalMs"), 1000}, + {QStringLiteral("syncIntervalMs"), 1000}, + {QStringLiteral("respondToPeerDelay"), true}}; + } + + static bool setupGuidedPtp(Fixture& f, const QString& role) { + if (!f.profileReady) return false; + seedVerified(f, identity(), QStringLiteral("COM7"), false); + f.device.ptpAvailable_ = true; + f.device.ioWorkerReady_ = true; // No actual COM opened by this harness. + return f.session.requestStartPtpConfigured(role, ptpValidProfile()); + } + + static bool ptpGuidedStartNeedsAcks() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role || + d.ptpRunning_ || !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config || + !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTP configuration accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_start || + d.ptpRunning_) return false; + d.processLine(QStringLiteral("PTP start accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + return !d.ptpStarting() && d.ptpRunning_ && + d.ptpRole_ == QStringLiteral("RECEIVER") && + d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")); + } + + static bool ptpGuidedRejectedConfigStops() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; + d.processLine(QStringLiteral("PTP configuration rejected: invalid profile")); + return !d.ptpStarting() && !d.ptpRunning_ && + d.ptpStatus_ == QStringLiteral("PTP start failed"); + } + + static bool ptpGuidedStopCancelsStart() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + if (!f.session.requestStopPtp() || d.ptpStarting()) return false; + // ACK from now-abandoned transaction cannot trigger CONFIG or START. + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + return !d.ptpStarting() && d.ptpStartStage_ == + DeviceController::PtpStartStage::idle; + } + + static bool ptpGuidedRoleAndGenerationGuards() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE role=MONITOR value=2 running=0")); + if (d.ptpStarting() || d.ptpStatus_ != QStringLiteral("PTP start failed")) + return false; + if (!f.session.requestStartPtpConfigured(QStringLiteral("RECEIVER"), ptpValidProfile())) + return false; + const quint64 previous = d.sessionGeneration_; + d.setSessionGeneration(previous + 1); + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + return !d.ptpStarting() && d.ptpStartStage_ == + DeviceController::PtpStartStage::idle; + } + static bool staleReleaseGenerationIsIgnored() { Fixture fixture; if (!fixture.profileReady) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 7fd0b3dd3..b5630ae2e 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -86,6 +86,13 @@ const QRegularExpression kSmpSynchExpression{ } // namespace DeviceController::DeviceController(QObject* parent) : QObject(parent) { + ptpStartTimer_.setSingleShot(true); + connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { + if (!ptpStarting()) return; + const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime; + failPtpStartup(QStringLiteral("PTP acknowledgement timed out. Start cancelled."), rollback); + }); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); @@ -106,6 +113,7 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); + resetPtpStartup(); if (ioWorker_ == nullptr || !ioThread_.isRunning()) { ioWorker_ = nullptr; @@ -320,7 +328,8 @@ void DeviceController::connectWorkerSignals() { profileArmed_ = false; emit profileStateChanged(); } - setError(message); + if (ptpStarting()) failPtpStartup(QStringLiteral("PTP serial command rejected: ") + message, false); + else setError(message); }); connect(ioWorker_, &DeviceIoWorker::transportError, this, @@ -372,6 +381,7 @@ QString DeviceController::ptpTxFailures() const { return ptpTxFailures_; } void DeviceController::setSessionGeneration(const quint64 generation) { if (generation == 0 || generation == sessionGeneration_) return; + resetPtpStartup(); sessionGeneration_ = generation; pendingAutoDetect_ = false; pendingConnectPort_.clear(); @@ -972,32 +982,163 @@ bool DeviceController::setCtSaturation( bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } bool DeviceController::startPtp() { return sendCommand(QStringLiteral("PTP START")); } -bool DeviceController::stopPtp() { return sendCommand(QStringLiteral("PTP STOP")); } +bool DeviceController::stopPtp() { + resetPtpStartup(); + ptpStatus_ = QStringLiteral("PTP stop requested"); + emit ptpStateChanged(); + return sendCommand(QStringLiteral("PTP STOP")); +} + +QString DeviceController::ptpConfigCommand(const QVariantMap& profile) { + const auto parse = [&profile](const char* key, const unsigned maximum, unsigned& out) { + const QVariant value = profile.value(QString::fromLatin1(key)); + bool valid = false; + const uint number = value.toUInt(&valid); + if (!valid || number > maximum) return false; + out = number; + return true; + }; + unsigned domain{}, transport{}, vlan{}, pcp{}, announce{}, sync{}; + if (!parse("domain", 255U, domain) || + !parse("transportSpecific", 15U, transport) || + !parse("vlanId", 4094U, vlan) || + !parse("vlanPriority", 7U, pcp) || + !parse("announceIntervalMs", 10000U, announce) || + !parse("syncIntervalMs", 5000U, sync) || + announce < 100U || sync < 20U || + (profile.value(QStringLiteral("vlanEnabled")).toBool() && vlan == 0U)) { + return {}; + } + return QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") + .arg(domain).arg(transport) + .arg(profile.value(QStringLiteral("vlanEnabled")).toBool() ? 1 : 0) + .arg(vlan).arg(pcp).arg(announce).arg(sync) + .arg(profile.value(QStringLiteral("respondToPeerDelay")).toBool() ? 1 : 0); +} + +void DeviceController::resetPtpStartup() noexcept { + ptpStartTimer_.stop(); + ptpStartStage_ = PtpStartStage::idle; + pendingPtpRole_.clear(); + pendingPtpConfig_.clear(); +} + +void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { + resetPtpStartup(); + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("PTP start failed"); + emit ptpStateChanged(); + setError(reason); + if (rollback && connected_ && sessionGeneration_ != 0) { + // Ordered on the same serial worker after any delayed START. + static_cast(sendCommand(QStringLiteral("PTP STOP"))); + } +} + +bool DeviceController::startPtpConfigured(const QString& requestedRole, + const QVariantMap& profile) { + const QString role = requestedRole.trimmed().toUpper(); + if (!deviceVerified_ || !connected_ || !ioWorkerReady_ || !ptpAvailable_ || + ptpRunning_ || ptpStarting() || + (role != QStringLiteral("SOURCE") && + role != QStringLiteral("RECEIVER") && + role != QStringLiteral("MONITOR"))) { + setError(QStringLiteral("Connect a PTP-capable ESP32-P4 and stop timing before starting.")); + return false; + } + const QString command = ptpConfigCommand(profile); + if (command.isEmpty()) { + setError(QStringLiteral("Invalid PTP settings. Check domain, VLAN and timing intervals.")); + return false; + } + pendingPtpRole_ = role; + pendingPtpConfig_ = command; + ptpStartStage_ = PtpStartStage::awaiting_role; + ptpStatus_ = QStringLiteral("Selecting PTP timing mode..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role))) { + failPtpStartup(QStringLiteral("Could not queue PTP role selection."), false); + return false; + } + ptpStartTimer_.start(5000); + return true; +} + +void DeviceController::advancePtpStartup(const QString& line) { + if (!ptpStarting()) return; + const bool rejected = + line.contains(QStringLiteral("PTPROLE rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTPROLE configuration unavailable"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP configuration rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || + line.contains(QStringLiteral("PTP unavailable"), Qt::CaseInsensitive); + if (rejected) { + const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime; + failPtpStartup(QStringLiteral("Board rejected PTP mode, settings or start."), rollback); + return; + } + if (ptpStartStage_ == PtpStartStage::awaiting_role) { + const auto reply = kPtpRoleExpression.match(line); + if (!reply.hasMatch()) return; + if (reply.captured(1).toUpper() != pendingPtpRole_ || + reply.captured(3) != QStringLiteral("0")) { + failPtpStartup(QStringLiteral("PTP mode readback does not match the selection."), false); + return; + } + const QString command = pendingPtpConfig_; + ptpStartStage_ = PtpStartStage::awaiting_config; + ptpStatus_ = QStringLiteral("Applying PTP settings..."); + emit ptpStateChanged(); + if (!sendCommand(command)) { + failPtpStartup(QStringLiteral("Could not queue PTP configuration."), false); + return; + } + ptpStartTimer_.start(5000); + } else if (ptpStartStage_ == PtpStartStage::awaiting_config && + line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_start; + ptpStatus_ = QStringLiteral("Starting PTP runtime..."); + emit ptpStateChanged(); + if (!sendCommand(QStringLiteral("PTP START"))) { + failPtpStartup(QStringLiteral("Could not queue PTP START."), true); + return; + } + ptpStartTimer_.start(10000); + } else if (ptpStartStage_ == PtpStartStage::awaiting_start && + line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_runtime; + ptpStatus_ = QStringLiteral("Verifying live PTP runtime..."); + emit ptpStateChanged(); + if (!sendPtpShow()) { + failPtpStartup(QStringLiteral("Could not read back PTP runtime."), true); + return; + } + ptpStartTimer_.start(5000); + } else if (ptpStartStage_ == PtpStartStage::awaiting_runtime) { + const auto readback = kPtpStatusExpression.match(line); + if (!readback.hasMatch()) return; + if (!ptpRunning_ || ptpRole_ != pendingPtpRole_) { + failPtpStartup(QStringLiteral("PTP runtime does not confirm the selected mode."), true); + return; + } + resetPtpStartup(); + ptpStatus_ = QStringLiteral("PTP runtime active; external timing still unqualified"); + emit ptpStateChanged(); + } +} bool DeviceController::configurePtp(const QVariantMap& profile) { - if (!deviceVerified_ || ptpRunning_) { - setError(ptpRunning_ - ? QStringLiteral("Stop PTP Lab TX before applying expert timing settings.") - : QStringLiteral("Verify the ESP32-P4 before configuring PTP.")); + if (!deviceVerified_ || ptpRunning_ || ptpStarting()) { + setError(QStringLiteral("Stop PTP and finish pending operations before changing settings.")); return false; } - const auto domain = profile.value(QStringLiteral("domain")).toUInt(); - const auto transport = profile.value(QStringLiteral("transportSpecific")).toUInt(); - const bool vlanEnabled = profile.value(QStringLiteral("vlanEnabled")).toBool(); - const auto vlanId = profile.value(QStringLiteral("vlanId")).toUInt(); - const auto pcp = profile.value(QStringLiteral("vlanPriority")).toUInt(); - const auto announceMs = profile.value(QStringLiteral("announceIntervalMs")).toUInt(); - const auto syncMs = profile.value(QStringLiteral("syncIntervalMs")).toUInt(); - const bool pdelay = profile.value(QStringLiteral("respondToPeerDelay")).toBool(); - if (domain > 255U || transport > 15U || (vlanEnabled && (vlanId == 0U || vlanId > 4094U)) || - pcp > 7U || announceMs < 100U || announceMs > 10000U || - syncMs < 20U || syncMs > 5000U) { - setError(QStringLiteral("PTP expert profile is outside the firmware safety bounds.")); + const QString command = ptpConfigCommand(profile); + if (command.isEmpty()) { + setError(QStringLiteral("PTP expert profile is outside firmware safety bounds.")); return false; } - return sendCommand(QStringLiteral("PTP CONFIG %1 %2 %3 %4 %5 %6 %7 %8") - .arg(domain).arg(transport).arg(vlanEnabled ? 1 : 0).arg(vlanId).arg(pcp) - .arg(announceMs).arg(syncMs).arg(pdelay ? 1 : 0)); + return sendCommand(command); } void DeviceController::clearLog() { @@ -1250,11 +1391,8 @@ void DeviceController::processLine(const QString& rawLine) { emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); static_cast(sendPtpShow()); } - if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { - ptpStatus_ = ptpRole_ == QStringLiteral("SOURCE") ? QStringLiteral("Source TX verified") : QStringLiteral("Timing runtime started"); - emit deviceMessage(ptpRole_ == QStringLiteral("SOURCE") - ? QStringLiteral("PTP source start verified; reading live TX counters.") - : QStringLiteral("PTP %1 runtime started.").arg(ptpRole_.toLower())); + if (line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive) && !ptpStarting()) { + ptpStatus_ = QStringLiteral("PTP start acknowledged; requesting live status"); static_cast(sendPtpShow()); } if (line.contains(QStringLiteral("PTP start rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTP source readiness timeout"), Qt::CaseInsensitive)) { @@ -1266,6 +1404,8 @@ void DeviceController::processLine(const QString& rawLine) { : QStringLiteral("PTP %1 could not start. Check the Ethernet link and retry.").arg(ptpRole_.toLower())); } + advancePtpStartup(line); + if (profileDeploying_ && line.contains(QStringLiteral("PROFILE"), Qt::CaseInsensitive) && line.contains(QStringLiteral("rejected"), Qt::CaseInsensitive)) { @@ -1282,6 +1422,7 @@ void DeviceController::resetTelemetry() { } void DeviceController::resetPtpState() { + resetPtpStartup(); ptpAvailable_ = false; ptpRunning_ = false; ptpStatus_ = QStringLiteral("Waiting for device"); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 1cd3131a6..36699a0ce 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -5,6 +5,7 @@ #include #include #include +#include #include class DeterministicSessionHarness; @@ -60,6 +61,7 @@ class DeviceController : public QObject { Q_PROPERTY(bool profileDeploying READ profileDeploying NOTIFY profileStateChanged) Q_PROPERTY(bool ptpAvailable READ ptpAvailable NOTIFY ptpStateChanged) Q_PROPERTY(bool ptpRunning READ ptpRunning NOTIFY ptpStateChanged) + Q_PROPERTY(bool ptpStarting READ ptpStarting NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpStatus READ ptpStatus NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpDomain READ ptpDomain NOTIFY ptpStateChanged) Q_PROPERTY(QString ptpTransportSpecific READ ptpTransportSpecific NOTIFY ptpStateChanged) @@ -142,6 +144,7 @@ class DeviceController : public QObject { [[nodiscard]] bool profileDeploying() const noexcept; [[nodiscard]] bool ptpAvailable() const noexcept; [[nodiscard]] bool ptpRunning() const noexcept; + [[nodiscard]] bool ptpStarting() const noexcept { return ptpStartStage_ != PtpStartStage::idle; } [[nodiscard]] QString ptpStatus() const; [[nodiscard]] QString ptpDomain() const; [[nodiscard]] QString ptpTransportSpecific() const; @@ -227,6 +230,7 @@ class DeviceController : public QObject { bool sendPtpShow(); bool startPtp(); + bool startPtpConfigured(const QString& role, const QVariantMap& profile); bool stopPtp(); bool configurePtp(const QVariantMap& profile); @@ -290,6 +294,8 @@ class DeviceController : public QObject { private: friend class DeterministicSessionHarness; + enum class PtpStartStage { idle, awaiting_role, awaiting_config, awaiting_start, awaiting_runtime }; + enum class BinaryProfileStage { idle, waiting_status, @@ -335,6 +341,10 @@ class DeviceController : public QObject { void failBinaryProfileDeployment(const QString& message); void resetTelemetry(); void resetPtpState(); + void advancePtpStartup(const QString& line); + void failPtpStartup(const QString& reason, bool rollback); + void resetPtpStartup() noexcept; + [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); static QString cleanLine(const QString& rawLine); static QString utf8Hex(const QString& text); static QString compactMac(const QString& text); @@ -370,6 +380,10 @@ class DeviceController : public QObject { BinaryProfileTransfer binaryProfile_{}; bool ptpAvailable_{false}; bool ptpRunning_{false}; + PtpStartStage ptpStartStage_{PtpStartStage::idle}; + QTimer ptpStartTimer_; + QString pendingPtpRole_; + QString pendingPtpConfig_; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index af5e39488..c2817b5a4 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -430,6 +430,12 @@ bool SmartSessionController::requestStartPtp() { return deviceControlAvailable() && device_ != nullptr && device_->startPtp(); } +bool SmartSessionController::requestStartPtpConfigured(const QString& role, + const QVariantMap& profile) { + return deviceControlAvailable() && device_ != nullptr && + device_->startPtpConfigured(role, profile); +} + bool SmartSessionController::requestStopPtp() { return deviceControlAvailable() && device_ != nullptr && device_->stopPtp(); } diff --git a/apps/arstack_studio/src/SmartSessionController.hpp b/apps/arstack_studio/src/SmartSessionController.hpp index f75cf1b93..8b00a16ec 100644 --- a/apps/arstack_studio/src/SmartSessionController.hpp +++ b/apps/arstack_studio/src/SmartSessionController.hpp @@ -194,6 +194,7 @@ class SmartSessionController : public QObject { Q_INVOKABLE bool requestSmpSynch(const QString& mode); Q_INVOKABLE bool requestConfigurePtp(const QVariantMap& profile); Q_INVOKABLE bool requestStartPtp(); + Q_INVOKABLE bool requestStartPtpConfigured(const QString& role, const QVariantMap& profile); Q_INVOKABLE bool requestStopPtp(); Q_INVOKABLE bool beginFirmwareUpdate(); diff --git a/docs/STUDIO_NATIVE_PTP_GUIDED_START.md b/docs/STUDIO_NATIVE_PTP_GUIDED_START.md new file mode 100644 index 000000000..22a87bd32 --- /dev/null +++ b/docs/STUDIO_NATIVE_PTP_GUIDED_START.md @@ -0,0 +1,31 @@ +# Native ARStack Studio — acknowledged PTP start + +This implementation improves the existing **Qt/C++/QML ARStack Studio Windows +application**. It does not create a web control surface, a CLI for operators, +or an independent firmware flasher. + +The Timing panel submits one *Start configured PTP* intent through the +SmartSessionController. DeviceController then enforces the following +acknowledgment boundary over the existing generation-scoped serial worker: + +1. Select SOURCE, RECEIVER or MONITOR; await the matching + `PTPROLE role= ... running=0` readback. +2. Apply bounded timing settings; await `PTP configuration accepted`. +3. Send START; await `PTP start accepted`. +4. Request live status; require `PTP status=RUNNING` and exact requested role. + +Qt event-loop timers bound each phase; there are no thread sleeps, blocking +reads, retries without acknowledgment, or extra owners of the serial port. +STOP, disconnect and session generation changes disarm pending starts. +When a failed phase may already have queued START, a STOP is queued on +the same ordered transport. + +Deterministic native session harness regressions cover successful ACK +order, config rejection, cancellation and wrong role/generation. + +**Limitations**: firmware-reported RUNNING/LOCKED is not independently measured +PTP grandmaster accuracy, PPS, MAC timestamp phase, or receiver acceptance. +No changes to the stable firmware, embedded PTP Draft stack, public installer +or `smpSynch AUTO` promotion. An independently tested Windows installer with +matching firmware remains a release gate; this source change is not itself a +published release. From 725b51eff28dbbe99e44256218f510f72f5e34c7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:17:21 +0700 Subject: [PATCH 02/22] fix(studio): keep PTP START acknowledgements distinct from SV publisher START --- apps/arstack_studio/src/DeterministicSessionHarness.cpp | 5 +++-- apps/arstack_studio/src/DeviceController.cpp | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 314d0addd..31bf28f74 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -489,10 +489,11 @@ class DeterministicSessionHarness final { if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_start || d.ptpRunning_) return false; d.processLine(QStringLiteral("PTP start accepted")); - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; + // A PTP-only START must NEVER switch the SV 4I+4V publisher on. + if (d.running_ || d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); - return !d.ptpStarting() && d.ptpRunning_ && + return !d.running_ && !d.ptpStarting() && d.ptpRunning_ && d.ptpRole_ == QStringLiteral("RECEIVER") && d.ptpStatus_.contains(QStringLiteral("external timing still unqualified")); } diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index b5630ae2e..45857e0d5 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1257,8 +1257,12 @@ void DeviceController::processLine(const QString& rawLine) { if (line.isEmpty()) return; appendLog(QStringLiteral("←"), line); - if (line.contains(QStringLiteral("START accepted"), Qt::CaseInsensitive)) setRunning(true); - if (line.contains(QStringLiteral("STOP accepted"), Qt::CaseInsensitive)) setRunning(false); + // SV lifecycle acknowledgements are not PTP lifecycle acknowledgements. + // Never report 4I+4V output RUNNING merely because PTP START succeeded. + if (line.contains(QStringLiteral("START accepted: profile armed"), Qt::CaseInsensitive)) + setRunning(true); + if (line.contains(QStringLiteral("STOP accepted: SV transmission suppressed"), Qt::CaseInsensitive)) + setRunning(false); auto match = kStateExpression.match(line); if (match.hasMatch()) { From a328da13133a6473dc6a28a69f5d2aebab7a7198 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:17:55 +0700 Subject: [PATCH 03/22] fix(workbench): preserve bounded initial SCL-assisted MMS q/t evidence (M2J) Retain true typed Quality/TimeQuality provenance from confirmed SCL-assisted initial MMS Read samples in both canonical native Browser trees. Keep the shared portable SCL adapter Qt-free; project only the first 96 eligible q/t details via the existing typed decoder. Preserve all authoritative leaf values, diagnose budget overflow with explicit Read fallback, and reject malformed/wrong-kind/failed-root evidence. Regression covers 150 mixed q/t/scalar leaves. Exact-head native Qt, portable SCL Connect and SCL Workbench CI all green. --- .../ied_simulator/src/MmsClientController.cpp | 33 ++++- .../src/MmsSclClientProjection.hpp | 21 +++- .../src/ied_browser_large_model_qa.cpp | 113 ++++++++++++++++++ .../ied_simulator/test_browser_convergence.py | 17 +++ 4 files changed, 178 insertions(+), 6 deletions(-) diff --git a/apps/ied_simulator/src/MmsClientController.cpp b/apps/ied_simulator/src/MmsClientController.cpp index d34165f9e..a173f73f0 100644 --- a/apps/ied_simulator/src/MmsClientController.cpp +++ b/apps/ied_simulator/src/MmsClientController.cpp @@ -355,8 +355,25 @@ bool MmsClientController::connectToIed() { auto model = std::make_shared( arstack::iedsim::build_scl_live_model(*trustedScl, *snapshot)); - auto initialValues = std::make_shared>( - arstack::iedsim::collect_scl_snapshot_values(*trustedScl, *snapshot)); + const auto snapshotValues = + arstack::iedsim::collect_scl_snapshot_values(*trustedScl, *snapshot); + // The portable adapter has no Qt dependency. Convert typed q/t + // once on the MMS worker before publishing to the GUI thread. + auto initialValues = std::make_shared>(); + initialValues->reserve(static_cast(snapshotValues.size())); + std::size_t totalTyped{}; + std::size_t projectedTyped{}; + for (const auto& value : snapshotValues) { + if (arstack::iedsim::isSclTypedEvidenceLeaf(value.item)) ++totalTyped; + if (value.typedSample) ++projectedTyped; + const auto evidence = value.typedSample + ? arstack::iedsim::decodeEngineeringEvidence( + QString::fromStdString(value.item), *value.typedSample) + : QVariantMap{}; + initialValues->push_back({ + snapshotKey(value), QString::fromStdString(value.display), + evidence}); + } worker->discovery.reset(); worker->trustedScl = trustedScl; @@ -366,12 +383,12 @@ bool MmsClientController::connectToIed() { if (self) { QMetaObject::invokeMethod( self, - [self, generation, model, snapshot, initialValues, associationProfile, healthSummary, requestedTrustedSclPath] { + [self, generation, model, snapshot, initialValues, totalTyped, projectedTyped, associationProfile, healthSummary, requestedTrustedSclPath] { if (!self || self->generation_ != generation) return; self->treeModel_.applyDocument(*model); for (const auto& value : *initialValues) { self->treeModel_.applyReadValue( - snapshotKey(value), QString::fromStdString(value.display)); + value.key, value.display, value.engineeringEvidence); } self->iedName_ = QString::fromStdString(model->identity.ied_name); self->modelSummary_ = QString::fromStdString(model->summary); @@ -392,9 +409,15 @@ bool MmsClientController::connectToIed() { *model, requestedTrustedSclPath); for (const auto& value : *initialValues) { self->engineeringContext_->treeModel()->applyReadValue( - snapshotKey(value), QString::fromStdString(value.display)); + value.key, value.display, value.engineeringEvidence); } } + if (totalTyped > projectedTyped) { + self->appendDiagnostic( + QStringLiteral("Initial q/t detail bounded: %1 of %2 typed leaves; " + "select a leaf and Read explicitly for its evidence.") + .arg(projectedTyped).arg(totalTyped)); + } self->appendDiagnostic(healthSummary); self->appendDiagnostic( QStringLiteral( diff --git a/apps/ied_simulator/src/MmsSclClientProjection.hpp b/apps/ied_simulator/src/MmsSclClientProjection.hpp index b65087c47..52750862a 100644 --- a/apps/ied_simulator/src/MmsSclClientProjection.hpp +++ b/apps/ied_simulator/src/MmsSclClientProjection.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -21,8 +22,18 @@ struct SclSnapshotValue final { std::string domain; std::string item; std::string display; + // Hold only typed q/t samples, never the entire snapshot value payload. + // Pure C++ adapter: portable non-Qt SCL profile must still compile. + std::unique_ptr typedSample; }; +inline constexpr std::size_t kMaximumSclInitialTypedEvidence = 96U; + +[[nodiscard]] inline bool isSclTypedEvidenceLeaf(const std::string& item) noexcept { + return item.size() >= 2 && item[item.size() - 2] == '$' && + (item.back() == 'q' || item.back() == 't'); +} + namespace detail { [[nodiscard]] inline std::string upper(std::string value) { @@ -297,6 +308,7 @@ namespace detail { const ar::iec61850::mms::MmsSclAssistedConnectResult& snapshot) { std::vector values; values.reserve(snapshot.mapped_leaf_count); + std::size_t typedCount{}; for (const auto& batch : snapshot.batches) { for (const auto& root : batch.roots) { if (!root.mapping_success()) continue; @@ -304,10 +316,17 @@ namespace detail { if (!leaf.value || leaf.model_entry_index >= document.model_entries.size()) continue; const auto& entry = document.model_entries[leaf.model_entry_index]; if (entry.ied_name != snapshot.ied_name) continue; + const auto item = detail::mms_item(entry); + const bool retainTyped = isSclTypedEvidenceLeaf(item) && + typedCount < kMaximumSclInitialTypedEvidence; + if (retainTyped) ++typedCount; values.push_back({ entry.ied_name + entry.ld_inst, - detail::mms_item(entry), + item, ar::iec61850::mms::MmsDataCodec::to_display_string(*leaf.value), + retainTyped + ? std::make_unique(*leaf.value) + : nullptr, }); } } diff --git a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp index 20a92b26a..3ff728cf4 100644 --- a/apps/ied_simulator/src/ied_browser_large_model_qa.cpp +++ b/apps/ied_simulator/src/ied_browser_large_model_qa.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "MmsLiveTreeModel.hpp" #include "MmsEngineeringEvidence.hpp" +#include "MmsSclClientProjection.hpp" #include "BrowserReportMemberProjection.hpp" #include "BrowserBoundedMmsPreview.hpp" @@ -313,6 +314,118 @@ int main(int argc, char** argv) { return 25; } + // A successful SCL-assisted initial MMS Read already contains typed q/t; + // never infer from display strings or fabricated post-connect values. + { + namespace mms = ar::iec61850::mms; + namespace scl = ar::iec61850::scl; + scl::SclDocument engineering; + engineering.model_entries.reserve(5); + const auto entry = [](const std::string& object, const std::string& attribute) { + scl::SclDataSetEntry value; + value.ied_name = "IED_TYPED"; + value.ld_inst = "LD0"; + value.ln_class = "XCBR"; + value.ln_inst = "1"; + value.do_name = object; + value.da_name = attribute; + value.functional_constraint = "ST"; + return value; + }; + engineering.model_entries.push_back(entry("Pos", "q")); + engineering.model_entries.push_back(entry("Pos", "t")); + engineering.model_entries.push_back(entry("BadPos", "q")); + engineering.model_entries.push_back(entry("WrongPos", "q")); + engineering.model_entries.push_back(entry("Pos", "stVal")); + + mms::MmsSclAssistedConnectResult snapshot; + snapshot.ied_name = "IED_TYPED"; + mms::MmsInitialFcBatchResult batch; + mms::MmsInitialFcRootResult root; + root.root_value = mms::MmsDataValue::boolean(true); + const auto push = [&root](const std::size_t index, mms::MmsDataValue value) { + mms::MmsSclMappedLeaf mapped; + mapped.model_entry_index = index; + mapped.value = std::move(value); + root.mapped_leaves.push_back(std::move(mapped)); + }; + push(0U, mms::MmsDataValue::bit_string(3U, bits)); + push(1U, mms::MmsDataValue::utc_time(utc)); + push(2U, mms::MmsDataValue::bit_string(2U, bits)); + push(3U, mms::MmsDataValue::boolean(true)); + push(4U, mms::MmsDataValue::boolean(false)); + batch.roots.push_back(std::move(root)); + snapshot.batches.push_back(std::move(batch)); + snapshot.mapped_leaf_count = 5U; + const auto mapped = arstack::iedsim::collect_scl_snapshot_values(engineering, snapshot); + const auto mappedEvidence = [](const arstack::iedsim::SclSnapshotValue& value) { + return value.typedSample + ? arstack::iedsim::decodeEngineeringEvidence( + QString::fromStdString(value.item), *value.typedSample) + : QVariantMap{}; + }; + if (mapped.size() != 5U || + !mapped.at(0).typedSample || !mapped.at(1).typedSample || + mappedEvidence(mapped.at(0)) != qualityEvidence || + mappedEvidence(mapped.at(1)) != timeEvidence || + !mappedEvidence(mapped.at(2)).isEmpty() || + !mappedEvidence(mapped.at(3)).isEmpty() || + mapped.at(4).typedSample || + mapped.at(0).item != "XCBR1$ST$Pos$q" || + mapped.at(1).item != "XCBR1$ST$Pos$t") { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL trusted_scl_initial_typed_evidence\n"; + return 27; + } + // Bound typed initial-snapshot copies and decoded GUI maps at 96, + // independently of the number of successfully mapped values. + mms::MmsSclAssistedConnectResult largeSnapshot; + largeSnapshot.ied_name = "IED_TYPED"; + mms::MmsInitialFcBatchResult largeBatch; + mms::MmsInitialFcRootResult largeRoot; + largeRoot.root_value = mms::MmsDataValue::boolean(true); + const auto qEntry = engineering.model_entries.at(0); + engineering.model_entries.resize(150, qEntry); + for (std::size_t i = 0; i < 150U; ++i) { + mms::MmsSclMappedLeaf leaf; + leaf.model_entry_index = i; + leaf.value = mms::MmsDataValue::bit_string(3U, bits); + largeRoot.mapped_leaves.push_back(std::move(leaf)); + } + largeBatch.roots.push_back(std::move(largeRoot)); + largeSnapshot.batches.push_back(std::move(largeBatch)); + largeSnapshot.mapped_leaf_count = 150U; + const auto bounded = arstack::iedsim::collect_scl_snapshot_values( + engineering, largeSnapshot); + std::size_t typedVisible{}; + std::size_t eligibleOrdinal{}; + bool ordinalMismatch{}; + for (const auto& item : bounded) { + const bool eligible = arstack::iedsim::isSclTypedEvidenceLeaf(item.item); + const bool expectedTyped = eligible && + eligibleOrdinal < arstack::iedsim::kMaximumSclInitialTypedEvidence; + if (static_cast(item.typedSample) != expectedTyped) + ordinalMismatch = true; + if (item.typedSample) ++typedVisible; + if (eligible) ++eligibleOrdinal; + } + // Scalar leaves do not consume the q/t budget; assert on candidate + // ordinal, not an assumed fixed position in a mixed FC-root batch. + if (bounded.size() != 150U || + eligibleOrdinal != 149U || + typedVisible != arstack::iedsim::kMaximumSclInitialTypedEvidence || + ordinalMismatch || bounded.back().display.empty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL initial_qt_evidence_budget\n"; + return 29; + } + + // A failed FC-root Read must never populate GUI evidence. + snapshot.batches.front().roots.front().access_failure_code = 1U; + if (!arstack::iedsim::collect_scl_snapshot_values(engineering, snapshot).empty()) { + std::cerr << "IED_BROWSER_LARGE_MODEL_FAIL failed_root_evidence\n"; + return 28; + } + } + // Project 300 decoded report members but allocate only 256 GUI rows. // A shared one-item evidence budget prevents a q/t storm from allocating // a detailed map per member or per retained report frame. diff --git a/apps/ied_simulator/test_browser_convergence.py b/apps/ied_simulator/test_browser_convergence.py index b63e7234c..3844b1f3f 100644 --- a/apps/ied_simulator/test_browser_convergence.py +++ b/apps/ied_simulator/test_browser_convergence.py @@ -105,6 +105,23 @@ if 'readEngineeringSelected()' in data_set or 'readEngineeringSelected()' in data_model: raise SystemExit("BROWSER_TYPED_EVIDENCE_FAIL passive inspector initiated MMS Read") +# Typed MMS evidence from the initial trusted-SCL snapshot must survive into +# both canonical trees. The large-model QA exercises valid/invalid wire cases. +snapshot_projection = (workspace_path.parent.parent / "src" / "MmsSclClientProjection.hpp").read_text(encoding="utf-8") +mms_client = (workspace_path.parent.parent / "src" / "MmsClientController.cpp").read_text(encoding="utf-8") +large_model_qa = (workspace_path.parent.parent / "src" / "ied_browser_large_model_qa.cpp").read_text(encoding="utf-8") +if ("isSclTypedEvidenceLeaf(item)" not in snapshot_projection or + "item[item.size() - 2] == '$'" not in snapshot_projection or + "kMaximumSclInitialTypedEvidence = 96U" not in snapshot_projection or + "typedCount < kMaximumSclInitialTypedEvidence" not in snapshot_projection or + "initial_qt_evidence_budget" not in large_model_qa or + '"MmsEngineeringEvidence.hpp"' in snapshot_projection or + "trusted_scl_initial_typed_evidence" not in large_model_qa or + "failed_root_evidence" not in large_model_qa or + "snapshotValues" not in mms_client or + mms_client.count("value.key, value.display, value.engineeringEvidence);") < 2): + raise SystemExit("BROWSER_INITIAL_SNAPSHOT_TYPED_EVIDENCE_FAIL missing typed provenance") + report_controller = (workspace_path.parent.parent / "src" / "MmsReportController.cpp").read_text(encoding="utf-8") for token in [ 'kMaximumTypedReportEvidence = 96', From 8be7b8a85ae5769ef84e2ff69bb3a0a2e40e9a19 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:18:50 +0700 Subject: [PATCH 04/22] fix(studio): default native PTP Sync cadence to supported 1000 ms --- apps/arstack_studio/qml/PtpExpertPanel.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 5ad7de6e6..172e8417a 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -301,7 +301,7 @@ SurfacePanel { Label { text: "Announce interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: announceField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 100; top: 10000 } } Label { text: "Sync interval (ms)"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } - NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "250"; validator: IntValidator { bottom: 20; top: 5000 } } + NumericField { id: syncField; theme: panel.theme; monoFont: panel.uiFont; text: "1000"; validator: IntValidator { bottom: 20; top: 5000 } } CheckBox { id: vlanCheck; text: "VLAN"; font.family: panel.uiFont; font.pixelSize: 9 } NumericField { id: vlanField; enabled: vlanCheck.checked; theme: panel.theme; monoFont: panel.uiFont; text: "100"; validator: IntValidator { bottom: 1; top: 4094 } } Label { text: "VLAN PCP"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 9 } From fb0457fccff221cb746505a23f3e5c2ddc32e74d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:34:13 +0700 Subject: [PATCH 05/22] ci(studio): package side-by-side native Windows GUI preview with pinned stable firmware --- .../arstack-studio-native-preview.yml | 173 ++++++++++++++++++ apps/arstack_studio/CMakeLists.txt | 6 + .../packaging/ARStackStudio.nsi | 53 ++++-- apps/arstack_studio/src/main.cpp | 5 + docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md | 62 +++++++ 5 files changed, 279 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/arstack-studio-native-preview.yml create mode 100644 docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml new file mode 100644 index 000000000..7a9c8d7ba --- /dev/null +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -0,0 +1,173 @@ +name: ARStack Studio Native Preview + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'apps/arstack_studio/**' + - '.github/workflows/arstack-studio-native-preview.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + windows-preview: + if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: windows-2022 + timeout-minutes: 40 + env: + PREVIEW_SHA: ${{ github.event.pull_request.head.sha }} + BASE_PORTABLE_SHA256: 27dfa5374afe9ac9f9235d86dfa26131a3d2f3d26a73205f1fa188a6a3d9ee4c + BASE_PORTABLE_URL: https://github.com/masarray/arstack61850/releases/download/v0.1.1/ARStack-Studio-0.1.1-win-x64-portable.zip + + steps: + - name: Checkout exact GUI PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Install Qt + uses: jurplel/install-qt-action@v4 + with: + version: '6.10.3' + arch: win64_msvc2022_64 + modules: 'qtserialport' + cache: true + + - name: Build real native preview executable and session harness + shell: pwsh + 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 + 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' } + + - name: Verify immutable public v0.1.1 firmware and flasher source + shell: pwsh + run: | + $archive = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-portable.zip' + Invoke-WebRequest -Uri $env:BASE_PORTABLE_URL -OutFile $archive + $actual = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $env:BASE_PORTABLE_SHA256) { + throw "Public v0.1.1 base archive SHA-256 mismatch: $actual" + } + $baseline = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-verified' + Expand-Archive -LiteralPath $archive -DestinationPath $baseline -Force + foreach ($name in @('firmware\firmware-manifest.json', 'tools\espflash.exe', 'ARStackStudio.exe')) { + if (-not (Test-Path (Join-Path $baseline $name))) { + throw "Pinned public base package is incomplete: $name" + } + } + Write-Host 'Pinned v0.1.1 public firmware / flasher archive: VERIFIED' + + - name: Stage isolated GUI preview with pinned release firmware + shell: pwsh + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $baseline = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-verified' + New-Item -Path $stage -ItemType Directory -Force | Out-Null + $gui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' + if (-not (Test-Path $gui)) { throw 'Fresh preview GUI executable missing' } + $exe = Join-Path $stage 'ARStackStudio.exe' + Copy-Item $gui $exe + windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $exe + if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed' } + Copy-Item (Join-Path $baseline 'firmware') (Join-Path $stage 'firmware') -Recurse + Copy-Item (Join-Path $baseline 'tools') (Join-Path $stage 'tools') -Recurse + if (Test-Path (Join-Path $baseline 'licenses')) { + Copy-Item (Join-Path $baseline 'licenses') (Join-Path $stage 'licenses') -Recurse + } + Copy-Item LICENSE,NOTICE,THIRD_PARTY_NOTICES.md,COPYRIGHT.md -Destination $stage + $meta = [ordered]@{ + schema = 'arstack.studio.preview-provenance.v1' + display = 'ARStack Studio Engineering Preview - not a public stable release' + guiCommit = $env:PREVIEW_SHA + guiVersion = '0.1.1-preview' + firmwareRelease = 'v0.1.1' + pinnedFirmwarePackageSha256 = $env:BASE_PORTABLE_SHA256 + embeddedExperimentalM3 = $false + ptpHardwareQualified = $false + publicRelease = $false + } + $meta | ConvertTo-Json | Set-Content (Join-Path $stage 'PREVIEW-PROVENANCE.json') -Encoding utf8 + + - name: Smoke native packaged GUI, unchanged firmware contract and flasher + shell: pwsh + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $exe = Join-Path $stage 'ARStackStudio.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stage 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + & $exe $check + if ($LASTEXITCODE -ne 0) { throw "Preview GUI failed packaged smoke $check" } + } + & $env:ARSTACK_STUDIO_ESPFLASH --version + if ($LASTEXITCODE -ne 0) { throw 'Pinned flasher execution failed' } + Write-Host 'Native GUI, firmware manifest, shutdown and flasher: PASS' + + - name: Build portable and side-by-side Windows preview setup + shell: pwsh + run: | + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $zip = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-portable.zip' + Compress-Archive -Path "$stage\*" -DestinationPath $zip -CompressionLevel Optimal -Force + choco install nsis -y --no-progress + if ($LASTEXITCODE -ne 0) { throw 'NSIS installation failed' } + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' + & makensis '/DARSTACK_PREVIEW' "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$installer" '/DAPP_VERSION=0.1.1' $script + if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { + throw 'Side-by-side preview installer build failed' + } + $files = @($zip, $installer) + $checks = foreach ($file in $files) { + $sha = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash.ToLowerInvariant() + "$sha $([IO.Path]::GetFileName($file))" + } + $checks | Set-Content (Join-Path $PWD 'dist\SHA256SUMS.txt') -Encoding ascii + + - name: Fresh install smoke in isolated preview directory + shell: pwsh + run: | + $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' + $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioPreviewCleanInstall' + & $installer '/S' "/D=$installDir" + if ($LASTEXITCODE -ne 0) { throw 'Preview installer failed' } + $exe = Join-Path $installDir 'ARStackStudio.exe' + if (-not (Test-Path $exe)) { throw 'Preview installer did not deploy GUI .exe' } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' + $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' + foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { + & $exe $check + if ($LASTEXITCODE -ne 0) { throw "Installed preview failed $check" } + } + $previewKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudioPreview' + if (-not (Test-Path $previewKey)) { throw 'Preview-specific registry identity missing' } + $stableKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio' + if (Test-Path $stableKey) { throw 'Preview unexpectedly modified stable installer registration' } + $uninstall = Join-Path $installDir 'Uninstall.exe' + if (-not (Test-Path $uninstall)) { throw 'Preview uninstaller missing' } + & $uninstall '/S' + if ($LASTEXITCODE -ne 0) { throw 'Preview uninstaller failed' } + Write-Host 'Preview install/run/uninstall and stable-key isolation: PASS' + + - name: Upload GUI-only Windows preview packages + uses: actions/upload-artifact@v4 + with: + name: arstack-studio-native-preview-win-x64-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }} + path: | + dist/ARStack-Studio-Preview-win-x64-setup.exe + dist/ARStack-Studio-Preview-win-x64-portable.zip + dist/SHA256SUMS.txt + if-no-files-found: error + retention-days: 7 diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index be5234e8f..d6dfe913c 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -158,6 +158,12 @@ target_compile_features(arstack_session_harness PRIVATE cxx_std_20) target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) + +# Opt-in Windows engineering preview identity; OFF for all stable builds. +option(ARSTACK_STUDIO_ENGINEERING_PREVIEW "Mark this Studio build as an isolated preview" OFF) +if(ARSTACK_STUDIO_ENGINEERING_PREVIEW) + target_compile_definitions(arstack_studio PRIVATE ARSTACK_STUDIO_ENGINEERING_PREVIEW=1) +endif() target_compile_definitions(arstack_session_harness PRIVATE ARSTACK_STUDIO_VERSION="${PROJECT_VERSION}" ) diff --git a/apps/arstack_studio/packaging/ARStackStudio.nsi b/apps/arstack_studio/packaging/ARStackStudio.nsi index 5d114bdda..a9d71b883 100644 --- a/apps/arstack_studio/packaging/ARStackStudio.nsi +++ b/apps/arstack_studio/packaging/ARStackStudio.nsi @@ -12,16 +12,29 @@ Unicode true !define APP_VERSION "0.1.0" !endif -Name "ARStack Studio" +!ifdef ARSTACK_PREVIEW + !define PACKAGE_NAME "ARStack Studio Preview" + !define PACKAGE_DIR "$LOCALAPPDATA\Programs\ARStack Studio Preview" + !define PACKAGE_REG "Software\ARStack61850\ARStack Studio Preview" + !define PACKAGE_UNINSTALL "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudioPreview" + !define PACKAGE_MENU "ARStack Studio Preview" +!else + !define PACKAGE_NAME "ARStack Studio" + !define PACKAGE_DIR "$LOCALAPPDATA\Programs\ARStack Studio" + !define PACKAGE_REG "Software\ARStack61850\ARStack Studio" + !define PACKAGE_UNINSTALL "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" + !define PACKAGE_MENU "ARStack Studio" +!endif +Name "${PACKAGE_NAME}" OutFile "${OUTPUT_FILE}" -InstallDir "$LOCALAPPDATA\Programs\ARStack Studio" -InstallDirRegKey HKCU "Software\ARStack61850\ARStack Studio" "InstallDir" +InstallDir "${PACKAGE_DIR}" +InstallDirRegKey HKCU "${PACKAGE_REG}" "InstallDir" RequestExecutionLevel user SetCompressor /SOLID lzma SetCompressorDictSize 32 VIProductVersion "${APP_VERSION}.0" -VIAddVersionKey "ProductName" "ARStack Studio" +VIAddVersionKey "ProductName" "${PACKAGE_NAME}" VIAddVersionKey "CompanyName" "ARStack61850" VIAddVersionKey "FileDescription" "IEC 61850 Sampled Values Injector / Generator" VIAddVersionKey "FileVersion" "${APP_VERSION}" @@ -42,27 +55,27 @@ Section "ARStack Studio" SEC_MAIN SetOutPath "$INSTDIR" File /r "${SOURCE_DIR}\*" - WriteRegStr HKCU "Software\ARStack61850\ARStack Studio" "InstallDir" "$INSTDIR" + WriteRegStr HKCU "${PACKAGE_REG}" "InstallDir" "$INSTDIR" WriteUninstaller "$INSTDIR\Uninstall.exe" - CreateDirectory "$SMPROGRAMS\ARStack Studio" - CreateShortcut "$SMPROGRAMS\ARStack Studio\ARStack Studio.lnk" "$INSTDIR\ARStackStudio.exe" - CreateShortcut "$SMPROGRAMS\ARStack Studio\Uninstall ARStack Studio.lnk" "$INSTDIR\Uninstall.exe" + CreateDirectory "$SMPROGRAMS\${PACKAGE_MENU}" + CreateShortcut "$SMPROGRAMS\${PACKAGE_MENU}\${PACKAGE_NAME}.lnk" "$INSTDIR\ARStackStudio.exe" + CreateShortcut "$SMPROGRAMS\${PACKAGE_MENU}\Uninstall ${PACKAGE_NAME}.lnk" "$INSTDIR\Uninstall.exe" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "DisplayName" "ARStack Studio" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "DisplayVersion" "${APP_VERSION}" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "Publisher" "ARStack61850" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "InstallLocation" "$INSTDIR" - WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "UninstallString" '"$INSTDIR\Uninstall.exe"' - WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "NoModify" 1 - WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" "NoRepair" 1 + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "DisplayName" "${PACKAGE_NAME}" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "DisplayVersion" "${APP_VERSION}" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "Publisher" "ARStack61850" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "InstallLocation" "$INSTDIR" + WriteRegStr HKCU "${PACKAGE_UNINSTALL}" "UninstallString" '"$INSTDIR\Uninstall.exe"' + WriteRegDWORD HKCU "${PACKAGE_UNINSTALL}" "NoModify" 1 + WriteRegDWORD HKCU "${PACKAGE_UNINSTALL}" "NoRepair" 1 SectionEnd Section "Uninstall" - Delete "$SMPROGRAMS\ARStack Studio\ARStack Studio.lnk" - Delete "$SMPROGRAMS\ARStack Studio\Uninstall ARStack Studio.lnk" - RMDir "$SMPROGRAMS\ARStack Studio" + Delete "$SMPROGRAMS\${PACKAGE_MENU}\${PACKAGE_NAME}.lnk" + Delete "$SMPROGRAMS\${PACKAGE_MENU}\Uninstall ${PACKAGE_NAME}.lnk" + RMDir "$SMPROGRAMS\${PACKAGE_MENU}" RMDir /r "$INSTDIR" - DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio" - DeleteRegKey HKCU "Software\ARStack61850\ARStack Studio" + DeleteRegKey HKCU "${PACKAGE_UNINSTALL}" + DeleteRegKey HKCU "${PACKAGE_REG}" SectionEnd diff --git a/apps/arstack_studio/src/main.cpp b/apps/arstack_studio/src/main.cpp index 57d851b21..14e3389c5 100644 --- a/apps/arstack_studio/src/main.cpp +++ b/apps/arstack_studio/src/main.cpp @@ -438,8 +438,13 @@ int main(int argc, char* argv[]) { QGuiApplication app(argc, argv); app.setQuitOnLastWindowClosed(true); QCoreApplication::setOrganizationName(QStringLiteral("ARStack61850")); +#ifdef ARSTACK_STUDIO_ENGINEERING_PREVIEW + QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio Preview")); + QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION "-preview")); +#else QCoreApplication::setApplicationName(QStringLiteral("ARStack Studio")); QCoreApplication::setApplicationVersion(QStringLiteral(ARSTACK_STUDIO_VERSION)); +#endif // S6 ownership starts before any firmware process, QML object, or serial // worker exists. A second Studio process therefore cannot race for the diff --git a/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md new file mode 100644 index 000000000..4b508d04c --- /dev/null +++ b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md @@ -0,0 +1,62 @@ +# ARStack Studio native Windows preview — side-by-side operator candidate + +**Scope:** Build the existing native Qt/C++/QML `ARStackStudio.exe` from the exact +GitHub pull-request head. Package a Windows installer/portable ZIP that a user +can open directly. There is **no web UI, external Python tool, or manual flash +file selection** in the operator workflow. + +## Firmware selection and provenance + +- The candidate is deliberately **GUI-only**. It uses the public v0.1.1 + firmware, manifest and standalone flasher from the [published v0.1.1 + portable release](https://github.com/masarray/arstack61850/releases/tag/v0.1.1). +- CI verifies the exact 46 MB source ZIP's SHA-256: + `27dfa5374afe9ac9f9235d86dfa26131a3d2f3d26a73205f1fa188a6a3d9ee4c`. + It copies **only** firmware, tools and their licenses from this authenticated + baseline into the newly compiled GUI package. It does not silently bundle + firmware from the experimental M3 branch. +- A `PREVIEW-PROVENANCE.json` file ties the compiled GUI commit to that + immutable public firmware archive; this is a traceability record, not a + firmware cryptographic signing scheme. +- Firmware compatibility and packaged operation are checked using **Studio's + existing native firmware manifest validator**, not a parallel Python parser. + +## Isolation policy + +- The NSIS packaging script supports an opt-in `ARSTACK_PREVIEW` channel. It + has a distinct installation directory, uninstall registry key, program-menu + folder, display name, and uninstaller; **normal stable packaging defaults are + unchanged**. +- The preview GUI advertises `0.1.1-preview` in its window title. This is the + *GUI channel label*, not a new embedded firmware version. +- Studio keeps its existing cross-process single-instance serial-port lock; + stable Studio and preview **must not control the same device concurrently**. +- Only a PR-triggered workflow builds preview candidates. It never creates a + GitHub Release, tag, merges to main, or overwrites v0.1.1. +- Artifact lifetime is seven days. Installer and portable ZIP SHA-256 files are + shipped together. + +## Gates + +1. Build the exact PR-head Qt/C++ Studio Windows executable with warnings-as-errors. +2. Run the production deterministic device/session harness. +3. Verify the pinned v0.1.1 release source archive checksum before extracting + firmware/flasher from it. +4. Run Studio's own profile/firmware/P0 policy and application-lifecycle checks + **against the staged Windows executable and firmware**; invoke bundled + espflash standalone to verify its executable availability. +5. Produce an independent *preview-only* NSIS installer and portable ZIP. +6. Silent-install the preview into a clean Windows test location; rerun native + firmware/P0/lifecycle checks, verify preview-specific registry identity and + absence of stable registration, and silently uninstall. +7. Upload the Windows artifacts only if every gate succeeds. + +Hardware PTP phase accuracy, external GM/PPS and IED acceptance are not proven +by a Windows installer. The SOURCE/RECEIVER M3 embedded firmware is not included; +corresponding features will appear unavailable on devices without the required +firmware capability. `smpSynch AUTO` remains fail-closed. + +**Operator:** Download the Windows preview setup from the successful GitHub +Actions run's Artifacts area and launch `ARStack-Studio-Preview-win-x64-setup.exe`. +The preview installs separately from the public Studio and operates through +the GUI. It is an engineering evaluation candidate, **not a stable release**. From 57d028ca4f3d16ea09f7138be05a9d895e87f0bb Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:40:26 +0700 Subject: [PATCH 06/22] build(studio): replace preview NSIS with Inno Setup smart upgrade and clean-uninstall gates --- .../arstack-studio-native-preview.yml | 70 ++++++++--- .../packaging/ARStackStudio.iss | 79 ++++++++++++ docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md | 119 +++++++++--------- 3 files changed, 188 insertions(+), 80 deletions(-) create mode 100644 apps/arstack_studio/packaging/ARStackStudio.iss diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 7a9c8d7ba..1f537c73a 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -114,19 +114,21 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Pinned flasher execution failed' } Write-Host 'Native GUI, firmware manifest, shutdown and flasher: PASS' - - name: Build portable and side-by-side Windows preview setup + - name: Build portable and Inno Setup Windows preview installer shell: pwsh run: | $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' $zip = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-portable.zip' Compress-Archive -Path "$stage\*" -DestinationPath $zip -CompressionLevel Optimal -Force - choco install nsis -y --no-progress - if ($LASTEXITCODE -ne 0) { throw 'NSIS installation failed' } - $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.nsi').Path + choco install innosetup --version 6.7.1 -y --no-progress + if ($LASTEXITCODE -ne 0) { throw 'Pinned Inno Setup installation failed' } + $compiler = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' + if (-not (Test-Path $compiler)) { throw "Pinned Inno Setup compiler missing: $compiler" } + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.iss').Path $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' - & makensis '/DARSTACK_PREVIEW' "/DSOURCE_DIR=$stage" "/DOUTPUT_FILE=$installer" '/DAPP_VERSION=0.1.1' $script + & $compiler '/DPREVIEW=1' '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$PWD\dist" $script if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { - throw 'Side-by-side preview installer build failed' + throw 'Inno Setup native GUI preview installer build failed' } $files = @($zip, $installer) $checks = foreach ($file in $files) { @@ -135,15 +137,16 @@ jobs: } $checks | Set-Content (Join-Path $PWD 'dist\SHA256SUMS.txt') -Encoding ascii - - name: Fresh install smoke in isolated preview directory + - name: Inno clean install, running-app upgrade and clean uninstall shell: pwsh run: | $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioPreviewCleanInstall' - & $installer '/S' "/D=$installDir" - if ($LASTEXITCODE -ne 0) { throw 'Preview installer failed' } + $silent = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', "/DIR=$installDir") + & $installer @silent + if ($LASTEXITCODE -ne 0) { throw 'Inno preview installation failed' } $exe = Join-Path $installDir 'ARStackStudio.exe' - if (-not (Test-Path $exe)) { throw 'Preview installer did not deploy GUI .exe' } + if (-not (Test-Path $exe)) { throw 'Preview installation is missing GUI EXE' } $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' @@ -151,15 +154,46 @@ jobs: & $exe $check if ($LASTEXITCODE -ne 0) { throw "Installed preview failed $check" } } - $previewKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudioPreview' - if (-not (Test-Path $previewKey)) { throw 'Preview-specific registry identity missing' } + $previewKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStack61850.Studio.Preview_is1' + if (-not (Test-Path $previewKey)) { throw 'Inno Preview AppId registration missing' } $stableKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio' - if (Test-Path $stableKey) { throw 'Preview unexpectedly modified stable installer registration' } - $uninstall = Join-Path $installDir 'Uninstall.exe' - if (-not (Test-Path $uninstall)) { throw 'Preview uninstaller missing' } - & $uninstall '/S' - if ($LASTEXITCODE -ne 0) { throw 'Preview uninstaller failed' } - Write-Host 'Preview install/run/uninstall and stable-key isolation: PASS' + if (Test-Path $stableKey) { throw 'Preview unexpectedly changed stable NSIS registration' } + $userNote = Join-Path $installDir 'operator-owned-note.txt' + 'preserve my own data' | Set-Content -LiteralPath $userNote + $runningApp = Start-Process -FilePath $exe -PassThru + try { + if (-not $runningApp.WaitForInputIdle(15000)) { + throw 'GUI did not become interactive during hot-update test' + } + if ($runningApp.HasExited) { throw 'GUI exited before hot-update test' } + & $installer @silent + if ($LASTEXITCODE -ne 0) { throw 'Inno upgrade with running GUI failed' } + $runningApp.Refresh() + if (-not $runningApp.HasExited) { + throw 'Inno did not gracefully close the GUI for upgrade' + } + } finally { + if (-not $runningApp.HasExited) { + $runningApp.CloseMainWindow() | Out-Null + if (-not $runningApp.WaitForExit(10000)) { $runningApp.Kill() } + } + $runningApp.Dispose() + } + if (-not (Test-Path -LiteralPath $userNote)) { + throw 'Preview upgrade removed an operator-owned file' + } + & $exe '--check-firmware-contract' + if ($LASTEXITCODE -ne 0) { throw 'Upgraded GUI firmware contract failed' } + $uninstall = Join-Path $installDir 'unins000.exe' + if (-not (Test-Path $uninstall)) { throw 'Inno-generated uninstaller missing' } + & $uninstall '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' + if ($LASTEXITCODE -ne 0) { throw 'Inno preview uninstall failed' } + if (Test-Path $exe) { throw 'Uninstall left an application-owned GUI EXE' } + if (Test-Path $previewKey) { throw 'Uninstall left Inno AppId entry' } + if (-not (Test-Path $userNote)) { throw 'Uninstall deleted operator-owned data' } + if (Test-Path $stableKey) { throw 'Preview uninstall changed stable registration' } + Remove-Item -LiteralPath $userNote -Force + Write-Host 'Inno preview install, hot upgrade, data preservation and clean uninstall: PASS' - name: Upload GUI-only Windows preview packages uses: actions/upload-artifact@v4 diff --git a/apps/arstack_studio/packaging/ARStackStudio.iss b/apps/arstack_studio/packaging/ARStackStudio.iss new file mode 100644 index 000000000..1bf4ad43c --- /dev/null +++ b/apps/arstack_studio/packaging/ARStackStudio.iss @@ -0,0 +1,79 @@ +; ARStack Studio Windows installer - Inno Setup 6 +; Stable and Preview use separate AppId and install directories. Never run +; the old NSIS uninstaller implicitly: safe migration is separately gated. + +#ifndef STAGE_DIR + #error STAGE_DIR is mandatory +#endif +#ifndef PREVIEW + #define PREVIEW 0 +#endif +#ifndef STUDIO_VERSION + #define STUDIO_VERSION "0.1.1" +#endif + +#if PREVIEW + #define ProductName "ARStack Studio Preview" + #define ProductId "ARStack61850.Studio.Preview" + #define ProductFolder "ARStack Studio Preview" + #define ProductOutput "ARStack-Studio-Preview-win-x64-setup" + #define ProductRegistry "Software\ARStack61850\ARStack Studio Preview" +#else + #define ProductName "ARStack Studio" + #define ProductId "ARStack61850.Studio.Stable" + #define ProductFolder "ARStack Studio" + #define ProductOutput "ARStack-Studio-win-x64-setup" + #define ProductRegistry "Software\ARStack61850\ARStack Studio" +#endif + +[Setup] +AppId={#ProductId} +AppName={#ProductName} +AppVerName={#ProductName} {#STUDIO_VERSION} +AppVersion={#STUDIO_VERSION} +AppPublisher=ARStack61850 +AppPublisherURL=https://github.com/masarray/arstack61850 +AppSupportURL=https://github.com/masarray/arstack61850/issues +AppUpdatesURL=https://github.com/masarray/arstack61850/releases +UninstallDisplayName={#ProductName} +DefaultDirName={localappdata}\Programs\{#ProductFolder} +DefaultGroupName={#ProductName} +OutputBaseFilename={#ProductOutput} +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +CloseApplications=yes +CloseApplicationsFilter=ARStackStudio.exe,*.dll +RestartApplications=no +SetupMutex=ARStackStudioInstaller +UsePreviousAppDir=yes +UsePreviousGroup=yes +DisableDirPage=auto +DisableProgramGroupPage=auto +WizardStyle=modern +Compression=lzma2 +SolidCompression=yes +SetupLogging=yes +UninstallLogging=yes +Uninstallable=yes +MinVersion=10.0 + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Files] +Source: "{#STAGE_DIR}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\{#ProductName}"; Filename: "{app}\ARStackStudio.exe" +Name: "{group}\Uninstall {#ProductName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#ProductName}"; Filename: "{app}\ARStackStudio.exe"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:" + +[Registry] +Root: HKCU; Subkey: "{#ProductRegistry}"; Flags: uninsdeletekeyifempty +Root: HKCU; Subkey: "{#ProductRegistry}"; ValueType: string; ValueName: "InstallDir"; ValueData: "{app}"; Flags: uninsdeletevalue + +[Run] +Filename: "{app}\ARStackStudio.exe"; Description: "Launch {#ProductName}"; Flags: nowait postinstall skipifsilent diff --git a/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md index 4b508d04c..abec79b49 100644 --- a/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md +++ b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md @@ -1,62 +1,57 @@ -# ARStack Studio native Windows preview — side-by-side operator candidate - -**Scope:** Build the existing native Qt/C++/QML `ARStackStudio.exe` from the exact -GitHub pull-request head. Package a Windows installer/portable ZIP that a user -can open directly. There is **no web UI, external Python tool, or manual flash -file selection** in the operator workflow. - -## Firmware selection and provenance - -- The candidate is deliberately **GUI-only**. It uses the public v0.1.1 - firmware, manifest and standalone flasher from the [published v0.1.1 - portable release](https://github.com/masarray/arstack61850/releases/tag/v0.1.1). -- CI verifies the exact 46 MB source ZIP's SHA-256: - `27dfa5374afe9ac9f9235d86dfa26131a3d2f3d26a73205f1fa188a6a3d9ee4c`. - It copies **only** firmware, tools and their licenses from this authenticated - baseline into the newly compiled GUI package. It does not silently bundle - firmware from the experimental M3 branch. -- A `PREVIEW-PROVENANCE.json` file ties the compiled GUI commit to that - immutable public firmware archive; this is a traceability record, not a - firmware cryptographic signing scheme. -- Firmware compatibility and packaged operation are checked using **Studio's - existing native firmware manifest validator**, not a parallel Python parser. - -## Isolation policy - -- The NSIS packaging script supports an opt-in `ARSTACK_PREVIEW` channel. It - has a distinct installation directory, uninstall registry key, program-menu - folder, display name, and uninstaller; **normal stable packaging defaults are - unchanged**. -- The preview GUI advertises `0.1.1-preview` in its window title. This is the - *GUI channel label*, not a new embedded firmware version. -- Studio keeps its existing cross-process single-instance serial-port lock; - stable Studio and preview **must not control the same device concurrently**. -- Only a PR-triggered workflow builds preview candidates. It never creates a - GitHub Release, tag, merges to main, or overwrites v0.1.1. -- Artifact lifetime is seven days. Installer and portable ZIP SHA-256 files are - shipped together. - -## Gates - -1. Build the exact PR-head Qt/C++ Studio Windows executable with warnings-as-errors. -2. Run the production deterministic device/session harness. -3. Verify the pinned v0.1.1 release source archive checksum before extracting - firmware/flasher from it. -4. Run Studio's own profile/firmware/P0 policy and application-lifecycle checks - **against the staged Windows executable and firmware**; invoke bundled - espflash standalone to verify its executable availability. -5. Produce an independent *preview-only* NSIS installer and portable ZIP. -6. Silent-install the preview into a clean Windows test location; rerun native - firmware/P0/lifecycle checks, verify preview-specific registry identity and - absence of stable registration, and silently uninstall. -7. Upload the Windows artifacts only if every gate succeeds. - -Hardware PTP phase accuracy, external GM/PPS and IED acceptance are not proven -by a Windows installer. The SOURCE/RECEIVER M3 embedded firmware is not included; -corresponding features will appear unavailable on devices without the required -firmware capability. `smpSynch AUTO` remains fail-closed. - -**Operator:** Download the Windows preview setup from the successful GitHub -Actions run's Artifacts area and launch `ARStack-Studio-Preview-win-x64-setup.exe`. -The preview installs separately from the public Studio and operates through -the GUI. It is an engineering evaluation candidate, **not a stable release**. +# ARStack Studio Inno Setup — GUI-first Windows installer + +The native Qt/C++ Studio .exe is the only operator interface. The new installer +uses Inno Setup 6.7.1 (pinned in CI), keeping the existing NSIS script only +for historical v0.1.1 reproducibility. + +## User-focused behavior + +- **Install/update:** same Inno AppId detects an earlier installation in its + channel and uses the previous install directory; no second "copy" is made. + The next preview installer upgrades the previous preview through the same + normal double-click GUI, not a Python script. +- **Running Studio:** Windows Restart Manager requests graceful application + closure before overwriting running EXE/DLLs. Do not force-kill or silently + resume SV output. For unattended CI, Inno uses /CLOSEAPPLICATIONS. +- **Safety:** preview and stable use **different** AppIds, uninstall registry + entries, install folders and Start Menu shortcuts. Both still share + Studio's cross-process serial-device single-instance lock. +- **No admin required:** per-user installation with PrivilegesRequired=lowest. +- **Remove:** Inno unregisters its own install, executable, tools, firmware, + and shortcuts. Never delete unknown files or application/user data by + wildcard. Keep operator-authored files untouched. +- **Traceability:** GUI commit, pinned public v0.1.1 firmware archive digest, + and unqualified embedded PTP status are included with the preview. + +## Acceptance + +The PR-triggered Windows workflow builds the exact PR-head Qt executable, +runs the native session harness, verifies the public v0.1.1 firmware ZIP's +SHA-256, stages firmware/tools into the preview GUI, executes packaged +application smoke, and produces a **single Windows Inno Setup .exe** plus a +portable ZIP. Then on a clean Windows runner it performs: + +1. Silent first-time install to an isolated preview path. +2. Inno AppId registration and Studio firmware/policy/lifecycle checks. +3. Launch the GUI, request an in-place update via the same installer and + confirm the first process was gracefully retired. +4. Confirm user-owned data survives upgrading. +5. Run the updated installed native firmware contract. +6. Uninstall, check owned EXE and AppId registration are gone while personal + data is preserved, and ensure public NSIS stable registration was untouched. +7. Upload files only after all tests pass. + +## Release safeguards and remaining boundaries + +**The v0.1.1 NSIS installer cannot be silently taken over by a new Inno AppId +without a separate safe migration contract.** Preview deliberately never +uninstalls or modifies the public stable channel. A subsequent stable-channel +migration must validate ownership/path, safely stop Studio, run the genuine +legacy uninstaller with bounded completion, validate rollback / known data +preservation, and test install/upgrade/uninstall from a real v0.1.1 image +before replacing the stable installer. Until then do not promote this to +public stable. + +The operator needs only ARStack Studio and the bundled firmware manager; +Windows signing/trust and physical PTP/GM/PPS/IED interoperability remain +independent release gates. \ No newline at end of file From d1c41240652b714cb9ff2b86e8b64e02ce0e9caa Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:49:08 +0700 Subject: [PATCH 07/22] feat(installer): gated ownership-safe migration from public NSIS v0.1.1 to Inno --- .../arstack-studio-native-preview.yml | 53 +++++++++++++ .../packaging/ARStackStudio.iss | 75 +++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 1f537c73a..3ee69a8dd 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -195,6 +195,59 @@ jobs: Remove-Item -LiteralPath $userNote -Force Write-Host 'Inno preview install, hot upgrade, data preservation and clean uninstall: PASS' + - name: Real public NSIS v0.1.1 to Inno migration acceptance + shell: pwsh + run: | + # The stable-channel Inno package below is only a CI migration + # fixture and is NEVER uploaded or released. + $oldUrl = 'https://github.com/masarray/arstack61850/releases/download/v0.1.1/ARStack-Studio-0.1.1-win-x64-setup.exe' + $oldSha = 'aa2bf3a1d8609c4c4d25c82ec7050a56004323b9086ed8f056db918292785a19' + $oldSetup = Join-Path $env:RUNNER_TEMP 'verified-v0.1.1-nsis-setup.exe' + Invoke-WebRequest -Uri $oldUrl -OutFile $oldSetup + $actual = (Get-FileHash $oldSetup -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $oldSha) { throw 'Legacy NSIS installer SHA-256 mismatch' } + $stableDir = Join-Path $env:LOCALAPPDATA 'Programs\ARStack Studio' + $legacyKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio' + $innoKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStack61850.Studio.Stable_is1' + if (Test-Path $legacyKey) { throw 'Unexpected preexisting stable NSIS registration on runner' } + & $oldSetup '/S' "/D=$stableDir" + if ($LASTEXITCODE -ne 0) { throw 'Actual v0.1.1 NSIS install failed' } + if (-not (Test-Path $legacyKey) -or + -not (Test-Path (Join-Path $stableDir 'Uninstall.exe'))) { + throw 'Legacy NSIS installation evidence missing' + } + $note = Join-Path $stableDir 'field-operator-owned-note.txt' + 'preserved on installer migration' | Set-Content -LiteralPath $note + + $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' + $compiler = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' + $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.iss').Path + & $compiler '/DPREVIEW=0' '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$env:RUNNER_TEMP" $script + if ($LASTEXITCODE -ne 0) { throw 'Stable-channel migration fixture compilation failed' } + $migrationSetup = Join-Path $env:RUNNER_TEMP 'ARStack-Studio-win-x64-setup.exe' + if (-not (Test-Path $migrationSetup)) { throw 'Inno migration fixture missing' } + & $migrationSetup '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' '/CLOSEAPPLICATIONS' + if ($LASTEXITCODE -ne 0) { throw 'Verified NSIS to Inno migration failed' } + if (Test-Path $legacyKey) { throw 'Legacy NSIS uninstall registration was not cleaned' } + if (Test-Path (Join-Path $stableDir 'Uninstall.exe')) { + throw 'Legacy NSIS uninstaller still present after migration' + } + if (-not (Test-Path $innoKey)) { throw 'New Inno Stable AppId missing' } + if (-not (Test-Path $note)) { throw 'Migration lost operator data' } + $newGui = Join-Path $stableDir 'ARStackStudio.exe' + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stableDir 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stableDir 'tools\espflash.exe' + & $newGui '--check-firmware-contract' + if ($LASTEXITCODE -ne 0) { throw 'Migrated firmware package contract failed' } + $uninstall = Join-Path $stableDir 'unins000.exe' + & $uninstall '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' + if ($LASTEXITCODE -ne 0) { throw 'Migrated Inno uninstall failed' } + if (Test-Path $newGui -or (Test-Path $innoKey) -or -not (Test-Path $note)) { + throw 'Migrated Inno uninstall did not remove owned files safely' + } + Remove-Item $note -Force + Write-Host 'Actual v0.1.1 NSIS to Inno + owned cleanup + data preservation: PASS' + - name: Upload GUI-only Windows preview packages uses: actions/upload-artifact@v4 with: diff --git a/apps/arstack_studio/packaging/ARStackStudio.iss b/apps/arstack_studio/packaging/ARStackStudio.iss index 1bf4ad43c..dfa2fd349 100644 --- a/apps/arstack_studio/packaging/ARStackStudio.iss +++ b/apps/arstack_studio/packaging/ARStackStudio.iss @@ -77,3 +77,78 @@ Root: HKCU; Subkey: "{#ProductRegistry}"; ValueType: string; ValueName: "Install [Run] Filename: "{app}\ARStackStudio.exe"; Description: "Launch {#ProductName}"; Flags: nowait postinstall skipifsilent + + +; This migration is compiled only for the STABLE channel. Preview cannot +; remove or update the public NSIS installation. The old NSIS uninstaller +; recursively deletes {app}; do not execute it as part of a new installation. +; Instead, after verified Inno replacement succeeds, retire only its +; proven-owned old uninstall executable and ARP registration. +#if PREVIEW +; Preview upgrades use Inno's native same-AppId uninstall-log tracking. +#else +[Code] +const + LegacyNsisUninstallKey = + 'Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio'; + +function VerifiedLegacyNsis: Boolean; +var + OldName, OldVersion, OldDir, OldUninstall: String; + ExpectedDir, ExpectedUninstall: String; +begin + Result := False; + if not RegKeyExists(HKCU, LegacyNsisUninstallKey) then + Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'DisplayName', OldName) then Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'DisplayVersion', OldVersion) then Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'InstallLocation', OldDir) then Exit; + if not RegQueryStringValue(HKCU, LegacyNsisUninstallKey, + 'UninstallString', OldUninstall) then Exit; + + ExpectedDir := ExpandConstant('{localappdata}\Programs\ARStack Studio'); + ExpectedUninstall := '"' + AddBackslash(ExpectedDir) + 'Uninstall.exe"'; + Result := + (CompareText(OldName, 'ARStack Studio') = 0) and + (CompareText(OldVersion, '0.1.1') = 0) and + (CompareText(OldDir, ExpectedDir) = 0) and + (CompareText(OldUninstall, ExpectedUninstall) = 0) and + FileExists(AddBackslash(ExpectedDir) + 'Uninstall.exe') and + FileExists(AddBackslash(ExpectedDir) + 'ARStackStudio.exe'); +end; + +function PrepareToInstall(var NeedsRestart: Boolean): String; +begin + Result := ''; + if RegKeyExists(HKCU, LegacyNsisUninstallKey) and + not VerifiedLegacyNsis then + begin + Result := 'An older ARStack Studio installation was detected, but its ' + + 'ownership, version or install path could not be verified. ' + + 'No files were changed. Please contact ARStack support for a safe upgrade.'; + end; +end; + +procedure CurStepChanged(CurStep: TSetupStep); +var + OldUninstaller: String; +begin + if (CurStep <> ssPostInstall) or + not RegKeyExists(HKCU, LegacyNsisUninstallKey) then + Exit; + if not VerifiedLegacyNsis then + RaiseException('Legacy ARStack Studio identity changed during installation.'); + + OldUninstaller := ExpandConstant('{app}\Uninstall.exe'); + if not DeleteFile(OldUninstaller) then + RaiseException('Could not retire the verified old NSIS uninstaller.'); + + if not RegDeleteKeyIncludingSubkeys(HKCU, LegacyNsisUninstallKey) then + RaiseException('Could not remove the old NSIS uninstall registration.'); + + Log('Verified NSIS v0.1.1 registry and old uninstaller retired after Inno setup.'); +end; +#endif From c3756150ae070aa8d55a0d95d159e0728d4e3d54 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:49:37 +0700 Subject: [PATCH 08/22] test(studio): localize Windows reference-template failure before installer packaging --- .../workflows/arstack-studio-native-preview.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 3ee69a8dd..913578305 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -48,6 +48,11 @@ jobs: 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' } + # Differentiate product code regressions from missing deployed DLLs. + $nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' + & $nativeGui '--check-reference-template' + Write-Host "NATIVE_BUILD_REFERENCE_TEMPLATE_EXIT=$LASTEXITCODE" + if ($LASTEXITCODE -ne 0) { throw 'Native Windows reference template regression before packaging' } - name: Verify immutable public v0.1.1 firmware and flasher source shell: pwsh @@ -65,6 +70,10 @@ jobs: throw "Pinned public base package is incomplete: $name" } } + $legacyGui = Join-Path $baseline 'ARStackStudio.exe' + & $legacyGui '--check-reference-template' + Write-Host "PUBLIC_BASELINE_REFERENCE_TEMPLATE_EXIT=$LASTEXITCODE" + if ($LASTEXITCODE -ne 0) { throw 'Verified public base GUI reference template regression' } Write-Host 'Pinned v0.1.1 public firmware / flasher archive: VERIFIED' - name: Stage isolated GUI preview with pinned release firmware @@ -108,7 +117,11 @@ jobs: $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { & $exe $check - if ($LASTEXITCODE -ne 0) { throw "Preview GUI failed packaged smoke $check" } + Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$LASTEXITCODE" + if ($LASTEXITCODE -ne 0) { + Get-ChildItem $stage -Filter '*.dll' | Select-Object -ExpandProperty Name + throw "Preview GUI failed packaged smoke $check with code $LASTEXITCODE" + } } & $env:ARSTACK_STUDIO_ESPFLASH --version if ($LASTEXITCODE -ne 0) { throw 'Pinned flasher execution failed' } From ff64f50d100ef2d14c7b1d553e61274ac47e72cc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 08:52:18 +0700 Subject: [PATCH 09/22] fix(ci): wait on GUI installers and application exit codes in Windows smoke --- .../arstack-studio-native-preview.yml | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 913578305..ec31433e5 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -50,9 +50,9 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Native session regression failed' } # Differentiate product code regressions from missing deployed DLLs. $nativeGui = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' - & $nativeGui '--check-reference-template' - Write-Host "NATIVE_BUILD_REFERENCE_TEMPLATE_EXIT=$LASTEXITCODE" - if ($LASTEXITCODE -ne 0) { throw 'Native Windows reference template regression before packaging' } + $probe = Start-Process -FilePath $nativeGui -ArgumentList '--check-reference-template' -Wait -PassThru + Write-Host "NATIVE_BUILD_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" + if ($probe.ExitCode -ne 0) { throw 'Native Windows reference template regression before packaging' } - name: Verify immutable public v0.1.1 firmware and flasher source shell: pwsh @@ -71,9 +71,9 @@ jobs: } } $legacyGui = Join-Path $baseline 'ARStackStudio.exe' - & $legacyGui '--check-reference-template' - Write-Host "PUBLIC_BASELINE_REFERENCE_TEMPLATE_EXIT=$LASTEXITCODE" - if ($LASTEXITCODE -ne 0) { throw 'Verified public base GUI reference template regression' } + $probe = Start-Process -FilePath $legacyGui -ArgumentList '--check-reference-template' -Wait -PassThru + Write-Host "PUBLIC_BASELINE_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" + if ($probe.ExitCode -ne 0) { throw 'Verified public base GUI reference template regression' } Write-Host 'Pinned v0.1.1 public firmware / flasher archive: VERIFIED' - name: Stage isolated GUI preview with pinned release firmware @@ -116,11 +116,11 @@ jobs: $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stage 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' foreach ($check in @('--check-reference-template', '--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { - & $exe $check - Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$LASTEXITCODE" - if ($LASTEXITCODE -ne 0) { + $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru + Write-Host "PACKAGED_PREVIEW_CHECK=$check EXIT=$($probe.ExitCode)" + if ($probe.ExitCode -ne 0) { Get-ChildItem $stage -Filter '*.dll' | Select-Object -ExpandProperty Name - throw "Preview GUI failed packaged smoke $check with code $LASTEXITCODE" + throw "Preview GUI failed packaged smoke $check with code $($probe.ExitCode)" } } & $env:ARSTACK_STUDIO_ESPFLASH --version @@ -155,17 +155,17 @@ jobs: run: | $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' $installDir = Join-Path $env:RUNNER_TEMP 'ARStackStudioPreviewCleanInstall' - $silent = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', "/DIR=$installDir") - & $installer @silent - if ($LASTEXITCODE -ne 0) { throw 'Inno preview installation failed' } + $silent = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS', "/DIR=`"$installDir`"") + $install = Start-Process -FilePath $installer -ArgumentList $silent -Wait -PassThru + if ($install.ExitCode -ne 0) { throw "Inno preview install exit $($install.ExitCode)" } $exe = Join-Path $installDir 'ARStackStudio.exe' if (-not (Test-Path $exe)) { throw 'Preview installation is missing GUI EXE' } $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $installDir 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $installDir 'tools\espflash.exe' $env:ESPFLASH_SKIP_UPDATE_CHECK = 'true' foreach ($check in @('--check-firmware-contract', '--check-p0-policy', '--check-app-lifecycle')) { - & $exe $check - if ($LASTEXITCODE -ne 0) { throw "Installed preview failed $check" } + $probe = Start-Process -FilePath $exe -ArgumentList $check -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw "Installed preview failed $check code $($probe.ExitCode)" } } $previewKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStack61850.Studio.Preview_is1' if (-not (Test-Path $previewKey)) { throw 'Inno Preview AppId registration missing' } @@ -179,8 +179,8 @@ jobs: throw 'GUI did not become interactive during hot-update test' } if ($runningApp.HasExited) { throw 'GUI exited before hot-update test' } - & $installer @silent - if ($LASTEXITCODE -ne 0) { throw 'Inno upgrade with running GUI failed' } + $upgrade = Start-Process -FilePath $installer -ArgumentList $silent -Wait -PassThru + if ($upgrade.ExitCode -ne 0) { throw "Inno GUI hot-update exit $($upgrade.ExitCode)" } $runningApp.Refresh() if (-not $runningApp.HasExited) { throw 'Inno did not gracefully close the GUI for upgrade' @@ -195,12 +195,12 @@ jobs: if (-not (Test-Path -LiteralPath $userNote)) { throw 'Preview upgrade removed an operator-owned file' } - & $exe '--check-firmware-contract' - if ($LASTEXITCODE -ne 0) { throw 'Upgraded GUI firmware contract failed' } + $probe = Start-Process -FilePath $exe -ArgumentList '--check-firmware-contract' -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw 'Upgraded GUI firmware contract failed' } $uninstall = Join-Path $installDir 'unins000.exe' if (-not (Test-Path $uninstall)) { throw 'Inno-generated uninstaller missing' } - & $uninstall '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' - if ($LASTEXITCODE -ne 0) { throw 'Inno preview uninstall failed' } + $removal = Start-Process -FilePath $uninstall -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') -Wait -PassThru + if ($removal.ExitCode -ne 0) { throw "Inno preview uninstall exit $($removal.ExitCode)" } if (Test-Path $exe) { throw 'Uninstall left an application-owned GUI EXE' } if (Test-Path $previewKey) { throw 'Uninstall left Inno AppId entry' } if (-not (Test-Path $userNote)) { throw 'Uninstall deleted operator-owned data' } @@ -223,8 +223,8 @@ jobs: $legacyKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStackStudio' $innoKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ARStack61850.Studio.Stable_is1' if (Test-Path $legacyKey) { throw 'Unexpected preexisting stable NSIS registration on runner' } - & $oldSetup '/S' "/D=$stableDir" - if ($LASTEXITCODE -ne 0) { throw 'Actual v0.1.1 NSIS install failed' } + $legacyInstall = Start-Process -FilePath $oldSetup -ArgumentList @('/S', "/D=$stableDir") -Wait -PassThru + if ($legacyInstall.ExitCode -ne 0) { throw "Historical NSIS install exit $($legacyInstall.ExitCode)" } if (-not (Test-Path $legacyKey) -or -not (Test-Path (Join-Path $stableDir 'Uninstall.exe'))) { throw 'Legacy NSIS installation evidence missing' @@ -239,8 +239,8 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Stable-channel migration fixture compilation failed' } $migrationSetup = Join-Path $env:RUNNER_TEMP 'ARStack-Studio-win-x64-setup.exe' if (-not (Test-Path $migrationSetup)) { throw 'Inno migration fixture missing' } - & $migrationSetup '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' '/CLOSEAPPLICATIONS' - if ($LASTEXITCODE -ne 0) { throw 'Verified NSIS to Inno migration failed' } + $migrated = Start-Process -FilePath $migrationSetup -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS') -Wait -PassThru + if ($migrated.ExitCode -ne 0) { throw "Verified NSIS to Inno migration exit $($migrated.ExitCode)" } if (Test-Path $legacyKey) { throw 'Legacy NSIS uninstall registration was not cleaned' } if (Test-Path (Join-Path $stableDir 'Uninstall.exe')) { throw 'Legacy NSIS uninstaller still present after migration' @@ -250,11 +250,11 @@ jobs: $newGui = Join-Path $stableDir 'ARStackStudio.exe' $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $stableDir 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $stableDir 'tools\espflash.exe' - & $newGui '--check-firmware-contract' - if ($LASTEXITCODE -ne 0) { throw 'Migrated firmware package contract failed' } + $probe = Start-Process -FilePath $newGui -ArgumentList '--check-firmware-contract' -Wait -PassThru + if ($probe.ExitCode -ne 0) { throw 'Migrated firmware package contract failed' } $uninstall = Join-Path $stableDir 'unins000.exe' - & $uninstall '/VERYSILENT' '/SUPPRESSMSGBOXES' '/NORESTART' - if ($LASTEXITCODE -ne 0) { throw 'Migrated Inno uninstall failed' } + $removed = Start-Process -FilePath $uninstall -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') -Wait -PassThru + if ($removed.ExitCode -ne 0) { throw "Migrated Inno uninstall exit $($removed.ExitCode)" } if (Test-Path $newGui -or (Test-Path $innoKey) -or -not (Test-Path $note)) { throw 'Migrated Inno uninstall did not remove owned files safely' } From 7ce902547d69d87206f09f2ea8747145793fda9a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:02:44 +0700 Subject: [PATCH 10/22] fix(ci): copy pinned firmware/tool contents into canonical native Studio bundle paths --- .../arstack-studio-native-preview.yml | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index ec31433e5..df8b94d1f 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -74,6 +74,14 @@ jobs: $probe = Start-Process -FilePath $legacyGui -ArgumentList '--check-reference-template' -Wait -PassThru Write-Host "PUBLIC_BASELINE_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" if ($probe.ExitCode -ne 0) { throw 'Verified public base GUI reference template regression' } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $baseline 'firmware' + $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $baseline 'tools\espflash.exe' + $native = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' + $firmwareContract = Start-Process -FilePath $native -ArgumentList '--check-firmware-contract' -Wait -PassThru + Write-Host "NATIVE_WITH_PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)" + if ($firmwareContract.ExitCode -ne 0) { + throw 'Native Studio cannot accept the pinned public firmware bundle before packaging' + } Write-Host 'Pinned v0.1.1 public firmware / flasher archive: VERIFIED' - name: Stage isolated GUI preview with pinned release firmware @@ -88,11 +96,36 @@ jobs: Copy-Item $gui $exe windeployqt --release --qmldir "$PWD\apps\arstack_studio\qml" --compiler-runtime $exe if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed' } - Copy-Item (Join-Path $baseline 'firmware') (Join-Path $stage 'firmware') -Recurse - Copy-Item (Join-Path $baseline 'tools') (Join-Path $stage 'tools') -Recurse + # Copy directory CONTENTS to a pre-created destination, not directory + # objects to ambiguous destination names (PowerShell nesting hazard). + foreach ($dir in @('firmware', 'tools')) { + $sourceDir = Join-Path $baseline $dir + $targetDir = Join-Path $stage $dir + if (-not (Test-Path $sourceDir)) { throw "Pinned bundle folder missing: $dir" } + New-Item -Path $targetDir -ItemType Directory -Force | Out-Null + Copy-Item -Path (Join-Path $sourceDir '*') -Destination $targetDir -Recurse -Force + } if (Test-Path (Join-Path $baseline 'licenses')) { - Copy-Item (Join-Path $baseline 'licenses') (Join-Path $stage 'licenses') -Recurse + $licenseDir = Join-Path $stage 'licenses' + New-Item -Path $licenseDir -ItemType Directory -Force | Out-Null + Copy-Item -Path (Join-Path $baseline 'licenses\*') -Destination $licenseDir -Recurse -Force + } + foreach ($mandatory in @('firmware\firmware-manifest.json', 'tools\espflash.exe')) { + $full = Join-Path $stage $mandatory + if (-not (Test-Path $full -PathType Leaf)) { + throw "Preview staging lost required top-level bundle path: $mandatory" + } + } + $manifest = Get-Content -Raw (Join-Path $stage 'firmware\firmware-manifest.json') | ConvertFrom-Json + $firmwareFile = Join-Path (Join-Path $stage 'firmware') $manifest.image + if (-not (Test-Path $firmwareFile -PathType Leaf)) { + throw "Preview manifest firmware image missing: $($manifest.image)" + } + $imageSha = (Get-FileHash -Algorithm SHA256 -LiteralPath $firmwareFile).Hash.ToLowerInvariant() + if ($imageSha -ne $manifest.sha256.ToLowerInvariant()) { + throw "Preview staged firmware image hash mismatch: $imageSha" } + Write-Host "Staged real GUI with pinned firmware image and flasher at canonical paths: PASS" Copy-Item LICENSE,NOTICE,THIRD_PARTY_NOTICES.md,COPYRIGHT.md -Destination $stage $meta = [ordered]@{ schema = 'arstack.studio.preview-provenance.v1' From eb7fba27c1a945b8ef09fd8cb3c67d36a0ba5cb5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:31:36 +0700 Subject: [PATCH 11/22] test(studio): capture baseline vs new native firmware contract failure evidence --- .../arstack-studio-native-preview.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index df8b94d1f..f0da1f269 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -77,10 +77,25 @@ jobs: $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $baseline 'firmware' $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $baseline 'tools\espflash.exe' $native = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' - $firmwareContract = Start-Process -FilePath $native -ArgumentList '--check-firmware-contract' -Wait -PassThru + $logDir = Join-Path $env:RUNNER_TEMP 'studio-firmware-diagnostics' + New-Item -Path $logDir -ItemType Directory -Force | Out-Null + $baselineStdErr = Join-Path $logDir 'baseline.stderr.log' + $baselineStdOut = Join-Path $logDir 'baseline.stdout.log' + $baselineContract = Start-Process -FilePath $legacyGui -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $baselineStdErr -RedirectStandardOutput $baselineStdOut + Write-Host "PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($baselineContract.ExitCode)" + Get-Content $baselineStdErr -ErrorAction SilentlyContinue + if ($baselineContract.ExitCode -ne 0) { throw 'Pinned public v0.1.1 GUI cannot validate its own firmware' } + $nativeStdErr = Join-Path $logDir 'native.stderr.log' + $nativeStdOut = Join-Path $logDir 'native.stdout.log' + $firmwareContract = Start-Process -FilePath $native -ArgumentList '--check-firmware-contract' -Wait -PassThru -RedirectStandardError $nativeStdErr -RedirectStandardOutput $nativeStdOut Write-Host "NATIVE_WITH_PUBLIC_BASELINE_FIRMWARE_CONTRACT_EXIT=$($firmwareContract.ExitCode)" + Get-Content $nativeStdErr -ErrorAction SilentlyContinue if ($firmwareContract.ExitCode -ne 0) { - throw 'Native Studio cannot accept the pinned public firmware bundle before packaging' + $manifest = Get-Content -Raw (Join-Path $baseline 'firmware\firmware-manifest.json') | ConvertFrom-Json + Write-Host "PUBLIC_FIRMWARE_MANIFEST_VERSION=$($manifest.version) PROTOCOL=$($manifest.protocol) IMAGE=$($manifest.image)" + $file = Join-Path (Join-Path $baseline 'firmware') $manifest.image + Write-Host "PUBLIC_FIRMWARE_IMAGE_EXISTS=$(Test-Path $file) SHA_MATCH=$((Test-Path $file) -and ((Get-FileHash -Algorithm SHA256 $file).Hash.ToLowerInvariant() -eq $manifest.sha256.ToLowerInvariant())) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)" + throw 'New native GUI disagrees with public firmware bundle; see stdout/stderr above' } Write-Host 'Pinned v0.1.1 public firmware / flasher archive: VERIFIED' From bc5415beebaeac3a7e1abed3c8035e718da5489d Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:35:06 +0700 Subject: [PATCH 12/22] fix(studio): bundle matched PR-head ESP32-P4 firmware in isolated Inno preview --- .../arstack-studio-native-preview.yml | 66 ++++++++++++++++--- .github/workflows/esp32p4-smv-injector.yml | 6 +- docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md | 29 +++++--- 3 files changed, 84 insertions(+), 17 deletions(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index f0da1f269..f8c4e98cc 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -15,7 +15,13 @@ concurrency: cancel-in-progress: true jobs: + # Build one compatible SOURCE firmware bundle from the exact reviewed PR + # rather than taking an older public firmware that lacks PROFILE-BINARY-V1. + firmware: + uses: ./.github/workflows/esp32p4-smv-injector.yml + windows-preview: + needs: firmware if: ${{ !github.event.pull_request.draft && github.event.pull_request.head.repo.full_name == github.repository }} runs-on: windows-2022 timeout-minutes: 40 @@ -54,7 +60,13 @@ jobs: Write-Host "NATIVE_BUILD_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" if ($probe.ExitCode -ne 0) { throw 'Native Windows reference template regression before packaging' } - - name: Verify immutable public v0.1.1 firmware and flasher source + - name: Download matched ESP32-P4 firmware built by the same workflow + uses: actions/download-artifact@v4 + with: + name: arstack-esp32p4-smv-firmware + path: firmware-package + + - name: Verify immutable public v0.1.1 flasher source and matched firmware shell: pwsh run: | $archive = Join-Path $env:RUNNER_TEMP 'arstack-v0.1.1-portable.zip' @@ -74,7 +86,33 @@ jobs: $probe = Start-Process -FilePath $legacyGui -ArgumentList '--check-reference-template' -Wait -PassThru Write-Host "PUBLIC_BASELINE_REFERENCE_TEMPLATE_EXIT=$($probe.ExitCode)" if ($probe.ExitCode -ne 0) { throw 'Verified public base GUI reference template regression' } - $env:ARSTACK_STUDIO_FIRMWARE_DIR = Join-Path $baseline 'firmware' + # Older v0.1.1 public firmware remains an immutable historical + # source for the verified flasher ONLY. Its capability contract is + # missing PROFILE-BINARY-V1 and cannot be shipped with this GUI. + $newFirmware = Join-Path $PWD 'firmware-package' + $manifestPath = Join-Path $newFirmware 'firmware-manifest.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) { + throw 'Matching firmware job did not provide its manifest' + } + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + $expectedCapabilities = @('SMV-4I4V', 'PROFILE', 'PROFILE-BINARY-V1', 'LIVE-SETPOINTS', 'SESSION-LEASE', 'PTP-P2', 'SMPSYNCH-AUTO') + foreach ($capability in $expectedCapabilities) { + if ($manifest.capabilities -notcontains $capability) { throw "PR firmware lacks capability: $capability" } + } + if ($manifest.sourceCommit -ne $env:PREVIEW_SHA) { + throw "Firmware source SHA $($manifest.sourceCommit) differs from exact Studio PR head $env:PREVIEW_SHA" + } + if ($manifest.version -ne '0.1.1' -or $manifest.chip -ne 'esp32p4') { + throw 'PR firmware version or target does not match native Studio' + } + $image = Join-Path $newFirmware $manifest.image + if (-not (Test-Path -LiteralPath $image -PathType Leaf)) { + throw 'Matching firmware image is missing' + } + if ((Get-FileHash -Algorithm SHA256 -LiteralPath $image).Hash.ToLowerInvariant() -ne $manifest.sha256.ToLowerInvariant()) { + throw 'Matching PR firmware image SHA-256 mismatch' + } + $env:ARSTACK_STUDIO_FIRMWARE_DIR = $newFirmware $env:ARSTACK_STUDIO_ESPFLASH = Join-Path $baseline 'tools\espflash.exe' $native = Join-Path $PWD 'build-studio-preview\Release\arstack_studio.exe' $logDir = Join-Path $env:RUNNER_TEMP 'studio-firmware-diagnostics' @@ -97,7 +135,7 @@ jobs: Write-Host "PUBLIC_FIRMWARE_IMAGE_EXISTS=$(Test-Path $file) SHA_MATCH=$((Test-Path $file) -and ((Get-FileHash -Algorithm SHA256 $file).Hash.ToLowerInvariant() -eq $manifest.sha256.ToLowerInvariant())) FLASHER_EXISTS=$(Test-Path $env:ARSTACK_STUDIO_ESPFLASH)" throw 'New native GUI disagrees with public firmware bundle; see stdout/stderr above' } - Write-Host 'Pinned v0.1.1 public firmware / flasher archive: VERIFIED' + Write-Host 'Pinned public v0.1.1 flasher source + matched PR-head firmware: VERIFIED' - name: Stage isolated GUI preview with pinned release firmware shell: pwsh @@ -113,10 +151,16 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed' } # Copy directory CONTENTS to a pre-created destination, not directory # objects to ambiguous destination names (PowerShell nesting hazard). + # Firmware is from the exact PR HEAD; only pinned flasher/tools + # originate from the public v0.1.1 archive. foreach ($dir in @('firmware', 'tools')) { - $sourceDir = Join-Path $baseline $dir + $sourceDir = if ($dir -eq 'firmware') { + Join-Path $PWD 'firmware-package' + } else { + Join-Path $baseline 'tools' + } $targetDir = Join-Path $stage $dir - if (-not (Test-Path $sourceDir)) { throw "Pinned bundle folder missing: $dir" } + if (-not (Test-Path $sourceDir)) { throw "Source bundle folder missing: $dir" } New-Item -Path $targetDir -ItemType Directory -Force | Out-Null Copy-Item -Path (Join-Path $sourceDir '*') -Destination $targetDir -Recurse -Force } @@ -140,15 +184,21 @@ jobs: if ($imageSha -ne $manifest.sha256.ToLowerInvariant()) { throw "Preview staged firmware image hash mismatch: $imageSha" } - Write-Host "Staged real GUI with pinned firmware image and flasher at canonical paths: PASS" + if ($manifest.sourceCommit -ne $env:PREVIEW_SHA -or + $manifest.capabilities -notcontains 'PROFILE-BINARY-V1') { + throw 'Staged firmware changed identity or required capability' + } + Write-Host "Staged Studio GUI with PR-head firmware and pinned flasher at canonical paths: PASS" Copy-Item LICENSE,NOTICE,THIRD_PARTY_NOTICES.md,COPYRIGHT.md -Destination $stage $meta = [ordered]@{ schema = 'arstack.studio.preview-provenance.v1' display = 'ARStack Studio Engineering Preview - not a public stable release' guiCommit = $env:PREVIEW_SHA guiVersion = '0.1.1-preview' - firmwareRelease = 'v0.1.1' - pinnedFirmwarePackageSha256 = $env:BASE_PORTABLE_SHA256 + firmwareVersion = '0.1.1' + firmwareSourceCommit = $env:PREVIEW_SHA + firmwareCompile = 'ESP-IDF v5.5.4 / exact PR HEAD' + pinnedFlasherBasePackageSha256 = $env:BASE_PORTABLE_SHA256 embeddedExperimentalM3 = $false ptpHardwareQualified = $false publicRelease = $false diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 4f96e9fd9..60ee70495 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -58,6 +58,10 @@ jobs: working-directory: embedded/esp32p4_smv_injector steps: - uses: actions/checkout@v4 + with: + # PR packaging must use exactly the reviewed source SHA, not a + # synthetic merge SHA that may change between queue and build. + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Select ESP32-P4 target run: source /opt/esp/idf/export.sh && idf.py set-target esp32p4 @@ -134,7 +138,7 @@ jobs: set -euo pipefail image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" sha="$(sha256sum "build/${image}" | awk '{print $1}')" - python3 - "$image" "$sha" "$GITHUB_SHA" <<'PY' + python3 - "$image" "$sha" "$(git rev-parse HEAD)" <<'PY' import json import os import sys diff --git a/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md index abec79b49..efd1a7367 100644 --- a/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md +++ b/docs/STUDIO_NATIVE_PREVIEW_PACKAGING.md @@ -20,14 +20,25 @@ for historical v0.1.1 reproducibility. - **Remove:** Inno unregisters its own install, executable, tools, firmware, and shortcuts. Never delete unknown files or application/user data by wildcard. Keep operator-authored files untouched. -- **Traceability:** GUI commit, pinned public v0.1.1 firmware archive digest, - and unqualified embedded PTP status are included with the preview. +- **Traceability:** GUI commit, matching PR-HEAD ESP-IDF firmware source/manifest/hash, + pinned public v0.1.1 **flasher tool** archive digest, and unqualified + embedded PTP status are included with the preview. ## Acceptance +The historical published v0.1.1 firmware manifest does not declare +PROFILE-BINARY-V1, which the modern native Studio requires. The new bundle +must be built from the same source commit rather than accepting an obsolete +image or disabling the capability check. + The PR-triggered Windows workflow builds the exact PR-head Qt executable, -runs the native session harness, verifies the public v0.1.1 firmware ZIP's -SHA-256, stages firmware/tools into the preview GUI, executes packaged +runs the native session harness, builds a fresh matching ESP32-P4 firmware +bundle through the shared ESP-IDF workflow from the exact PR HEAD, +verifies its manifest and image hash and PROFILE-BINARY-V1 capability, and +independently verifies the pinned public v0.1.1 archive's SHA-256 **only to +reuse the existing bundled flasher and licenses**. The historical firmware +is not compatible with the new Studio and is never staged. It stages the +matched firmware/tools into the preview GUI, executes packaged application smoke, and produces a **single Windows Inno Setup .exe** plus a portable ZIP. Then on a clean Windows runner it performs: @@ -46,10 +57,12 @@ portable ZIP. Then on a clean Windows runner it performs: **The v0.1.1 NSIS installer cannot be silently taken over by a new Inno AppId without a separate safe migration contract.** Preview deliberately never uninstalls or modifies the public stable channel. A subsequent stable-channel -migration must validate ownership/path, safely stop Studio, run the genuine -legacy uninstaller with bounded completion, validate rollback / known data -preservation, and test install/upgrade/uninstall from a real v0.1.1 image -before replacing the stable installer. Until then do not promote this to +migration must validate ownership/path, safely stop Studio, **avoid running +the legacy NSIS uninstaller that recursively deletes user files**, and retire +only verified NSIS ownership records after the new Inno installation is in +place. A Windows CI fixture installs the actual hash-pinned v0.1.1 NSIS package, +tests migration, firmware validity, and data preservation, then uninstalls +Inno. This is an acceptance fixture only, never a published stable installer. Until then do not promote this to public stable. The operator needs only ARStack Studio and the bundled firmware manager; From 4b8de644b9405913e54673fc2fa317fbd18e8db5 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:35:44 +0700 Subject: [PATCH 13/22] ci(esp32): isolate reusable preview firmware build from standalone workflow concurrency --- .github/workflows/esp32p4-smv-injector.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index 60ee70495..d771a0923 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -38,7 +38,7 @@ permissions: contents: read concurrency: - group: esp32p4-smv-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} + group: esp32p4-smv-${{ github.workflow }}-${{ github.event.pull_request.head.repo.full_name || github.repository }}-${{ github.event.pull_request.head.ref || github.ref_name }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: From 82d2f18cae18166e5d85cff97f92727d212fd043 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:46:55 +0700 Subject: [PATCH 14/22] fix(esp32): fail closed on invalid firmware source commit provenance --- .github/workflows/esp32p4-smv-injector.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/esp32p4-smv-injector.yml b/.github/workflows/esp32p4-smv-injector.yml index d771a0923..ed51794a8 100644 --- a/.github/workflows/esp32p4-smv-injector.yml +++ b/.github/workflows/esp32p4-smv-injector.yml @@ -134,15 +134,22 @@ jobs: test -s "build/${image}" - name: Create release manifest and SHA-256 contract + env: + # Container git may lack safe.directory or even be unavailable. + # The checkout explicitly selected this verified PR-head SHA. + ARSTACK_SOURCE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} run: | set -euo pipefail image="arstack-esp32p4-smv-${ARSTACK_FIRMWARE_VERSION}.bin" sha="$(sha256sum "build/${image}" | awk '{print $1}')" - python3 - "$image" "$sha" "$(git rev-parse HEAD)" <<'PY' + python3 - "$image" "$sha" "$ARSTACK_SOURCE_COMMIT" <<'PY' import json import os + import re import sys image, sha256, source_commit = sys.argv[1:] + if not re.fullmatch(r"[0-9a-f]{40}", source_commit): + raise SystemExit("Firmware source SHA unavailable/invalid; refusing a zero-provenance package") manifest = { "schema": "arstack.studio.firmware.v1", "product": "ARStack ESP32-P4 SMV Injector", @@ -175,6 +182,7 @@ jobs: assert 'PTP-P2' in manifest['capabilities'] assert 'SMPSYNCH-AUTO' in manifest['capabilities'] assert 'PROFILE-BINARY-V1' in manifest['capabilities'] + assert __import__('re').fullmatch(r'[0-9a-f]{40}', manifest['sourceCommit']) assert actual == manifest['sha256'] print(f"Firmware package: PASS · {manifest['image']} · PTP-P2 · pre-v3 · SHA-256 {actual}") PY From ebc62a117eff1c523849b9eb4202a1bd29a888b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:54:34 +0700 Subject: [PATCH 15/22] fix(studio): stop independent PTP runtime on GUI shutdown before Inno update --- .../src/DeterministicSessionHarness.cpp | 17 ++++++++++++++ apps/arstack_studio/src/DeviceController.cpp | 9 ++++---- apps/arstack_studio/src/DeviceController.hpp | 1 + apps/arstack_studio/src/DeviceIoWorker.cpp | 23 +++++++++++++------ apps/arstack_studio/src/DeviceIoWorker.hpp | 2 +- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 31bf28f74..bc52dee7c 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -42,6 +42,7 @@ class DeterministicSessionHarness final { {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start", ptpGuidedStopCancelsStart()}, + {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, {"profile rejection -> bounded terminal error", profileRejectionIsTerminal()}, @@ -457,6 +458,22 @@ class DeterministicSessionHarness final { !fixture.session.startReady(); } + static bool ptpOnlyShutdownIntent() { + Fixture fixture; + auto& device = fixture.device; + device.running_ = false; + device.ptpRunning_ = false; + device.ptpStartStage_ = DeviceController::PtpStartStage::idle; + if (device.needsPtpStopBeforeShutdown()) return false; + device.ptpRunning_ = true; + if (!device.needsPtpStopBeforeShutdown()) return false; + device.ptpRunning_ = false; + device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_start; + if (!device.needsPtpStopBeforeShutdown()) return false; + device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_role; + return device.needsPtpStopBeforeShutdown(); + } + static QVariantMap ptpValidProfile() { return {{QStringLiteral("domain"), 0}, {QStringLiteral("transportSpecific"), 0}, diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 45857e0d5..9dab09cbc 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -113,6 +113,7 @@ bool DeviceController::shutdown() { shuttingDown_ = true; pendingAutoDetect_ = false; pendingConnectPort_.clear(); + const bool requestPtpStop = needsPtpStopBeforeShutdown(); resetPtpStartup(); if (ioWorker_ == nullptr || !ioThread_.isRunning()) { @@ -124,9 +125,9 @@ bool DeviceController::shutdown() { return true; } - const bool requestStop = running_; + const bool requestSvStop = running_; if (QThread::currentThread() == &ioThread_) { - ioWorker_->shutdown(requestStop); + ioWorker_->shutdown(requestSvStop, requestPtpStop); ioThread_.quit(); shuttingDown_ = false; return false; @@ -136,8 +137,8 @@ bool DeviceController::shutdown() { DeviceIoWorker* const worker = ioWorker_; const bool queued = QMetaObject::invokeMethod( worker, - [worker, requestStop, acknowledged] { - worker->shutdown(requestStop); + [worker, requestSvStop, requestPtpStop, acknowledged] { + worker->shutdown(requestSvStop, requestPtpStop); acknowledged->release(); }, Qt::QueuedConnection); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 36699a0ce..7777b6b12 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -341,6 +341,7 @@ class DeviceController : public QObject { void failBinaryProfileDeployment(const QString& message); void resetTelemetry(); void resetPtpState(); + [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); void resetPtpStartup() noexcept; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 70230eec7..b6022855d 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -4,6 +4,7 @@ #include "DeviceController.hpp" +#include #include #include #include @@ -107,7 +108,7 @@ bool DeviceIoWorker::adoptGeneration(const quint64 generation, const bool closeO return true; } -void DeviceIoWorker::shutdown(const bool requestStop) { +void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpStop) { if (!initialized_ || shuttingDown_) return; shuttingDown_ = true; @@ -118,14 +119,22 @@ void DeviceIoWorker::shutdown(const bool requestStop) { probeQueue_.clear(); genericProbeActive_ = false; - if (requestStop && serial_ != nullptr && serial_->isOpen()) { - const QByteArray stopBytes{"STOP\n"}; - if (serial_->write(stopBytes) == stopBytes.size()) { - static_cast(serial_->waitForBytesWritten(120)); - } + // Stop both independent publishers before handing the COM port to an + // installer. Keep SV stop ahead of PTP stop so no new SV frame is sent + // during grandmaster shutdown. This runs once during teardown only. + commandQueue_.clear(); + if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { + const auto sendBoundedStop = [this](const QByteArray& command) { + const qint64 queued = serial_->write(command); + if (queued != command.size() || !serial_->waitForBytesWritten(120)) { + qWarning().noquote() + << "Device shutdown: STOP transmission could not be confirmed on the serial link."; + } + }; + if (requestSvStop) sendBoundedStop(QByteArrayLiteral("STOP\n")); + if (requestPtpStop) sendBoundedStop(QByteArrayLiteral("PTP STOP\n")); } - commandQueue_.clear(); exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 66bca1cf8..1affb5337 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -100,7 +100,7 @@ class DeviceIoWorker final : public QObject { public slots: void initialize(); - void shutdown(bool requestStop); + void shutdown(bool requestSvStop, bool requestPtpStop); void refreshPorts(quint64 generation); void autoDetectAndConnect(quint64 generation); void connectPort(const QString& portName, quint64 generation); From 06db65ee4e5ff9796b91718f69e32de4ff3adaa9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:56:21 +0700 Subject: [PATCH 16/22] fix(studio): bound full serial STOP drain instead of assuming first bytesWritten completes --- apps/arstack_studio/src/DeviceIoWorker.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index b6022855d..757254c32 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -5,6 +5,7 @@ #include "DeviceController.hpp" #include +#include #include #include #include @@ -126,9 +127,19 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { const auto sendBoundedStop = [this](const QByteArray& command) { const qint64 queued = serial_->write(command); - if (queued != command.size() || !serial_->waitForBytesWritten(120)) { + if (queued != command.size()) { + qWarning().noquote() << "Device shutdown: bounded STOP write was rejected."; + return; + } + QElapsedTimer deadline; + deadline.start(); + while (serial_->bytesToWrite() > 0 && deadline.elapsed() < 120) { + const int remaining = 120 - static_cast(deadline.elapsed()); + if (!serial_->waitForBytesWritten(remaining)) break; + } + if (serial_->bytesToWrite() > 0) { qWarning().noquote() - << "Device shutdown: STOP transmission could not be confirmed on the serial link."; + << "Device shutdown: STOP bytes remained queued after the bounded drain."; } }; if (requestSvStop) sendBoundedStop(QByteArrayLiteral("STOP\n")); From 305ac0c94c8d5552b32f74cccf46bad51ae141b2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 09:59:49 +0700 Subject: [PATCH 17/22] fix(installer): unambiguous Inno stable/preview channel and safe PTP-aware GUI shutdown --- .github/workflows/arstack-studio-native-preview.yml | 8 +++++--- apps/arstack_studio/packaging/ARStackStudio.iss | 7 ++----- docs/STUDIO_NATIVE_PTP_GUIDED_START.md | 13 +++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index f8c4e98cc..83dd73073 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -237,7 +237,7 @@ jobs: if (-not (Test-Path $compiler)) { throw "Pinned Inno Setup compiler missing: $compiler" } $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.iss').Path $installer = Join-Path $PWD 'dist\ARStack-Studio-Preview-win-x64-setup.exe' - & $compiler '/DPREVIEW=1' '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$PWD\dist" $script + & $compiler '/DARSTACK_PREVIEW' '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$PWD\dist" $script if ($LASTEXITCODE -ne 0 -or -not (Test-Path $installer)) { throw 'Inno Setup native GUI preview installer build failed' } @@ -333,10 +333,12 @@ jobs: $stage = Join-Path $PWD 'dist\ARStack-Studio-Preview' $compiler = Join-Path ([Environment]::GetFolderPath('ProgramFilesX86')) 'Inno Setup 6\ISCC.exe' $script = (Resolve-Path 'apps\arstack_studio\packaging\ARStackStudio.iss').Path - & $compiler '/DPREVIEW=0' '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$env:RUNNER_TEMP" $script + & $compiler '/DSTUDIO_VERSION=0.1.1' "/DSTAGE_DIR=$stage" "/O$env:RUNNER_TEMP" $script if ($LASTEXITCODE -ne 0) { throw 'Stable-channel migration fixture compilation failed' } $migrationSetup = Join-Path $env:RUNNER_TEMP 'ARStack-Studio-win-x64-setup.exe' - if (-not (Test-Path $migrationSetup)) { throw 'Inno migration fixture missing' } + if (-not (Test-Path $migrationSetup)) { throw 'Stable-channel Inno fixture must have a stable installer identity' } + $accidentalPreview = Join-Path $env:RUNNER_TEMP 'ARStack-Studio-Preview-win-x64-setup.exe' + if (Test-Path $accidentalPreview) { throw 'Stable migration compiled a Preview AppId accidentally' } $migrated = Start-Process -FilePath $migrationSetup -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICATIONS') -Wait -PassThru if ($migrated.ExitCode -ne 0) { throw "Verified NSIS to Inno migration exit $($migrated.ExitCode)" } if (Test-Path $legacyKey) { throw 'Legacy NSIS uninstall registration was not cleaned' } diff --git a/apps/arstack_studio/packaging/ARStackStudio.iss b/apps/arstack_studio/packaging/ARStackStudio.iss index dfa2fd349..d0013daf6 100644 --- a/apps/arstack_studio/packaging/ARStackStudio.iss +++ b/apps/arstack_studio/packaging/ARStackStudio.iss @@ -5,14 +5,11 @@ #ifndef STAGE_DIR #error STAGE_DIR is mandatory #endif -#ifndef PREVIEW - #define PREVIEW 0 -#endif #ifndef STUDIO_VERSION #define STUDIO_VERSION "0.1.1" #endif -#if PREVIEW +#ifdef ARSTACK_PREVIEW #define ProductName "ARStack Studio Preview" #define ProductId "ARStack61850.Studio.Preview" #define ProductFolder "ARStack Studio Preview" @@ -84,7 +81,7 @@ Filename: "{app}\ARStackStudio.exe"; Description: "Launch {#ProductName}"; Flags ; recursively deletes {app}; do not execute it as part of a new installation. ; Instead, after verified Inno replacement succeeds, retire only its ; proven-owned old uninstall executable and ARP registration. -#if PREVIEW +#ifdef ARSTACK_PREVIEW ; Preview upgrades use Inno's native same-AppId uninstall-log tracking. #else [Code] diff --git a/docs/STUDIO_NATIVE_PTP_GUIDED_START.md b/docs/STUDIO_NATIVE_PTP_GUIDED_START.md index 22a87bd32..09b0a4993 100644 --- a/docs/STUDIO_NATIVE_PTP_GUIDED_START.md +++ b/docs/STUDIO_NATIVE_PTP_GUIDED_START.md @@ -29,3 +29,16 @@ No changes to the stable firmware, embedded PTP Draft stack, public installer or `smpSynch AUTO` promotion. An independently tested Windows installer with matching firmware remains a release gate; this source change is not itself a published release. + +## GUI shutdown and Inno Restart Manager + +The Studio shutdown owner sends bounded STOP commands on the existing serial +worker for independently running SMV and PTP, in that order. PTP also receives +STOP during a pending START transaction. The worker drains each command within +a 120 ms monotonic wall-clock bound and reports incomplete flushes, then closes +the COM port. No extra serial owner, unbounded sleep or detached worker exists. + +Inno Setup requests ordinary application closure through Windows Restart +Manager before upgrading files. This provides best-effort protocol-safe +shutdown without claiming that a disconnected or failed COM link can certify +a physical STOP; hardware testing remains a separate gate. From 8fa52fac7b62dee375b0b6fd6e92f9bce71b5724 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 10:10:15 +0700 Subject: [PATCH 18/22] test(installer): correct PowerShell expression grouping in final NSIS migration gate --- .github/workflows/arstack-studio-native-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/arstack-studio-native-preview.yml b/.github/workflows/arstack-studio-native-preview.yml index 83dd73073..6ec0a0cf0 100644 --- a/.github/workflows/arstack-studio-native-preview.yml +++ b/.github/workflows/arstack-studio-native-preview.yml @@ -355,7 +355,7 @@ jobs: $uninstall = Join-Path $stableDir 'unins000.exe' $removed = Start-Process -FilePath $uninstall -ArgumentList @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART') -Wait -PassThru if ($removed.ExitCode -ne 0) { throw "Migrated Inno uninstall exit $($removed.ExitCode)" } - if (Test-Path $newGui -or (Test-Path $innoKey) -or -not (Test-Path $note)) { + if ((Test-Path $newGui) -or (Test-Path $innoKey) -or (-not (Test-Path $note))) { throw 'Migrated Inno uninstall did not remove owned files safely' } Remove-Item $note -Force From a0adcbdb70182e8232458e906953454dfeea5282 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 10:34:33 +0700 Subject: [PATCH 19/22] fix(studio): require PTP role ACK and live PTP2 readback before GUI START succeeds --- apps/arstack_studio/qml/PtpExpertPanel.qml | 9 ++-- .../src/DeterministicSessionHarness.cpp | 54 ++++++++++++++++++- apps/arstack_studio/src/DeviceController.cpp | 35 +++++++++--- apps/arstack_studio/src/DeviceController.hpp | 3 +- .../src/SmartSessionController.cpp | 3 +- docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md | 24 +++++++++ 6 files changed, 113 insertions(+), 15 deletions(-) create mode 100644 docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index 172e8417a..d2d43dde0 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -61,7 +61,8 @@ SurfacePanel { && Number(panel.device.ptpTxFailures) === 0 readonly property string headlineState: panel.startPending ? "STARTING" : panel.device.ptpRunning - ? (panel.receiverRole ? panel.device.ptpDiscipline : panel.activeRole + " ACTIVE") + ? (panel.receiverRole ? (panel.device.ptpDiscipline === "LOCKED" ? "BOARD LOCKED" : panel.device.ptpDiscipline) + : panel.activeRole + " ACTIVE") : panel.device.ptpStatus === "PTP start failed" ? "START FAILED" : "OFF" Timer { @@ -106,8 +107,8 @@ SurfacePanel { theme: panel.theme monoFont: panel.uiFont state: panel.headlineState - stateColor: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green - : panel.headlineState === "START FAILED" ? panel.theme.red + stateColor: panel.headlineState === "START FAILED" ? panel.theme.red + : panel.receiverRole && panel.device.ptpRunning ? panel.theme.amber : panel.device.ptpRunning ? panel.theme.green : panel.startPending ? panel.theme.amber : panel.theme.muted } @@ -271,7 +272,7 @@ SurfacePanel { anchors.margins: 10 columns: 4 Label { text: "Discipline"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } - Label { text: panel.device.ptpDiscipline; color: panel.device.ptpDiscipline === "LOCKED" ? panel.theme.green : panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } + Label { text: panel.device.ptpDiscipline === "LOCKED" ? "LOCKED (board)" : panel.device.ptpDiscipline; color: panel.theme.amber; font.family: panel.monoFont; font.pixelSize: 10; font.weight: Font.DemiBold } Label { text: "Selected source"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } Label { text: panel.device.ptpSource === "NONE" ? "—" : panel.device.ptpSource; color: panel.theme.text; font.family: panel.monoFont; font.pixelSize: 9 } Label { text: "Offset"; color: panel.theme.muted; font.family: panel.uiFont; font.pixelSize: 8 } diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index bc52dee7c..5d2225d89 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -40,6 +40,8 @@ class DeterministicSessionHarness final { {"stale generation release -> ignored", staleReleaseGenerationIsIgnored()}, {"firmware probe before serial release -> blocked", firmwareProbeRequiresReleasedPort()}, {"PTP staged START requires role/config/runtime ACKs", ptpGuidedStartNeedsAcks()}, + {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, + {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, {"PTP STOP cancels pending start", ptpGuidedStopCancelsStart()}, {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, @@ -470,7 +472,7 @@ class DeterministicSessionHarness final { device.ptpRunning_ = false; device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_start; if (!device.needsPtpStopBeforeShutdown()) return false; - device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_role; + device.ptpStartStage_ = DeviceController::PtpStartStage::awaiting_role_ack; return device.needsPtpStopBeforeShutdown(); } @@ -493,12 +495,57 @@ class DeterministicSessionHarness final { return f.session.requestStartPtpConfigured(role, ptpValidProfile()); } + static QString ptpRoleEvidence(const QString& role) { + return QStringLiteral("PTP2 role=%1 discipline=UNLOCKED source=NONE offset=NA path=NA " + "jitter=NA freq=0 global=0 measured=NA rxAnnounce=0 rxSync=0 rxFollowUp=0 " + "rxPdelay=0 pdelayReq=0 accepted=0 rejected=0").arg(role); + } + + static bool ptpStaleRoleReadbackBlocked() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack) return false; + if (f.session.requestPtpRefresh()) return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_readback) + return false; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + return d.ptpStartStage_ == DeviceController::PtpStartStage::awaiting_config; + } + + static bool ptpRuntimeRoleEvidenceRequired() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); + if (d.ptpStarting() || d.ptpRunning_ || d.ptpStatus_ != QStringLiteral("PTP start failed")) + return false; + if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) + return false; + d.processLine(QStringLiteral("PTPROLE accepted")); + d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); + d.processLine(QStringLiteral("PTP configuration accepted")); + d.processLine(QStringLiteral("PTP start accepted")); + d.processLine(ptpRoleEvidence(QStringLiteral("RECEIVER"))); + return !d.ptpStarting() && !d.ptpRunning_ && + d.ptpStatus_ == QStringLiteral("PTP start failed"); + } + static bool ptpGuidedStartNeedsAcks() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; auto& d = f.device; - if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role || + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_ack || d.ptpRunning_ || !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTPROLE accepted")); d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config || !d.ptpStarting()) return false; @@ -508,6 +555,7 @@ class DeterministicSessionHarness final { d.processLine(QStringLiteral("PTP start accepted")); // A PTP-only START must NEVER switch the SV 4I+4V publisher on. if (d.running_ || d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; + d.processLine(ptpRoleEvidence(QStringLiteral("RECEIVER"))); d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); return !d.running_ && !d.ptpStarting() && d.ptpRunning_ && @@ -519,6 +567,7 @@ class DeterministicSessionHarness final { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; d.processLine(QStringLiteral("PTP configuration rejected: invalid profile")); @@ -541,6 +590,7 @@ class DeterministicSessionHarness final { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; auto& d = f.device; + d.processLine(QStringLiteral("PTPROLE accepted")); d.processLine(QStringLiteral("PTPROLE role=MONITOR value=2 running=0")); if (d.ptpStarting() || d.ptpStatus_ != QStringLiteral("PTP start failed")) return false; diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 9dab09cbc..8b8cf03f3 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1022,6 +1022,7 @@ void DeviceController::resetPtpStartup() noexcept { ptpStartStage_ = PtpStartStage::idle; pendingPtpRole_.clear(); pendingPtpConfig_.clear(); + ptpRuntimeRoleSeen_ = false; } void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { @@ -1054,8 +1055,8 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, } pendingPtpRole_ = role; pendingPtpConfig_ = command; - ptpStartStage_ = PtpStartStage::awaiting_role; - ptpStatus_ = QStringLiteral("Selecting PTP timing mode..."); + ptpStartStage_ = PtpStartStage::awaiting_role_ack; + ptpStatus_ = QStringLiteral("Waiting for board to accept PTP mode..."); emit ptpStateChanged(); if (!sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role))) { failPtpStartup(QStringLiteral("Could not queue PTP role selection."), false); @@ -1079,7 +1080,17 @@ void DeviceController::advancePtpStartup(const QString& line) { failPtpStartup(QStringLiteral("Board rejected PTP mode, settings or start."), rollback); return; } - if (ptpStartStage_ == PtpStartStage::awaiting_role) { + if (ptpStartStage_ == PtpStartStage::awaiting_role_ack) { + // A SHOW response cannot replace the explicit PTPROLE acceptance. + if (line.contains(QStringLiteral("PTPROLE accepted"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_role_readback; + ptpStatus_ = QStringLiteral("Confirming PTP mode on board..."); + emit ptpStateChanged(); + ptpStartTimer_.start(5000); + } + return; + } + if (ptpStartStage_ == PtpStartStage::awaiting_role_readback) { const auto reply = kPtpRoleExpression.match(line); if (!reply.hasMatch()) return; if (reply.captured(1).toUpper() != pendingPtpRole_ || @@ -1109,7 +1120,8 @@ void DeviceController::advancePtpStartup(const QString& line) { } else if (ptpStartStage_ == PtpStartStage::awaiting_start && line.contains(QStringLiteral("PTP start accepted"), Qt::CaseInsensitive)) { ptpStartStage_ = PtpStartStage::awaiting_runtime; - ptpStatus_ = QStringLiteral("Verifying live PTP runtime..."); + ptpRuntimeRoleSeen_ = false; + ptpStatus_ = QStringLiteral("Verifying live PTP role and runtime..."); emit ptpStateChanged(); if (!sendPtpShow()) { failPtpStartup(QStringLiteral("Could not read back PTP runtime."), true); @@ -1117,10 +1129,19 @@ void DeviceController::advancePtpStartup(const QString& line) { } ptpStartTimer_.start(5000); } else if (ptpStartStage_ == PtpStartStage::awaiting_runtime) { + const auto roleEvidence = kPtpMeasuredStateExpression.match(line); + if (roleEvidence.hasMatch()) { + if (roleEvidence.captured(1).toUpper() != pendingPtpRole_) { + failPtpStartup(QStringLiteral("PTP runtime role differs from the selected mode."), true); + return; + } + ptpRuntimeRoleSeen_ = true; + return; + } const auto readback = kPtpStatusExpression.match(line); if (!readback.hasMatch()) return; - if (!ptpRunning_ || ptpRole_ != pendingPtpRole_) { - failPtpStartup(QStringLiteral("PTP runtime does not confirm the selected mode."), true); + if (!ptpRuntimeRoleSeen_ || !ptpRunning_ || ptpRole_ != pendingPtpRole_) { + failPtpStartup(QStringLiteral("PTP requires matching PTP2 role and RUNNING readback."), true); return; } resetPtpStartup(); @@ -1392,7 +1413,7 @@ void DeviceController::processLine(const QString& rawLine) { ptpStatus_ = QStringLiteral("Not enabled in firmware"); emit ptpStateChanged(); } - if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive)) { + if (line.contains(QStringLiteral("PTP configuration accepted"), Qt::CaseInsensitive) && !ptpStarting()) { emit deviceMessage(QStringLiteral("PTP expert profile accepted.")); static_cast(sendPtpShow()); } diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 7777b6b12..7ef564325 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -294,7 +294,7 @@ class DeviceController : public QObject { private: friend class DeterministicSessionHarness; - enum class PtpStartStage { idle, awaiting_role, awaiting_config, awaiting_start, awaiting_runtime }; + enum class PtpStartStage { idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, awaiting_start, awaiting_runtime }; enum class BinaryProfileStage { idle, @@ -385,6 +385,7 @@ class DeviceController : public QObject { QTimer ptpStartTimer_; QString pendingPtpRole_; QString pendingPtpConfig_; + bool ptpRuntimeRoleSeen_{false}; bool ioWorkerReady_{false}; bool ioWorkerAffinityValid_{false}; bool shuttingDown_{false}; diff --git a/apps/arstack_studio/src/SmartSessionController.cpp b/apps/arstack_studio/src/SmartSessionController.cpp index c2817b5a4..ef337feaf 100644 --- a/apps/arstack_studio/src/SmartSessionController.cpp +++ b/apps/arstack_studio/src/SmartSessionController.cpp @@ -408,7 +408,8 @@ bool SmartSessionController::requestZero() { } bool SmartSessionController::requestPtpRefresh() { - if (!deviceControlAvailable() || device_ == nullptr) return false; + // A guided START owns its ACK/readback order; do not interleave SHOWs. + if (!deviceControlAvailable() || device_ == nullptr || device_->ptpStarting()) return false; const bool ptp = device_->sendPtpShow(); const bool sync = device_->sendSmpSynchShow(); return ptp && sync; diff --git a/docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md b/docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md new file mode 100644 index 000000000..15fb0c64e --- /dev/null +++ b/docs/STUDIO_PTP_RUNTIME_ROLE_GATE.md @@ -0,0 +1,24 @@ +# Native Studio PTP readback authority + +This refines ARStack Studio's existing C++ guided-start transaction, not a +web UI or a second serial-control implementation. + +The former implementation accepted a matching role readback without requiring +the firmware's explicit `PTPROLE accepted` first. It also accepted +`PTP status=RUNNING` using a stale configured role. Periodic QML refresh +could interleave extra SHOW commands. + +Now the native transaction requires an explicit role acceptance, matching +stopped role readback, configuration acceptance, START acceptance, then +role-matched PTP2 runtime evidence and a RUNNING runtime status. Bounded Qt +timeouts, session-generation revocation and STOP/rollback are retained. +The independent presentation refresh is disabled only while START is pending. + +The Timing panel labels receiver LOCKED as board-reported, not proof of +independently calibrated PTP/PPS/GM timing or SV synchronization. Tests include +stale early readback, missing PTP2 evidence, role mismatch, and successful +ordered ACKs. M3 hardware acceptance remains pending. + +No release, embedded firmware source, or stable NSIS/Inno installer is +modified. The existing preview workflow must again build matched firmware and +Windows installer from the exact new PR head. From 61be86f6804bad8e1ae9a042620e763512da7901 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 11:02:21 +0700 Subject: [PATCH 20/22] docs(workbench): canonical ARSAS-inspired GUI/SSOT strategy and cross-thread handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish one actionable M2K–M2P milestone and ownership plan; clarify canonical MMS/q/t/RCB evidence, UI/CI/performance budgets and multi-PR merge gates. Refresh obsolete M2G handoff and add discovery pointers for all engineers. Documentation only; no runtime behavior changed. --- AI_READ_FIRST.md | 2 +- PROJECT_HANDOFF.md | 2 +- docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md | 179 ++++++++++++++++++++++ docs/WORKBENCH_CURRENT_HANDOFF.md | 142 +++++------------ 4 files changed, 223 insertions(+), 102 deletions(-) create mode 100644 docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md diff --git a/AI_READ_FIRST.md b/AI_READ_FIRST.md index dccfa502c..5a94e2737 100644 --- a/AI_READ_FIRST.md +++ b/AI_READ_FIRST.md @@ -4,7 +4,7 @@ This repository contains protocol behavior that has been reconstructed from stan Read the topic contract before changing the corresponding code: -- **Current native Qt Workbench / IED Browser progress, exact baseline, red PR and optimized coding handoff:** [`docs/WORKBENCH_CURRENT_HANDOFF.md`](docs/WORKBENCH_CURRENT_HANDOFF.md). Read this before working on `apps/ied_simulator` Browser/Reports/DataSets/Global Data/q/t. +- **Native Qt Workbench / IED Browser cross-thread strategy, ARSAS design reference, SSOT, milestones, ownership and merge gates:** [`docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md`](docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md). **Current checkpoint and active PRs:** [`docs/WORKBENCH_CURRENT_HANDOFF.md`](docs/WORKBENCH_CURRENT_HANDOFF.md). Read **both** before editing `apps/ied_simulator` Browser/Reports/DataSets/Global Data/SG/q/t. - **Live IP discovery vs trusted-SCL online connect:** [`docs/ONLINE_MODEL_CONNECT_DECISION.md`](docs/ONLINE_MODEL_CONNECT_DECISION.md), then [`LIVE_DISCOVERY_PROFILE.md`](LIVE_DISCOVERY_PROFILE.md). - **RCB reporting, URCB/BRCB activation, report bit semantics, replay/recovery:** [`RCB_REPORTING.md`](RCB_REPORTING.md). - **Open SCL, live MMS reconstruction, canonical model normalization, IED-name resolution, and Edition 2 / Edition 1 export:** [`SCL_EXPORT.md`](SCL_EXPORT.md), then [`docs/SCL_IMPORT_NORMALIZATION_PROFILE.md`](docs/SCL_IMPORT_NORMALIZATION_PROFILE.md) and [`docs/SCL_EXPORT_RECONSTRUCTION_PROFILE.md`](docs/SCL_EXPORT_RECONSTRUCTION_PROFILE.md). diff --git a/PROJECT_HANDOFF.md b/PROJECT_HANDOFF.md index 1133fa617..061b6c53b 100644 --- a/PROJECT_HANDOFF.md +++ b/PROJECT_HANDOFF.md @@ -2,7 +2,7 @@ This file is the **current-state handoff** for a new engineer or AI thread. Read [`NORTH_STAR.md`](NORTH_STAR.md) first for the long-term direction, then use this file to continue active development without reconstructing the bench history from chat. -> **Current native IEC 61850 Workbench / Browser engineering track (M2A–M2G):** read [docs/WORKBENCH_CURRENT_HANDOFF.md](docs/WORKBENCH_CURRENT_HANDOFF.md) before resuming Qt Browser, DataSets, Global Data, Reports, typed q/t, CI or Windows Workbench work. This is a dated, SHA-backed checkpoint and lists the active red PR. The SMV/ESP32-P4 release history below belongs to a separate product track. +> **Current native IEC 61850 Workbench / Browser engineering track (M2A–M2J merged; M2K PR #176 active as of 2026-10-15):** read [docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md](docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md) for the shared ARSAS-inspired GUI/SSOT execution plan, cross-thread ownership and CI/merge gates; then read [docs/WORKBENCH_CURRENT_HANDOFF.md](docs/WORKBENCH_CURRENT_HANDOFF.md) for the latest checkpoint and active blockers. **Always refresh GitHub refs**. The SMV/ESP32-P4 release history below is a separate product track. Last major product milestone update: **2026-09-15 — stable public SMV Injector v0.1.0 released**. diff --git a/docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md b/docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md new file mode 100644 index 000000000..78de76d5e --- /dev/null +++ b/docs/WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md @@ -0,0 +1,179 @@ +# ARStack61850 native IED Browser — ARSAS-inspired, SSOT-first engineering strategy + +**Status:** cross-thread execution contract; first written 2026-10-15 (WIB), against ARStack main a328da13133a and ARSAS main 45348eccf24e941dd185e5fb729217923846c610. **Re-query GitHub before acting.** This is a living strategy, not a claim of completed functionality. + +**Product:** the installed **native Qt/QML ARStack Workbench** (apps/ied_simulator), **not** a web GUI, a new desktop app, a WPF port, or the separate ESP32-P4 timing/SV firmware track. The objective is an IEDScout-class engineering workflow while borrowing effective visual/interaction patterns from the ARSAS/ARIEC61850 Windows Explorer. ARStack's own IEC 61850 models and wire evidence remain authoritative. + +## 0. Read order for every thread + +1. [AGENTS.md](../AGENTS.md) and [AI_READ_FIRST.md](../AI_READ_FIRST.md) — hard rules. +2. [This strategy](WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md) — common direction, SSOT, handoffs, gates, ownership. +3. [WORKBENCH_CURRENT_HANDOFF.md](WORKBENCH_CURRENT_HANDOFF.md) — live checkpoint; update after each accepted milestone. +4. [CI_WORKFLOW_ROUTING.md](CI_WORKFLOW_ROUTING.md), [RCB_REPORTING_REFERENCE.md](RCB_REPORTING_REFERENCE.md), [ONLINE_MODEL_CONNECT_DECISION.md](ONLINE_MODEL_CONNECT_DECISION.md) as applicable. +5. Recheck GitHub main SHA, open PRs and their review threads, exact-head Actions, and current file content. This document's SHAs and status are **as-of-snapshot**, not a substitute for live checks. + +**Reviewable design references (pinned to an actual ARSAS revision):** + +- [ARSAS MainWindow.xaml — IEC 61850 Explorer / LIVE SIGNAL VALUES](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.xaml): IED cards, Connect/Open SCL/Add IED workflow, dense live grid (Signal, IEC Telegram, Value, Quality, IED Timestamp, Acquisition), search, and guarded command panel. +- [ARSAS ExplorerSignalGrid](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.ExplorerSignalGrid.cs): column sizing and timestamp detail. +- [ARSAS SclLiveModelAuthority](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.SclLiveModelAuthority.cs) and [live projection](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/SclLiveSignalModelProjection.Authority.cs): design/live authority reconciliation. +- [ARSAS shared static DataSet authority](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/MainWindow.SharedStaticDataSetAuthority.cs): preserve static report-first behavior, without automatic cyclic MMS. +- [ARSAS IoList FAT workspace](https://github.com/masarray/arsas/blob/45348eccf24e941dd185e5fb729217923846c610/IoListTestingWindow.xaml): multi-IED selection and engineering context. + +**Copy the *interaction principles*, not WPF/C# runtime, invented values, unsupported IEC semantics, or duplicated services.** ARSAS' IEC 61850 Explorer is a useful reference, but not proof that every ARSAS screen literally matches an IEDScout tab. + +## 1. Exact baseline, active work and merge dependencies + +At creation: +- ARStack main: [a328da13 (M2J merged)](https://github.com/masarray/arstack61850/commit/a328da13133a6473dc6a28a69f5d2aebab7a7198). M2H [#171](https://github.com/masarray/arstack61850/pull/171), M2I [#173](https://github.com/masarray/arstack61850/pull/173) and M2J [#174](https://github.com/masarray/arstack61850/pull/174) are merged. +- M2J post-merge Qt, release hardening, SCL-assisted connect and SCL-assisted Workbench workflows completed successfully at this main SHA. +- **Active integration candidate M2K: [PR #176](https://github.com/masarray/arstack61850/pull/176)**, head **5c5e425c20acb1b849399f39bf62da0f68c0942b** at writing. The SCL-Assisted Workbench CI passed; its exact-head native Qt run [38678320479](https://github.com/masarray/arstack61850/actions/runs/38678320479) was still **in progress** when last inspected. **Do not mark M2K merged/green or start conflicting edits from an assumption.** +- Unmerged PR [#172](https://github.com/masarray/arstack61850/pull/172) (another q/t visual QA branch), [#164](https://github.com/masarray/arstack61850/pull/164) (older q/t branch), and legacy Browser recovery PRs exist. They are **not automatically safe to merge**; review differences against main and accepted M2I/M2J first. Some PTP/SV and native Studio branches are distinct tracks (for example #175). +- M2K's approved scope is the ARSAS-inspired native All-IED live grid, explicit Quality / IED Timestamp columns, bounded search and truthful sample-received state in the canonical MMS tree, with regression gates. The existing M2K branch owns those files until merged/closed. **One integration owner**, no duplicate competing PR for M2K. +- Every future milestone lists both base SHA and exact PR head, a bounded change set, CI links, review resolution and merge SHA. Update the companion [current handoff](WORKBENCH_CURRENT_HANDOFF.md) at accepted checkpoints, not after every experimental commit. + +## 2. Authoritative architecture — a single model, multiple views + +The graph is a dependency contract, **not a request to add layers**: + + Open SCL / normalize ----------+ + | + Online MMS discovery -----------+--> IedEngineeringContextController + | | + Trusted-SCL assisted validation-+ +--> canonical MMS model / MmsLiveTreeModel + | indexed domain/item and IEC aliases + per-IED MMS association (MmsClientController)-+--> actual read samples + typed evidence + per-IED RCB association (MmsReportController)--+--> actual report frames + RCB lifecycle + | + QML navigation, Data Model, DataSets, + Reports, Global Data, SG, Files, GOOSE + | + All-IED is a **bounded presentation only**, + never a second protocol model or acquisition owner + +Current code authority / edit boundaries: + +| Concern / role | Owned source; do not clone | +| --- | --- | +| IED source identity, SCL vs online, context generation | apps/ied_simulator/src/IedEngineeringContextController.* | +| Canonical domain/LD/LN/DO/DA, indexed IEC/MMS aliases, value and selected state | apps/ied_simulator/src/MmsLiveTreeModel.hpp/.cpp | +| MMS confirmed request/read and per-IED session | apps/ied_simulator/src/MmsClientController.* and IedBrowserFleetController.* | +| IEC Quality and UTC TimeQuality type validation | apps/ied_simulator/src/MmsEngineeringEvidence.hpp (**one shared typed decoder**) | +| Trusted-SCL initial mapped Read | apps/ied_simulator/src/MmsSclClientProjection.hpp, with Qt conversion only in Workbench boundary | +| RCB activation, framed InformationReport, replay and cleanup | apps/ied_simulator/src/MmsReportController.* and BrowserReportMemberProjection.hpp | +| Navigation, viewport and inspectors | apps/ied_simulator/qml/IedBrowserWorkspace.qml, IedBrowserNavigation.qml, BrowserDataModelPane.qml, BrowserDataSetPane.qml, BrowserReportPane.qml | +| Per-IED watchlist (presentation only) | apps/ied_simulator/qml/BrowserGlobalDataPane.qml | +| Cross-IED watchlist (bounded, view only) | apps/ied_simulator/qml/FleetGlobalDataPane.qml and Main.qml | +| SG/File/GOOSE existing behavior | existing MmsFileSettingsController, BrowserGoosePane, service/controller paths; identify live owner before editing | + +**No new protocol parser in QML, separate signal store, second SCL model, second quality decoder, UI-owned MMS polling, or WPF/.NET subsystem.** Only already-confirmed source values can become live evidence. All-IED aggregation must carry the original IED slot and never silently redirect an action to a different association. + +### Provenance and lifecycle state contract + +- A discovered/structurally **readable DA is not a received MMS value**. The canonical tree must distinguish: loaded/discovered, no sample (NOT READ), successful sample (including a legitimate empty scalar), failed/unknown, offline, and mismatched/stale generation. Do not mark LIVE from readable alone. +- Keep value, typed q/t, server timestamp, local receipt time (only if really captured), acquisition source (explicit Read versus confirmed RCB report versus design-only) and source-generation coherent. Extend metadata **only on the current authoritative event-ingress path**; do not invent second value caches to satisfy the grid. +- On a new SCL/IED identity, disconnect, generation replacement or report lifecycle reset: invalidate context-dependent selection, unverified live claims, stale q/t/SG evidence and delayed callbacks. A late callback from an obsolete generation must be dropped, not applied to the next IED. +- Distinguish a displayed '-' for absent evidence from valid IEC Boolean false, validity good, TimeQuality false bits, or a *received empty value*. 'Unknown' must not be silently converted into false. +- Typed q/t inspection must be sourced from actual MMS BIT STRING/UTC Time and correct leaf **only**. No guessed q/t bit positions, no timestamp synthesis, no q/t from arbitrary decimal strings. +- Angle exists only when source type/CDC/structured vector supports a phasor angle (for example an applicable complex measurement); **never append angle to frequency Hz, TotW, or generic scalar just because another row had an angle**. Units/multipliers require SCL or validated metadata, not a hardcoded unit-per-column shortcut. Do not project every measurement as a generic magnitude/angle tuple. + +### Protocol/reporting constraints + +- Report/DataSet members maintain confirmed ordered FCDA identity and **received frame ordinal**; Report ID and optional fields must be decoded from the real frame. Missing/invalid/segmented values cannot be shifted to a neighboring member. +- **Static DataSet/RCB must remain report-first/report-only** where configured; no silent periodic Read to fill missing rows. Initial bounded snapshot and user-triggered explicit Read are distinct authorities. URCB reservation and BRCB behavior cannot be collapsed. +- Controls/write/SG switching/file operations require effective server-state readback/guarded user intent and clear error presentation. A successful request submission alone is not proof of effect. + +## 3. UX target and user workflow (ARSAS guidance, IEDScout-class outcome) + +**Primary journey:** Open SCL or Add IED by endpoint -> resolve exact IED/AP/domain -> inspect offline hierarchy -> Connect/Discover/Validate -> browse Data Model/Measurement -> select DA / Read explicitly -> inspect typed Value/Quality/TimeQuality/unit/provenance -> choose bounded DataSet -> inspect ordered FCDA -> inspect report controls -> confirm/enable static RCB under correct guard -> receive evidence mapped to report member -> monitor All-IED grid -> disconnect/reopen without cross-context residue. + +| Native workbench surface | Required contents and behavior | ARSAS idea adopted / exact acceptance | +| --- | --- | --- | +| Fleet header / IED cards | one card per IED; endpoint, loaded/connected state, action availability, explicit selected IED | ARSAS Explorer card-driven navigation; no global implicit Connect All | +| Data Model / Measurement | LD/LN/DO/DA indexed tree; FC and MMS/SCL type; current scalar/vector value; engineering unit if proven; q/t & read-state visible | dense selected inspector, lazy drilldown; scalar frequency/power never borrow phase angle | +| DataSets | searchable ordered FCD/FCDA members; one FC label, valid alias resolution, bounded lazy DO->DA expansion | selection changes inspector without MMS Read; no duplicate FC or phantom value | +| Reports | RCB list grouped by LLN0; selected RCB attributes / effective state; last actual InformationReport and aligned members | DataSet binding, RptID, state, GI, reason, q/t; static report-only path preserved | +| Setting Groups | actual SGCB state, active/edit SG, selected group parameter values with typed semantics | never claim write applied before effective readback; no phantom SG values | +| Files | file listing, safe names/sizes, guarded transfer/download progress, cancellation/errors | IO bounded, no blocking UI; user-visible provenance of local vs server | +| GOOSE | configured GOOSE attributes/selection/evidence, source identity | no auto-subscribe/transmit or fabricated live state on SCL-only source | +| Per-IED Global Data | canonical-selected DA, DataSet, Report, GOOSE watchlist | add/remove/refresh explicit; display read status and q/t as separate evidence | +| All-IED Global Data | IED, IEC reference, Value, Quality, IED Timestamp, acquisition/status; filter by IED/ref/value | 1:1 slot+reference identity, visible q/t, event-driven row patch, bounded presentation and no cross-IED requests | + +**Presentation rules:** professional dense hierarchy and consistent affordances; show why action is disabled (offline, busy, no loaded model, incompatible trusted SCL). Do not hide primary QA fields in tooltips only. Prefer stable row identity and virtualization over resetting the list on every report. Filters change only what is visible, not the watchlist, subscription or report authority. Preserve selection/scroll state across value-only updates when possible. Test at realistic Windows scaling and narrow/wide sizes; do not claim pixel parity without actual captured selected-state screenshots and human review. + +## 4. Bounded, optimized implementation contract + +Existing enforceable or established bounds to **preserve** (verify against current source at every milestone): +- Canonical large-model fixture exercises **60,106 nodes**; continue lazy/virtualized tree projection and indexed MMS/IEC lookups; no full-tree QML rewalk per value. +- MmsLiveDetailModel caps its projected rows at **4,096**; per FCDA lazy expansion at **64 children**. +- Global Data explicit MMS refresh caps at **64 targets per action**; do not multiply requests by each visible delegate. +- Per-IED watchlist **256 entries**, All-IED presentation **2,048 rows**, per-presented text preview **256 chars** (M2K scope). +- RCB report GUI member projection **256 members**, display preview **1,024 chars**, initial typed q/t evidence budget **96 items**. These caps are presentation/initial projection limits, **not permission to drop or truncate authoritative source frames**. +- No unbounded search index, duplicate semantic cache, one Timer per row, eager QString formatting of attacker-sized MMS strings, blocking conversion on packet callback, or heap growth proportional to unlimited incoming events. + +**Optimization sequence:** measure before changing data structure; use already-built indexes, model roles and stable key projections; patch changed rows and coalesce bursts on GUI event loop; keep conversion off the UI and protocol hot paths; validate bounds **before** string allocation. No speculative abstraction layers, broad refactors, more background workers, fixed sleeps or fake evidence merely to pass a test. + +**Capacity and performance acceptance:** record node/visible count, model build and selection latency, filter latency, UI update/refresh time, result count, allocations or bounded-memory high water where instrumented; compare to **last known good CI under same runner/workload**. No increase of asymptotic complexity; any material regression needs a measured justification. For interactive views, test repeated value bursts, 8 independent IEDs, sparse/empty lists, 2,048 watched rows, large SCL and oversized untrusted values. + +## 5. Staged delivery order and integration gates + +Every milestone is a **small vertical slice** (one actual user action through the existing model to rendered evidence, plus negative/regression cases). Do not wait for one giant UI rewrite. + +| Gate | Scope and dependencies | Merge acceptance | +| --- | --- | --- | +| **M2K — ongoing, PR #176** | ARSAS-style All-IED grid + canonical hasReceivedValue; finish exact-head CI and review, **then** merge | visible q/t, correct NOT READ vs empty received, stable coalesced updates, multi-IED P6E and 60k regression, PR SHA matches CI | +| **M2L — next** | **Canonical measurement semantics / provenance first**, building on merged M2K: correct scalar vs vector, engineering unit, quality, clock, read state, acquisition; one typed projection contract | fixture matrix: MX magnitude, structured vector+angle when valid, frequency (Hz), TotW/power, Boolean, invalid unit/type/q/t, stale generation; no angle on Hz/TotW; no 'LIVE' without sample | +| **M2M** | Data Model and DataSets **rendered native workflow**: selection, FCDA aliases, tree expansion, inspector/read button, per-IED Global Data handoff | deterministic native screenshots with actual selected DA/FCDA (not overview only), no implicit Read, 64 child bound, multi-IED isolation | +| **M2N** | Reports/RCB and Global Data provenance: requested vs effective RCB; actual report q/t and ordered frame members; consolidated live grid acquisition | static report-only; GI/URCB/BRCB and cleanup, malformed/omitted/segmented report tests, actual selected native screenshot and no cross-IED leakage | +| **M2O** | Setting Groups / Files / GOOSE productivity and safety; one controller and explicit user workflow per feature | selected SG values and readback, negative/safe control cases, guarded transfer/cancel, configured-vs-live GOOSE state | +| **M2P** | End-to-end visual/interaction/scale polish, Windows executable delivery and physical IED trials | screenshot compare with reference by section/state, keyboard/mouse/resize, stress + cancel/reconnect, exact Windows installer artifact lineage, real IED evidence distinguished from simulation | + +Milestone order may be rearranged **only for explicit blockers/dependencies**, and the current handoff must record the reason. A screenshot-only cosmetic PR cannot silently supersede a missing semantic/measurement test. Full GUI parity remains a **goal**, not a CI status. + +## 6. Parallel-thread ownership and collaboration protocol + +**Integration steward** (one thread at a time): +- Maintains this strategy and current handoff, checks PR queue, decides dependency order, owns final exact-head merge with expected SHA, reconciles conflicts and performs post-merge main check. +- Publishes a short checkpoint per accepted milestone (baseline SHA, PR number/HEAD, changed source, green exact-head runs, artifacts, unverified gaps). +- Does **not** auto-merge legacy/parallel branches #172, #164, #136/#137 or firmware PRs on name similarity alone. First compare with main and accepted work. + +**Track A — canonical data/provenance** (serial owner): MmsLiveTreeModel, typed evidence, context/source generation, measurement semantics and hardening; ARSAS-inspired output **must** come from these existing models. Typical M2L. Any report decoder overlap requires an explicit handoff to Track C. + +**Track B — native Qt UX** (parallel only on disjoint paths): IedBrowserNavigation, BrowserDataModelPane, BrowserDataSetPane, Global Data QML, selection/visual states and GUI QA. No decoder, new model, timer-driven polling, or another source of truth. Typical M2M. + +**Track C — protocol-dependent panels**: MmsReportController/Report projection, SG/File/GOOSE controller APIs, actual RCB lifecycle/cleanup. Follow reporting profile; no network/polling behavioral changes as “UI polish”. Typical M2N–M2O. + +**Track D — evidence/CI/release**: fixture matrix, native Qt smoke, screenshot capture of selected states, large-model/performance and Windows artifacts. Works concurrently on **test paths not already being modified**; coordinates test_browser_convergence.py and test_multi_ied_workspace.py with the feature owner (these files are shared cross-cutting conflict points). Never “fix” failing CI by relaxing a correct invariant. + +**Ownership rule:** avoid two open PRs editing the same authoritative source file. If needed, designate one integration candidate and explicitly merge/rebase/reconcile prior changes **before** new work; compare diffs and check lease, never force-push another thread's branch. Prefer separate feature PRs based on merged main. Cross-thread coordination is visible **in the PR description and the handoff**, not assumed from chat context. + +**Handoff contract for a thread opening work:** include base/ref and fresh main SHA; active dependency PRs with HEAD; precise canonical owner and target files; observed failure/UX gap with source/fixture; expected semantic invariants; acceptance tests and performance budget. On exit update current handoff with status (planned, in progress, exact-head green, merged, or blocked), logs/artifact links, review objections and next-step owner. Another thread must first reconcile these entries with live GitHub, not blindly reuse them. + +## 7. CI, QA and production acceptance + +**Before code**: read the relevant files and source contracts, reproduce/trace current behavior, enumerate model consumers, identify exact no-read/no-poll/no-cross-IED invariants, and inspect current GitHub PR state. + +**Before pushing**: do local/source preflight for touched files, deterministic negative tests, and a single coherent batch. Avoid re-triggering heavy CI with one-line follow-up commits that were preventable by review. Keep all tests that assert semantically correct contracts; update only **stale coupling to implementation spelling** after verifying the new runtime route. + +**Targeted CI** (select only relevant gates, see CI routing policy): +- Qt compile/QML smoke + Browser convergence + P6E Multi-IED authority + canonical engineering context/60k large model. +- SCL-assisted Connect/Workbench when changing SCL projection, source reconciliation or session ownership. +- RCB/Interop/Control/Files/Settings tests when changing those services. +- Native screenshot QA for *selected* q/t/DataSet/RCB states and malformed/unread variants; an overview screenshot alone proves only layout. +- Windows Qt lifecycle/release-hardening where changes touch actual Windows behavior; **Studio Windows installer is tag/manual release**, not every Browser source PR. + +**Never equate:** source token tests with rendered UX; CI green with physical IED interoperability; release job complete with downloadable installer unless an exact artifact/release exists; synthetic test input with a physical IED capture; shown timestamp with a proven device-quality timestamp. + +**Required negative matrix:** offline/disconnected; empty source; multiple IEDs with same LD name; failed or timed-out Read; late response after source generation changes; report member omissions/segmentation; q/t truncated/wrong MMS type; SG unsupported/readback mismatch; FCDA alias/FC mismatch; long untrusted metadata; exceeding each budget; repeated filter + selection + reconnect. Run high-impact negative cases in native QML/C++ integration tests, not just string checks. + +**Merge gate:** review live diff/ownership, verify no unresolved review thread, require **completed/success, non-skipped, exact PR-head** relevant gates, squash merge with expected_head_sha, re-read main HEAD, then verify post-merge CI separately. A docs-only PR should use relevant cheap document/link checks, not dispatch unrelated firmware/Windows build storms. + +## 8. Immediate handoff to the next two threads + +**Integrator now:** check [PR #176](https://github.com/masarray/arstack61850/pull/176) against current main and exact-head Qt run [38678320479](https://github.com/masarray/arstack61850/actions/runs/38678320479). Its source and routing regression changes must stay coordinated. If green and reviews resolved, squash-merge with SHA lease, then verify main integration. If red, use the failed job log and fix the **actual** root cause in #176; do not open a duplicate M2K branch. + +**Track A (after M2K merged):** start M2L by tracing actual Measurement DO/DA typed metadata and quality/time provenance, with baseline screenshots/fixtures including **Hz/TotW must not have phasor angle**. Produce a signed-off data-contract test **before** changing presentation. + +**Track B:** study and build the ARSAS-vs-ARStack **screen-state comparison matrix** while Track A owns the canonical model. Avoid overlapping #176's All-IED QML before merge. Implement only after A's model contracts are confirmed, using the existing QML panels; propose small selected-state screenshot QA in a separate non-conflicting path. + +**Definition of progress:** merged code, verified CI, inspectable native screenshots, honest unverified items and an updated on-repo handoff. More lines of code, a second GUI stack, or an unchecked screenshot do not count. diff --git a/docs/WORKBENCH_CURRENT_HANDOFF.md b/docs/WORKBENCH_CURRENT_HANDOFF.md index 76d78f5f7..c39aa3227 100644 --- a/docs/WORKBENCH_CURRENT_HANDOFF.md +++ b/docs/WORKBENCH_CURRENT_HANDOFF.md @@ -1,117 +1,59 @@ -# Native IEC 61850 Workbench — current engineering / AI-thread handoff +# Native Qt IEC 61850 Workbench — current cross-thread handoff -> **Scope:** native Qt IEC 61850 Workbench in `apps/ied_simulator` of `masarray/arstack61850`, not the legacy web application. -> **Verified snapshot:** 2026-10-15 (WIB). Always refresh GitHub refs before editing; this document records a specific checkpoint. -> **Mandatory context:** [AGENTS.md](../AGENTS.md), [AI_READ_FIRST.md](../AI_READ_FIRST.md), [PROJECT_HANDOFF.md](../PROJECT_HANDOFF.md), [CI policy](CI_WORKFLOW_ROUTING.md), [RCB reporting contract](../RCB_REPORTING.md). +> **Updated:** 2026-10-15 (WIB), snapshot only; always refresh GitHub refs and PR state before editing. +> **Primary strategy and ownership contract:** [WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md](WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md). +> **Scope:** installed native ARStack Workbench in apps/ied_simulator. Not a web app, WPF clone, or the separate ESP32-P4/SMV Studio track. -## A. Live GitHub baseline at the snapshot +## Current checkpoint -| Topic | Verifiable status | +| Item | Verified at this snapshot | | --- | --- | -| Repository/default branch | [masarray/arstack61850](https://github.com/masarray/arstack61850), `main` | -| Latest *Workbench code* baseline before this docs-only commit | `a4f0abe2536c318b24f2877b491107bca981cd45` | -| M2A Reports layout | PR [#159](https://github.com/masarray/arstack61850/pull/159), merged `6081df29` | -| M2B canonical indexed DataSets | PR [#160](https://github.com/masarray/arstack61850/pull/160), merged `d527c894` | -| M2C Global Data IEC/MMS read | PR [#161](https://github.com/masarray/arstack61850/pull/161), merged `d4374434` | -| M2D-1 typed q/t MMS Read | PR [#163](https://github.com/masarray/arstack61850/pull/163), merged `e749617f` | -| M2D-2 typed q/t RCB reports | PR [#165](https://github.com/masarray/arstack61850/pull/165), merged `af3ced5d` | -| M2E bounded report GUI projection | PR [#167](https://github.com/masarray/arstack61850/pull/167), merged `35eda54d` | -| M2F streaming value preview | PR [#168](https://github.com/masarray/arstack61850/pull/168), **merged** as `a4f0abe2`; Qt run [38644305224](https://github.com/masarray/arstack61850/actions/runs/38644305224) **success** | -| M2G bounding untrusted RCB metadata | PR [#169](https://github.com/masarray/arstack61850/pull/169), **OPEN, RED** at `c6cf52c2b8c37d1f5ca55c9e7399c3a5ad4035aa`; Qt run [38646373741](https://github.com/masarray/arstack61850/actions/runs/38646373741) **failure** | +| Repo / production branch | [masarray/arstack61850](https://github.com/masarray/arstack61850), main | +| main before documentation-only update | [a328da13133a6473dc6a28a69f5d2aebab7a7198](https://github.com/masarray/arstack61850/commit/a328da13133a6473dc6a28a69f5d2aebab7a7198) | +| M2G | PR [#169](https://github.com/masarray/arstack61850/pull/169) **already superseded by merged later work**; old “fix PR #169” instructions are obsolete | +| M2H / M2I / M2J | PR [#171](https://github.com/masarray/arstack61850/pull/171), [#173](https://github.com/masarray/arstack61850/pull/173), [#174](https://github.com/masarray/arstack61850/pull/174) **merged** | +| M2J post-merge | Exact-main Qt, SCL-assisted Connect/Workbench and Release Hardening **success** | +| **M2K live work** | PR [#176](https://github.com/masarray/arstack61850/pull/176), head **5c5e425c20acb1b849399f39bf62da0f68c0942b** when checked | +| M2K SCL-assisted | [run 38678320482](https://github.com/masarray/arstack61850/actions/runs/38678320482) **success** on that head | +| M2K native Qt | [run 38678320479](https://github.com/masarray/arstack61850/actions/runs/38678320479) **in progress** when last checked; do not assume a merge or success | +| Parallel/legacy PRs | #172, #164, older Browser recovery; evaluate against current main, do not auto-merge duplicate work. PTP/SMV/Studio PRs are separate | -Merging a docs-only handoff can advance `main` without changing Workbench code. Treat the SHA above as **code checkpoint at snapshot**, not the perpetual HEAD. Many open Draft PRs belong to separate PTP/SMV, earlier Browser recovery or parallel q/t work (#164); do not conflate them or merge obsolete branches casually. +## No-regression source of truth -## B. Immediate blocker: fix M2G PR #169, do not open a duplicate +1. Open SCL and live discovery converge in the existing IedEngineeringContextController and canonical MmsLiveTreeModel; index IEC/MMS references instead of reparsing or duplicating trees. +2. MmsClientController is the only confirmed Read/write path; MmsReportController owns report/RCB states; MmsEngineeringEvidence.hpp is the shared q/t decoder. +3. DA readable does **not** imply a sample was received. Preserve actual empty-string sample vs NOT READ, unknown quality/time, and generation/association provenance. No stale evidence after reconnect/source replacement. +4. Report member reference and evidence must map to exact received frame ordinal and actual Report ID. Static reporting remains report-only without implicit periodic MMS Read. +5. Preserve verified bounds: 60k+ node large-model regression, 4,096 detail rows, 64 lazy FCDA DAs, 64 explicit Read targets, 256 per-IED watched objects, 2,048 All-IED rendered objects, 256-char All-IED preview, 256 visible report members, 1,024-char report preview, 96 initial typed q/t details. These are presentation bounds; do not drop authoritative frames. +6. Scalar Hz and total power (TotW) do not acquire an “angle” through a generic presentation shortcut; only display units/phasor components proven by typed metadata. +7. Never claim pixel-level IEDScout/ARSAS parity, latest installer parity, or real physical IED interop on synthetic fixtures alone. -**Exact observed failure:** `BROWSER_REPORT_BOUNDED_PROJECTION_FAIL missing=#include "BrowserBoundedMmsPreview.hpp"previewMmsValue(*member.value`. +## Immediate action and owners -GitHub Actions log confirms native Qt **Build passed**, then **Browser convergence source-contract** failed. Root cause is missing comma in `apps/ied_simulator/test_browser_convergence.py`: Python silently concatenates two adjacent string literals in the `projection_header` required-token list. +**Integration owner (first):** re-query main, PR #176 head, exact-head Qt result, review threads. Fix failures in **that PR**, not in a new duplicate. If green, squash-merge with expected-head lease, verify main integration and update this table. -```python -'#include "BrowserBoundedMmsPreview.hpp"' -'previewMmsValue(*member.value', -``` +**Canonical/SSOT owner (second):** M2L Measurement semantics and value/q/t/unit/angle provenance on the existing MMS model. Tests for MX vector, scalar Hz, TotW, malformed q/t, unset value, SCL-vs-live authority and source-generation mismatch first. -Add the comma after the first string, **preserve both assertions**, run the targeted test and then the exact-head Qt workflow on PR #169. Do not revert the correct M2F/M2G performance work to appease the stale contract. +**QML/UX owner (parallel on disjoint files):** compare ARSAS Explorer live grid, selected-state IEDScout screenshots and ARStack Qt Data Model/DataSets/Reports/SG. Build a screen-state matrix and deterministic native captures; do not change the canonical model or edit #176's All-IED files concurrently. -PR #169 changes only: -- `apps/ied_simulator/src/BrowserReportMemberProjection.hpp` -- `apps/ied_simulator/src/ied_browser_large_model_qa.cpp` -- `apps/ied_simulator/test_browser_convergence.py` +**Protocol/RCB owner:** preserve exact Reporting contract ([RCB_REPORTING_REFERENCE.md](RCB_REPORTING_REFERENCE.md)); no new polling, invented report values or uncontrolled writes. -Its goal: cap huge untrusted DataSet reference strings and Reason-for-Inclusion before QString conversion, cap complete GUI preview including marker, flag truncated identities honestly, and never decode q/t evidence from a truncated reference. Regression includes ~1.5 MB reference/reason strings and many reason entries. **Not yet accepted / merged** at snapshot. +**QA/CI owner:** source-contract, actual rendered selected-state screenshots, 60k-node stress, 8-IED isolation, oversized metadata, read error and reconnect. Coordinates shared test files with the feature owner; source token checks alone do not establish rendered semantics. -## C. User objective: engineering-grade IEDScout-like GUI/UX +## One-turn resume checklist -The target is a **native Windows Qt Workbench** resembling reference IEDScout *Global Data, File, Browser, Reports and DataSets* workflow, backed by truthful IEC 61850 data. User values measurable improvements and rejects naive coding, noisy UI, mismatched screenshots and excessive CI. +1. Read [strategy](WORKBENCH_ARSAS_GUI_SSOT_STRATEGY.md), [AGENTS](../AGENTS.md), [AI_READ_FIRST](../AI_READ_FIRST.md) and [CI policy](CI_WORKFLOW_ROUTING.md). +2. Inspect live main, open PRs and run results, exact affected files and current integration owner. Record base SHA/head SHA before touching a file. +3. Reproduce observed wrong behavior, identify canonical state owner, set typed/value provenance requirements, then make **one small coherent vertical slice**, including failure/large-input regression. +4. Check applicable exact-head CI, review, squash merge with expected_head_sha and re-read main; never call in-progress/skipped CI PASS. +5. Update this handoff with accepted checkpoint and links. Keep separate lifecycle: code merged vs native artifact built vs actual Windows installer vs real IED tested. -- `Open SCL` and `Discovery IED` must converge onto **one canonical IED model** and same browsing / static Dataset / reporting pipeline. Do not fork semantic trees or repeat outdated discovery base code. -- Expose clear `Browse IED` and `Dataset Signals` choices. **Static RCB reporting must remain report-only** when selected; never silently mix in cyclic MMS polling. -- LD/LN/DO/DA hierarchy with clean LD labels (avoid repeated IED name in UI), correct per-node value, q/t detail and engineering unit only when source provides it. -- Reports inspector follows `Control Block Attributes → Information Received in Last Report → Data`. Match report frames by **Report ID**, and member evidence by exact **received-frame member ordinal**, not assumed DataSet index. -- DataSets: dense ordered FCDA list, exactly **one FC tag**, lazy FC-filtered DO→DA expansion, no automatic MMS read merely on navigation. -- File download/open, splitters, screen density and pixel-level visual parity still need separate explicit QA. **CI green ≠ screenshot parity or real-device validation.** +## Useful commands (if native Qt toolchain available) -Do not confuse this track with the released **native ARStack Studio / ESP32-P4 SMV Injector** (`apps/arstack_studio`) or the separate ARSAS repository. No firmware/PTP/SMV changes are authorized by this GUI handoff. + python3 apps/ied_simulator/test_browser_convergence.py apps/ied_simulator/qml/IedBrowserWorkspace.qml apps/ied_simulator/qml/IedBrowserNavigation.qml apps/ied_simulator/qml/BrowserStatusConsole.qml apps/ied_simulator/qml/BrowserDataModelPane.qml + cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja -DCMAKE_BUILD_TYPE=Release + cmake --build build-ied-simulator-qt --target arstack_ied_simulator ied_browser_large_model_qa ied_browser_session_qa ied_report_workspace_qa --parallel 2 + ./build-ied-simulator-qt/ied_browser_large_model_qa + QT_QPA_PLATFORM=offscreen ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test -## D. Ownership and key source paths - -| Layer | Authoritative source | -| --- | --- | -| Canonical IEC/MMS DO/DA + indexed IEC/MMS aliases + current value/evidence | `apps/ied_simulator/src/MmsLiveTreeModel.hpp/.cpp` | -| SCL and discovered engineering model | `apps/ied_simulator/src/IedEngineeringContextController.*` | -| Successful typed MMS Read / post-write evidence | `apps/ied_simulator/src/MmsClientController.cpp` | -| **Single** IEC Quality 13-bit / UTC TimeQuality typed decoder | `apps/ied_simulator/src/MmsEngineeringEvidence.hpp` | -| Existing RCB runtime/snapshot projection, ReportID & members | `apps/ied_simulator/src/MmsReportController.cpp` | -| Bounded RCB member presentation, currently modified by M2G | `apps/ied_simulator/src/BrowserReportMemberProjection.hpp` | -| Presentation-only streaming MMS preview (M2F) | `apps/ied_simulator/src/BrowserBoundedMmsPreview.hpp` | -| Native Qt Browser layout | `apps/ied_simulator/qml/IedBrowserWorkspace.qml`; `IedBrowserNavigation.qml` | -| Qt inspectors | `BrowserDataModelPane.qml`, `BrowserDataSetPane.qml`, `BrowserGlobalDataPane.qml`, `BrowserReportPane.qml`, `BrowserEngineeringEvidence.qml` | -| QA | `apps/ied_simulator/test_browser_convergence.py`; `src/ied_browser_large_model_qa.cpp` | -| CI routing | `.github/workflows/ied-simulator-qt.yml` and `docs/CI_WORKFLOW_ROUTING.md` | - -**Non-negotiable invariants:** - -1. Never fabricate measurements, quality flags, timestamps or RCB state; absent or malformed evidence is `Unknown` / `—`, **not** assumed false. -2. One canonical model / typed decoder; never build second semantic cache, reporting association, implicit polling loop or new q/t parser in QML. -3. Keep full authoritative MMS report frame intact; cap **presentation only**. Established limits: **256 displayed report members**, **1024-character GUI preview**, **96 typed q/t evidence items per snapshot**, **64 DAs per lazy expanded DataSet row**. M2F streams large values before allocating huge intermediary strings; M2G bounds metadata before QString allocation. -4. Evidence from an RCB frame aligns to the actual received member ordinal and Report ID. Failed/untyped values must not show fabricated details. Do not misalign on omitted members. -5. No periodic MMS reads on expand, no protocol/firmware regression, no hidden blocking allocations on hot callbacks. Follow [AGENTS.md](../AGENTS.md). - -## E. Mandatory optimized implementation & CI workflow - -Use **RECON -> EXACT BASELINE -> ROOT CAUSE -> INVARIANTS -> SMALL COHERENT DESIGN -> IMPLEMENT -> NEGATIVE/LARGE INPUT QA -> ONE COMMIT -> TARGETED EXACT-SHA CI -> REVIEW/MERGE**. - -For the current Browser source contract, run locally if code/toolchain is available: - -```bash -python3 apps/ied_simulator/test_browser_convergence.py \ - apps/ied_simulator/qml/IedBrowserWorkspace.qml \ - apps/ied_simulator/qml/IedBrowserNavigation.qml \ - apps/ied_simulator/qml/BrowserStatusConsole.qml \ - apps/ied_simulator/qml/BrowserDataModelPane.qml - -cmake -S apps/ied_simulator -B build-ied-simulator-qt -G Ninja -DCMAKE_BUILD_TYPE=Release -cmake --build build-ied-simulator-qt \ - --target arstack_ied_simulator ied_browser_large_model_qa ied_browser_session_qa ied_report_workspace_qa \ - --parallel 2 -./build-ied-simulator-qt/ied_browser_large_model_qa -QT_QPA_PLATFORM=offscreen ./build-ied-simulator-qt/arstack_ied_simulator --smoke-test -``` - -See the actual workflow for all required CI gates, including MMS data-plane/GUI checks. Do not claim success based solely on Build, skipped checks, or generic screenshots. - -- Fix open PR **#169 first**. Inspect **current** PR head before pushing (other threads may advance it). Commit to the **same PR**, not a duplicate. Never force-push or overwrite parallel work. -- Prefer one coherent commit / at most necessary regression correction, with local preflight before expensive CI. Avoid build storms and unrelated ESP-IDF, Studio installer, C++ matrices. -- If CI red, inspect exact failed job logs. Fix real code/test cause without reverting correct architecture. Verify exact-head and required checks before squash merge. -- Keep Windows artifact lineage honest: earlier M2D-1 ZIP is **not** latest M2F/M2G. Physical IED q/t and full IEDScout GUI parity remain **unverified** until actually tested. - -## F. Next-step acceptance sequence - -1. Requery `main`, PR #169 status/head and latest Actions; do not assume snapshot still current. -2. If still red due missing comma, fix the Python token-list typo, run Browser contract, exact-head Qt CI; verify 1.5 MB metadata tests, no eager QString conversion, correct q/t provenance, preview bounds. -3. Once CI success, inspect PR #169 diff, squash-merge with `expected_head_sha` and verify new `main` integration. -4. Select **one** meaningful next milestone: exact-screen visual regression for DataSets/Data Model/Quality/TimeQuality versus provided IEDScout references; or measured value/report memory/latency profiling. Do not promise pixel parity without screenshots and Windows/IED evidence. -5. Separate known-good and unfinished behavior in progress reports. New thread must **perform repository actions and provide verifiable PR/commit/CI links**, not merely restate strategy. - -**Status at snapshot:** M2A–M2F merged; M2G open/CI red; IEDScout GUI parity, full real-IED q/t interoperability and latest Windows installer not yet accepted. +The full workflow defines the remaining real MMS, P6E Multi-IED and native QA gates. Follow [CI policy](CI_WORKFLOW_ROUTING.md) for cost-efficient targeting. From 58ed8599e75ab8deb3f8db2a477ecb8bc2b3047e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 13:25:35 +0700 Subject: [PATCH 21/22] fix(studio): make PTP STOP fail-closed under queued START and transport saturation Reserve a single coalesced priority STOP slot in DeviceIoWorker and discard unsent PTP START while preserving other bounded FIFO commands. Hold cancellation until firmware stop-request ACK plus subsequent STOPPED readback, polling within a fixed deadline and blocking unsafe restart on unverified STOP. Ignore stale different-role SHOW telemetry until the requested stopped role arrives. Add deterministic queue/stop/role regressions and native GUI recovery state. --- apps/arstack_studio/qml/PtpExpertPanel.qml | 13 +- .../src/DeterministicSessionHarness.cpp | 83 +++++++++++-- apps/arstack_studio/src/DeviceController.cpp | 114 +++++++++++++++--- apps/arstack_studio/src/DeviceController.hpp | 9 +- apps/arstack_studio/src/DeviceIoWorker.cpp | 44 ++++++- apps/arstack_studio/src/DeviceIoWorker.hpp | 4 + 6 files changed, 237 insertions(+), 30 deletions(-) diff --git a/apps/arstack_studio/qml/PtpExpertPanel.qml b/apps/arstack_studio/qml/PtpExpertPanel.qml index d2d43dde0..80d1de77f 100644 --- a/apps/arstack_studio/qml/PtpExpertPanel.qml +++ b/apps/arstack_studio/qml/PtpExpertPanel.qml @@ -59,7 +59,13 @@ SurfacePanel { && Number(panel.device.ptpSyncSent) > 0 && Number(panel.device.ptpFollowUpSent) > 0 && Number(panel.device.ptpTxFailures) === 0 - readonly property string headlineState: panel.startPending ? "STARTING" + readonly property bool stopPending: panel.startPending && + (panel.device.ptpStatus.indexOf("Stopping PTP") === 0 || + panel.device.ptpStatus.indexOf("Checking that board") === 0 || + panel.device.ptpStatus.indexOf("PTP STOP unverified") === 0) + readonly property string headlineState: panel.stopPending + ? (panel.device.ptpStatus.indexOf("unverified") !== -1 ? "STOP UNVERIFIED" : "STOPPING") + : panel.startPending ? "STARTING" : panel.device.ptpRunning ? (panel.receiverRole ? (panel.device.ptpDiscipline === "LOCKED" ? "BOARD LOCKED" : panel.device.ptpDiscipline) : panel.activeRole + " ACTIVE") @@ -184,7 +190,8 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont tone: "accent" - text: panel.startPending ? "Starting…" + text: panel.stopPending ? "Stop pending…" + : panel.startPending ? "Starting…" : modeCombo.currentIndex === 0 ? "Start PTP Source" : modeCombo.currentIndex === 1 ? "Start PTP Receiver" : "Start Monitor" enabled: panel.session && panel.device.ptpAvailable && !panel.device.ptpRunning && !panel.startPending && !panel.session.updatingFirmware @@ -194,7 +201,7 @@ SurfacePanel { theme: panel.theme uiFont: panel.uiFont tone: "danger" - text: "Stop PTP" + text: panel.device.ptpStatus.indexOf("PTP STOP unverified") === 0 ? "Retry Stop PTP" : "Stop PTP" enabled: panel.session && (panel.device.ptpRunning || panel.startPending) && !panel.session.updatingFirmware onClicked: { panel.session.requestStopPtp() diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 5d2225d89..08313baaf 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -2,6 +2,7 @@ #include "DeterministicSessionHarness.hpp" +#include "DeviceIoWorker.hpp" #include "FirmwareManager.hpp" #include "SclProfileModel.hpp" #include "SmartSessionController.hpp" @@ -43,7 +44,10 @@ class DeterministicSessionHarness final { {"PTP stale role readback cannot bypass ACK", ptpStaleRoleReadbackBlocked()}, {"PTP runtime requires matching PTP2 role", ptpRuntimeRoleEvidenceRequired()}, {"PTP rejected config never starts", ptpGuidedRejectedConfigStops()}, - {"PTP STOP cancels pending start", ptpGuidedStopCancelsStart()}, + {"PTP STOP cancels pending start after verified readback", ptpGuidedStopCancelsStart()}, + {"PTP STOP full queue uses bounded priority slot", ptpSafetyStopBypassesFullQueue()}, + {"PTP STOP requires firmware ACK and stopped readback", ptpStopConfirmationCannotBeGuessed()}, + {"PTP STOP timeout blocks restart pending manual recovery", ptpUnverifiedStopBlocksRestart()}, {"App teardown must stop PTP without SV output", ptpOnlyShutdownIntent()}, {"PTP role mismatch and new session revoke pending start", ptpGuidedRoleAndGenerationGuards()}, {"profile timeout -> bounded terminal error", profileTimeoutIsTerminal()}, @@ -575,15 +579,77 @@ class DeterministicSessionHarness final { d.ptpStatus_ == QStringLiteral("PTP start failed"); } + static QString ptpStoppedStatus() { + return QStringLiteral( + "PTP status=STOPPED Announce=0 Sync=0 FollowUp=0 PdelayFrames=0 TXfail=0"); + } + static bool ptpGuidedStopCancelsStart() { Fixture f; if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; auto& d = f.device; - if (!f.session.requestStopPtp() || d.ptpStarting()) return false; + if (!f.session.requestStopPtp() || + d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; // ACK from now-abandoned transaction cannot trigger CONFIG or START. d.processLine(QStringLiteral("PTPROLE role=SOURCE value=0 running=0")); - return !d.ptpStarting() && d.ptpStartStage_ == - DeviceController::PtpStartStage::idle; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + d.processLine(QStringLiteral("PTP stop requested")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback) return false; + d.processLine(ptpStoppedStatus()); + return !d.ptpStarting() && + d.ptpStartStage_ == DeviceController::PtpStartStage::idle && + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); + } + + static bool ptpSafetyStopBypassesFullQueue() { + DeviceIoWorker worker; + for (int i = 0; i < DeviceIoWorker::commandQueueCapacity(); ++i) { + worker.commandQueue_.enqueue(DeviceIoWorker::PendingCommand{ + i == 7 ? QStringLiteral("PTP START") : QStringLiteral("PROFILE SHOW"), + QByteArrayLiteral("PROFILE SHOW\n"), false, false}); + } + worker.queuePriorityPtpStop(); // no COM: test pure queue mutation only + const bool reserved = worker.ptpStopPending_ && + worker.commandQueue_.size() == DeviceIoWorker::commandQueueCapacity() - 1; + bool noPendingStart = true; + for (const auto& cmd : worker.commandQueue_) { + if (cmd.text == QStringLiteral("PTP START")) noPendingStart = false; + } + worker.queuePriorityPtpStop(); // coalesced, never double-allocates + return reserved && noPendingStart && worker.ptpStopPending_ && + worker.commandQueue_.size() == DeviceIoWorker::commandQueueCapacity() - 1; + } + + static bool ptpStopConfirmationCannotBeGuessed() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; + auto& d = f.device; + if (!d.stopPtp()) return false; + d.processLine(ptpStoppedStatus()); // stale SHOW reply, before STOP ACK + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(QStringLiteral( + "PTP status=RUNNING Announce=1 Sync=1 FollowUp=0 PdelayFrames=0 TXfail=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_readback || + !d.ptpStopPollTimer_.isActive()) return false; + d.processLine(ptpStoppedStatus()); + return d.ptpStartStage_ == DeviceController::PtpStartStage::idle && + !d.ptpStarting() && !d.ptpStopPollTimer_.isActive(); + } + + static bool ptpUnverifiedStopBlocksRestart() { + Fixture f; + if (!setupGuidedPtp(f, QStringLiteral("SOURCE"))) return false; + auto& d = f.device; + if (!d.stopPtp()) return false; + d.markPtpStopUnconfirmed(QStringLiteral("Injected STOP verification timeout")); + const bool blocksRestart = d.ptpStarting() && + d.ptpStartStage_ == DeviceController::PtpStartStage::stop_unconfirmed && + !f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()); + if (!blocksRestart || !d.stopPtp()) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + return !d.ptpStarting() && d.ptpStartStage_ == DeviceController::PtpStartStage::idle; } static bool ptpGuidedRoleAndGenerationGuards() { @@ -591,11 +657,12 @@ class DeterministicSessionHarness final { if (!setupGuidedPtp(f, QStringLiteral("RECEIVER"))) return false; auto& d = f.device; d.processLine(QStringLiteral("PTPROLE accepted")); + // Earlier SHOW telemetry must not abort or advance RECEIVER selection. d.processLine(QStringLiteral("PTPROLE role=MONITOR value=2 running=0")); - if (d.ptpStarting() || d.ptpStatus_ != QStringLiteral("PTP start failed")) - return false; - if (!f.session.requestStartPtpConfigured(QStringLiteral("RECEIVER"), ptpValidProfile())) - return false; + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_role_readback || + !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_config) return false; const quint64 previous = d.sessionGeneration_; d.setSessionGeneration(previous + 1); d.processLine(QStringLiteral("PTPROLE role=RECEIVER value=1 running=0")); diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index 8b8cf03f3..d0b8ae90b 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -89,10 +89,24 @@ DeviceController::DeviceController(QObject* parent) : QObject(parent) { ptpStartTimer_.setSingleShot(true); connect(&ptpStartTimer_, &QTimer::timeout, this, [this] { if (!ptpStarting()) return; + if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { + markPtpStopUnconfirmed(QStringLiteral( + "PTP STOP was not confirmed by the board. Retry Stop PTP; output may still be active.")); + return; + } + if (ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || ptpStartStage_ == PtpStartStage::awaiting_runtime; failPtpStartup(QStringLiteral("PTP acknowledgement timed out. Start cancelled."), rollback); }); + ptpStopPollTimer_.setSingleShot(true); + connect(&ptpStopPollTimer_, &QTimer::timeout, this, [this] { + if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback && + !sendPtpShow()) { + markPtpStopUnconfirmed(QStringLiteral("Could not verify board PTP STOP status.")); + } + }); ioWorker_ = new DeviceIoWorker; ioWorker_->moveToThread(&ioThread_); ioThread_.setObjectName(QStringLiteral("ARStackDeviceIo")); @@ -329,8 +343,14 @@ void DeviceController::connectWorkerSignals() { profileArmed_ = false; emit profileStateChanged(); } - if (ptpStarting()) failPtpStartup(QStringLiteral("PTP serial command rejected: ") + message, false); - else setError(message); + if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack || + ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { + markPtpStopUnconfirmed(QStringLiteral("PTP STOP verification command rejected: ") + message); + } else if (ptpStarting() && ptpStartStage_ != PtpStartStage::stop_unconfirmed) { + const bool rollback = ptpStartStage_ == PtpStartStage::awaiting_start || + ptpStartStage_ == PtpStartStage::awaiting_runtime; + failPtpStartup(QStringLiteral("PTP serial command rejected: ") + message, rollback); + } else setError(message); }); connect(ioWorker_, &DeviceIoWorker::transportError, this, @@ -984,10 +1004,11 @@ bool DeviceController::setCtSaturation( bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } bool DeviceController::startPtp() { return sendCommand(QStringLiteral("PTP START")); } bool DeviceController::stopPtp() { - resetPtpStartup(); - ptpStatus_ = QStringLiteral("PTP stop requested"); - emit ptpStateChanged(); - return sendCommand(QStringLiteral("PTP STOP")); + if (!connected_ || !ioWorkerReady_ || !deviceVerified_) { + setError(QStringLiteral("Connect the verified ESP32-P4 before stopping PTP.")); + return false; + } + return beginPtpStopVerification(); } QString DeviceController::ptpConfigCommand(const QVariantMap& profile) { @@ -1019,22 +1040,53 @@ QString DeviceController::ptpConfigCommand(const QVariantMap& profile) { void DeviceController::resetPtpStartup() noexcept { ptpStartTimer_.stop(); + ptpStopPollTimer_.stop(); ptpStartStage_ = PtpStartStage::idle; pendingPtpRole_.clear(); pendingPtpConfig_.clear(); ptpRuntimeRoleSeen_ = false; } +bool DeviceController::beginPtpStopVerification() { + ptpStartTimer_.stop(); + ptpStopPollTimer_.stop(); + ptpStartStage_ = PtpStartStage::awaiting_stop_ack; + pendingPtpRole_.clear(); + pendingPtpConfig_.clear(); + ptpRuntimeRoleSeen_ = false; + ptpStatus_ = QStringLiteral("Stopping PTP; waiting for board confirmation..."); + emit ptpStateChanged(); + // Emergency STOP has one reserved slot in the existing serial owner and + // eliminates unsent START commands, even when the normal queue is full. + if (!sendCommand(QStringLiteral("PTP STOP"))) { + markPtpStopUnconfirmed(QStringLiteral("Could not queue safety PTP STOP.")); + return false; + } + ptpStartTimer_.start(5000); + return true; +} + +void DeviceController::markPtpStopUnconfirmed(const QString& reason) { + ptpStartTimer_.stop(); + ptpStopPollTimer_.stop(); + // Failed verification is not proof that the transmitter stopped. Block + // new START intents until an explicit STOP retry or a new session. + ptpStartStage_ = PtpStartStage::stop_unconfirmed; + ptpStatus_ = QStringLiteral("PTP STOP unverified — output may still be active"); + emit ptpStateChanged(); + setError(reason); +} + void DeviceController::failPtpStartup(const QString& reason, const bool rollback) { + setError(reason); + if (rollback && connected_ && sessionGeneration_ != 0) { + static_cast(beginPtpStopVerification()); + return; + } resetPtpStartup(); ptpRunning_ = false; ptpStatus_ = QStringLiteral("PTP start failed"); emit ptpStateChanged(); - setError(reason); - if (rollback && connected_ && sessionGeneration_ != 0) { - // Ordered on the same serial worker after any delayed START. - static_cast(sendCommand(QStringLiteral("PTP STOP"))); - } } bool DeviceController::startPtpConfigured(const QString& requestedRole, @@ -1067,7 +1119,37 @@ bool DeviceController::startPtpConfigured(const QString& requestedRole, } void DeviceController::advancePtpStartup(const QString& line) { - if (!ptpStarting()) return; + if (!ptpStarting() || ptpStartStage_ == PtpStartStage::stop_unconfirmed) return; + if (ptpStartStage_ == PtpStartStage::awaiting_stop_ack) { + // Firmware emits "PTP stop requested", not "PTP stop accepted". + // A stale STOPPED status from an older SHOW cannot confirm this STOP. + if (line.contains(QStringLiteral("PTP stop requested"), Qt::CaseInsensitive)) { + ptpStartStage_ = PtpStartStage::awaiting_stop_readback; + ptpStatus_ = QStringLiteral("Checking that board PTP is stopped..."); + emit ptpStateChanged(); + if (!sendPtpShow()) { + markPtpStopUnconfirmed(QStringLiteral("Could not request PTP STOP readback.")); + return; + } + ptpStartTimer_.start(5000); + } + return; + } + if (ptpStartStage_ == PtpStartStage::awaiting_stop_readback) { + const auto readback = kPtpStatusExpression.match(line); + if (!readback.hasMatch()) return; + if (readback.captured(1).compare(QStringLiteral("STOPPED"), Qt::CaseInsensitive) == 0) { + resetPtpStartup(); + ptpRunning_ = false; + ptpStatus_ = QStringLiteral("PTP stopped — confirmed by board"); + emit ptpStateChanged(); + } else if (!ptpStopPollTimer_.isActive()) { + // Firmware stop is asynchronous. Poll on Qt timers without + // extending the absolute five-second verification deadline. + ptpStopPollTimer_.start(250); + } + return; + } const bool rejected = line.contains(QStringLiteral("PTPROLE rejected"), Qt::CaseInsensitive) || line.contains(QStringLiteral("PTPROLE configuration unavailable"), Qt::CaseInsensitive) || @@ -1093,9 +1175,11 @@ void DeviceController::advancePtpStartup(const QString& line) { if (ptpStartStage_ == PtpStartStage::awaiting_role_readback) { const auto reply = kPtpRoleExpression.match(line); if (!reply.hasMatch()) return; - if (reply.captured(1).toUpper() != pendingPtpRole_ || - reply.captured(3) != QStringLiteral("0")) { - failPtpStartup(QStringLiteral("PTP mode readback does not match the selection."), false); + // A prior PROFILE SHOW can emit old role telemetry after the ACK. + // Wait for the selected role; fail only if it is itself still running. + if (reply.captured(1).toUpper() != pendingPtpRole_) return; + if (reply.captured(3) != QStringLiteral("0")) { + failPtpStartup(QStringLiteral("Selected PTP role is still running."), false); return; } const QString command = pendingPtpConfig_; diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 7ef564325..4f88929ad 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -294,7 +294,11 @@ class DeviceController : public QObject { private: friend class DeterministicSessionHarness; - enum class PtpStartStage { idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, awaiting_start, awaiting_runtime }; + enum class PtpStartStage { + idle, awaiting_role_ack, awaiting_role_readback, awaiting_config, + awaiting_start, awaiting_runtime, awaiting_stop_ack, awaiting_stop_readback, + stop_unconfirmed + }; enum class BinaryProfileStage { idle, @@ -344,6 +348,8 @@ class DeviceController : public QObject { [[nodiscard]] bool needsPtpStopBeforeShutdown() const noexcept { return ptpRunning_ || ptpStarting(); } void advancePtpStartup(const QString& line); void failPtpStartup(const QString& reason, bool rollback); + bool beginPtpStopVerification(); + void markPtpStopUnconfirmed(const QString& reason); void resetPtpStartup() noexcept; [[nodiscard]] static QString ptpConfigCommand(const QVariantMap& profile); static QString cleanLine(const QString& rawLine); @@ -383,6 +389,7 @@ class DeviceController : public QObject { bool ptpRunning_{false}; PtpStartStage ptpStartStage_{PtpStartStage::idle}; QTimer ptpStartTimer_; + QTimer ptpStopPollTimer_; QString pendingPtpRole_; QString pendingPtpConfig_; bool ptpRuntimeRoleSeen_{false}; diff --git a/apps/arstack_studio/src/DeviceIoWorker.cpp b/apps/arstack_studio/src/DeviceIoWorker.cpp index 757254c32..218939a4b 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.cpp +++ b/apps/arstack_studio/src/DeviceIoWorker.cpp @@ -124,6 +124,7 @@ void DeviceIoWorker::shutdown(const bool requestSvStop, const bool requestPtpSto // installer. Keep SV stop ahead of PTP stop so no new SV frame is sent // during grandmaster shutdown. This runs once during teardown only. commandQueue_.clear(); + ptpStopPending_ = false; if ((requestSvStop || requestPtpStop) && serial_ != nullptr && serial_->isOpen()) { const auto sendBoundedStop = [this](const QByteArray& command) { const qint64 queued = serial_->write(command); @@ -243,6 +244,7 @@ bool DeviceIoWorker::openPortInternal(const QString& portName, const bool automa identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); + ptpStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -290,6 +292,7 @@ void DeviceIoWorker::closePortInternal(const bool emitRelease) { identifyAttempts_ = 0; pendingRx_.clear(); commandQueue_.clear(); + ptpStopPending_ = false; exclusiveCommandsRemaining_ = 0; writeActive_ = false; activeCommand_ = {}; @@ -368,6 +371,13 @@ bool DeviceIoWorker::enqueueCommandsInternal( } if (commands.isEmpty()) return true; + if (!exclusive && commands.size() == 1 && + commands.constFirst().trimmed() == QStringLiteral("PTP STOP")) { + // One reserved safety slot cannot be rejected by normal queue saturation. + queuePriorityPtpStop(); + return true; + } + if (exclusiveCommandsRemaining_ > 0) { if (reportRejection) { emit commandRejected(activeGeneration_, exclusive @@ -408,10 +418,38 @@ bool DeviceIoWorker::enqueueCommandsInternal( return true; } -void DeviceIoWorker::pumpWriteQueue() { - if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || commandQueue_.isEmpty()) return; +void DeviceIoWorker::queuePriorityPtpStop() { + // Bounded (64 entries) and infrequent: revoke every unsent PTP START. + // Preserve all other queued commands; STOP runs after the active write. + QQueue retained; + while (!commandQueue_.isEmpty()) { + PendingCommand command = commandQueue_.dequeue(); + if (command.text == QStringLiteral("PTP START")) { + if (command.exclusive && exclusiveCommandsRemaining_ > 0) { + --exclusiveCommandsRemaining_; + } + continue; + } + retained.enqueue(std::move(command)); + } + commandQueue_ = std::move(retained); + if (!(writeActive_ && activeCommand_.text == QStringLiteral("PTP STOP"))) { + ptpStopPending_ = true; // coalesce repeated urgent STOP intents + } + pumpWriteQueue(); +} - activeCommand_ = commandQueue_.dequeue(); +void DeviceIoWorker::pumpWriteQueue() { + if (serial_ == nullptr || !serial_->isOpen() || writeActive_ || + (!ptpStopPending_ && commandQueue_.isEmpty())) return; + + if (ptpStopPending_) { + ptpStopPending_ = false; + activeCommand_ = PendingCommand{ + QStringLiteral("PTP STOP"), QByteArrayLiteral("PTP STOP\n"), false, false}; + } else { + activeCommand_ = commandQueue_.dequeue(); + } const qint64 accepted = serial_->write(activeCommand_.bytes); if (accepted != activeCommand_.bytes.size()) { const QString detail = accepted < 0 diff --git a/apps/arstack_studio/src/DeviceIoWorker.hpp b/apps/arstack_studio/src/DeviceIoWorker.hpp index 1affb5337..56c8be1cd 100644 --- a/apps/arstack_studio/src/DeviceIoWorker.hpp +++ b/apps/arstack_studio/src/DeviceIoWorker.hpp @@ -130,6 +130,8 @@ public slots: void transportError(quint64 generation, const QString& message, bool fatal); private: + friend class DeterministicSessionHarness; + struct PendingCommand final { QString text; QByteArray bytes; @@ -149,6 +151,7 @@ public slots: bool quiet, bool exclusive, bool reportRejection); + void queuePriorityPtpStop(); void pumpWriteQueue(); void completeActiveWrite(); void processReadyRead(); @@ -177,5 +180,6 @@ public slots: bool genericProbeActive_{false}; bool identityConfirmed_{false}; bool writeActive_{false}; + bool ptpStopPending_{false}; // reserved bounded emergency STOP slot bool heartbeatEnabled_{false}; }; \ No newline at end of file From c9c01551789232e84f182d9092e52fe8848df486 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Thu, 15 Oct 2026 13:30:07 +0700 Subject: [PATCH 22/22] test(studio): validate role-mismatch rollback through STOPPED before retry Update pre-existing PTP runtime regression for stronger fail-closed cancellation semantics; exercise both guided and legacy direct START plus role-selection guards while PTP STOP remains unconfirmed. --- .../src/DeterministicSessionHarness.cpp | 17 ++++++++++++++--- apps/arstack_studio/src/DeviceController.cpp | 10 +++++++++- apps/arstack_studio/src/DeviceController.hpp | 4 ++-- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/arstack_studio/src/DeterministicSessionHarness.cpp b/apps/arstack_studio/src/DeterministicSessionHarness.cpp index 08313baaf..5829964cc 100644 --- a/apps/arstack_studio/src/DeterministicSessionHarness.cpp +++ b/apps/arstack_studio/src/DeterministicSessionHarness.cpp @@ -530,8 +530,15 @@ class DeterministicSessionHarness final { if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_runtime) return false; d.processLine(QStringLiteral( "PTP status=RUNNING Announce=1 Sync=1 FollowUp=1 PdelayFrames=0 TXfail=0")); - if (d.ptpStarting() || d.ptpRunning_ || d.ptpStatus_ != QStringLiteral("PTP start failed")) - return false; + // Missing PTP2 evidence after START must trigger a verified rollback, + // not silently clear state while the board might still transmit. + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting() || + f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile()) || + d.startPtp() || d.setPtpRole(QStringLiteral("MONITOR"))) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); + if (d.ptpStarting() || d.ptpRunning_) return false; if (!f.session.requestStartPtpConfigured(QStringLiteral("SOURCE"), ptpValidProfile())) return false; d.processLine(QStringLiteral("PTPROLE accepted")); @@ -539,8 +546,12 @@ class DeterministicSessionHarness final { d.processLine(QStringLiteral("PTP configuration accepted")); d.processLine(QStringLiteral("PTP start accepted")); d.processLine(ptpRoleEvidence(QStringLiteral("RECEIVER"))); + if (d.ptpStartStage_ != DeviceController::PtpStartStage::awaiting_stop_ack || + !d.ptpStarting()) return false; + d.processLine(QStringLiteral("PTP stop requested")); + d.processLine(ptpStoppedStatus()); return !d.ptpStarting() && !d.ptpRunning_ && - d.ptpStatus_ == QStringLiteral("PTP start failed"); + d.ptpStatus_.contains(QStringLiteral("confirmed by board")); } static bool ptpGuidedStartNeedsAcks() { diff --git a/apps/arstack_studio/src/DeviceController.cpp b/apps/arstack_studio/src/DeviceController.cpp index d0b8ae90b..bbb1bb5e7 100644 --- a/apps/arstack_studio/src/DeviceController.cpp +++ b/apps/arstack_studio/src/DeviceController.cpp @@ -1002,7 +1002,15 @@ bool DeviceController::setCtSaturation( } bool DeviceController::sendPtpShow() { return sendCommand(QStringLiteral("PTP SHOW")); } -bool DeviceController::startPtp() { return sendCommand(QStringLiteral("PTP START")); } +bool DeviceController::startPtp() { + // Legacy direct START must not bypass a guided START, in-flight STOP, + // or an unverified cancellation. Both paths share this device authority. + if (!deviceVerified_ || !ptpAvailable_ || ptpRunning_ || ptpStarting()) { + setError(QStringLiteral("PTP must be verified and stopped before START.")); + return false; + } + return sendCommand(QStringLiteral("PTP START")); +} bool DeviceController::stopPtp() { if (!connected_ || !ioWorkerReady_ || !deviceVerified_) { setError(QStringLiteral("Connect the verified ESP32-P4 before stopping PTP.")); diff --git a/apps/arstack_studio/src/DeviceController.hpp b/apps/arstack_studio/src/DeviceController.hpp index 4f88929ad..f77ebf2c3 100644 --- a/apps/arstack_studio/src/DeviceController.hpp +++ b/apps/arstack_studio/src/DeviceController.hpp @@ -244,8 +244,8 @@ class DeviceController : public QObject { setError(QStringLiteral("PTP role must be SOURCE, RECEIVER, or MONITOR.")); return false; } - if (ptpRunning_) { - setError(QStringLiteral("Stop PTP before changing its operating role.")); + if (ptpRunning_ || ptpStarting()) { + setError(QStringLiteral("Confirm PTP is stopped before changing its operating role.")); return false; } return sendCommand(QStringLiteral("PROFILE PTPROLE %1").arg(role));